tts-voice-creator-clone-and.../core/audio.py
mARTin-B78 c7a1e35539 Security audit, modular refactor, and container-name field
Security fixes:
- Block /proc /sys /dev /run /boot in /api/browse-dirs (path traversal)
- Verify yt-dlp output stays inside TEMP_DIR before registration
- Remove Access-Control-Allow-Origin: * from /api/proxy-audio
- TTL-based temp file registry (default 2h) to prevent disk fill

Performance:
- Cache settings + routing rules in memory (mtime-checked); eliminates
  per-request disk reads on every TTS call

UI:
- Add container name (optional) field to Docker stack TTS/STT engine
  cards (Qwen3 Voice Clone, Voice Design, Custom Voice, Streaming,
  NVIDIA Magpie, Parakeet) — enables Stop/Start/Restart buttons on
  all engine cards, matching the existing Other Local TTS/STT cards

Refactor — backend:
- server.py: 5560 lines → 43-line entry point
- core/ package: constants, registry, validation, docker_client,
  config, routing, audio, voice, presets, tts_helpers
- routes/ package: admin, settings, library, stt, sources, docker,
  tts, conversation (FastAPI APIRouter modules)
- Dockerfile + docker-compose.yml updated to include core/ and routes/

Refactor — frontend:
- static/app.js: 8744 lines → 16 modules in static/js/
  utils, voice-inspector, voice-sources, integrations, routing,
  settings, voice-clone, voice-library, tts-preview, benchmark,
  stt, init, engines, ai-backends, generation, conversation
- static/loader.js updated to load modules sequentially

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:13:07 +02:00

209 lines
7.5 KiB
Python

"""Audio helpers: conversion, trim, normalize, loudness, auto-trim."""
from __future__ import annotations
import io
import subprocess
import uuid
import wave
from pathlib import Path
from pydub import AudioSegment
from core.constants import _VOICE_TARGET_DBFS, _VOICE_PEAK_DBFS
from core.registry import TEMP_DIR
def _to_wav_24k(src: Path) -> Path:
out = TEMP_DIR / f"{src.stem}_24k.wav"
seg = AudioSegment.from_file(str(src))
seg = seg.set_frame_rate(24000).set_channels(1).set_sample_width(2)
seg.export(str(out), format="wav")
return out
def _to_wav_16k(src: Path) -> Path:
"""16kHz mono WAV — required by Whisper/WhisperX VAD and wav2vec2 alignment."""
out = TEMP_DIR / f"{src.stem}_16k.wav"
seg = AudioSegment.from_file(str(src))
seg = seg.set_frame_rate(16000).set_channels(1).set_sample_width(2)
seg.export(str(out), format="wav")
return out
def _trim(src: Path, start_s: float, end_s: float) -> Path:
seg = AudioSegment.from_file(str(src))
trimmed = seg[int(start_s * 1000):int(end_s * 1000)]
trimmed, _ = _normalize_segment(trimmed)
out = TEMP_DIR / f"{uuid.uuid4().hex}_trimmed.wav"
trimmed.export(str(out), format="wav")
return out
def _duration(path: Path) -> float:
if path.suffix.lower() == ".wav":
try:
with wave.open(str(path), "rb") as wf:
frames = wf.getnframes()
rate = wf.getframerate()
if rate:
return frames / float(rate)
except Exception:
pass
try:
seg = AudioSegment.from_file(str(path))
return len(seg) / 1000.0
except Exception:
pass
probe = subprocess.run(
[
"ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
str(path),
],
capture_output=True,
text=True,
timeout=10,
)
if probe.returncode != 0:
raise RuntimeError(probe.stderr.strip() or "ffprobe failed")
return float(probe.stdout.strip())
def _normalize_segment(seg: AudioSegment, target_dbfs: float = _VOICE_TARGET_DBFS,
peak_dbfs: float = _VOICE_PEAK_DBFS) -> tuple[AudioSegment, dict]:
before_dbfs = seg.dBFS if seg.dBFS != float("-inf") else None
before_peak = seg.max_dBFS if seg.max_dBFS != float("-inf") else None
if before_dbfs is None or before_peak is None:
return seg, {"before_dbfs": before_dbfs, "after_dbfs": before_dbfs, "gain_db": 0.0, "peak_dbfs": before_peak}
gain = target_dbfs - before_dbfs
if before_peak + gain > peak_dbfs:
gain = peak_dbfs - before_peak
normalized = seg.apply_gain(gain)
after_dbfs = normalized.dBFS if normalized.dBFS != float("-inf") else None
after_peak = normalized.max_dBFS if normalized.max_dBFS != float("-inf") else None
return normalized, {
"before_dbfs": round(before_dbfs, 2),
"after_dbfs": round(after_dbfs, 2) if after_dbfs is not None else None,
"gain_db": round(gain, 2),
"peak_dbfs": round(after_peak, 2) if after_peak is not None else None,
}
def _export_normalized_wav(src: Path, dest: Path, target_dbfs: float = _VOICE_TARGET_DBFS) -> dict:
seg = AudioSegment.from_file(str(src))
seg = seg.set_frame_rate(24000).set_channels(1).set_sample_width(2)
seg, info = _normalize_segment(seg, target_dbfs=target_dbfs)
seg.export(str(dest), format="wav")
return info
def _loudness_info(path: Path) -> dict:
seg = AudioSegment.from_file(str(path))
dbfs = seg.dBFS if seg.dBFS != float("-inf") else None
peak = seg.max_dBFS if seg.max_dBFS != float("-inf") else None
return {
"dbfs": round(dbfs, 2) if dbfs is not None else None,
"peak_dbfs": round(peak, 2) if peak is not None else None,
"target_dbfs": _VOICE_TARGET_DBFS,
}
def _auto_trim_bounds(path: Path) -> dict:
seg = AudioSegment.from_file(str(path)).set_channels(1)
dur_ms = len(seg)
if dur_ms <= 20_000:
return {
"start": 0.0,
"end": dur_ms / 1000.0,
"duration": dur_ms / 1000.0,
"reason": "Audio is already short enough.",
}
chunk_ms = 250
chunks = []
overall_db = seg.dBFS if seg.dBFS != float("-inf") else -60.0
speech_floor = max(overall_db - 18.0, -45.0)
for pos in range(0, dur_ms, chunk_ms):
ch = seg[pos:pos + chunk_ms]
db = ch.dBFS if ch.dBFS != float("-inf") else -80.0
max_db = ch.max_dBFS if ch.max_dBFS != float("-inf") else -80.0
chunks.append({"db": db, "speech": db >= speech_floor, "clipped": max_db > -1.0})
def score_window(start_ms: int, length_ms: int) -> tuple[float, dict]:
first = max(0, start_ms // chunk_ms)
last = min(len(chunks), (start_ms + length_ms + chunk_ms - 1) // chunk_ms)
win = chunks[first:last]
if not win:
return -9999.0, {}
speech_ratio = sum(1 for c in win if c["speech"]) / len(win)
silence_ratio = 1.0 - speech_ratio
clip_ratio = sum(1 for c in win if c["clipped"]) / len(win)
speech_dbs = [c["db"] for c in win if c["speech"]]
avg_db = sum(speech_dbs) / len(speech_dbs) if speech_dbs else -80.0
variance = sum((x - avg_db) ** 2 for x in speech_dbs) / len(speech_dbs) if speech_dbs else 100.0
loudness_penalty = abs(avg_db - (-20.0)) * 1.7
steadiness_penalty = min(18.0, variance ** 0.5 * 1.4)
duration_s = length_ms / 1000.0
duration_penalty = abs(duration_s - 12.0) * 0.9
score = (
speech_ratio * 100.0
- silence_ratio * 55.0
- clip_ratio * 85.0
- loudness_penalty
- steadiness_penalty
- duration_penalty
)
return score, {
"speech_ratio": speech_ratio,
"silence_ratio": silence_ratio,
"clip_ratio": clip_ratio,
"avg_db": avg_db,
}
best = None
window_lengths = [8_000, 10_000, 12_000, 15_000, 18_000]
for length_ms in window_lengths:
if length_ms > dur_ms:
continue
for start_ms in range(0, dur_ms - length_ms + 1, 500):
score, metrics = score_window(start_ms, length_ms)
if best is None or score > best["score"]:
best = {"start_ms": start_ms, "length_ms": length_ms, "score": score, "metrics": metrics}
if best is None:
end_ms = min(dur_ms, 12_000)
return {"start": 0.0, "end": end_ms / 1000.0, "duration": end_ms / 1000.0,
"reason": "Using the beginning because no stable speech window was found."}
start_s = best["start_ms"] / 1000.0
end_s = (best["start_ms"] + best["length_ms"]) / 1000.0
m = best["metrics"]
return {
"start": round(start_s, 2),
"end": round(end_s, 2),
"duration": round(end_s - start_s, 2),
"score": round(best["score"], 2),
"reason": (
f"Selected {end_s - start_s:.1f}s with "
f"{m.get('speech_ratio', 0) * 100:.0f}% speech, "
f"{m.get('silence_ratio', 0) * 100:.0f}% silence, "
f"avg {m.get('avg_db', -80):.1f} dBFS."
),
}
def _audio_segment_from_wav(audio: bytes) -> AudioSegment:
segment = AudioSegment.from_file(io.BytesIO(audio), format="wav")
return segment.set_channels(1).set_sample_width(2).set_frame_rate(24000)
def _wav_bytes_from_segment(segment: AudioSegment) -> bytes:
out = io.BytesIO()
segment.export(out, format="wav")
return out.getvalue()