mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-21 14:08:10 +02:00
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:
1 parent
0209c3eacf
commit
797e12dde5
14 files changed
+152
-725
No files matched your search
+31
-102
@@ -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():
|
||||
|
||||
Reference in new issue
Block a user