vois to text

This commit is contained in:
2026-02-19 20:17:47 +05:00
parent ee644609b8
commit 8764b4dbc0
8 changed files with 690 additions and 188 deletions

View File

@@ -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}")

View File

@@ -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

236
api/lib/transcript_utils.py Normal file
View File

@@ -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

View File

@@ -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)

View File

@@ -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`.

342
api/route/media_convert.py Normal file
View File

@@ -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)}

View File

@@ -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: