Files
LLM-infa/api/route/media_convert.py
2026-03-01 21:42:05 +05:00

345 lines
12 KiB
Python

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")
# MoviePy video-to-audio endpoints moved to api/route/moviepy.py
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)}