vois to text
This commit is contained in:
@@ -23,6 +23,7 @@ WORKDIR /app
|
|||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
aria2 \
|
aria2 \
|
||||||
|
ffmpeg \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy wheels from builder and install
|
# Copy wheels from builder and install
|
||||||
|
|||||||
@@ -89,6 +89,11 @@ class VoskModelManager:
|
|||||||
if wf.getnchannels() != 1:
|
if wf.getnchannels() != 1:
|
||||||
raise ValueError("Audio must be mono (1 channel)")
|
raise ValueError("Audio must be mono (1 channel)")
|
||||||
recognizer = KaldiRecognizer(self.model, wf.getframerate())
|
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:
|
while True:
|
||||||
data = wf.readframes(4000)
|
data = wf.readframes(4000)
|
||||||
if not data:
|
if not data:
|
||||||
@@ -98,3 +103,32 @@ class VoskModelManager:
|
|||||||
return result.get("text", "")
|
return result.get("text", "")
|
||||||
except wave.Error as e:
|
except wave.Error as e:
|
||||||
raise ValueError(f"Invalid WAV file: {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}")
|
||||||
|
|||||||
69
api/lib/diarize_embeddings.py
Normal file
69
api/lib/diarize_embeddings.py
Normal 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
236
api/lib/transcript_utils.py
Normal 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
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from api.route import default, subscription
|
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.subscription import init_db
|
||||||
#from api.db.connection import wait_for_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(subscription.router)
|
||||||
#app.include_router(users.router)
|
#app.include_router(users.router)
|
||||||
app.include_router(installing.router)
|
app.include_router(installing.router)
|
||||||
app.include_router(convert_from_mp3.router)
|
app.include_router(media_convert.router)
|
||||||
app.include_router(convert_mp3_to_text.router)
|
app.include_router(mp3_ffmpeg_stream.router)
|
||||||
|
|||||||
@@ -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
342
api/route/media_convert.py
Normal 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)}
|
||||||
@@ -103,6 +103,10 @@ async def mp3_to_text_ffmpeg(req: Mp3ToTextRequest):
|
|||||||
recognizer = None
|
recognizer = None
|
||||||
try:
|
try:
|
||||||
recognizer = KaldiRecognizer(mgr.model, 16000)
|
recognizer = KaldiRecognizer(mgr.model, 16000)
|
||||||
|
try:
|
||||||
|
recognizer.SetWords(True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
bytes_read = 0
|
bytes_read = 0
|
||||||
total = None
|
total = None
|
||||||
try:
|
try:
|
||||||
Reference in New Issue
Block a user