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>
209 lines
7.5 KiB
Python
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()
|