from fastapi import APIRouter, HTTPException from pydantic import BaseModel from fastapi.responses import FileResponse import os from pathlib import Path import uuid from typing import List import asyncio import subprocess import shutil import uuid import wave router = APIRouter(prefix="/convert", tags=["convert"]) @router.get("/list-audio", response_model=List[str]) async def list_audio_files(): base_dir = Path(__file__).resolve().parent.parent.parent / "data" / "audio" if not base_dir.exists(): return [] files = [f.name for f in base_dir.iterdir() if f.is_file()] return files class DownloadAudioRequest(BaseModel): filename: str @router.post("/download-audio") async def download_audio_file(data: DownloadAudioRequest): base_dir = Path(__file__).resolve().parent.parent.parent / "data" / "audio" src = (base_dir / data.filename).resolve() try: src.relative_to(base_dir.parent) except Exception: raise HTTPException(status_code=400, detail="Invalid filename") if not src.exists(): raise HTTPException(status_code=404, detail="File not found") return FileResponse(str(src), filename=src.name, media_type="audio/mpeg") class VideoToMp3Request(BaseModel): filename: str def _run_moviepy(src: Path, dest: Path): from moviepy import VideoFileClip clip = VideoFileClip(str(src)) try: if clip.audio is None: raise RuntimeError("Source file has no audio stream") clip.audio.write_audiofile(str(dest), codec="libmp3lame", bitrate="192k") finally: clip.close() @router.post("/video-to-mp3") async def video_to_mp3(req: VideoToMp3Request): try: import moviepy # noqa: F401 except Exception: raise HTTPException(status_code=503, detail="moviepy is not installed in the runtime. Rebuild image or install moviepy/imageio-ffmpeg") data_dir = Path(__file__).resolve().parent.parent.parent / "data" src = (data_dir / req.filename).resolve() try: src.relative_to(data_dir) except Exception: raise HTTPException(status_code=400, detail="Invalid filename") if not src.exists() or not src.is_file(): raise HTTPException(status_code=404, detail="Source file not found") out_dir = data_dir / "audio" out_dir.mkdir(parents=True, exist_ok=True) dest = out_dir / f"{src.stem}.mp3" try: await asyncio.get_running_loop().run_in_executor(None, _run_moviepy, src, dest) except Exception as e: raise HTTPException(status_code=500, detail=f"conversion failed: {e}") return FileResponse(str(dest), filename=dest.name, media_type="audio/mpeg") class VideoToWavRequest(BaseModel): filename: str def _run_moviepy_to_wav(src: Path, dest: Path): from moviepy import VideoFileClip clip = VideoFileClip(str(src)) try: if clip.audio is None: raise RuntimeError("Source file has no audio stream") clip.audio.write_audiofile(str(dest), fps=16000, nbytes=2) finally: clip.close() @router.post("/video-to-wav") async def video_to_wav(req: VideoToWavRequest): try: import moviepy # noqa: F401 except Exception: raise HTTPException(status_code=503, detail="moviepy is not installed in the runtime. Rebuild image or install moviepy/imageio-ffmpeg") data_dir = Path(__file__).resolve().parent.parent.parent / "data" src = (data_dir / req.filename).resolve() try: src.relative_to(data_dir) except Exception: raise HTTPException(status_code=400, detail="Invalid filename") if not src.exists() or not src.is_file(): raise HTTPException(status_code=404, detail="Source file not found") out_dir = data_dir / "audio" out_dir.mkdir(parents=True, exist_ok=True) dest = out_dir / f"{src.stem}.wav" try: await asyncio.get_running_loop().run_in_executor(None, _run_moviepy_to_wav, src, dest) except Exception as e: raise HTTPException(status_code=500, detail=f"conversion failed: {e}") return FileResponse(str(dest), filename=dest.name, media_type="audio/wav") class Mp3ToTextRequest(BaseModel): filename: str model_path: str def _run_ffmpeg(src: Path, dest: Path): cmd = [ "ffmpeg", "-y", "-i", str(src), "-ac", "1", "-ar", "16000", "-vn", "-f", "wav", str(dest), ] subprocess.run(cmd, check=True) @router.post("/mp3-to-text") async def mp3_to_text(req: Mp3ToTextRequest): logger = __import__("logging").getLogger("convert.mp3-to-text") try: if shutil.which("ffmpeg") is None: raise HTTPException(status_code=503, detail="ffmpeg is not available in the runtime. Install ffmpeg in the environment/container.") except Exception: raise HTTPException(status_code=503, detail="ffmpeg is not available in the runtime. Install ffmpeg in the environment/container.") data_dir = Path(__file__).resolve().parent.parent.parent / "data" audio_dir = data_dir / "audio" src = (audio_dir / req.filename).resolve() try: src.relative_to(audio_dir) except Exception: raise HTTPException(status_code=400, detail="Invalid filename") if not src.exists() or not src.is_file(): raise HTTPException(status_code=404, detail="Source file not found") audio_dir.mkdir(parents=True, exist_ok=True) dest = audio_dir / f"{src.stem}_{uuid.uuid4().hex}.wav" try: logger.info("Starting ffmpeg conversion %s -> %s", src.name, dest.name) await asyncio.get_running_loop().run_in_executor(None, _run_ffmpeg, src, dest) logger.info("ffmpeg conversion finished") except subprocess.CalledProcessError as e: raise HTTPException(status_code=500, detail=f"ffmpeg conversion failed: {e}") except Exception as e: raise HTTPException(status_code=500, detail=f"conversion failed: {e}") try: from api.lib.audio_processing import VoskModelManager except Exception as e: try: dest.unlink() except Exception: pass raise HTTPException(status_code=500, detail=f"Internal import error: {e}") try: logger.info("Starting transcription stage for %s", dest.name) project_root = Path(__file__).resolve().parent.parent.parent models_dir = project_root / "models" model_path = Path(req.model_path) if not model_path.is_absolute(): model_path = project_root / req.model_path model_path = model_path.resolve() try: model_path.relative_to(models_dir) except Exception: raise HTTPException(status_code=400, detail="model_path must point inside the project's models directory") if not model_path.exists(): raise HTTPException(status_code=400, detail=f"Model path not found: {model_path}") mgr = VoskModelManager(model_path) res = mgr.transcribe_wav_with_words(dest) text = res.get("text", "") # Save plain transcript (no speaker split, no chapters) text_dir = project_root / "data" / "text" text_dir.mkdir(parents=True, exist_ok=True) base_name = dest.stem json_path = text_dir / f"{base_name}.json" txt_path = text_dir / f"{base_name}.txt" with open(json_path, "w", encoding="utf-8") as fh: import json as _json _json.dump({"text": text}, fh, ensure_ascii=False, indent=2) with open(txt_path, "w", encoding="utf-8") as fh: fh.write(text) logger.info("Transcription stage finished for %s", dest.name) finally: try: dest.unlink() except Exception: pass return {"text": text} class WavToTextRequest(BaseModel): filename: str model_path: str use_embedding: bool = False @router.post("/wav-to-text") async def wav_to_text(req: WavToTextRequest): logger = __import__("logging").getLogger("convert.wav-to-text") data_dir = Path(__file__).resolve().parent.parent.parent / "data" audio_dir = data_dir / "audio" src = (audio_dir / req.filename).resolve() try: src.relative_to(audio_dir) except Exception: raise HTTPException(status_code=400, detail="Invalid filename") if not src.exists() or not src.is_file(): raise HTTPException(status_code=404, detail="Source file not found") try: from api.lib.audio_processing import VoskModelManager except Exception as e: raise HTTPException(status_code=500, detail=f"Internal import error: {e}") project_root = Path(__file__).resolve().parent.parent.parent models_dir = project_root / "models" model_path = Path(req.model_path) if not model_path.is_absolute(): model_path = project_root / req.model_path model_path = model_path.resolve() try: model_path.relative_to(models_dir) except Exception: raise HTTPException(status_code=400, detail="model_path must point inside the project's models directory") if not model_path.exists(): raise HTTPException(status_code=400, detail=f"Model path not found: {model_path}") temp_dest = None to_cleanup = False try: if shutil.which("ffmpeg"): temp_dest = audio_dir / f"{src.stem}_mono_{uuid.uuid4().hex}.wav" cmd = [ "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(src), "-ac", "1", "-ar", "16000", "-sample_fmt", "s16", str(temp_dest), ] try: logger.info("Normalizing WAV via ffmpeg: %s -> %s", src.name, temp_dest.name) subprocess.run(cmd, check=True) src_for_transcription = temp_dest to_cleanup = True except subprocess.CalledProcessError as e: raise HTTPException(status_code=500, detail=f"ffmpeg normalization failed: {e}") else: try: with wave.open(str(src), "rb") as wf: channels = wf.getnchannels() rate = wf.getframerate() sampwidth = wf.getsampwidth() if channels != 1 or rate != 16000 or sampwidth != 2: raise HTTPException(status_code=415, detail=("WAV must be mono PCM16 16kHz. " "Install ffmpeg in the runtime or convert the file before calling this endpoint.")) src_for_transcription = src except wave.Error as e: raise HTTPException(status_code=400, detail=f"Invalid WAV file: {e}") mgr = VoskModelManager(model_path) res = mgr.transcribe_wav_with_words(src_for_transcription) text = res.get("text", "") words = res.get("words", []) # Save plain transcript (no speaker split, no chapters) text_dir = project_root / "data" / "text" text_dir.mkdir(parents=True, exist_ok=True) base_name = src.stem json_path = text_dir / f"{base_name}.json" txt_path = text_dir / f"{base_name}.txt" import json as _json with open(json_path, "w", encoding="utf-8") as fh: _json.dump({"text": text}, fh, ensure_ascii=False, indent=2) with open(txt_path, "w", encoding="utf-8") as fh: fh.write(text) logger.info("WAV transcription finished: %s", src.name) except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Transcription failed: {e}") finally: if to_cleanup and temp_dest is not None: try: temp_dest.unlink() except Exception: pass return {"text": text, "json": str(json_path.name), "txt": str(txt_path.name)}