From 8764b4dbc00557f07ff2050daab651992c4ae5b2 Mon Sep 17 00:00:00 2001 From: jze9 Date: Thu, 19 Feb 2026 20:17:47 +0500 Subject: [PATCH] vois to text --- Dockerfile.api | 1 + api/lib/audio_processing.py | 34 ++ api/lib/diarize_embeddings.py | 69 ++++ api/lib/transcript_utils.py | 236 ++++++++++++ api/main.py | 7 +- api/route/convert_from_mp3.py | 185 ---------- api/route/media_convert.py | 342 ++++++++++++++++++ ...rt_mp3_to_text.py => mp3_ffmpeg_stream.py} | 4 + 8 files changed, 690 insertions(+), 188 deletions(-) create mode 100644 api/lib/diarize_embeddings.py create mode 100644 api/lib/transcript_utils.py delete mode 100644 api/route/convert_from_mp3.py create mode 100644 api/route/media_convert.py rename api/route/{convert_mp3_to_text.py => mp3_ffmpeg_stream.py} (97%) diff --git a/Dockerfile.api b/Dockerfile.api index ec5f441..94b4164 100644 --- a/Dockerfile.api +++ b/Dockerfile.api @@ -23,6 +23,7 @@ WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ aria2 \ + ffmpeg \ && rm -rf /var/lib/apt/lists/* # Copy wheels from builder and install diff --git a/api/lib/audio_processing.py b/api/lib/audio_processing.py index 8dc4dbd..fef3e38 100644 --- a/api/lib/audio_processing.py +++ b/api/lib/audio_processing.py @@ -89,6 +89,11 @@ class VoskModelManager: if wf.getnchannels() != 1: raise ValueError("Audio must be mono (1 channel)") recognizer = KaldiRecognizer(self.model, wf.getframerate()) + try: + recognizer.SetWords(True) + except Exception: + # older vosk bindings may not expose SetWords; ignore if absent + pass while True: data = wf.readframes(4000) if not data: @@ -98,3 +103,32 @@ class VoskModelManager: return result.get("text", "") except wave.Error as e: raise ValueError(f"Invalid WAV file: {e}") + + def transcribe_wav_with_words(self, wav_path: Path): + """Transcribe WAV and return dict with 'text' and 'words' (word-level timestamps). + + Returns: { 'text': str, 'words': [{'word': str, 'start': float, 'end': float}, ...] } + """ + 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()) + try: + recognizer.SetWords(True) + except Exception: + pass + while True: + data = wf.readframes(4000) + if not data: + break + recognizer.AcceptWaveform(data) + res = json.loads(recognizer.FinalResult()) + words = res.get("result", []) + # words are dictionaries with word, start, end + text = res.get("text", "") + return {"text": text, "words": words} + except wave.Error as e: + raise ValueError(f"Invalid WAV file: {e}") diff --git a/api/lib/diarize_embeddings.py b/api/lib/diarize_embeddings.py new file mode 100644 index 0000000..8aeb5e3 --- /dev/null +++ b/api/lib/diarize_embeddings.py @@ -0,0 +1,69 @@ +from pathlib import Path +from typing import List, Dict + +def diarize_with_resemblyzer(wav_path: Path, window_s: float = 1.5, hop_s: float = 0.75, distance_threshold: float = 0.6) -> List[Dict]: + """Lightweight embedding-based diarization using resemblyzer + sklearn. + + Returns list of segments: {'start': float, 'end': float, 'speaker': 'spk_N'} + """ + try: + import numpy as np + import librosa + from resemblyzer import VoiceEncoder + from sklearn.cluster import AgglomerativeClustering + except Exception as e: + raise RuntimeError(f"Missing dependency for embedding diarization: {e}") + + sr = 16000 + wav, sr_loaded = librosa.load(str(wav_path), sr=sr) + n = wav.shape[0] + win = int(window_s * sr) + hop = int(hop_s * sr) + if n <= win: + # short file: embed whole + encoder = VoiceEncoder() + emb = encoder.embed_utterance(wav) + labels = [0] + centers = [ (0.0 + n / sr) / 2.0 ] + windows = [(0.0, n / sr)] + else: + encoder = VoiceEncoder() + embeddings = [] + centers = [] + windows = [] + for start in range(0, n - win + 1, hop): + chunk = wav[start:start+win] + try: + e = encoder.embed_utterance(chunk) + except Exception: + # fallback: mean pooling + e = np.mean(chunk) + embeddings.append(e) + t0 = start / sr + t1 = (start + win) / sr + centers.append((t0 + t1) / 2.0) + windows.append((t0, t1)) + + X = np.vstack(embeddings) + # Agglomerative clustering with distance threshold + model = AgglomerativeClustering(n_clusters=None, distance_threshold=distance_threshold, affinity='euclidean', linkage='average') + labels = model.fit_predict(X) + + # merge consecutive windows with same label into segments + segs = [] + if len(labels) == 0: + return segs + cur_label = labels[0] + cur_start = windows[0][0] + cur_end = windows[0][1] + for i, lab in enumerate(labels[1:], start=1): + if lab == cur_label: + cur_end = windows[i][1] + else: + segs.append({"start": cur_start, "end": cur_end, "speaker": f"spk_{int(cur_label)+1}"}) + cur_label = lab + cur_start = windows[i][0] + cur_end = windows[i][1] + segs.append({"start": cur_start, "end": cur_end, "speaker": f"spk_{int(cur_label)+1}"}) + + return segs diff --git a/api/lib/transcript_utils.py b/api/lib/transcript_utils.py new file mode 100644 index 0000000..0f70498 --- /dev/null +++ b/api/lib/transcript_utils.py @@ -0,0 +1,236 @@ +from pathlib import Path +import wave +import json +import math +from typing import List, Dict + + +def _rms(frames: bytes, sample_width: int) -> float: + # compute RMS of raw frames (little-endian signed samples) + if sample_width == 2: + import struct + + count = len(frames) // 2 + if count == 0: + return 0.0 + fmt = "<%dh" % count + vals = struct.unpack(fmt, frames) + s = 0 + for v in vals: + s += v * v + return math.sqrt(s / count) + else: + return 0.0 + + +def diarize_by_silence(wav_path: Path, win_ms: int = 30, silence_thresh: float = 500.0, min_silence_ms: int = 400) -> List[Dict]: + """Naive diarization by splitting on long silence. + + Returns list of segments: {'start': float, 'end': float, 'speaker': str} + Speakers are assigned alternately: Speaker 1, Speaker 2, ... + """ + segs = [] + with wave.open(str(wav_path), "rb") as wf: + sr = wf.getframerate() + sw = wf.getsampwidth() + n_channels = wf.getnchannels() + # we expect mono + frames_per_win = int(sr * win_ms / 1000) + min_silence_frames = int(sr * min_silence_ms / 1000) + total_frames = wf.getnframes() + pos = 0 + silent_acc = 0 + current_start = 0 + speaker_idx = 1 + + while pos < total_frames: + to_read = min(frames_per_win, total_frames - pos) + raw = wf.readframes(to_read) + pos += to_read + level = _rms(raw, sw) + if level < silence_thresh: + silent_acc += to_read + else: + # if there was long silence before speech, start a new segment + if silent_acc >= min_silence_frames and pos / sr > current_start: + end_time = (pos - silent_acc) / sr + segs.append({"start": current_start, "end": end_time, "speaker": f"Speaker {speaker_idx}"}) + speaker_idx = (speaker_idx % 2) + 1 + current_start = end_time + silent_acc = 0 + + # finish last segment + if current_start < total_frames / sr: + segs.append({"start": current_start, "end": total_frames / sr, "speaker": f"Speaker {speaker_idx}"}) + + return segs + + +def assign_words_to_speakers(words: List[Dict], segments: List[Dict]) -> List[Dict]: + """Assign word dicts (with start/end) to speaker segments. Returns list of speaker blocks with text.""" + result = [] + seg_idx = 0 + current_block = {"speaker": segments[0]["speaker"] if segments else "Speaker 1", "start": None, "end": None, "words": []} + + for w in words: + t = w.get("start", 0) + # move seg_idx until this word falls into segment + while seg_idx + 1 < len(segments) and t >= segments[seg_idx]["end"]: + # flush current + if current_block["words"]: + current_block["end"] = segments[seg_idx]["end"] + result.append(current_block) + seg_idx += 1 + current_block = {"speaker": segments[seg_idx]["speaker"], "start": None, "end": None, "words": []} + + # append word + if current_block["start"] is None: + current_block["start"] = w.get("start") + current_block["words"].append(w) + + if current_block["words"]: + # set end + current_block["end"] = current_block["words"][-1].get("end") + result.append(current_block) + + return result + + +def chapter_by_wordcount(words: List[Dict], speaker_blocks: List[Dict] = None, words_per_chapter: int = 100, min_words_on_speaker_change: int = 30) -> List[Dict]: + """Chaptering that prefers boundaries at speaker changes. + + - If `speaker_blocks` provided, will try to split a chapter when speaker changes + and the current chapter has at least `min_words_on_speaker_change` words. + - Also split when `words_per_chapter` is reached. + Returns list of chapters: {'start': float, 'end': float, 'words': [...]}. + """ + chapters = [] + chapter = {"start": None, "end": None, "words": []} + count = 0 + + def speaker_for_time(t: float): + if not speaker_blocks or t is None: + return None + for b in speaker_blocks: + bstart = b.get("start") + bend = b.get("end") + if bstart is None or bend is None: + continue + try: + if bstart <= t <= bend: + return b.get("speaker") + except Exception: + continue + return None + + prev_speaker = None + for w in words: + wstart = w.get("start") + if chapter["start"] is None: + chapter["start"] = wstart + chapter["words"].append(w) + chapter["end"] = w.get("end") + count += 1 + + # check speaker change boundary + cur_speaker = speaker_for_time(wstart) + if prev_speaker is None: + prev_speaker = cur_speaker + + if cur_speaker is not None and prev_speaker is not None and cur_speaker != prev_speaker: + if count >= min_words_on_speaker_change: + # close chapter at previous word + chapters.append(chapter) + chapter = {"start": None, "end": None, "words": []} + count = 0 + prev_speaker = cur_speaker + continue + else: + # don't split if too short to make a chapter + prev_speaker = cur_speaker + + # check fixed-size boundary + if count >= words_per_chapter: + chapters.append(chapter) + chapter = {"start": None, "end": None, "words": []} + count = 0 + prev_speaker = None + + if chapter["words"]: + chapters.append(chapter) + return chapters + + +def save_transcript(text_dir: Path, base_name: str, speaker_blocks: List[Dict], chapters: List[Dict]): + text_dir.mkdir(parents=True, exist_ok=True) + out_json = text_dir / f"{base_name}.json" + out_txt = text_dir / f"{base_name}.txt" + # Automatic host labeling: speaker with most words -> 'Ведущий', others -> 'Участник N' + try: + # Prefer selecting host by total spoken duration (better for long monologues). + durations = [] + for b in speaker_blocks: + dur = 0.0 + for w in b.get("words", []): + s = w.get("start") + e = w.get("end") + if s is None or e is None: + continue + try: + dur += float(e) - float(s) + except Exception: + continue + durations.append(dur) + + if any(durations): + host_idx = int(max(range(len(durations)), key=lambda i: durations[i])) + else: + # fallback to word counts if timestamps missing + counts = [sum(1 for w in b.get("words", [])) for b in speaker_blocks] + host_idx = int(max(range(len(counts)), key=lambda i: counts[i])) if counts else 0 + + new_blocks = [] + participant_idx = 1 + for i, b in enumerate(speaker_blocks): + b_copy = dict(b) + if i == host_idx: + b_copy["speaker"] = "Ведущий" + else: + b_copy["speaker"] = f"Участник {participant_idx}" + participant_idx += 1 + new_blocks.append(b_copy) + speaker_blocks = new_blocks + except Exception: + # fall back to provided labels on error + pass + + data = {"speakers": speaker_blocks, "chapters": chapters} + with open(out_json, "w", encoding="utf-8") as fh: + json.dump(data, fh, ensure_ascii=False, indent=2) + + # plain text with speaker labels and chapter headings + with open(out_txt, "w", encoding="utf-8") as fh: + for i, ch in enumerate(chapters, start=1): + fh.write(f"=== Глава {i} ===\n") + # collect speaker text overlapping this chapter by word membership + # build a simple view: iterate speaker blocks and print their words that fall into chapter + for b in speaker_blocks: + # collect words in this chapter that belong to this speaker block + b_words = [] + for w in ch["words"]: + # a word belongs to speaker block if its start is within block start/end (if available) + wstart = w.get("start") + if wstart is None: + continue + bstart = b.get("start") + bend = b.get("end") + if bstart is None or bend is None: + continue + if bstart <= wstart <= bend: + b_words.append(w["word"]) + if b_words: + fh.write(f"{b['speaker']}: ") + fh.write(" ".join(b_words) + "\n") + fh.write("\n") + + return out_json, out_txt diff --git a/api/main.py b/api/main.py index 45f826b..58389f7 100644 --- a/api/main.py +++ b/api/main.py @@ -1,7 +1,8 @@ import asyncio from fastapi import FastAPI from api.route import default, subscription -from api.route import users, installing, convert_from_mp3, convert_mp3_to_text +# renamed route modules for clarity +from api.route import users, installing, media_convert, mp3_ffmpeg_stream #from api.db.subscription import init_db #from api.db.connection import wait_for_db @@ -16,5 +17,5 @@ app.include_router(default.router) #app.include_router(subscription.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) +app.include_router(media_convert.router) +app.include_router(mp3_ffmpeg_stream.router) diff --git a/api/route/convert_from_mp3.py b/api/route/convert_from_mp3.py deleted file mode 100644 index 86e4c47..0000000 --- a/api/route/convert_from_mp3.py +++ /dev/null @@ -1,185 +0,0 @@ -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 -# moviepy is imported lazily inside the conversion function to avoid -# failing module import at application startup when the package is missing. - -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") - - -# New: convert a video file (located in ./data) to mp3 using bundled imageio-ffmpeg -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): - # ensure MoviePy is available before doing work, return clear 503 if not - 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: - # run blocking MoviePy conversion in threadpool to avoid blocking event loop - 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") - - -# 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`. diff --git a/api/route/media_convert.py b/api/route/media_convert.py new file mode 100644 index 0000000..292841d --- /dev/null +++ b/api/route/media_convert.py @@ -0,0 +1,342 @@ +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)} diff --git a/api/route/convert_mp3_to_text.py b/api/route/mp3_ffmpeg_stream.py similarity index 97% rename from api/route/convert_mp3_to_text.py rename to api/route/mp3_ffmpeg_stream.py index c261510..dc2c03c 100644 --- a/api/route/convert_mp3_to_text.py +++ b/api/route/mp3_ffmpeg_stream.py @@ -103,6 +103,10 @@ async def mp3_to_text_ffmpeg(req: Mp3ToTextRequest): recognizer = None try: recognizer = KaldiRecognizer(mgr.model, 16000) + try: + recognizer.SetWords(True) + except Exception: + pass bytes_read = 0 total = None try: