drugi commit

This commit is contained in:
Mirek Sobczak
2026-08-03 22:48:27 +02:00
parent 53b58363f6
commit 8d3823327a
5 changed files with 7106 additions and 0 deletions
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env python3
"""Convert a long text file to MP3 using the existing Riva TTS client.
The script splits input text into smaller chunks, synthesizes each chunk with
`python-clients/scripts/tts/talk.py`, joins all WAV chunks, and finally converts
the result to MP3 using ffmpeg.
"""
from __future__ import annotations
import argparse
import re
import shutil
import subprocess
import sys
import tempfile
import wave
from pathlib import Path
from typing import List
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Convert a long TXT file to MP3 using NVIDIA Riva TTS.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument("--input", type=Path, required=True, help="Path to input .txt file.")
parser.add_argument("--output", type=Path, required=True, help="Path to output .mp3 file.")
parser.add_argument("--server", default="0.0.0.0:50051", help="Riva server address.")
parser.add_argument("--language-code", default="pl-PL", help="Language code for TTS.")
parser.add_argument(
"--voice",
default="Chatterbox-Multilingual.pl-PL.Male",
help="Riva voice name.",
)
parser.add_argument(
"--talk-script",
type=Path,
default=Path("python-clients/scripts/tts/talk.py"),
help="Path to existing talk.py script.",
)
parser.add_argument(
"--max-chars",
type=int,
default=700,
help="Maximum characters per TTS chunk.",
)
parser.add_argument(
"--sample-rate-hz",
type=int,
default=22050,
help="Output sample rate used by talk.py.",
)
parser.add_argument(
"--keep-wav",
action="store_true",
help="Keep final merged WAV next to MP3.",
)
return parser.parse_args()
def normalize_whitespace(text: str) -> str:
text = text.replace("\r\n", "\n").replace("\r", "\n")
lines = [line.strip() for line in text.split("\n")]
return "\n".join(lines)
def split_sentence_if_needed(sentence: str, max_chars: int) -> List[str]:
if len(sentence) <= max_chars:
return [sentence]
words = sentence.split()
parts: List[str] = []
current = ""
for word in words:
candidate = f"{current} {word}".strip() if current else word
if len(candidate) <= max_chars:
current = candidate
else:
if current:
parts.append(current)
# Single very long token fallback.
if len(word) > max_chars:
for i in range(0, len(word), max_chars):
parts.append(word[i : i + max_chars])
current = ""
else:
current = word
if current:
parts.append(current)
return parts
def chunk_text(text: str, max_chars: int) -> List[str]:
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
sentence_splitter = re.compile(r"(?<=[.!?…])\s+")
chunks: List[str] = []
current = ""
for paragraph in paragraphs:
sentences = [s.strip() for s in sentence_splitter.split(paragraph) if s.strip()]
expanded_sentences: List[str] = []
for s in sentences:
expanded_sentences.extend(split_sentence_if_needed(s, max_chars))
for sentence in expanded_sentences:
candidate = f"{current} {sentence}".strip() if current else sentence
if len(candidate) <= max_chars:
current = candidate
else:
if current:
chunks.append(current)
current = sentence
if current:
chunks.append(current)
current = ""
if current:
chunks.append(current)
if not chunks:
raise ValueError("Input text is empty after preprocessing.")
return chunks
def synthesize_chunk(
talk_script: Path,
server: str,
language_code: str,
voice: str,
sample_rate_hz: int,
text: str,
output_wav: Path,
) -> None:
cmd = [
sys.executable,
str(talk_script),
"--server",
server,
"--language-code",
language_code,
"--voice",
voice,
"--text",
text,
"--stream",
"--sample-rate-hz",
str(sample_rate_hz),
"--output",
str(output_wav),
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(
"TTS synthesis failed.\n"
f"Command: {' '.join(cmd)}\n"
f"stdout:\n{result.stdout}\n"
f"stderr:\n{result.stderr}"
)
def merge_wav_files(wav_files: List[Path], output_wav: Path) -> None:
if not wav_files:
raise ValueError("No WAV files to merge.")
with wave.open(str(wav_files[0]), "rb") as first:
params = first.getparams()
frames = [first.readframes(first.getnframes())]
for wav_path in wav_files[1:]:
with wave.open(str(wav_path), "rb") as wf:
if (
wf.getnchannels() != params.nchannels
or wf.getsampwidth() != params.sampwidth
or wf.getframerate() != params.framerate
or wf.getcomptype() != params.comptype
):
raise RuntimeError(f"Incompatible WAV params in {wav_path}")
frames.append(wf.readframes(wf.getnframes()))
with wave.open(str(output_wav), "wb") as out:
out.setparams(params)
for chunk in frames:
out.writeframes(chunk)
def convert_wav_to_mp3(input_wav: Path, output_mp3: Path) -> None:
ffmpeg = shutil.which("ffmpeg")
if ffmpeg is None:
raise RuntimeError(
"ffmpeg not found in PATH. Install ffmpeg to generate MP3 output."
)
cmd = [
ffmpeg,
"-y",
"-i",
str(input_wav),
"-codec:a",
"libmp3lame",
"-q:a",
"2",
str(output_mp3),
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(
"MP3 conversion failed.\n"
f"Command: {' '.join(cmd)}\n"
f"stdout:\n{result.stdout}\n"
f"stderr:\n{result.stderr}"
)
def main() -> int:
args = parse_args()
if args.max_chars < 50:
raise ValueError("--max-chars should be at least 50.")
input_path = args.input.expanduser().resolve()
output_mp3 = args.output.expanduser().resolve()
talk_script = args.talk_script.expanduser().resolve()
if not input_path.is_file():
raise FileNotFoundError(f"Input file not found: {input_path}")
if not talk_script.is_file():
raise FileNotFoundError(f"talk.py not found: {talk_script}")
output_mp3.parent.mkdir(parents=True, exist_ok=True)
merged_wav = output_mp3.with_suffix(".wav")
text = input_path.read_text(encoding="utf-8")
text = normalize_whitespace(text)
chunks = chunk_text(text, args.max_chars)
print(f"Input split into {len(chunks)} chunks (max {args.max_chars} chars each).")
with tempfile.TemporaryDirectory(prefix="tts_chunks_") as tmp_dir:
tmp_path = Path(tmp_dir)
wav_paths: List[Path] = []
for idx, chunk in enumerate(chunks, start=1):
chunk_wav = tmp_path / f"chunk_{idx:05d}.wav"
print(f"[{idx}/{len(chunks)}] Synthesizing chunk...")
synthesize_chunk(
talk_script=talk_script,
server=args.server,
language_code=args.language_code,
voice=args.voice,
sample_rate_hz=args.sample_rate_hz,
text=chunk,
output_wav=chunk_wav,
)
wav_paths.append(chunk_wav)
print("Merging WAV chunks...")
merge_wav_files(wav_paths, merged_wav)
print("Converting WAV to MP3...")
convert_wav_to_mp3(merged_wav, output_mp3)
if not args.keep_wav and merged_wav.exists():
merged_wav.unlink()
print(f"Done: {output_mp3}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc: # pragma: no cover
print(f"Error: {exc}", file=sys.stderr)
raise SystemExit(1)