refactor(tts): replace Minimax+Freesound with Piper TTS

Remove Minimax Speech API and Freesound integrations entirely.
Add Piper TTS as the sole TTS provider via configurable HTTP URL.

- Add piper_config DB table (url field, per-user)
- Add GET/POST /api/piper/config routes
- Python: replace generate_tts_minimax with generate_tts_piper (GET ?text=, WAV→MP3)
- Simplify generate_tts_with_subs: piper_url param replaces tts_provider+minimax fields
- Drop ttsVoice/ttsProvider from all UI, API, and background job params
- Settings page: replace Minimax+Freesound cards with single Piper URL input
- Remove freeSoundService init from server startup and CSP headers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Sonnet 4.6 committed 2026-05-19 09:15:26 +02:00
1 parent 0209c3eacf
commit 797e12dde5
14 files changed
+152 -725

No files matched your search

+31 -102
View File
@@ -158,10 +158,7 @@ class ReelRequest(BaseModel):
font_size: int = 64
music_volume: float = 0.25
tts_enabled: bool = False
tts_voice: str = "fr-FR-VivienneMultilingualNeural"
tts_provider: str = "edge_tts" # "edge_tts" or "minimax"
minimax_api_key: Optional[str] = None
minimax_group_id: Optional[str] = None
piper_url: Optional[str] = None
draw_text: bool = True
stabilize: bool = False # Stabilisation vidéo via vidstab
enable_ending_effect: bool = True
@@ -195,82 +192,44 @@ def clean_text_for_tts(text: str) -> str:
return " ".join(text.split())
def generate_tts_minimax(text: str, voice_id: str, api_key: str, audio_path: Path, group_id: Optional[str] = None) -> None:
"""Generate TTS audio using Minimax Speech API (T2A v2)."""
url = f"https://api.minimax.io/v1/t2a_v2?GroupId={group_id}" if group_id else "https://api.minimax.io/v1/t2a_v2"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
payload = {
"model": "speech-02-hd",
"text": text,
"voice_setting": {
"voice_id": voice_id,
"speed": 1.0,
"pitch": 0,
"vol": 1.0,
},
"audio_setting": {
"format": "mp3",
"sample_rate": 32000,
"bitrate": 128000,
"channel": 1,
},
}
print(f"🔊 Minimax API request: voice_id={voice_id}, model=speech-02-hd, text_len={len(text)}")
response = requests.post(url, headers=headers, json=payload, timeout=60)
print(f"🔊 Minimax API response: status={response.status_code}, content_type={response.headers.get('Content-Type', '?')}")
def generate_tts_piper(text: str, piper_url: str, audio_path: Path) -> None:
"""Generate TTS audio using Piper TTS HTTP API."""
print(f"🔊 Piper TTS request: url={piper_url}, text_len={len(text)}")
response = requests.get(piper_url, params={"text": text}, timeout=60)
print(f"🔊 Piper API response: status={response.status_code}, content_type={response.headers.get('Content-Type', '?')}")
response.raise_for_status()
content_type = response.headers.get("Content-Type", "")
if "audio" in content_type:
# Direct binary audio response
audio_bytes = response.content
print(f"🔊 Minimax returned binary audio directly ({len(audio_bytes)} bytes)")
else:
data = response.json()
print(f"🔊 Minimax JSON response keys: {list(data.keys())}")
tmp_path = audio_path.with_suffix(".tmp")
with open(tmp_path, "wb") as f:
f.write(response.content)
base_resp = data.get("base_resp", {})
if base_resp.get("status_code") != 0:
raise Exception(f"Minimax API error: {base_resp.get('status_msg', 'unknown error')}")
audio_raw = data.get("data", {}).get("audio") or data.get("audio")
if not audio_raw:
raise Exception(f"Minimax API: no audio field in response. Keys: {list(data.keys())}")
audio_bytes = bytes.fromhex(audio_raw)
with open(audio_path, "wb") as f:
f.write(audio_bytes)
print(f"✅ Minimax TTS audio saved: {audio_path.stat().st_size} bytes")
subprocess.run([
"ffmpeg", "-y", "-i", str(tmp_path),
"-codec:a", "libmp3lame", "-b:a", "128k",
str(audio_path)
], check=True, capture_output=True)
tmp_path.unlink(missing_ok=True)
print(f"✅ Piper TTS audio saved: {audio_path.stat().st_size} bytes")
async def generate_tts_with_subs(
text: str,
voice: str,
audio_path: Path,
ass_path: Path,
display_text: Optional[str] = None,
delay: float = 0.0,
tts_provider: str = "edge_tts",
minimax_api_key: Optional[str] = None,
minimax_group_id: Optional[str] = None,
piper_url: Optional[str] = None,
):
"""Generate TTS audio with word-level synchronized subtitles.
Uses edge_tts.Communicate.stream() to capture WordBoundary events,
providing millisecond-accurate subtitle timing instead of linear estimation.
For Minimax provider, falls back to ffsubsync (no word boundaries available).
Uses Piper TTS when piper_url is set, otherwise falls back to edge_tts.
Piper uses ffsubsync for subtitle sync (no word boundaries available).
"""
# Minimax branch: no WordBoundary events, use ffsubsync for subtitle sync
if tts_provider == "minimax":
if not minimax_api_key:
raise Exception("Minimax API key required but not provided")
# Piper branch: no WordBoundary events, use ffsubsync for subtitle sync
if piper_url:
text_to_display = display_text if display_text else text
print(f"🔊 Minimax TTS with voice: {voice}, group_id={bool(minimax_group_id)}")
generate_tts_minimax(text, voice, minimax_api_key, audio_path, group_id=minimax_group_id)
generate_tts_piper(text, piper_url, audio_path)
audio_duration = None
try:
@@ -280,18 +239,21 @@ async def generate_tts_with_subs(
]
dur_proc = subprocess.run(dur_cmd, stdout=subprocess.PIPE, text=True)
audio_duration = float(dur_proc.stdout.strip())
print(f"⏱️ Minimax TTS Duration: {audio_duration:.2f}s")
print(f"⏱️ Piper TTS Duration: {audio_duration:.2f}s")
except Exception as e:
print(f"⚠️ Could not measure Minimax TTS duration: {e}")
print(f"⚠️ Could not measure Piper TTS duration: {e}")
unsynced_srt_path = audio_path.with_suffix(".unsynced.srt")
synced_srt_path = audio_path.with_suffix(".synced.srt")
generate_unsynced_srt(text_to_display, unsynced_srt_path, total_duration=audio_duration)
run_ffsubsync(audio_path, unsynced_srt_path, synced_srt_path)
convert_srt_to_ass(synced_srt_path, ass_path, font_size=65, delay=delay)
print("✅ Minimax TTS synchronisation completed with ffsubsync")
print("✅ Piper TTS synchronisation completed with ffsubsync")
return
# edge_tts fallback branch
voice = "fr-FR-VivienneMultilingualNeural"
# Determine gender of requested voice to choose appropriate fallbacks
is_male = any(name in voice for name in ["Remi", "Henri", "Paul"])
@@ -880,35 +842,18 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
try:
# Clean text for TTS (remove hashtags/emojis)
tts_clean_text = clean_text_for_tts(request.text)
print(f"🔊 TTS enabled. Provider: {request.tts_provider}, Voice: {request.tts_voice}")
print(f"🔊 TTS has minimax_api_key: {bool(request.minimax_api_key)}, group_id: {bool(request.minimax_group_id)}")
print(f"🔊 TTS enabled. Piper URL: {bool(request.piper_url)}")
print(f"🔊 TTS cleaned: '{tts_clean_text[:80]}...'")
# Check for male/female voice map (edge_tts only)
voice = request.tts_voice
if request.tts_provider != "minimax":
if voice == "male":
voice = "fr-FR-RemyMultilingualNeural"
elif voice == "female":
voice = "fr-FR-VivienneMultilingualNeural"
elif not voice or "Neural" not in voice:
voice = "fr-FR-VivienneMultilingualNeural"
print(f"🔊 Using voice: {voice}")
if tts_clean_text:
print(f"🔊 Generating TTS audio to: {tts_audio_path}")
# Pass original text (with emojis) for subtitles, cleaned text for audio
await generate_tts_with_subs(
tts_clean_text,
voice,
tts_audio_path,
tts_ass_path,
display_text=clean_text_for_display(request.text),
delay=2.0,
tts_provider=request.tts_provider,
minimax_api_key=request.minimax_api_key,
minimax_group_id=request.minimax_group_id,
piper_url=request.piper_url,
)
# Verify files were created
@@ -1281,28 +1226,12 @@ async def preview_tts(request: ReelRequest, x_api_key: str = Header(None)):
clean_text = clean_text_for_tts(request.text)
# Determine voice (reuse logic)
voice = request.tts_voice
if request.tts_provider != "minimax":
if voice == "male":
voice = "fr-FR-RemyMultilingualNeural"
elif voice == "female":
voice = "fr-FR-VivienneMultilingualNeural"
elif not voice or "Neural" not in voice:
voice = "fr-FR-VivienneMultilingualNeural"
# Pass original text for subtitles (implied in SRT for preview too if needed, though mostly audio)
# Preview TTS generation doesn't technically need the full 2s video delay, but if the frontend plays it against standard timing it might.
# Adding delay=0.0 here explicitly since it's just audio preview, or delay=2.0 if previewing video directly.
await generate_tts_with_subs(
clean_text,
voice,
tts_audio_path,
tts_srt_path,
display_text=clean_text_for_display(request.text),
tts_provider=request.tts_provider,
minimax_api_key=request.minimax_api_key,
minimax_group_id=request.minimax_group_id,
piper_url=request.piper_url,
)
if not tts_audio_path.exists():