lite-start
This commit is contained in:
100
api/lib/audio_processing.py
Normal file
100
api/lib/audio_processing.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from pathlib import Path
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import logging
|
||||
import wave
|
||||
import json
|
||||
import subprocess
|
||||
import shutil
|
||||
|
||||
try:
|
||||
from vosk import Model, KaldiRecognizer
|
||||
except Exception:
|
||||
Model = None
|
||||
KaldiRecognizer = None
|
||||
|
||||
|
||||
class FFmpegConverter:
|
||||
"""Utility to convert audio files using ffmpeg."""
|
||||
|
||||
@staticmethod
|
||||
def convert_mp3_to_wav(src: Path, dest: Path) -> None:
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(src),
|
||||
"-ac",
|
||||
"1",
|
||||
"-ar",
|
||||
"16000",
|
||||
"-vn",
|
||||
"-f",
|
||||
"wav",
|
||||
str(dest),
|
||||
]
|
||||
subprocess.run(cmd, check=True)
|
||||
|
||||
|
||||
class VoskModelManager:
|
||||
"""Loads a Vosk model and provides transcription methods.
|
||||
|
||||
Loading captures model stderr into Python logs to show progress.
|
||||
"""
|
||||
|
||||
def __init__(self, model_path: Path):
|
||||
self.logger = logging.getLogger("vosk.model_manager")
|
||||
if Model is None or KaldiRecognizer is None:
|
||||
raise RuntimeError("vosk is not installed in runtime")
|
||||
self.model = self._load_model_with_progress(model_path)
|
||||
|
||||
def _load_model_with_progress(self, path: Path):
|
||||
logger = logging.getLogger("vosk.loader")
|
||||
r_fd, w_fd = os.pipe()
|
||||
saved_stderr_fd = os.dup(sys.stderr.fileno())
|
||||
os.dup2(w_fd, sys.stderr.fileno())
|
||||
|
||||
def _reader(fd):
|
||||
with os.fdopen(fd, "rb") as fh:
|
||||
for raw in iter(fh.readline, b""):
|
||||
try:
|
||||
logger.info(raw.decode(errors="ignore").rstrip())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
reader_thread = threading.Thread(target=_reader, args=(r_fd,), daemon=True)
|
||||
reader_thread.start()
|
||||
|
||||
try:
|
||||
model = Model(str(path))
|
||||
finally:
|
||||
try:
|
||||
os.dup2(saved_stderr_fd, sys.stderr.fileno())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
os.close(saved_stderr_fd)
|
||||
except Exception:
|
||||
pass
|
||||
reader_thread.join(timeout=2)
|
||||
|
||||
return model
|
||||
|
||||
def transcribe_wav(self, wav_path: Path) -> str:
|
||||
if not wav_path.exists():
|
||||
raise FileNotFoundError(wav_path)
|
||||
try:
|
||||
with wave.open(str(wav_path), "rb") as wf:
|
||||
if wf.getnchannels() != 1:
|
||||
raise ValueError("Audio must be mono (1 channel)")
|
||||
recognizer = KaldiRecognizer(self.model, wf.getframerate())
|
||||
while True:
|
||||
data = wf.readframes(4000)
|
||||
if not data:
|
||||
break
|
||||
recognizer.AcceptWaveform(data)
|
||||
result = json.loads(recognizer.FinalResult())
|
||||
return result.get("text", "")
|
||||
except wave.Error as e:
|
||||
raise ValueError(f"Invalid WAV file: {e}")
|
||||
@@ -1,7 +1,7 @@
|
||||
import asyncio
|
||||
from fastapi import FastAPI
|
||||
from api.route import default, subscription
|
||||
from api.route import users, installing, convert_from_mp3
|
||||
from api.route import users, installing, convert_from_mp3, convert_mp3_to_text
|
||||
#from api.db.subscription import init_db
|
||||
#from api.db.connection import wait_for_db
|
||||
|
||||
@@ -17,3 +17,4 @@ app.include_router(default.router)
|
||||
#app.include_router(users.router)
|
||||
app.include_router(installing.router)
|
||||
app.include_router(convert_from_mp3.router)
|
||||
app.include_router(convert_mp3_to_text.router)
|
||||
|
||||
@@ -6,6 +6,9 @@ from pathlib import Path
|
||||
import uuid
|
||||
from typing import List
|
||||
import asyncio
|
||||
import subprocess
|
||||
import shutil
|
||||
import uuid
|
||||
# moviepy is imported lazily inside the conversion function to avoid
|
||||
# failing module import at application startup when the package is missing.
|
||||
|
||||
@@ -85,4 +88,98 @@ async def video_to_mp3(req: VideoToMp3Request):
|
||||
return FileResponse(str(dest), filename=dest.name, media_type="audio/mpeg")
|
||||
|
||||
|
||||
# Convert an existing MP3 (in data/audio) to WAV and run transcription
|
||||
class Mp3ToTextRequest(BaseModel):
|
||||
filename: str
|
||||
model_path: str
|
||||
|
||||
|
||||
def _run_ffmpeg(src: Path, dest: Path):
|
||||
# produce mono 16k WAV suitable for Vosk
|
||||
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}")
|
||||
|
||||
# Use class-based transcriber
|
||||
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)
|
||||
# Resolve model path (relative to project root or absolute) and ensure it's inside ./models
|
||||
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)
|
||||
text = mgr.transcribe_wav(dest)
|
||||
logger.info("Transcription stage finished for %s", dest.name)
|
||||
finally:
|
||||
try:
|
||||
dest.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"text": text}
|
||||
|
||||
|
||||
# Note: this endpoint uses the bundled ffmpeg provided by `imageio-ffmpeg`.
|
||||
|
||||
134
api/route/convert_mp3_to_text.py
Normal file
134
api/route/convert_mp3_to_text.py
Normal file
@@ -0,0 +1,134 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import shutil
|
||||
import json
|
||||
import logging
|
||||
|
||||
try:
|
||||
from vosk import Model, KaldiRecognizer
|
||||
except Exception:
|
||||
Model = None
|
||||
KaldiRecognizer = None
|
||||
|
||||
router = APIRouter(prefix="/convert", tags=["convert"])
|
||||
|
||||
|
||||
class Mp3ToTextRequest(BaseModel):
|
||||
filename: str
|
||||
model_path: str
|
||||
|
||||
|
||||
@router.post("/mp3-to-text-ffmpeg")
|
||||
async def mp3_to_text_ffmpeg(req: Mp3ToTextRequest):
|
||||
"""Convert MP3 -> PCM via ffmpeg (stdout) and transcribe with Vosk.
|
||||
|
||||
This endpoint requires `ffmpeg` available in PATH. It decodes the input
|
||||
MP3 to raw PCM s16le 16k mono and streams it into Vosk recognizer.
|
||||
"""
|
||||
logger = logging.getLogger("convert.mp3")
|
||||
|
||||
if Model is None or KaldiRecognizer is None:
|
||||
raise HTTPException(status_code=503, detail="vosk is not installed in runtime")
|
||||
|
||||
project_root = Path(__file__).resolve().parent.parent.parent
|
||||
audio_dir = project_root / "data" / "audio"
|
||||
models_dir = project_root / "models"
|
||||
|
||||
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")
|
||||
|
||||
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}")
|
||||
|
||||
if shutil.which("ffmpeg") is None:
|
||||
raise HTTPException(status_code=503, detail="ffmpeg is not available in the runtime. Install ffmpeg to use this endpoint.")
|
||||
|
||||
try:
|
||||
from api.lib.audio_processing import FFmpegConverter, VoskModelManager
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Internal import error: {e}")
|
||||
|
||||
if shutil.which("ffmpeg") is None:
|
||||
raise HTTPException(status_code=503, detail="ffmpeg is not available in the runtime. Install ffmpeg to use this endpoint.")
|
||||
|
||||
# Stream decode via ffmpeg to temporary process and feed recognizer
|
||||
try:
|
||||
mgr = VoskModelManager(model_path)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to load Vosk model: {e}")
|
||||
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
str(src),
|
||||
"-f",
|
||||
"s16le",
|
||||
"-acodec",
|
||||
"pcm_s16le",
|
||||
"-ac",
|
||||
"1",
|
||||
"-ar",
|
||||
"16000",
|
||||
"-",
|
||||
]
|
||||
|
||||
logger.info("Starting ffmpeg streaming decode")
|
||||
try:
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to start ffmpeg: {e}")
|
||||
|
||||
if proc.stdout is None:
|
||||
proc.kill()
|
||||
raise HTTPException(status_code=500, detail="ffmpeg did not provide stdout")
|
||||
|
||||
recognizer = None
|
||||
try:
|
||||
recognizer = KaldiRecognizer(mgr.model, 16000)
|
||||
bytes_read = 0
|
||||
total = None
|
||||
try:
|
||||
total = src.stat().st_size
|
||||
except Exception:
|
||||
total = None
|
||||
|
||||
last_pct = 0
|
||||
while True:
|
||||
chunk = proc.stdout.read(4000)
|
||||
if not chunk:
|
||||
break
|
||||
recognizer.AcceptWaveform(chunk)
|
||||
bytes_read += len(chunk)
|
||||
if total:
|
||||
pct = int(bytes_read * 100 / total)
|
||||
if pct - last_pct >= 5:
|
||||
last_pct = pct
|
||||
logger.info("Decoding+transcription progress: %s%%", pct)
|
||||
|
||||
result = json.loads(recognizer.FinalResult())
|
||||
text = result.get("text", "")
|
||||
logger.info("Transcription finished; result length=%d", len(text))
|
||||
return {"text": text}
|
||||
finally:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
Reference in New Issue
Block a user