"""Voice library CRUD, upload, audio serving, trim/process, route-sounds.""" from __future__ import annotations import asyncio import re import shutil import uuid from pathlib import Path from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile from fastapi.responses import FileResponse, Response from core.config import _load_settings from core.constants import _VOICES_DIR_DEFAULT, _OUTPUT_DIR_DEFAULT, _VOICE_TARGET_DBFS, _VOICE_PEAK_DBFS, _BENCHMARK_SENTENCES, _MAX_TTS_OUTPUT_SECONDS, _MAX_UPLOAD_BYTES, _MAX_PICTURE_BYTES, _MAX_SOUND_BYTES from core.registry import _registry_put, _registry_get, TEMP_DIR from core.validation import _copy_limited, _safe_child_path, _validate_http_url from core.audio import ( _to_wav_24k, _to_wav_16k, _trim, _duration, _export_normalized_wav, _loudness_info, _auto_trim_bounds, _normalize_segment, ) from core.voice import ( _AUDIO_EXTS, _AUDIO_MIME, _UPLOAD_EXTS, _PICTURE_EXTS, _SOUND_ASSET_DIRS, _find_voice_audio, _load_meta, _save_meta, _picture_path, _picture_mime, _active_voices_dir, _hidden_voices_dir, _voice_audio_files, _voice_audio_from_request, _voice_entry, _backup_original_voice, _backup_candidates, _backup_audio_suffix, _remove_audio_variants, _remove_voice_package, _voice_package_paths, _move_voice_package, _read_reference_text, _voice_health, _is_internal_voice_file, _benchmark_voice, ) router = APIRouter() # ── Upload ──────────────────────────────────────────────────────────────────── @router.post("/api/upload") async def upload(file: UploadFile = File(...)): suffix = Path(file.filename or "audio").suffix.lower() or ".bin" if suffix not in _UPLOAD_EXTS: raise HTTPException(400, "Unsupported audio/video file type") dest = TEMP_DIR / f"{uuid.uuid4().hex}{suffix}" with dest.open("wb") as f: _copy_limited(file.file, f, _MAX_UPLOAD_BYTES) try: wav = _to_wav_24k(dest) except Exception as e: raise HTTPException(400, f"Audio conversion failed: {e}") fid = uuid.uuid4().hex _registry_put(fid, wav) return {"id": fid, "duration": _duration(wav), "filename": file.filename} # ── Route sounds ────────────────────────────────────────────────────────────── @router.get("/api/route-sounds") async def list_route_sounds(): settings = _load_settings() scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) sounds = [] seen = set() for dirname in sorted(_SOUND_ASSET_DIRS): root = scan_dir / dirname if not root.exists() or not root.is_dir(): continue for path in sorted(root.rglob("*"), key=lambda p: str(p).lower()): if not path.is_file() or path.suffix.lower() not in _AUDIO_EXTS: continue try: safe = _safe_child_path(scan_dir, path) rel = str(safe.relative_to(scan_dir)) except Exception: continue if rel in seen: continue seen.add(rel) try: size = path.stat().st_size except Exception: size = None sounds.append({ "path": rel, "name": path.name, "folder": str(path.parent.relative_to(scan_dir)), "duration": None, "size": size, "type": path.suffix.lower().lstrip("."), }) return {"sounds": sounds} @router.get("/api/route-sounds/file/{sound_path:path}") async def route_sound_file(sound_path: str): from core.tts_helpers import _route_sound_path try: path = _route_sound_path(_load_settings(), sound_path) except Exception: raise HTTPException(404, "Sound not found") if path is None: raise HTTPException(404, "Sound not found") mime = _AUDIO_MIME.get(path.suffix.lower(), "audio/wav") return FileResponse(str(path), media_type=mime, filename=path.name) @router.post("/api/route-sounds/upload") async def upload_route_sound(file: UploadFile = File(...), name: str = Form("")): from pydub import AudioSegment suffix = Path(file.filename or "sound").suffix.lower() or ".bin" if suffix not in _AUDIO_EXTS: raise HTTPException(400, "Unsupported sound file type") raw_name = Path(name or file.filename or "sound").stem.strip() safe_name = re.sub(r"[^A-Za-z0-9_\-.]+", "_", raw_name).strip("._-")[:80] or f"sound_{uuid.uuid4().hex[:8]}" settings = _load_settings() scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) sounds_dir = scan_dir / "sounds" sounds_dir.mkdir(parents=True, exist_ok=True) target = sounds_dir / f"{safe_name}.wav" tmp = TEMP_DIR / f"{uuid.uuid4().hex}{suffix}" with tmp.open("wb") as f: _copy_limited(file.file, f, _MAX_SOUND_BYTES) try: segment = AudioSegment.from_file(str(tmp)).set_channels(1).set_sample_width(2).set_frame_rate(24000) segment.export(str(target), format="wav") duration = len(segment) / 1000.0 except Exception as e: raise HTTPException(400, f"Sound conversion failed: {e}") finally: try: tmp.unlink(missing_ok=True) except Exception: pass return {"ok": True, "path": str(target.relative_to(scan_dir)), "filename": target.name, "duration": round(duration, 2)} # ── Audio serving ───────────────────────────────────────────────────────────── @router.get("/api/audio/{fid}") async def serve_audio(fid: str): path = _registry_get(fid) if path is None or not path.exists(): raise HTTPException(404, "File not found") return FileResponse(str(path), media_type="audio/wav") # ── Auto-trim / Process ─────────────────────────────────────────────────────── @router.post("/api/auto-trim") async def auto_trim(request: Request): data = await request.json() fid: str = data["id"] src = _registry_get(fid) if src is None or not src.exists(): raise HTTPException(404, "Source file not found") try: return _auto_trim_bounds(src) except Exception as e: raise HTTPException(400, f"Auto trim failed: {e}") @router.post("/api/process") async def process(request: Request): data = await request.json() fid: str = data["id"] start = float(data.get("start", 0)) end: float | None = data.get("end") src = _registry_get(fid) if src is None or not src.exists(): raise HTTPException(404, "Source file not found") dur = _duration(src) if end is None or end <= start: end = dur trimmed = _trim(src, start, end) nid = uuid.uuid4().hex _registry_put(nid, trimmed) return {"id": nid, "duration": _duration(trimmed)} # ── Save voice ──────────────────────────────────────────────────────────────── @router.post("/api/save") async def save_voice(request: Request): data = await request.json() fid: str = data["id"] voice_id: str = data["voice_id"].strip() transcript: str = data.get("transcript", "").strip() if not voice_id: raise HTTPException(400, "Voice ID is required") if not re.match(r"^[A-Za-z0-9_\-\.]+$", voice_id): raise HTTPException(400, "Voice ID may only contain A-Z, 0-9, _, -, .") src = _registry_get(fid) if src is None or not src.exists(): raise HTTPException(404, "Processed audio not found") settings = _load_settings() out_dir = _active_voices_dir(settings) out_dir.mkdir(parents=True, exist_ok=True) wav_dest = out_dir / f"{voice_id}.wav" txt_dest = out_dir / f"{voice_id}.reference.txt" _remove_audio_variants(out_dir, voice_id) loudness = _export_normalized_wav(src, wav_dest) txt_dest.write_text(transcript, encoding="utf-8") meta = _load_meta(wav_dest) meta["enabled"] = True meta["loudness"] = loudness _save_meta(wav_dest, meta) return {"voice_id": voice_id, "wav": str(wav_dest), "txt": str(txt_dest), "loudness": loudness} # ── Voice library CRUD ──────────────────────────────────────────────────────── @router.post("/api/voice-load") @router.post("/api/voice/load") async def load_voice_for_edit(request: Request): data = await request.json() voice_id: str = data.get("voice_id", "") settings = _load_settings() scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) audio = _voice_audio_from_request(data, scan_dir) if audio is None: raise HTTPException(404, f"Voice '{voice_id}' not found") try: wav = _to_wav_24k(audio) except Exception as e: raise HTTPException(400, f"Audio conversion failed: {e}") fid = uuid.uuid4().hex _registry_put(fid, wav) ref = audio.with_suffix(".reference.txt") return { "id": fid, "voice_id": voice_id or audio.stem, "path": str(audio), "duration": _duration(wav), "transcript": ref.read_text(encoding="utf-8").strip() if ref.exists() else "", "file_type": audio.suffix.lower().lstrip("."), } @router.post("/api/voice-replace") @router.post("/api/voice/replace") async def replace_voice_audio(request: Request): data = await request.json() fid: str = data["id"] voice_id: str = data["voice_id"].strip() transcript: str = data.get("transcript", "").strip() if not voice_id: raise HTTPException(400, "Voice ID is required") if not re.match(r"^[A-Za-z0-9_\-\.]+$", voice_id): raise HTTPException(400, "Voice ID may only contain A-Z, 0-9, _, -, .") src = _registry_get(fid) if src is None or not src.exists(): raise HTTPException(404, "Processed audio not found") settings = _load_settings() scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) current = _voice_audio_from_request(data, scan_dir) if current is None: raise HTTPException(404, f"Voice '{voice_id}' not found") parent = current.parent old_id = current.stem meta = _load_meta(current) wav_dest = parent / f"{voice_id}.wav" txt_dest = parent / f"{voice_id}.reference.txt" if voice_id != old_id: old_files = {p.resolve() for p in _voice_package_paths(current)} suffixes = _AUDIO_EXTS + [".reference.txt", ".meta.json"] + _PICTURE_EXTS for sfx in suffixes: target = parent / f"{voice_id}{sfx}" if target.exists() and target.resolve() not in old_files: raise HTTPException(409, f"Voice '{voice_id}' already exists") backup = _backup_original_voice(current) if voice_id == old_id else None _remove_audio_variants(parent, voice_id, keep=wav_dest) loudness = _export_normalized_wav(src, wav_dest) txt_dest.write_text(transcript, encoding="utf-8") meta["loudness"] = loudness if backup: meta["original_backup"] = str(backup) meta["needs_tts_restart"] = True _save_meta(wav_dest, meta) if voice_id != old_id: keep = {wav_dest, txt_dest, wav_dest.with_suffix(".meta.json")} for ext in _PICTURE_EXTS: old_pic = parent / f"{old_id}{ext}" if old_pic.exists(): new_pic = parent / f"{voice_id}{ext}" shutil.copy2(str(old_pic), str(new_pic)) keep.add(new_pic) _remove_voice_package(current, keep) return {"voice_id": voice_id, "wav": str(wav_dest), "txt": str(txt_dest), "duration": _duration(wav_dest), "file_type": "wav", "loudness": loudness, "backup": str(backup) if backup else None} @router.get("/api/voices") async def list_voices(): settings = _load_settings() scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) active_dir = _active_voices_dir(settings) hidden_dir = _hidden_voices_dir(settings) voices = [] if scan_dir.exists(): seen: set[str] = set() all_audio = list(_voice_audio_files(scan_dir)) def voice_sort_key(path: Path): try: path.relative_to(active_dir) folder_rank = 0 except ValueError: try: path.relative_to(hidden_dir) folder_rank = 1 except ValueError: folder_rank = 2 return (path.stem.lower(), folder_rank, str(path).lower()) for p in sorted(all_audio, key=voice_sort_key): if p.stem in seen: continue seen.add(p.stem) entry = _voice_entry(p) try: p.relative_to(hidden_dir) entry["enabled"] = False except ValueError: try: p.relative_to(active_dir) entry["enabled"] = True except ValueError: pass voices.append(entry) return voices @router.post("/api/voice/meta") async def update_voice_meta(request: Request): data = await request.json() voice_id: str = data["voice_id"] settings = _load_settings() scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) wav = _find_voice_audio(voice_id, scan_dir) if wav is None: raise HTTPException(404, f"Voice '{voice_id}' not found") meta = _load_meta(wav) if "enabled" in data: enabled = bool(data["enabled"]) wav = _move_voice_package(wav, _active_voices_dir(settings) if enabled else _hidden_voices_dir(settings)) meta = _load_meta(wav) meta["enabled"] = enabled for field in ("note", "rating", "flag", "gender", "loudness", "persona", "origin", "group", "name", "tag", "avatar"): if field in data: meta[field] = data[field] if "transcript" in data: meta["needs_tts_restart"] = True _save_meta(wav, meta) if "transcript" in data: wav.with_suffix(".reference.txt").write_text(data.get("transcript", "").strip(), encoding="utf-8") return {"ok": True, "path": str(wav), "enabled": meta.get("enabled", True), "transcript": wav.with_suffix(".reference.txt").read_text(encoding="utf-8").strip() if wav.with_suffix(".reference.txt").exists() else ""} @router.post("/api/voices/sync-folders") async def sync_voice_folders(): settings = _load_settings() scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) active_dir = _active_voices_dir(settings) hidden_dir = _hidden_voices_dir(settings) moved = {"active": 0, "hidden": 0} conflicts = [] if not scan_dir.exists(): return {"moved": moved, "conflicts": conflicts} seen: set[str] = set() all_audio = list(_voice_audio_files(scan_dir)) for p in sorted(all_audio, key=lambda x: x.stem.lower()): if p.stem in seen or not p.exists(): continue seen.add(p.stem) meta = _load_meta(p) enabled = meta.get("enabled") is not False target_dir = active_dir if enabled else hidden_dir try: new_audio = _move_voice_package(p, target_dir) meta = _load_meta(new_audio) meta["enabled"] = enabled _save_meta(new_audio, meta) moved["active" if enabled else "hidden"] += int(new_audio != p) except HTTPException as e: conflicts.append({"voice_id": p.stem, "detail": e.detail}) return {"moved": moved, "conflicts": conflicts, "active_dir": str(active_dir), "hidden_dir": str(hidden_dir)} @router.post("/api/voices/calculate-db") async def calculate_voice_db(): settings = _load_settings() scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) results = [] errors = [] if not scan_dir.exists(): return {"calculated": 0, "errors": errors, "voices": results} seen: set[str] = set() all_audio = list(_voice_audio_files(scan_dir)) for audio in sorted(all_audio, key=lambda p: p.stem.lower()): if audio.stem in seen: continue seen.add(audio.stem) try: loudness = _loudness_info(audio) meta = _load_meta(audio) meta["loudness"] = loudness _save_meta(audio, meta) results.append({"voice_id": audio.stem, "path": str(audio), "loudness": loudness}) except Exception as e: errors.append({"voice_id": audio.stem, "detail": str(e)}) return {"calculated": len(results), "errors": errors, "voices": results, "target_dbfs": _VOICE_TARGET_DBFS, "peak_dbfs": _VOICE_PEAK_DBFS} @router.post("/api/voices/benchmark") async def benchmark_voices(request: Request): data = await request.json() active_only = bool(data.get("active_only", True)) limit = int(data.get("limit") or 0) sample_text = str(data.get("text") or "").strip() sentences = [("sample", sample_text)] if sample_text else list(_BENCHMARK_SENTENCES) settings = _load_settings() scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) active_dir = _active_voices_dir(settings) results = [] errors = [] if not scan_dir.exists(): return { "benchmarked": 0, "errors": errors, "voices": results, "text": sample_text or "short / medium / long", "sentences": [{"label": label, "text": sent_text} for label, sent_text in sentences], "active_only": active_only, "max_tts_output_seconds": _MAX_TTS_OUTPUT_SECONDS, } target_voice = str(data.get("voice_id") or "").strip() seen: set[str] = set() if target_voice: audio = _find_voice_audio(target_voice, scan_dir) if audio is None: raise HTTPException(404, f"Voice '{target_voice}' not found") all_audio = [audio] else: all_audio = list(_voice_audio_files(active_dir if active_only and active_dir.exists() else scan_dir)) for audio in sorted(all_audio, key=lambda p: p.stem.lower()): if audio.stem in seen: continue seen.add(audio.stem) if limit and len(results) >= limit: break benchmark = await asyncio.to_thread(_benchmark_voice, audio, settings, sentences) try: meta = _load_meta(audio) meta["benchmark"] = benchmark _save_meta(audio, meta) except Exception as e: errors.append({"voice_id": audio.stem, "detail": f"Could not save benchmark: {e}"}) item = {"voice_id": audio.stem, "path": str(audio), "benchmark": benchmark} results.append(item) if not benchmark.get("ok", False): errors.append({"voice_id": audio.stem, "detail": benchmark.get("error", "Benchmark failed")}) return { "benchmarked": len(results), "errors": errors, "voices": results, "text": sample_text or "short / medium / long", "sentences": [{"label": label, "text": sent_text} for label, sent_text in sentences], "active_only": active_only, "max_tts_output_seconds": _MAX_TTS_OUTPUT_SECONDS, } @router.post("/api/voices/normalize-active") async def normalize_active_voices(): settings = _load_settings() active_dir = _active_voices_dir(settings) if not active_dir.exists(): return {"normalized": 0, "skipped": 0, "errors": [], "target_dbfs": _VOICE_TARGET_DBFS} normalized = [] skipped = [] errors = [] for wav in sorted((p for p in active_dir.rglob("*.wav") if not _is_internal_voice_file(p)), key=lambda p: p.stem.lower()): try: tmp = wav.with_suffix(".normalized.tmp.wav") loudness = _export_normalized_wav(wav, tmp) shutil.move(str(tmp), str(wav)) meta = _load_meta(wav) meta["enabled"] = True meta["loudness"] = loudness _save_meta(wav, meta) normalized.append({"voice_id": wav.stem, **loudness}) except Exception as e: errors.append({"voice_id": wav.stem, "detail": str(e)}) try: tmp = wav.with_suffix(".normalized.tmp.wav") if tmp.exists(): tmp.unlink() except Exception: pass for ext in [e for e in _AUDIO_EXTS if e != ".wav"]: skipped.extend(str(p) for p in active_dir.rglob(f"*{ext}") if not _is_internal_voice_file(p)) return { "normalized": len(normalized), "skipped": len(skipped), "errors": errors, "target_dbfs": _VOICE_TARGET_DBFS, "peak_dbfs": _VOICE_PEAK_DBFS, "voices": normalized, } @router.post("/api/voice/normalize") async def normalize_voice(request: Request): data = await request.json() target_dbfs = float(data.get("target_dbfs", _VOICE_TARGET_DBFS)) settings = _load_settings() scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) audio = _voice_audio_from_request(data, scan_dir) if audio is None: raise HTTPException(404, "Voice not found") if audio.suffix.lower() != ".wav": raise HTTPException(400, "Only WAV voices can be normalized in place") tmp = audio.with_suffix(".normalized.tmp.wav") try: loudness = _export_normalized_wav(audio, tmp, target_dbfs=target_dbfs) shutil.move(str(tmp), str(audio)) meta = _load_meta(audio) meta["loudness"] = loudness meta["needs_tts_restart"] = True _save_meta(audio, meta) return {"ok": True, "voice_id": audio.stem, "path": str(audio), "duration": _duration(audio), "file_type": "wav", "loudness": loudness} except Exception as e: if tmp.exists(): tmp.unlink() raise HTTPException(400, f"Normalize failed: {e}") @router.post("/api/voice/undo") async def undo_voice_edit(request: Request): data = await request.json() settings = _load_settings() scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) current = _voice_audio_from_request(data, scan_dir) if current is None: raise HTTPException(404, "Voice not found") meta = _load_meta(current) backup = next((p for p in _backup_candidates(current, meta) if p.exists()), None) if backup is None: raise HTTPException(404, "No original backup found") original_suffix = _backup_audio_suffix(backup, current.stem) or current.suffix.lower() restored = current.with_suffix(original_suffix) shutil.copy2(str(backup), str(restored)) if restored.resolve() != current.resolve(): _remove_audio_variants(current.parent, current.stem, keep=restored) try: meta["loudness"] = _loudness_info(restored) except Exception: meta.pop("loudness", None) meta["needs_tts_restart"] = True _save_meta(restored, meta) return { "ok": True, "voice_id": restored.stem, "path": str(restored), "duration": _duration(restored), "file_type": restored.suffix.lower().lstrip("."), "loudness": meta.get("loudness", {}), } # ── Voice rename ────────────────────────────────────────────────────────────── @router.post("/api/voice/rename") async def rename_voice(request: Request): data = await request.json() old_id: str = data["old_id"] new_id: str = data["new_id"].strip() if not re.match(r"^[A-Za-z0-9_\-\.]+$", new_id): raise HTTPException(400, "Invalid voice ID characters") settings = _load_settings() scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) wav = _find_voice_audio(old_id, scan_dir) if wav is None: raise HTTPException(404, f"Voice '{old_id}' not found") parent = wav.parent if any((parent / f"{new_id}{ext}").exists() for ext in _AUDIO_EXTS): raise HTTPException(409, f"Voice '{new_id}' already exists") from core.voice import _backup_path, _legacy_backup_path meta = _load_meta(wav) backup = next((p for p in _backup_candidates(wav, meta) if p.exists()), None) new_audio = parent / f"{new_id}{wav.suffix.lower()}" new_backup = _backup_path(new_audio) if backup and backup.exists() and backup.resolve() != new_backup.resolve() and new_backup.exists(): raise HTTPException(409, f"Backup already exists for '{new_id}'") suffixes = _AUDIO_EXTS + [".reference.txt", ".meta.json"] + _PICTURE_EXTS for sfx in suffixes: src = parent / f"{old_id}{sfx}" if src.exists(): src.rename(parent / f"{new_id}{sfx}") if backup and backup.exists(): if backup.resolve() != new_backup.resolve(): backup.rename(new_backup) # Also rename TTS config assets if mounted tts_config = Path("/tts-config") if tts_config.exists(): old_pt = tts_config / "speakers" / f"{old_id}.pt" if old_pt.exists(): old_pt.rename(tts_config / "speakers" / f"{new_id}.pt") old_seeds = tts_config / "seed_samples" / old_id if old_seeds.exists() and old_seeds.is_dir(): old_seeds.rename(tts_config / "seed_samples" / new_id) new_meta = _load_meta(new_audio) if backup and backup.exists(): new_meta["original_backup"] = str(new_backup) new_meta["needs_tts_restart"] = True _save_meta(new_audio, new_meta) return {"new_id": new_id, "path": str(new_audio), "file_type": new_audio.suffix.lower().lstrip(".")} # ── Voice delete ────────────────────────────────────────────────────────────── @router.post("/api/voices/delete-group") async def delete_voice_group(request: Request): """Delete every voice whose meta `group` matches the given name. Used to bulk-remove a rehearser play's auto-designed voices.""" data = await request.json() group = str(data.get("group") or "").strip() if not group: raise HTTPException(400, "No group specified") settings = _load_settings() scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) deleted_ids = [] for p in _voice_audio_files(scan_dir): meta = _load_meta(p) if str(meta.get("group") or "").strip() == group: for f in _voice_package_paths(p): if f.exists(): f.unlink() deleted_ids.append(p.stem) return {"deleted": deleted_ids, "count": len(deleted_ids)} @router.delete("/api/voice/{voice_id}") async def delete_voice(voice_id: str): if not re.match(r"^[A-Za-z0-9_\-\.]+$", voice_id): raise HTTPException(400, "Invalid voice ID") settings = _load_settings() scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) wav = _find_voice_audio(voice_id, scan_dir) if wav is None: raise HTTPException(404, f"Voice '{voice_id}' not found") deleted = [] for f in _voice_package_paths(wav): if f.exists(): f.unlink() deleted.append(f.name) return {"deleted": deleted} # ── Voice picture upload / serve ────────────────────────────────────────────── @router.post("/api/voice/picture") async def upload_picture(voice_id: str = Form(...), file: UploadFile = File(...)): settings = _load_settings() scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) wav = _find_voice_audio(voice_id, scan_dir) if wav is None: raise HTTPException(404, f"Voice '{voice_id}' not found") orig_suffix = Path(file.filename or "photo.jpg").suffix.lower() if orig_suffix not in _PICTURE_EXTS: orig_suffix = ".jpg" for ext in _PICTURE_EXTS: old = wav.with_suffix(ext) if old.exists(): old.unlink() dest = wav.with_suffix(orig_suffix) with dest.open("wb") as f: _copy_limited(file.file, f, _MAX_PICTURE_BYTES) return {"ok": True, "path": str(dest)} @router.post("/api/voice/picture-url") async def upload_picture_url(request: Request): import requests as req_lib from urllib.parse import urlparse data = await request.json() voice_id = str(data.get("voice_id") or "").strip() image_url = str(data.get("image_url") or "").strip() if not image_url or not re.match(r"^https?://", image_url, re.I): raise HTTPException(400, "Expected an http(s) URL") if not voice_id or not re.match(r"^[A-Za-z0-9_\-\.]+$", voice_id): raise HTTPException(400, "Invalid voice ID") settings = _load_settings() scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) wav = _find_voice_audio(voice_id, scan_dir) if wav is None: raise HTTPException(404, f"Voice '{voice_id}' not found") suffix = Path(urlparse(image_url).path).suffix.lower() if suffix not in _PICTURE_EXTS: suffix = ".jpg" dest = wav.with_suffix(suffix) _VOICE_SOURCE_HEADERS = { "User-Agent": "TTS Voice Creator voice-source-scraper/1.0 (+local)", "Accept": "text/html,application/json,text/plain;q=0.9,*/*;q=0.8", } try: with req_lib.get(image_url, headers=_VOICE_SOURCE_HEADERS, timeout=20, stream=True) as r: r.raise_for_status() content_type = (r.headers.get("content-type") or "").split(";", 1)[0].lower() if content_type and not content_type.startswith("image/"): raise HTTPException(400, "Image URL did not return an image") for ext in _PICTURE_EXTS: old = wav.with_suffix(ext) if old.exists(): old.unlink() total = 0 with dest.open("wb") as f: for chunk in r.iter_content(256 * 1024): if not chunk: continue total += len(chunk) if total > _MAX_PICTURE_BYTES: raise HTTPException(413, "Image file is too large") f.write(chunk) except HTTPException: raise except Exception as e: raise HTTPException(400, f"Image import failed: {e}") return {"ok": True, "voice_id": voice_id, "path": str(dest)} @router.get("/api/voice/picture/{voice_id}") async def serve_picture(voice_id: str): settings = _load_settings() scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) wav = _find_voice_audio(voice_id, scan_dir) if wav is None: raise HTTPException(404, "Voice not found") pic = _picture_path(wav) if pic is None: raise HTTPException(404, "No picture") return FileResponse(str(pic), media_type=_picture_mime(pic)) @router.get("/api/voice-file") async def voice_file(path: str): settings = _load_settings() scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) p = _safe_child_path(scan_dir, Path(path)) if not p.exists() or not p.is_file(): raise HTTPException(404, "File not found") if p.suffix.lower() not in _AUDIO_EXTS: raise HTTPException(404, "File not found") mime = _AUDIO_MIME.get(p.suffix.lower(), "audio/wav") return FileResponse( str(p), media_type=mime, headers={"Cache-Control": "no-store, max-age=0"}, )