Upload 143 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +13 -0
- Dockerfile +23 -0
- app/__init__.py +12 -0
- app/__pycache__/__init__.cpython-312.pyc +0 -0
- app/__pycache__/__init__.cpython-314.pyc +0 -0
- app/__pycache__/_cuda_bootstrap.cpython-312.pyc +0 -0
- app/__pycache__/_cuda_bootstrap.cpython-314.pyc +0 -0
- app/__pycache__/asr.cpython-312.pyc +0 -0
- app/__pycache__/asr.cpython-314.pyc +0 -0
- app/__pycache__/audio_source.cpython-312.pyc +0 -0
- app/__pycache__/audio_utils.cpython-312.pyc +0 -0
- app/__pycache__/audio_utils.cpython-314.pyc +0 -0
- app/__pycache__/chunking.cpython-312.pyc +0 -0
- app/__pycache__/chunking.cpython-314.pyc +0 -0
- app/__pycache__/config.cpython-312.pyc +0 -0
- app/__pycache__/config.cpython-314.pyc +0 -0
- app/__pycache__/embed.cpython-312.pyc +0 -0
- app/__pycache__/embed.cpython-314.pyc +0 -0
- app/__pycache__/ingest.cpython-312.pyc +0 -0
- app/__pycache__/ingest.cpython-314.pyc +0 -0
- app/__pycache__/manifest.cpython-312.pyc +0 -0
- app/__pycache__/manifest.cpython-314.pyc +0 -0
- app/__pycache__/models.cpython-312.pyc +0 -0
- app/__pycache__/models.cpython-314.pyc +0 -0
- app/__pycache__/query.cpython-312.pyc +0 -0
- app/__pycache__/query.cpython-314.pyc +0 -0
- app/__pycache__/rerank.cpython-312.pyc +0 -0
- app/__pycache__/rerank.cpython-314.pyc +0 -0
- app/__pycache__/server.cpython-312.pyc +0 -0
- app/__pycache__/server.cpython-314.pyc +0 -0
- app/__pycache__/store.cpython-312.pyc +0 -0
- app/__pycache__/store.cpython-314.pyc +0 -0
- app/__pycache__/translate.cpython-314.pyc +0 -0
- app/_cuda_bootstrap.py +88 -0
- app/asr.py +209 -0
- app/audio_source.py +36 -0
- app/audio_utils.py +56 -0
- app/chunking.py +124 -0
- app/config.py +190 -0
- app/embed.py +53 -0
- app/ingest.py +164 -0
- app/manifest.py +139 -0
- app/models.py +86 -0
- app/query.py +112 -0
- app/rerank.py +44 -0
- app/server.py +135 -0
- app/store.py +223 -0
- app/translate.py +78 -0
- app/web/app.js +197 -0
- app/web/index.html +46 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,16 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
lancedb/passages.lance/_indices/1bf8e725-d7c9-40b1-ba8f-f87711c6f146/part_0_invert.lance filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
lancedb/passages.lance/_indices/4036b9ca-15fe-43b1-ada3-eb7603ddda14/part_0_invert.lance filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
lancedb/passages.lance/_indices/8c701121-c985-485b-aaac-a1ff6805ad15/part_0_invert.lance filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
lancedb/passages.lance/_indices/bc5cf2ff-eef5-41f3-8c32-e623b0771590/part_0_invert.lance filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
lancedb/passages.lance/_indices/d0ae91ba-acf5-44fb-8863-fc9efad5ef09/part_1_invert.lance filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
lancedb/passages.lance/_indices/f9943ee7-cbfc-4972-9b4f-c5737473b88a/part_0_invert.lance filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
lancedb/passages.lance/_indices/fe5c2cf5-6aad-44b5-8308-a08422c6a259/part_0_invert.lance filter=lfs diff=lfs merge=lfs -text
|
| 43 |
+
lancedb/passages.lance/data/000101001101011011001000d12f72422994e6d4750671d1cb.lance filter=lfs diff=lfs merge=lfs -text
|
| 44 |
+
lancedb/passages.lance/data/000111110111000011000010fb6b6343448c22c242e8a7e820.lance filter=lfs diff=lfs merge=lfs -text
|
| 45 |
+
lancedb/passages.lance/data/1001010110001010001010117460c543a7ab8135cc05eb0a67.lance filter=lfs diff=lfs merge=lfs -text
|
| 46 |
+
lancedb/passages.lance/data/100110110110110110000101ab4f8a403c88cf44f5dfcc16e2.lance filter=lfs diff=lfs merge=lfs -text
|
| 47 |
+
lancedb/passages.lance/data/1110110000011010101111109886fb45d299593d03bffd0230.lance filter=lfs diff=lfs merge=lfs -text
|
| 48 |
+
lancedb/passages.lance/data/1111001101101101110100007dd4244f748f751fd19c94e429.lance filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Slim online SearchAudio image (CPU only). Build context = project root:
|
| 2 |
+
# docker build -f deploy/Dockerfile -t searchaudio-online .
|
| 3 |
+
FROM python:3.11-slim
|
| 4 |
+
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
ENV HF_HOME=/app/models \
|
| 7 |
+
HF_HUB_CACHE=/app/models/hub \
|
| 8 |
+
SEARCHAUDIO_AUDIO_MODE=archive \
|
| 9 |
+
PYTHONUNBUFFERED=1
|
| 10 |
+
|
| 11 |
+
COPY requirements-online.txt .
|
| 12 |
+
RUN pip install --no-cache-dir -r requirements-online.txt
|
| 13 |
+
|
| 14 |
+
# Bake bge-m3 into the image so cold starts don't download it; then run offline.
|
| 15 |
+
RUN python -c "from FlagEmbedding import BGEM3FlagModel; BGEM3FlagModel('BAAI/bge-m3', use_fp16=False)"
|
| 16 |
+
ENV HF_HUB_OFFLINE=1
|
| 17 |
+
|
| 18 |
+
COPY app ./app
|
| 19 |
+
COPY config.yaml ./config.yaml
|
| 20 |
+
COPY deploy/index/lancedb ./data/index/lancedb
|
| 21 |
+
|
| 22 |
+
EXPOSE 7860
|
| 23 |
+
CMD ["uvicorn", "app.server:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
|
app/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SearchAudio — fully-local semantic search over long Hindi audio talks.
|
| 2 |
+
|
| 3 |
+
Type an English sentence (or speak/upload a Hindi clip) and jump straight to the
|
| 4 |
+
moment in a recording where that topic is discussed. Everything runs offline on a
|
| 5 |
+
single Windows PC with an NVIDIA GPU.
|
| 6 |
+
|
| 7 |
+
Pipeline: audio -> ffmpeg normalize -> WhisperX ASR (word timestamps) ->
|
| 8 |
+
sentence-packed chunks -> bge-m3 embeddings -> LanceDB index.
|
| 9 |
+
Query: text/Hindi-audio -> bge-m3 -> LanceDB hybrid search -> bge-reranker -> results.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
__version__ = "0.1.0"
|
app/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (728 Bytes). View file
|
|
|
app/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (730 Bytes). View file
|
|
|
app/__pycache__/_cuda_bootstrap.cpython-312.pyc
ADDED
|
Binary file (3.86 kB). View file
|
|
|
app/__pycache__/_cuda_bootstrap.cpython-314.pyc
ADDED
|
Binary file (4.4 kB). View file
|
|
|
app/__pycache__/asr.cpython-312.pyc
ADDED
|
Binary file (11.6 kB). View file
|
|
|
app/__pycache__/asr.cpython-314.pyc
ADDED
|
Binary file (12 kB). View file
|
|
|
app/__pycache__/audio_source.cpython-312.pyc
ADDED
|
Binary file (3.01 kB). View file
|
|
|
app/__pycache__/audio_utils.cpython-312.pyc
ADDED
|
Binary file (2.75 kB). View file
|
|
|
app/__pycache__/audio_utils.cpython-314.pyc
ADDED
|
Binary file (3.12 kB). View file
|
|
|
app/__pycache__/chunking.cpython-312.pyc
ADDED
|
Binary file (6.74 kB). View file
|
|
|
app/__pycache__/chunking.cpython-314.pyc
ADDED
|
Binary file (7.81 kB). View file
|
|
|
app/__pycache__/config.cpython-312.pyc
ADDED
|
Binary file (8.39 kB). View file
|
|
|
app/__pycache__/config.cpython-314.pyc
ADDED
|
Binary file (10.8 kB). View file
|
|
|
app/__pycache__/embed.cpython-312.pyc
ADDED
|
Binary file (2.98 kB). View file
|
|
|
app/__pycache__/embed.cpython-314.pyc
ADDED
|
Binary file (3.51 kB). View file
|
|
|
app/__pycache__/ingest.cpython-312.pyc
ADDED
|
Binary file (8.92 kB). View file
|
|
|
app/__pycache__/ingest.cpython-314.pyc
ADDED
|
Binary file (9.86 kB). View file
|
|
|
app/__pycache__/manifest.cpython-312.pyc
ADDED
|
Binary file (8.03 kB). View file
|
|
|
app/__pycache__/manifest.cpython-314.pyc
ADDED
|
Binary file (10.1 kB). View file
|
|
|
app/__pycache__/models.cpython-312.pyc
ADDED
|
Binary file (4.43 kB). View file
|
|
|
app/__pycache__/models.cpython-314.pyc
ADDED
|
Binary file (5.58 kB). View file
|
|
|
app/__pycache__/query.cpython-312.pyc
ADDED
|
Binary file (6.04 kB). View file
|
|
|
app/__pycache__/query.cpython-314.pyc
ADDED
|
Binary file (6.88 kB). View file
|
|
|
app/__pycache__/rerank.cpython-312.pyc
ADDED
|
Binary file (2.72 kB). View file
|
|
|
app/__pycache__/rerank.cpython-314.pyc
ADDED
|
Binary file (3.25 kB). View file
|
|
|
app/__pycache__/server.cpython-312.pyc
ADDED
|
Binary file (6.95 kB). View file
|
|
|
app/__pycache__/server.cpython-314.pyc
ADDED
|
Binary file (7.84 kB). View file
|
|
|
app/__pycache__/store.cpython-312.pyc
ADDED
|
Binary file (12.4 kB). View file
|
|
|
app/__pycache__/store.cpython-314.pyc
ADDED
|
Binary file (14.5 kB). View file
|
|
|
app/__pycache__/translate.cpython-314.pyc
ADDED
|
Binary file (5.32 kB). View file
|
|
|
app/_cuda_bootstrap.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Make Windows able to find the CUDA / cuDNN DLLs that CTranslate2 needs.
|
| 2 |
+
|
| 3 |
+
The single most common failure when running faster-whisper / WhisperX on Windows is:
|
| 4 |
+
|
| 5 |
+
RuntimeError: Library cudnn_ops64_9.dll is not found ...
|
| 6 |
+
(or: Could not locate cudnn_ops_infer64_8.dll)
|
| 7 |
+
|
| 8 |
+
It happens because CTranslate2 loads cuDNN/cuBLAS by name via the OS loader, but the
|
| 9 |
+
DLLs live inside pip wheels (``nvidia-cudnn-cu12`` / ``nvidia-cublas-cu12``) or inside
|
| 10 |
+
``torch/lib`` (the cu128 wheel bundles them) — directories that are NOT on the default
|
| 11 |
+
DLL search path.
|
| 12 |
+
|
| 13 |
+
Importing this module **first** — before importing ``ctranslate2``, ``faster_whisper``
|
| 14 |
+
or ``whisperx`` — registers those directories with the OS loader using
|
| 15 |
+
``os.add_dll_directory`` (and prepends them to PATH as a belt-and-suspenders fallback).
|
| 16 |
+
|
| 17 |
+
Usage::
|
| 18 |
+
|
| 19 |
+
from app import _cuda_bootstrap # noqa: F401 (must come first)
|
| 20 |
+
from faster_whisper import WhisperModel # now finds cuDNN
|
| 21 |
+
|
| 22 |
+
This is a no-op on non-Windows platforms.
|
| 23 |
+
"""
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import importlib.util
|
| 27 |
+
import os
|
| 28 |
+
import sys
|
| 29 |
+
|
| 30 |
+
# cuDNN must come last so its directory is searched first (add_dll_directory is LIFO-ish
|
| 31 |
+
# in practice via PATH ordering); the order here is the order we *append* candidates.
|
| 32 |
+
_NVIDIA_PKGS = (
|
| 33 |
+
"nvidia.cuda_runtime",
|
| 34 |
+
"nvidia.cuda_nvrtc",
|
| 35 |
+
"nvidia.cublas",
|
| 36 |
+
"nvidia.cudnn",
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _add_dll_dir(path: str) -> bool:
|
| 41 |
+
if not path or not os.path.isdir(path):
|
| 42 |
+
return False
|
| 43 |
+
try:
|
| 44 |
+
os.add_dll_directory(path) # type: ignore[attr-defined] (Windows only)
|
| 45 |
+
except (OSError, AttributeError):
|
| 46 |
+
return False
|
| 47 |
+
# Some libraries resolve via LoadLibrary(name) which consults PATH, not the
|
| 48 |
+
# add_dll_directory list — so prepend to PATH too.
|
| 49 |
+
if path not in os.environ.get("PATH", ""):
|
| 50 |
+
os.environ["PATH"] = path + os.pathsep + os.environ.get("PATH", "")
|
| 51 |
+
return True
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _candidate_dirs() -> list[str]:
|
| 55 |
+
dirs: list[str] = []
|
| 56 |
+
for pkg in _NVIDIA_PKGS:
|
| 57 |
+
try:
|
| 58 |
+
spec = importlib.util.find_spec(pkg)
|
| 59 |
+
except (ImportError, ValueError, ModuleNotFoundError):
|
| 60 |
+
spec = None
|
| 61 |
+
if spec and spec.submodule_search_locations:
|
| 62 |
+
base = list(spec.submodule_search_locations)[0]
|
| 63 |
+
# Windows wheels put DLLs in /bin, Linux in /lib — add both, harmless if absent.
|
| 64 |
+
dirs.append(os.path.join(base, "bin"))
|
| 65 |
+
dirs.append(os.path.join(base, "lib"))
|
| 66 |
+
# torch's bundled CUDA libs (the cu128 wheel ships cudnn/cublas DLLs here).
|
| 67 |
+
try:
|
| 68 |
+
import torch # noqa: WPS433 (local import on purpose)
|
| 69 |
+
|
| 70 |
+
dirs.append(os.path.join(os.path.dirname(torch.__file__), "lib"))
|
| 71 |
+
except Exception: # noqa: BLE001 — torch may not be importable yet; ignore.
|
| 72 |
+
pass
|
| 73 |
+
return dirs
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def setup_cuda_dll_path() -> list[str]:
|
| 77 |
+
"""Register CUDA/cuDNN DLL directories with the Windows loader. Returns dirs added."""
|
| 78 |
+
if sys.platform != "win32":
|
| 79 |
+
return []
|
| 80 |
+
added: list[str] = []
|
| 81 |
+
for d in _candidate_dirs():
|
| 82 |
+
if _add_dll_dir(d):
|
| 83 |
+
added.append(d)
|
| 84 |
+
return added
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# Run on import. Safe to import multiple times.
|
| 88 |
+
ADDED_DLL_DIRS = setup_cuda_dll_path()
|
app/asr.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Speech-to-text with word-level timestamps, via WhisperX (faster-whisper backend).
|
| 2 |
+
|
| 3 |
+
WhisperX gives us three things in one pipeline:
|
| 4 |
+
1. Silero VAD to slice a 1h+ talk into speech regions (kills long-form hallucination).
|
| 5 |
+
2. faster-whisper / CTranslate2 large-v3 transcription (Hindi, batched, on the GPU).
|
| 6 |
+
3. wav2vec2 **forced alignment** -> per-word [start, end] accurate to ~tens of ms,
|
| 7 |
+
which is what makes "jump to the exact moment" reliable.
|
| 8 |
+
|
| 9 |
+
Blackwell / RTX 50-series note: use compute_type="float16". int8 crashes with
|
| 10 |
+
CUBLAS_STATUS_NOT_SUPPORTED on sm_120; 12 GB VRAM fits float16 large-v3 comfortably.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
# IMPORTANT: register CUDA/cuDNN DLL dirs BEFORE importing ctranslate2-backed libs.
|
| 15 |
+
from app import _cuda_bootstrap # noqa: F401 (side-effect import, must be first)
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
import wave
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
from typing import Any, Dict, List, Optional
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
|
| 24 |
+
from app.config import Config, get_config
|
| 25 |
+
|
| 26 |
+
SAMPLE_RATE = 16000
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def read_wav_mono16k(path: str | Path) -> "np.ndarray":
|
| 30 |
+
"""Read a 16 kHz mono PCM WAV into a float32 array in [-1, 1].
|
| 31 |
+
|
| 32 |
+
We always pre-normalize inputs to this exact format with ffmpeg (audio_utils), so we
|
| 33 |
+
can decode with the stdlib instead of depending on ffmpeg-on-PATH / torchaudio codecs.
|
| 34 |
+
"""
|
| 35 |
+
with wave.open(str(path), "rb") as w:
|
| 36 |
+
n_channels = w.getnchannels()
|
| 37 |
+
sampwidth = w.getsampwidth()
|
| 38 |
+
frames = w.readframes(w.getnframes())
|
| 39 |
+
if sampwidth != 2:
|
| 40 |
+
raise ValueError(f"expected 16-bit PCM wav, got sample width {sampwidth} bytes")
|
| 41 |
+
audio = np.frombuffer(frames, dtype=np.int16)
|
| 42 |
+
if n_channels > 1:
|
| 43 |
+
audio = audio.reshape(-1, n_channels).mean(axis=1)
|
| 44 |
+
return np.ascontiguousarray(audio.astype(np.float32) / 32768.0)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class Transcriber:
|
| 48 |
+
"""Lazy-loading wrapper around WhisperX. Load once, reuse for the whole run."""
|
| 49 |
+
|
| 50 |
+
def __init__(self, cfg: Optional[Config] = None):
|
| 51 |
+
self.cfg = cfg or get_config()
|
| 52 |
+
self._model = None
|
| 53 |
+
self._align_model = None
|
| 54 |
+
self._align_meta = None
|
| 55 |
+
self._align_lang: Optional[str] = None
|
| 56 |
+
self.device = self.cfg.asr["device"]
|
| 57 |
+
self.compute_type = self.cfg.asr["compute_type"]
|
| 58 |
+
|
| 59 |
+
# ---- model loading -------------------------------------------------
|
| 60 |
+
def _load_model(self):
|
| 61 |
+
if self._model is not None:
|
| 62 |
+
return self._model
|
| 63 |
+
import whisperx
|
| 64 |
+
|
| 65 |
+
a = self.cfg.asr
|
| 66 |
+
asr_options = {
|
| 67 |
+
"beam_size": a["beam_size"],
|
| 68 |
+
"condition_on_previous_text": a["condition_on_previous_text"],
|
| 69 |
+
}
|
| 70 |
+
# whisperx.load_model signatures differ slightly across versions; pass what we can.
|
| 71 |
+
kwargs: Dict[str, Any] = dict(
|
| 72 |
+
device=self.device,
|
| 73 |
+
compute_type=self.compute_type,
|
| 74 |
+
language=a["language"],
|
| 75 |
+
asr_options=asr_options,
|
| 76 |
+
)
|
| 77 |
+
if a.get("vad", True):
|
| 78 |
+
# Newer whisperx exposes vad_method; older uses default VAD. Try, then fall back.
|
| 79 |
+
try:
|
| 80 |
+
self._model = whisperx.load_model(a["model"], vad_method="silero", **kwargs)
|
| 81 |
+
except TypeError:
|
| 82 |
+
self._model = whisperx.load_model(a["model"], **kwargs)
|
| 83 |
+
else:
|
| 84 |
+
self._model = whisperx.load_model(a["model"], **kwargs)
|
| 85 |
+
return self._model
|
| 86 |
+
|
| 87 |
+
def _load_align(self, language: str):
|
| 88 |
+
if self._align_model is not None and self._align_lang == language:
|
| 89 |
+
return self._align_model, self._align_meta
|
| 90 |
+
import whisperx
|
| 91 |
+
|
| 92 |
+
model_name = self.cfg.asr.get("align_model") # None -> whisperx default for the lang
|
| 93 |
+
self._align_model, self._align_meta = whisperx.load_align_model(
|
| 94 |
+
language_code=language, device=self.device, model_name=model_name
|
| 95 |
+
)
|
| 96 |
+
self._align_lang = language
|
| 97 |
+
return self._align_model, self._align_meta
|
| 98 |
+
|
| 99 |
+
# ---- transcription -------------------------------------------------
|
| 100 |
+
def transcribe(self, wav_path: str | Path) -> Dict[str, Any]:
|
| 101 |
+
"""Full transcription + word alignment. Returns a transcript dict."""
|
| 102 |
+
import whisperx
|
| 103 |
+
|
| 104 |
+
wav_path = str(wav_path)
|
| 105 |
+
model = self._load_model()
|
| 106 |
+
audio = read_wav_mono16k(wav_path)
|
| 107 |
+
result = model.transcribe(
|
| 108 |
+
audio, batch_size=self.cfg.asr["batch_size"], language=self.cfg.asr["language"]
|
| 109 |
+
)
|
| 110 |
+
language = result.get("language", self.cfg.asr["language"])
|
| 111 |
+
|
| 112 |
+
if self.cfg.asr.get("align", True) and result.get("segments"):
|
| 113 |
+
try:
|
| 114 |
+
align_model, meta = self._load_align(language)
|
| 115 |
+
result = whisperx.align(
|
| 116 |
+
result["segments"],
|
| 117 |
+
align_model,
|
| 118 |
+
meta,
|
| 119 |
+
audio,
|
| 120 |
+
self.device,
|
| 121 |
+
return_char_alignments=False,
|
| 122 |
+
)
|
| 123 |
+
except Exception as exc: # noqa: BLE001
|
| 124 |
+
print(f"[asr] alignment failed ({exc}); falling back to segment timestamps")
|
| 125 |
+
result["segments"] = _segments_to_pseudo_words(result["segments"])
|
| 126 |
+
else:
|
| 127 |
+
result["segments"] = _segments_to_pseudo_words(result.get("segments", []))
|
| 128 |
+
|
| 129 |
+
duration = float(len(audio)) / 16000.0
|
| 130 |
+
return {
|
| 131 |
+
"language": language,
|
| 132 |
+
"duration": duration,
|
| 133 |
+
"segments": _clean_segments(result.get("segments", [])),
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
def transcribe_query(self, wav_path: str | Path) -> str:
|
| 137 |
+
"""Fast path for a short query clip: just the Hindi text, no alignment."""
|
| 138 |
+
import whisperx
|
| 139 |
+
|
| 140 |
+
model = self._load_model()
|
| 141 |
+
audio = read_wav_mono16k(wav_path)
|
| 142 |
+
result = model.transcribe(audio, batch_size=self.cfg.asr["batch_size"], language=self.cfg.asr["language"])
|
| 143 |
+
return " ".join(seg.get("text", "").strip() for seg in result.get("segments", [])).strip()
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# ---- helpers -----------------------------------------------------------
|
| 147 |
+
def _segments_to_pseudo_words(segments: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 148 |
+
"""When alignment is unavailable, synthesize one 'word' per segment spanning it.
|
| 149 |
+
|
| 150 |
+
Search still works; jump-to-moment lands within a few seconds instead of ms.
|
| 151 |
+
"""
|
| 152 |
+
out = []
|
| 153 |
+
for seg in segments:
|
| 154 |
+
if "start" not in seg or "end" not in seg:
|
| 155 |
+
continue
|
| 156 |
+
text = (seg.get("text") or "").strip()
|
| 157 |
+
seg = dict(seg)
|
| 158 |
+
seg["words"] = [{"word": text, "start": seg["start"], "end": seg["end"]}]
|
| 159 |
+
out.append(seg)
|
| 160 |
+
return out
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _clean_segments(segments: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 164 |
+
"""Keep only well-formed words (numeric start/end). Backfill missing word times."""
|
| 165 |
+
cleaned: List[Dict[str, Any]] = []
|
| 166 |
+
for seg in segments:
|
| 167 |
+
words_in = seg.get("words") or []
|
| 168 |
+
words_out: List[Dict[str, Any]] = []
|
| 169 |
+
# Pass 1: keep words that already have numeric timing.
|
| 170 |
+
last_end = seg.get("start")
|
| 171 |
+
for w in words_in:
|
| 172 |
+
start = w.get("start")
|
| 173 |
+
end = w.get("end")
|
| 174 |
+
token = (w.get("word") or w.get("text") or "").strip()
|
| 175 |
+
if not token:
|
| 176 |
+
continue
|
| 177 |
+
if isinstance(start, (int, float)) and isinstance(end, (int, float)):
|
| 178 |
+
words_out.append({"word": token, "start": float(start), "end": float(end)})
|
| 179 |
+
last_end = end
|
| 180 |
+
if not words_out:
|
| 181 |
+
# whole segment had no aligned words -> use the segment span
|
| 182 |
+
s = seg.get("start")
|
| 183 |
+
e = seg.get("end")
|
| 184 |
+
text = (seg.get("text") or "").strip()
|
| 185 |
+
if isinstance(s, (int, float)) and isinstance(e, (int, float)) and text:
|
| 186 |
+
words_out = [{"word": text, "start": float(s), "end": float(e)}]
|
| 187 |
+
if not words_out:
|
| 188 |
+
continue
|
| 189 |
+
cleaned.append(
|
| 190 |
+
{
|
| 191 |
+
"start": words_out[0]["start"],
|
| 192 |
+
"end": words_out[-1]["end"],
|
| 193 |
+
"text": (seg.get("text") or " ".join(w["word"] for w in words_out)).strip(),
|
| 194 |
+
"words": words_out,
|
| 195 |
+
}
|
| 196 |
+
)
|
| 197 |
+
return cleaned
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def save_transcript(transcript: Dict[str, Any], out_path: str | Path) -> None:
|
| 201 |
+
out_path = Path(out_path)
|
| 202 |
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 203 |
+
with open(out_path, "w", encoding="utf-8") as fh:
|
| 204 |
+
json.dump(transcript, fh, ensure_ascii=False, indent=1)
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def load_transcript(path: str | Path) -> Dict[str, Any]:
|
| 208 |
+
with open(path, "r", encoding="utf-8") as fh:
|
| 209 |
+
return json.load(fh)
|
app/audio_source.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build the playable URL for a recording, for local serving or archive.org streaming.
|
| 2 |
+
|
| 3 |
+
- ``local`` mode -> ``/audio/<encoded-source_file>`` (served by StaticFiles from disk).
|
| 4 |
+
- ``archive`` mode -> ``https://archive.org/download/<item>/<path>`` where the local
|
| 5 |
+
``source_file`` (whose ``%5C`` are path separators) is remapped: drop the configured
|
| 6 |
+
local prefix, prepend the configured archive path prefix. Verified to range-seek (206).
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import Optional
|
| 11 |
+
from urllib.parse import quote
|
| 12 |
+
|
| 13 |
+
from app.config import Config, get_config
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _archive_url(source_file: str, arch: dict) -> str:
|
| 17 |
+
rel = source_file.replace("%5C", "/")
|
| 18 |
+
prefix = (arch.get("strip_local_prefix") or "").strip("/")
|
| 19 |
+
if prefix and rel.startswith(prefix + "/"):
|
| 20 |
+
rel = rel[len(prefix) + 1:]
|
| 21 |
+
path_prefix = (arch.get("archive_path_prefix") or "").strip("/")
|
| 22 |
+
path = f"{path_prefix}/{rel}" if path_prefix else rel
|
| 23 |
+
base = (arch.get("base_url") or "https://archive.org/download").rstrip("/")
|
| 24 |
+
item = arch.get("item_id", "")
|
| 25 |
+
parts = ([item] if item else []) + path.split("/")
|
| 26 |
+
return base + "/" + "/".join(quote(seg) for seg in parts)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def audio_url(source_file: str, cfg: Optional[Config] = None) -> str:
|
| 30 |
+
cfg = cfg or get_config()
|
| 31 |
+
audio = cfg.get("audio", {}) or {}
|
| 32 |
+
if audio.get("mode", "local") == "archive":
|
| 33 |
+
return _archive_url(source_file, audio.get("archive", {}) or {})
|
| 34 |
+
route = cfg.server["audio_route"]
|
| 35 |
+
encoded = "/".join(quote(seg) for seg in source_file.split("/"))
|
| 36 |
+
return f"{route}/{encoded}"
|
app/audio_utils.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ffmpeg helpers: locate the binary and normalize audio to 16 kHz mono WAV.
|
| 2 |
+
|
| 3 |
+
Whisper/WhisperX expect 16 kHz mono; normalizing once up front also makes decoding of
|
| 4 |
+
exotic inputs (m4a/opus/wma/video containers) reliable. We always call ffmpeg by an
|
| 5 |
+
absolute path (missing-ffmpeg-on-PATH is the #1 cause of "the audio query does nothing").
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import shutil
|
| 10 |
+
import subprocess
|
| 11 |
+
import sys
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Optional
|
| 14 |
+
|
| 15 |
+
from app.config import get_config
|
| 16 |
+
|
| 17 |
+
AUDIO_EXTS = {
|
| 18 |
+
".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg", ".opus",
|
| 19 |
+
".wma", ".mp4", ".webm", ".mkv", ".mov", ".3gp",
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
_FFMPEG: Optional[str] = None
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def find_ffmpeg() -> str:
|
| 26 |
+
"""Locate ffmpeg: PATH first, then a bundled project ``bin/ffmpeg.exe``."""
|
| 27 |
+
global _FFMPEG
|
| 28 |
+
if _FFMPEG:
|
| 29 |
+
return _FFMPEG
|
| 30 |
+
exe = shutil.which("ffmpeg")
|
| 31 |
+
if not exe:
|
| 32 |
+
binname = "ffmpeg.exe" if sys.platform == "win32" else "ffmpeg"
|
| 33 |
+
bundled = get_config().root / "bin" / binname
|
| 34 |
+
if bundled.exists():
|
| 35 |
+
exe = str(bundled)
|
| 36 |
+
if not exe:
|
| 37 |
+
raise FileNotFoundError(
|
| 38 |
+
"ffmpeg not found. Install it (winget install Gyan.FFmpeg) or place ffmpeg.exe in ./bin/."
|
| 39 |
+
)
|
| 40 |
+
_FFMPEG = exe
|
| 41 |
+
return exe
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def normalize_to_wav(src: str | Path, dst: str | Path) -> Path:
|
| 45 |
+
"""Decode any input to 16 kHz mono 16-bit PCM WAV at ``dst``."""
|
| 46 |
+
dst = Path(dst)
|
| 47 |
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 48 |
+
cmd = [
|
| 49 |
+
find_ffmpeg(), "-y", "-hide_banner", "-loglevel", "error",
|
| 50 |
+
"-i", str(src), "-vn", "-ac", "1", "-ar", "16000",
|
| 51 |
+
"-c:a", "pcm_s16le", "-f", "wav", str(dst),
|
| 52 |
+
]
|
| 53 |
+
proc = subprocess.run(cmd, capture_output=True, text=True)
|
| 54 |
+
if proc.returncode != 0 or not dst.exists():
|
| 55 |
+
raise RuntimeError(f"ffmpeg failed for {src}: {proc.stderr.strip()[:500]}")
|
| 56 |
+
return dst
|
app/chunking.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Turn word-timestamped ASR output into searchable passages.
|
| 2 |
+
|
| 3 |
+
Strategy (robust + precise):
|
| 4 |
+
* Each ASR segment is one "unit" (Whisper/VAD segments already break near natural
|
| 5 |
+
pauses, i.e. roughly sentence boundaries). We never split *across* a unit when
|
| 6 |
+
packing, so passages start/end on sentence-ish boundaries.
|
| 7 |
+
* Units are packed greedily into ~target_seconds windows (hard cap at max_seconds),
|
| 8 |
+
carrying ``overlap_sentences`` trailing units into the next window so a topic that
|
| 9 |
+
straddles a boundary is still findable.
|
| 10 |
+
* A passage's [start_ms, end_ms] always comes from its first/last *word* timestamps,
|
| 11 |
+
so playback seeks to the exact spoken moment.
|
| 12 |
+
* If a single segment is longer than max_seconds (rare; a long VAD region), it is
|
| 13 |
+
split by word timing into target-sized sub-passages.
|
| 14 |
+
|
| 15 |
+
``split_sentences`` (Devanagari/danda-aware, via Indic NLP when available) is exposed for
|
| 16 |
+
reuse (e.g. snippet tidying / translation), with a regex fallback so it never hard-fails.
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import re
|
| 21 |
+
from typing import Any, Dict, List, Optional
|
| 22 |
+
|
| 23 |
+
from app.config import Config, get_config
|
| 24 |
+
from app.models import Passage
|
| 25 |
+
|
| 26 |
+
# Sentence-final punctuation for Hindi (danda, double danda) + Latin.
|
| 27 |
+
_SENT_END = "।॥?!."
|
| 28 |
+
_SENT_SPLIT_RE = re.compile(r"(?<=[।॥?!])\s+|(?<=[.?!])\s+(?=[A-Zऀ-ॿ])")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def split_sentences(text: str) -> List[str]:
|
| 32 |
+
"""Split Hindi/English text into sentences. Uses Indic NLP if installed, else regex."""
|
| 33 |
+
text = (text or "").strip()
|
| 34 |
+
if not text:
|
| 35 |
+
return []
|
| 36 |
+
try:
|
| 37 |
+
from indicnlp.tokenize import sentence_tokenize
|
| 38 |
+
|
| 39 |
+
sents = sentence_tokenize.sentence_split(text, lang="hi")
|
| 40 |
+
sents = [s.strip() for s in sents if s.strip()]
|
| 41 |
+
if sents:
|
| 42 |
+
return sents
|
| 43 |
+
except Exception: # noqa: BLE001 — indicnlp optional / may not init; fall through
|
| 44 |
+
pass
|
| 45 |
+
parts = _SENT_SPLIT_RE.split(text)
|
| 46 |
+
return [p.strip() for p in parts if p.strip()]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _split_long_unit(unit: Dict[str, Any], target_s: float) -> List[Dict[str, Any]]:
|
| 50 |
+
"""Split a too-long unit into ~target_s sub-units by word timing."""
|
| 51 |
+
words = unit["words"]
|
| 52 |
+
out: List[Dict[str, Any]] = []
|
| 53 |
+
buf: List[Dict[str, Any]] = []
|
| 54 |
+
start = words[0]["start"]
|
| 55 |
+
for w in words:
|
| 56 |
+
buf.append(w)
|
| 57 |
+
if w["end"] - start >= target_s:
|
| 58 |
+
out.append({"text": " ".join(x["word"] for x in buf), "words": buf})
|
| 59 |
+
buf = []
|
| 60 |
+
# next sub-unit starts at the next word
|
| 61 |
+
start = w["end"]
|
| 62 |
+
if buf:
|
| 63 |
+
out.append({"text": " ".join(x["word"] for x in buf), "words": buf})
|
| 64 |
+
return out
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _segments_to_units(segments: List[Dict[str, Any]], max_s: float, target_s: float) -> List[Dict[str, Any]]:
|
| 68 |
+
units: List[Dict[str, Any]] = []
|
| 69 |
+
for seg in segments:
|
| 70 |
+
words = seg.get("words") or []
|
| 71 |
+
if not words:
|
| 72 |
+
continue
|
| 73 |
+
dur = words[-1]["end"] - words[0]["start"]
|
| 74 |
+
unit = {"text": (seg.get("text") or " ".join(w["word"] for w in words)).strip(), "words": words}
|
| 75 |
+
if dur > max_s:
|
| 76 |
+
units.extend(_split_long_unit(unit, target_s))
|
| 77 |
+
else:
|
| 78 |
+
units.append(unit)
|
| 79 |
+
return units
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def chunk_transcript(
|
| 83 |
+
transcript: Dict[str, Any],
|
| 84 |
+
recording_id: str,
|
| 85 |
+
source_file: str,
|
| 86 |
+
cfg: Optional[Config] = None,
|
| 87 |
+
) -> List[Passage]:
|
| 88 |
+
cfg = cfg or get_config()
|
| 89 |
+
c = cfg.chunking
|
| 90 |
+
target_s = float(c["target_seconds"])
|
| 91 |
+
max_s = float(c["max_seconds"])
|
| 92 |
+
overlap = int(c["overlap_sentences"])
|
| 93 |
+
|
| 94 |
+
units = _segments_to_units(transcript.get("segments", []), max_s, target_s)
|
| 95 |
+
if not units:
|
| 96 |
+
return []
|
| 97 |
+
|
| 98 |
+
passages: List[Passage] = []
|
| 99 |
+
n = len(units)
|
| 100 |
+
i = 0
|
| 101 |
+
while i < n:
|
| 102 |
+
buf_words: List[Dict[str, Any]] = []
|
| 103 |
+
buf_texts: List[str] = []
|
| 104 |
+
start_time = units[i]["words"][0]["start"]
|
| 105 |
+
j = i
|
| 106 |
+
while j < n:
|
| 107 |
+
u = units[j]
|
| 108 |
+
buf_words.extend(u["words"])
|
| 109 |
+
buf_texts.append(u["text"])
|
| 110 |
+
cur_end = u["words"][-1]["end"]
|
| 111 |
+
j += 1
|
| 112 |
+
if cur_end - start_time >= target_s:
|
| 113 |
+
break # reached target; break on this unit boundary
|
| 114 |
+
text = " ".join(t for t in buf_texts if t).strip()
|
| 115 |
+
if buf_words and text:
|
| 116 |
+
passages.append(Passage.from_words(recording_id, source_file, buf_words, text))
|
| 117 |
+
if j >= n:
|
| 118 |
+
break
|
| 119 |
+
i = max(j - overlap, i + 1) # carry `overlap` trailing units; always progress
|
| 120 |
+
return passages
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def passages_text(passages: List[Passage]) -> List[str]:
|
| 124 |
+
return [p.hindi_text for p in passages]
|
app/config.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Load and resolve configuration from ``config.yaml`` at the project root.
|
| 2 |
+
|
| 3 |
+
Exposes a single ``get_config()`` returning a ``Config`` object with:
|
| 4 |
+
- dotted/dict access to every setting (``cfg.asr["model"]``, ``cfg["asr"]["model"]``)
|
| 5 |
+
- absolute, resolved paths under ``cfg.paths`` (audio_dir, work_dir, index_dir, models_dir)
|
| 6 |
+
- convenience properties for the LanceDB dir and the SQLite manifest path.
|
| 7 |
+
|
| 8 |
+
Defaults are baked in, so the app still runs if a key (or the whole file) is missing.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
from functools import lru_cache
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Any, Dict
|
| 17 |
+
|
| 18 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 19 |
+
CONFIG_PATH = PROJECT_ROOT / "config.yaml"
|
| 20 |
+
|
| 21 |
+
# Minimal defaults mirrored from config.yaml so the system degrades gracefully.
|
| 22 |
+
_DEFAULTS: Dict[str, Any] = {
|
| 23 |
+
"paths": {
|
| 24 |
+
"audio_dir": "data/audio",
|
| 25 |
+
"work_dir": "data/work",
|
| 26 |
+
"index_dir": "data/index",
|
| 27 |
+
"models_dir": "models",
|
| 28 |
+
},
|
| 29 |
+
"asr": {
|
| 30 |
+
"model": "large-v3",
|
| 31 |
+
"device": "cuda",
|
| 32 |
+
"compute_type": "float16",
|
| 33 |
+
"language": "hi",
|
| 34 |
+
"beam_size": 5,
|
| 35 |
+
"batch_size": 8,
|
| 36 |
+
"vad": True,
|
| 37 |
+
"condition_on_previous_text": False,
|
| 38 |
+
"align": True,
|
| 39 |
+
"align_model": None,
|
| 40 |
+
},
|
| 41 |
+
"chunking": {
|
| 42 |
+
"target_seconds": 45,
|
| 43 |
+
"max_seconds": 60,
|
| 44 |
+
"min_seconds": 8,
|
| 45 |
+
"overlap_sentences": 1,
|
| 46 |
+
},
|
| 47 |
+
"embedding": {
|
| 48 |
+
"model": "BAAI/bge-m3",
|
| 49 |
+
"device": "cuda",
|
| 50 |
+
"use_fp16": True,
|
| 51 |
+
"dim": 1024,
|
| 52 |
+
"batch_size": 32,
|
| 53 |
+
"max_length": 1024,
|
| 54 |
+
},
|
| 55 |
+
"reranker": {
|
| 56 |
+
"enabled": True,
|
| 57 |
+
"model": "BAAI/bge-reranker-v2-m3",
|
| 58 |
+
"device": "cuda",
|
| 59 |
+
"use_fp16": True,
|
| 60 |
+
"candidates": 80,
|
| 61 |
+
"batch_size": 16,
|
| 62 |
+
},
|
| 63 |
+
"translation": {
|
| 64 |
+
"enabled": False,
|
| 65 |
+
"model": "ai4bharat/indictrans2-indic-en-1B",
|
| 66 |
+
"device": "cuda",
|
| 67 |
+
},
|
| 68 |
+
"search": {"top_k": 20, "hybrid": True, "vector_weight": 0.7},
|
| 69 |
+
"server": {
|
| 70 |
+
"host": "127.0.0.1",
|
| 71 |
+
"port": 8000,
|
| 72 |
+
"audio_route": "/audio",
|
| 73 |
+
},
|
| 74 |
+
"audio": {
|
| 75 |
+
"mode": "local",
|
| 76 |
+
"archive": {
|
| 77 |
+
"base_url": "https://archive.org/download",
|
| 78 |
+
"item_id": "",
|
| 79 |
+
"strip_local_prefix": "",
|
| 80 |
+
"archive_path_prefix": "",
|
| 81 |
+
},
|
| 82 |
+
},
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
| 87 |
+
out = dict(base)
|
| 88 |
+
for key, val in (override or {}).items():
|
| 89 |
+
if isinstance(val, dict) and isinstance(out.get(key), dict):
|
| 90 |
+
out[key] = _deep_merge(out[key], val)
|
| 91 |
+
else:
|
| 92 |
+
out[key] = val
|
| 93 |
+
return out
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class Config:
|
| 97 |
+
"""Thin wrapper over the merged config dict with resolved absolute paths."""
|
| 98 |
+
|
| 99 |
+
def __init__(self, data: Dict[str, Any]):
|
| 100 |
+
self._data = data
|
| 101 |
+
self.root = PROJECT_ROOT
|
| 102 |
+
# Resolve every path to an absolute Path and ensure the directory exists.
|
| 103 |
+
self.paths = {
|
| 104 |
+
name: self._resolve(rel) for name, rel in data["paths"].items()
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
def _resolve(self, rel: str) -> Path:
|
| 108 |
+
p = Path(rel)
|
| 109 |
+
if not p.is_absolute():
|
| 110 |
+
p = PROJECT_ROOT / p
|
| 111 |
+
return p.resolve()
|
| 112 |
+
|
| 113 |
+
def ensure_dirs(self) -> None:
|
| 114 |
+
for p in self.paths.values():
|
| 115 |
+
p.mkdir(parents=True, exist_ok=True)
|
| 116 |
+
|
| 117 |
+
# ---- access helpers -------------------------------------------------
|
| 118 |
+
def __getitem__(self, key: str) -> Any:
|
| 119 |
+
return self._data[key]
|
| 120 |
+
|
| 121 |
+
def __getattr__(self, name: str) -> Any:
|
| 122 |
+
# Only called for attributes not found normally (so self._data etc. are safe).
|
| 123 |
+
try:
|
| 124 |
+
return self._data[name]
|
| 125 |
+
except KeyError as exc: # pragma: no cover
|
| 126 |
+
raise AttributeError(name) from exc
|
| 127 |
+
|
| 128 |
+
def get(self, key: str, default: Any = None) -> Any:
|
| 129 |
+
return self._data.get(key, default)
|
| 130 |
+
|
| 131 |
+
# ---- convenience paths ---------------------------------------------
|
| 132 |
+
@property
|
| 133 |
+
def lancedb_dir(self) -> Path:
|
| 134 |
+
return self.paths["index_dir"] / "lancedb"
|
| 135 |
+
|
| 136 |
+
@property
|
| 137 |
+
def manifest_path(self) -> Path:
|
| 138 |
+
return self.paths["index_dir"] / "manifest.sqlite"
|
| 139 |
+
|
| 140 |
+
@property
|
| 141 |
+
def audio_dir(self) -> Path:
|
| 142 |
+
return self.paths["audio_dir"]
|
| 143 |
+
|
| 144 |
+
@property
|
| 145 |
+
def work_dir(self) -> Path:
|
| 146 |
+
return self.paths["work_dir"]
|
| 147 |
+
|
| 148 |
+
@property
|
| 149 |
+
def models_dir(self) -> Path:
|
| 150 |
+
return self.paths["models_dir"]
|
| 151 |
+
|
| 152 |
+
def as_dict(self) -> Dict[str, Any]:
|
| 153 |
+
return dict(self._data)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
@lru_cache(maxsize=1)
|
| 157 |
+
def get_config() -> Config:
|
| 158 |
+
data = dict(_DEFAULTS)
|
| 159 |
+
if CONFIG_PATH.exists():
|
| 160 |
+
try:
|
| 161 |
+
import yaml # local import: only needed when a config file is present
|
| 162 |
+
|
| 163 |
+
with open(CONFIG_PATH, "r", encoding="utf-8") as fh:
|
| 164 |
+
loaded = yaml.safe_load(fh) or {}
|
| 165 |
+
data = _deep_merge(_DEFAULTS, loaded)
|
| 166 |
+
except Exception as exc: # noqa: BLE001
|
| 167 |
+
print(f"[config] WARNING: could not read {CONFIG_PATH}: {exc}; using defaults")
|
| 168 |
+
# Deployment can flip audio mode without editing the file (copy audio to avoid
|
| 169 |
+
# mutating the shared _DEFAULTS object).
|
| 170 |
+
env_mode = os.environ.get("SEARCHAUDIO_AUDIO_MODE")
|
| 171 |
+
if env_mode:
|
| 172 |
+
audio = dict(data.get("audio", {}) or {})
|
| 173 |
+
audio["mode"] = env_mode
|
| 174 |
+
data["audio"] = audio
|
| 175 |
+
cfg = Config(data)
|
| 176 |
+
# Point the HF cache at our local models dir so everything stays in the project
|
| 177 |
+
# and works fully offline (after a one-time download).
|
| 178 |
+
os.environ.setdefault("HF_HOME", str(cfg.models_dir))
|
| 179 |
+
os.environ.setdefault("HF_HUB_CACHE", str(cfg.models_dir / "hub"))
|
| 180 |
+
# Windows without Developer Mode/admin can't create symlinks (WinError 1314 during
|
| 181 |
+
# model downloads). Tell huggingface_hub to copy files instead.
|
| 182 |
+
if sys.platform == "win32":
|
| 183 |
+
os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS", "1")
|
| 184 |
+
os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS_WARNING", "1")
|
| 185 |
+
# Put the bundled ffmpeg on PATH so any library that shells out to `ffmpeg` by name
|
| 186 |
+
# (e.g. whisperx.load_audio) can find it, even though it lives in ./bin.
|
| 187 |
+
bin_dir = PROJECT_ROOT / "bin"
|
| 188 |
+
if bin_dir.is_dir() and str(bin_dir) not in os.environ.get("PATH", ""):
|
| 189 |
+
os.environ["PATH"] = str(bin_dir) + os.pathsep + os.environ.get("PATH", "")
|
| 190 |
+
return cfg
|
app/embed.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dense text embeddings via BAAI/bge-m3.
|
| 2 |
+
|
| 3 |
+
bge-m3 maps Hindi passages and an English query into the **same** vector space, so an
|
| 4 |
+
English sentence retrieves Hindi content natively — no query-time translation. It needs
|
| 5 |
+
no "query:"/"passage:" prefix (symmetric), and its dense vectors are unit-normalized, so
|
| 6 |
+
cosine == dot product.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import List, Optional
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
from app.config import Config, get_config
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class Embedder:
|
| 18 |
+
def __init__(self, cfg: Optional[Config] = None):
|
| 19 |
+
self.cfg = cfg or get_config()
|
| 20 |
+
self._model = None
|
| 21 |
+
|
| 22 |
+
def _load(self):
|
| 23 |
+
if self._model is not None:
|
| 24 |
+
return self._model
|
| 25 |
+
from FlagEmbedding import BGEM3FlagModel
|
| 26 |
+
|
| 27 |
+
e = self.cfg.embedding
|
| 28 |
+
# FlagEmbedding auto-selects CUDA when available; use_fp16 ~halves VRAM and ~2x speed.
|
| 29 |
+
self._model = BGEM3FlagModel(e["model"], use_fp16=bool(e["use_fp16"]))
|
| 30 |
+
return self._model
|
| 31 |
+
|
| 32 |
+
def embed_passages(self, texts: List[str]) -> np.ndarray:
|
| 33 |
+
"""Return an (N, dim) float32 array of dense embeddings."""
|
| 34 |
+
if not texts:
|
| 35 |
+
return np.zeros((0, self.cfg.embedding["dim"]), dtype=np.float32)
|
| 36 |
+
model = self._load()
|
| 37 |
+
e = self.cfg.embedding
|
| 38 |
+
out = model.encode(
|
| 39 |
+
texts,
|
| 40 |
+
batch_size=e["batch_size"],
|
| 41 |
+
max_length=e["max_length"],
|
| 42 |
+
return_dense=True,
|
| 43 |
+
return_sparse=False,
|
| 44 |
+
return_colbert_vecs=False,
|
| 45 |
+
)
|
| 46 |
+
vecs = np.asarray(out["dense_vecs"], dtype=np.float32)
|
| 47 |
+
if vecs.ndim == 1:
|
| 48 |
+
vecs = vecs.reshape(1, -1)
|
| 49 |
+
return np.ascontiguousarray(vecs)
|
| 50 |
+
|
| 51 |
+
def embed_query(self, text: str) -> np.ndarray:
|
| 52 |
+
"""Return a single (dim,) float32 vector for a query string."""
|
| 53 |
+
return self.embed_passages([text])[0]
|
app/ingest.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""End-to-end ingestion: audio folder -> searchable index. Resumable & incremental.
|
| 2 |
+
|
| 3 |
+
Per file: normalize (ffmpeg) -> transcribe+align (WhisperX) -> chunk -> [translate]
|
| 4 |
+
-> embed (bge-m3) -> upsert (LanceDB).
|
| 5 |
+
|
| 6 |
+
Resumability: the SQLite manifest records each file's furthest-completed status; the
|
| 7 |
+
normalized WAV and transcript JSON are cached on disk. Re-running skips finished work and
|
| 8 |
+
continues from where it stopped. One bad file is marked ``failed`` and never blocks the
|
| 9 |
+
rest. Incremental ingest is just re-running ``run_ingest`` — only new content hashes are
|
| 10 |
+
processed.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import traceback
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Optional
|
| 17 |
+
|
| 18 |
+
from app.asr import Transcriber, load_transcript, save_transcript
|
| 19 |
+
from app.audio_utils import AUDIO_EXTS, normalize_to_wav
|
| 20 |
+
from app.chunking import chunk_transcript
|
| 21 |
+
from app.config import Config, get_config
|
| 22 |
+
from app.manifest import Manifest
|
| 23 |
+
from app.models import Passage, file_content_hash, make_recording_id
|
| 24 |
+
from app.store import Store
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class Pipeline:
|
| 28 |
+
"""Holds the heavy models so they load once and are reused across all files."""
|
| 29 |
+
|
| 30 |
+
def __init__(self, cfg: Optional[Config] = None):
|
| 31 |
+
self.cfg = cfg or get_config()
|
| 32 |
+
self.cfg.ensure_dirs()
|
| 33 |
+
self.store = Store(self.cfg)
|
| 34 |
+
self._transcriber: Optional[Transcriber] = None
|
| 35 |
+
self._embedder = None
|
| 36 |
+
self._translator = None
|
| 37 |
+
|
| 38 |
+
@property
|
| 39 |
+
def transcriber(self) -> Transcriber:
|
| 40 |
+
if self._transcriber is None:
|
| 41 |
+
self._transcriber = Transcriber(self.cfg)
|
| 42 |
+
return self._transcriber
|
| 43 |
+
|
| 44 |
+
@property
|
| 45 |
+
def embedder(self):
|
| 46 |
+
if self._embedder is None:
|
| 47 |
+
from app.embed import Embedder
|
| 48 |
+
|
| 49 |
+
self._embedder = Embedder(self.cfg)
|
| 50 |
+
return self._embedder
|
| 51 |
+
|
| 52 |
+
@property
|
| 53 |
+
def translator(self):
|
| 54 |
+
if self._translator is None:
|
| 55 |
+
from app.translate import Translator
|
| 56 |
+
|
| 57 |
+
self._translator = Translator(self.cfg)
|
| 58 |
+
return self._translator
|
| 59 |
+
|
| 60 |
+
# ---- per-file processing -------------------------------------------
|
| 61 |
+
def process(self, content_hash: str, abs_path: str, rel_path: str, manifest: Manifest) -> int:
|
| 62 |
+
rid = make_recording_id(content_hash)
|
| 63 |
+
work = self.cfg.work_dir
|
| 64 |
+
wav_path = work / f"{rid}.wav"
|
| 65 |
+
transcript_path = work / f"{rid}.json"
|
| 66 |
+
|
| 67 |
+
# 1. normalize (cache)
|
| 68 |
+
if not wav_path.exists():
|
| 69 |
+
normalize_to_wav(abs_path, wav_path)
|
| 70 |
+
manifest.set_status(content_hash, "normalized")
|
| 71 |
+
|
| 72 |
+
# 2. transcribe + align (cache the transcript JSON)
|
| 73 |
+
if transcript_path.exists():
|
| 74 |
+
transcript = load_transcript(transcript_path)
|
| 75 |
+
else:
|
| 76 |
+
transcript = self.transcriber.transcribe(wav_path)
|
| 77 |
+
save_transcript(transcript, transcript_path)
|
| 78 |
+
manifest.set_status(content_hash, "transcribed", duration_s=transcript.get("duration"))
|
| 79 |
+
|
| 80 |
+
# 3. chunk
|
| 81 |
+
passages = chunk_transcript(transcript, rid, rel_path, self.cfg)
|
| 82 |
+
manifest.set_status(content_hash, "chunked", n_chunks=len(passages))
|
| 83 |
+
if not passages:
|
| 84 |
+
manifest.set_status(content_hash, "indexed", n_chunks=0)
|
| 85 |
+
return 0
|
| 86 |
+
|
| 87 |
+
# 4. optional translation for English snippets
|
| 88 |
+
if self.cfg.translation["enabled"]:
|
| 89 |
+
try:
|
| 90 |
+
glosses = self.translator.translate([p.hindi_text for p in passages])
|
| 91 |
+
for p, g in zip(passages, glosses):
|
| 92 |
+
p.english_gloss = g
|
| 93 |
+
except Exception as exc: # noqa: BLE001 — translation is non-essential
|
| 94 |
+
print(f"[ingest] translation skipped for {rel_path}: {exc}")
|
| 95 |
+
|
| 96 |
+
# 5. embed
|
| 97 |
+
vectors = self.embedder.embed_passages([p.hindi_text for p in passages])
|
| 98 |
+
manifest.set_status(content_hash, "embedded")
|
| 99 |
+
|
| 100 |
+
# 6. index (idempotent: clear any prior rows for this recording first)
|
| 101 |
+
self.store.delete_recording(rid)
|
| 102 |
+
self.store.upsert_passages(passages, vectors)
|
| 103 |
+
manifest.set_status(content_hash, "indexed", n_chunks=len(passages))
|
| 104 |
+
return len(passages)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def scan_audio_dir(manifest: Manifest, cfg: Optional[Config] = None) -> int:
|
| 108 |
+
"""Register any new audio files in the manifest. Returns count of newly added."""
|
| 109 |
+
cfg = cfg or get_config()
|
| 110 |
+
audio_dir = cfg.audio_dir
|
| 111 |
+
audio_dir.mkdir(parents=True, exist_ok=True)
|
| 112 |
+
new = 0
|
| 113 |
+
for path in sorted(audio_dir.rglob("*")):
|
| 114 |
+
if not path.is_file() or path.suffix.lower() not in AUDIO_EXTS:
|
| 115 |
+
continue
|
| 116 |
+
rel = path.relative_to(audio_dir).as_posix()
|
| 117 |
+
h = file_content_hash(str(path))
|
| 118 |
+
if manifest.upsert_file(h, rel, str(path)):
|
| 119 |
+
new += 1
|
| 120 |
+
return new
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def run_ingest(
|
| 124 |
+
cfg: Optional[Config] = None,
|
| 125 |
+
limit: Optional[int] = None,
|
| 126 |
+
optimize_every: int = 25,
|
| 127 |
+
include_failed: bool = False,
|
| 128 |
+
) -> dict:
|
| 129 |
+
"""Scan + process all pending files. Safe to interrupt and re-run."""
|
| 130 |
+
cfg = cfg or get_config()
|
| 131 |
+
cfg.ensure_dirs()
|
| 132 |
+
manifest = Manifest(cfg.manifest_path)
|
| 133 |
+
pipe = Pipeline(cfg)
|
| 134 |
+
|
| 135 |
+
added = scan_audio_dir(manifest, cfg)
|
| 136 |
+
pending = manifest.pending(include_failed=include_failed)
|
| 137 |
+
if limit:
|
| 138 |
+
pending = pending[:limit]
|
| 139 |
+
print(f"[ingest] {added} new file(s) found; {len(pending)} to process")
|
| 140 |
+
|
| 141 |
+
done = failed = 0
|
| 142 |
+
for idx, row in enumerate(pending, 1):
|
| 143 |
+
h, rel, ap = row["content_hash"], row["rel_path"], row["abs_path"]
|
| 144 |
+
print(f"[ingest] ({idx}/{len(pending)}) {rel}")
|
| 145 |
+
try:
|
| 146 |
+
n = pipe.process(h, ap, rel, manifest)
|
| 147 |
+
done += 1
|
| 148 |
+
print(f" -> indexed {n} passage(s)")
|
| 149 |
+
except Exception as exc: # noqa: BLE001 — isolate per-file failures
|
| 150 |
+
failed += 1
|
| 151 |
+
manifest.set_status(h, "failed", error=f"{exc}")
|
| 152 |
+
print(f" -> FAILED: {exc}")
|
| 153 |
+
traceback.print_exc()
|
| 154 |
+
if idx % optimize_every == 0:
|
| 155 |
+
pipe.store.optimize()
|
| 156 |
+
|
| 157 |
+
pipe.store.optimize()
|
| 158 |
+
if cfg.search["hybrid"]:
|
| 159 |
+
pipe.store.ensure_fts_index()
|
| 160 |
+
counts = manifest.counts()
|
| 161 |
+
manifest.close()
|
| 162 |
+
summary = {"new_files": added, "processed": done, "failed": failed, "manifest": counts}
|
| 163 |
+
print(f"[ingest] done: {summary}")
|
| 164 |
+
return summary
|
app/manifest.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLite manifest — the source of truth for what has been ingested.
|
| 2 |
+
|
| 3 |
+
Keyed on the file's **content hash** (not its path), so moving or renaming a recording
|
| 4 |
+
never triggers a re-transcribe. Each file walks through a status ladder; the bulk
|
| 5 |
+
ingest loop simply re-processes anything whose status is below ``indexed`` (and not
|
| 6 |
+
``failed``), which makes the whole multi-day run trivially resumable: kill it any time,
|
| 7 |
+
restart, and it continues where it left off.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import sqlite3
|
| 12 |
+
import time
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import Dict, List, Optional
|
| 15 |
+
|
| 16 |
+
# Ordered ladder. A file is "done" only at INDEXED. FAILED is terminal (until retried).
|
| 17 |
+
STATUS_LADDER = ["pending", "normalized", "transcribed", "chunked", "embedded", "indexed"]
|
| 18 |
+
STATUS_FAILED = "failed"
|
| 19 |
+
STATUS_DONE = "indexed"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _now() -> str:
|
| 23 |
+
return time.strftime("%Y-%m-%dT%H:%M:%S")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class Manifest:
|
| 27 |
+
def __init__(self, db_path: str | Path):
|
| 28 |
+
self.db_path = str(db_path)
|
| 29 |
+
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
|
| 30 |
+
self.conn = sqlite3.connect(self.db_path)
|
| 31 |
+
self.conn.row_factory = sqlite3.Row
|
| 32 |
+
self.conn.execute("PRAGMA journal_mode=WAL;")
|
| 33 |
+
self._init_db()
|
| 34 |
+
|
| 35 |
+
def _init_db(self) -> None:
|
| 36 |
+
self.conn.execute(
|
| 37 |
+
"""
|
| 38 |
+
CREATE TABLE IF NOT EXISTS files (
|
| 39 |
+
content_hash TEXT PRIMARY KEY,
|
| 40 |
+
rel_path TEXT NOT NULL,
|
| 41 |
+
abs_path TEXT NOT NULL,
|
| 42 |
+
duration_s REAL,
|
| 43 |
+
status TEXT NOT NULL DEFAULT 'pending',
|
| 44 |
+
error TEXT,
|
| 45 |
+
n_chunks INTEGER DEFAULT 0,
|
| 46 |
+
added_at TEXT,
|
| 47 |
+
updated_at TEXT
|
| 48 |
+
)
|
| 49 |
+
"""
|
| 50 |
+
)
|
| 51 |
+
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_status ON files(status);")
|
| 52 |
+
self.conn.commit()
|
| 53 |
+
|
| 54 |
+
# ---- writes ---------------------------------------------------------
|
| 55 |
+
def upsert_file(self, content_hash: str, rel_path: str, abs_path: str) -> bool:
|
| 56 |
+
"""Register a file. Returns True if it's new (caller may process it)."""
|
| 57 |
+
row = self.get(content_hash)
|
| 58 |
+
now = _now()
|
| 59 |
+
if row is None:
|
| 60 |
+
self.conn.execute(
|
| 61 |
+
"INSERT INTO files (content_hash, rel_path, abs_path, status, added_at, updated_at) "
|
| 62 |
+
"VALUES (?, ?, ?, 'pending', ?, ?)",
|
| 63 |
+
(content_hash, rel_path, abs_path, now, now),
|
| 64 |
+
)
|
| 65 |
+
self.conn.commit()
|
| 66 |
+
return True
|
| 67 |
+
# Known file: keep status, but refresh path if it moved.
|
| 68 |
+
if row["abs_path"] != abs_path or row["rel_path"] != rel_path:
|
| 69 |
+
self.conn.execute(
|
| 70 |
+
"UPDATE files SET rel_path=?, abs_path=?, updated_at=? WHERE content_hash=?",
|
| 71 |
+
(rel_path, abs_path, now, content_hash),
|
| 72 |
+
)
|
| 73 |
+
self.conn.commit()
|
| 74 |
+
return False
|
| 75 |
+
|
| 76 |
+
def set_status(
|
| 77 |
+
self,
|
| 78 |
+
content_hash: str,
|
| 79 |
+
status: str,
|
| 80 |
+
*,
|
| 81 |
+
error: Optional[str] = None,
|
| 82 |
+
duration_s: Optional[float] = None,
|
| 83 |
+
n_chunks: Optional[int] = None,
|
| 84 |
+
) -> None:
|
| 85 |
+
sets = ["status=?", "updated_at=?"]
|
| 86 |
+
vals: List[object] = [status, _now()]
|
| 87 |
+
if error is not None:
|
| 88 |
+
sets.append("error=?")
|
| 89 |
+
vals.append(error)
|
| 90 |
+
if duration_s is not None:
|
| 91 |
+
sets.append("duration_s=?")
|
| 92 |
+
vals.append(duration_s)
|
| 93 |
+
if n_chunks is not None:
|
| 94 |
+
sets.append("n_chunks=?")
|
| 95 |
+
vals.append(n_chunks)
|
| 96 |
+
vals.append(content_hash)
|
| 97 |
+
self.conn.execute(f"UPDATE files SET {', '.join(sets)} WHERE content_hash=?", vals)
|
| 98 |
+
self.conn.commit()
|
| 99 |
+
|
| 100 |
+
def remove(self, content_hash: str) -> None:
|
| 101 |
+
self.conn.execute("DELETE FROM files WHERE content_hash=?", (content_hash,))
|
| 102 |
+
self.conn.commit()
|
| 103 |
+
|
| 104 |
+
# ---- reads ----------------------------------------------------------
|
| 105 |
+
def get(self, content_hash: str) -> Optional[sqlite3.Row]:
|
| 106 |
+
cur = self.conn.execute("SELECT * FROM files WHERE content_hash=?", (content_hash,))
|
| 107 |
+
return cur.fetchone()
|
| 108 |
+
|
| 109 |
+
def pending(self, include_failed: bool = False) -> List[sqlite3.Row]:
|
| 110 |
+
"""Files that still need work (status below 'indexed')."""
|
| 111 |
+
if include_failed:
|
| 112 |
+
cur = self.conn.execute(
|
| 113 |
+
"SELECT * FROM files WHERE status != ? ORDER BY added_at", (STATUS_DONE,)
|
| 114 |
+
)
|
| 115 |
+
else:
|
| 116 |
+
cur = self.conn.execute(
|
| 117 |
+
"SELECT * FROM files WHERE status != ? AND status != ? ORDER BY added_at",
|
| 118 |
+
(STATUS_DONE, STATUS_FAILED),
|
| 119 |
+
)
|
| 120 |
+
return cur.fetchall()
|
| 121 |
+
|
| 122 |
+
def all(self) -> List[sqlite3.Row]:
|
| 123 |
+
return self.conn.execute("SELECT * FROM files ORDER BY added_at").fetchall()
|
| 124 |
+
|
| 125 |
+
def known_hashes(self) -> set:
|
| 126 |
+
return {r["content_hash"] for r in self.conn.execute("SELECT content_hash FROM files")}
|
| 127 |
+
|
| 128 |
+
def counts(self) -> Dict[str, int]:
|
| 129 |
+
cur = self.conn.execute("SELECT status, COUNT(*) c FROM files GROUP BY status")
|
| 130 |
+
return {r["status"]: r["c"] for r in cur.fetchall()}
|
| 131 |
+
|
| 132 |
+
def close(self) -> None:
|
| 133 |
+
self.conn.close()
|
| 134 |
+
|
| 135 |
+
def __enter__(self) -> "Manifest":
|
| 136 |
+
return self
|
| 137 |
+
|
| 138 |
+
def __exit__(self, *exc) -> None:
|
| 139 |
+
self.close()
|
app/models.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared data structures passed between pipeline stages.
|
| 2 |
+
|
| 3 |
+
A ``Passage`` is one searchable unit: a sentence-packed window of transcript with a
|
| 4 |
+
precise [start_ms, end_ms] span inside one recording. It is what we embed, index, and
|
| 5 |
+
return to the UI (so the player can seek straight to ``start_ms``).
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import hashlib
|
| 10 |
+
from dataclasses import asdict, dataclass, field
|
| 11 |
+
from typing import Any, Dict, List, Optional
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def make_recording_id(content_hash: str) -> str:
|
| 15 |
+
"""Short, stable id for a recording, derived from its content hash."""
|
| 16 |
+
return content_hash[:16]
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def make_passage_id(recording_id: str, start_ms: int) -> str:
|
| 20 |
+
"""Stable id for a passage (unique within the corpus, deterministic on re-ingest)."""
|
| 21 |
+
return f"{recording_id}:{start_ms:09d}"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass
|
| 25 |
+
class Passage:
|
| 26 |
+
id: str
|
| 27 |
+
recording_id: str
|
| 28 |
+
source_file: str # path relative to the audio_dir (used to build the audio URL)
|
| 29 |
+
start_ms: int
|
| 30 |
+
end_ms: int
|
| 31 |
+
hindi_text: str
|
| 32 |
+
n_words: int = 0
|
| 33 |
+
english_gloss: str = "" # filled only when translation is enabled
|
| 34 |
+
|
| 35 |
+
def to_row(self) -> Dict[str, Any]:
|
| 36 |
+
"""Row dict for LanceDB (vector is added separately at index time)."""
|
| 37 |
+
return asdict(self)
|
| 38 |
+
|
| 39 |
+
@staticmethod
|
| 40 |
+
def from_words(
|
| 41 |
+
recording_id: str,
|
| 42 |
+
source_file: str,
|
| 43 |
+
words: List[Dict[str, Any]],
|
| 44 |
+
text: str,
|
| 45 |
+
) -> "Passage":
|
| 46 |
+
start_ms = int(round(words[0]["start"] * 1000))
|
| 47 |
+
end_ms = int(round(words[-1]["end"] * 1000))
|
| 48 |
+
return Passage(
|
| 49 |
+
id=make_passage_id(recording_id, start_ms),
|
| 50 |
+
recording_id=recording_id,
|
| 51 |
+
source_file=source_file,
|
| 52 |
+
start_ms=start_ms,
|
| 53 |
+
end_ms=end_ms,
|
| 54 |
+
hindi_text=text.strip(),
|
| 55 |
+
n_words=len(words),
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@dataclass
|
| 60 |
+
class SearchResult:
|
| 61 |
+
recording_id: str
|
| 62 |
+
source_file: str
|
| 63 |
+
start_ms: int
|
| 64 |
+
end_ms: int
|
| 65 |
+
hindi_text: str
|
| 66 |
+
english_gloss: str
|
| 67 |
+
score: float
|
| 68 |
+
rerank_score: Optional[float] = None
|
| 69 |
+
|
| 70 |
+
@property
|
| 71 |
+
def start_seconds(self) -> float:
|
| 72 |
+
return self.start_ms / 1000.0
|
| 73 |
+
|
| 74 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 75 |
+
d = asdict(self)
|
| 76 |
+
d["start_seconds"] = round(self.start_seconds, 2)
|
| 77 |
+
return d
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def file_content_hash(path: str, chunk_size: int = 1 << 20) -> str:
|
| 81 |
+
"""SHA-256 of the file's bytes. Keys the manifest so renames don't re-transcribe."""
|
| 82 |
+
h = hashlib.sha256()
|
| 83 |
+
with open(path, "rb") as fh:
|
| 84 |
+
for block in iter(lambda: fh.read(chunk_size), b""):
|
| 85 |
+
h.update(block)
|
| 86 |
+
return h.hexdigest()
|
app/query.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Query side: text or Hindi-audio -> ranked moments.
|
| 2 |
+
|
| 3 |
+
text query -> bge-m3 embed -> LanceDB (dense-dominant hybrid) -> bge-reranker -> top-k
|
| 4 |
+
audio query -> ffmpeg -> faster-whisper(hi) -> Hindi text -> (same as above)
|
| 5 |
+
|
| 6 |
+
An English query matches Hindi passages natively in bge-m3's shared space, so there is no
|
| 7 |
+
query-time translation. The reranker reads (query, Hindi passage) pairs together and is
|
| 8 |
+
the main precision lever.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import tempfile
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import List, Optional, Tuple
|
| 15 |
+
|
| 16 |
+
from app.config import Config, get_config
|
| 17 |
+
from app.models import SearchResult
|
| 18 |
+
from app.store import Store
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class Searcher:
|
| 22 |
+
def __init__(self, cfg: Optional[Config] = None):
|
| 23 |
+
self.cfg = cfg or get_config()
|
| 24 |
+
self.store = Store(self.cfg)
|
| 25 |
+
self._embedder = None
|
| 26 |
+
self._reranker = None
|
| 27 |
+
self._transcriber = None
|
| 28 |
+
|
| 29 |
+
@property
|
| 30 |
+
def embedder(self):
|
| 31 |
+
if self._embedder is None:
|
| 32 |
+
from app.embed import Embedder
|
| 33 |
+
|
| 34 |
+
self._embedder = Embedder(self.cfg)
|
| 35 |
+
return self._embedder
|
| 36 |
+
|
| 37 |
+
@property
|
| 38 |
+
def reranker(self):
|
| 39 |
+
if self._reranker is None:
|
| 40 |
+
from app.rerank import Reranker
|
| 41 |
+
|
| 42 |
+
self._reranker = Reranker(self.cfg)
|
| 43 |
+
return self._reranker
|
| 44 |
+
|
| 45 |
+
@property
|
| 46 |
+
def transcriber(self):
|
| 47 |
+
if self._transcriber is None:
|
| 48 |
+
from app.asr import Transcriber
|
| 49 |
+
|
| 50 |
+
self._transcriber = Transcriber(self.cfg)
|
| 51 |
+
return self._transcriber
|
| 52 |
+
|
| 53 |
+
# ---- text search ----------------------------------------------------
|
| 54 |
+
def search_text(self, query: str, top_k: Optional[int] = None) -> List[SearchResult]:
|
| 55 |
+
query = (query or "").strip()
|
| 56 |
+
if not query:
|
| 57 |
+
return []
|
| 58 |
+
top_k = top_k or self.cfg.search["top_k"]
|
| 59 |
+
use_rerank = self.reranker.enabled
|
| 60 |
+
pool = max(self.cfg.reranker["candidates"], top_k) if use_rerank else top_k
|
| 61 |
+
|
| 62 |
+
qvec = self.embedder.embed_query(query)
|
| 63 |
+
rows = self.store.search(
|
| 64 |
+
qvec,
|
| 65 |
+
query_text=query,
|
| 66 |
+
k=pool,
|
| 67 |
+
hybrid=bool(self.cfg.search["hybrid"]),
|
| 68 |
+
vector_weight=float(self.cfg.search["vector_weight"]),
|
| 69 |
+
candidate_pool=max(pool, 80),
|
| 70 |
+
)
|
| 71 |
+
if not rows:
|
| 72 |
+
return []
|
| 73 |
+
|
| 74 |
+
if use_rerank:
|
| 75 |
+
scores = self.reranker.scores(query, [r["hindi_text"] for r in rows])
|
| 76 |
+
for r, s in zip(rows, scores):
|
| 77 |
+
r["rerank_score"] = s
|
| 78 |
+
rows.sort(key=lambda r: r.get("rerank_score", 0.0), reverse=True)
|
| 79 |
+
|
| 80 |
+
rows = rows[:top_k]
|
| 81 |
+
return [self._to_result(r) for r in rows]
|
| 82 |
+
|
| 83 |
+
# ---- audio search ---------------------------------------------------
|
| 84 |
+
def search_audio(self, audio_path: str | Path, top_k: Optional[int] = None) -> Tuple[str, List[SearchResult]]:
|
| 85 |
+
"""Transcribe a Hindi clip, then search. Returns (recognized_text, results)."""
|
| 86 |
+
from app.audio_utils import normalize_to_wav
|
| 87 |
+
|
| 88 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 89 |
+
wav = Path(tmp) / "query.wav"
|
| 90 |
+
normalize_to_wav(audio_path, wav)
|
| 91 |
+
text = self.transcriber.transcribe_query(wav)
|
| 92 |
+
results = self.search_text(text, top_k=top_k) if text.strip() else []
|
| 93 |
+
return text, results
|
| 94 |
+
|
| 95 |
+
# ---- helpers --------------------------------------------------------
|
| 96 |
+
@staticmethod
|
| 97 |
+
def _to_result(r: dict) -> SearchResult:
|
| 98 |
+
# Prefer the reranker score, else the dense cosine (nice 0..1 for display);
|
| 99 |
+
# fused RRF score is only a last resort (tiny values, ranking-only).
|
| 100 |
+
score = r.get("rerank_score")
|
| 101 |
+
if score is None:
|
| 102 |
+
score = r.get("score", r.get("fused_score", 0.0))
|
| 103 |
+
return SearchResult(
|
| 104 |
+
recording_id=r["recording_id"],
|
| 105 |
+
source_file=r["source_file"],
|
| 106 |
+
start_ms=int(r["start_ms"]),
|
| 107 |
+
end_ms=int(r["end_ms"]),
|
| 108 |
+
hindi_text=r.get("hindi_text", ""),
|
| 109 |
+
english_gloss=r.get("english_gloss", ""),
|
| 110 |
+
score=float(score),
|
| 111 |
+
rerank_score=(float(r["rerank_score"]) if "rerank_score" in r else None),
|
| 112 |
+
)
|
app/rerank.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cross-encoder reranking via BAAI/bge-reranker-v2-m3.
|
| 2 |
+
|
| 3 |
+
Dense retrieval is great for recall but ranks by a single vector dot-product. A
|
| 4 |
+
cross-encoder reads (query, passage) **together** and scores true relevance — the single
|
| 5 |
+
biggest precision lever for cross-lingual search. We only score the top ~80 candidates,
|
| 6 |
+
so it's cheap. ``normalize=True`` squashes scores to 0..1 (sigmoid) for nice display.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import List, Optional
|
| 11 |
+
|
| 12 |
+
from app.config import Config, get_config
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class Reranker:
|
| 16 |
+
def __init__(self, cfg: Optional[Config] = None):
|
| 17 |
+
self.cfg = cfg or get_config()
|
| 18 |
+
self._model = None
|
| 19 |
+
|
| 20 |
+
@property
|
| 21 |
+
def enabled(self) -> bool:
|
| 22 |
+
return bool(self.cfg.reranker["enabled"])
|
| 23 |
+
|
| 24 |
+
def _load(self):
|
| 25 |
+
if self._model is not None:
|
| 26 |
+
return self._model
|
| 27 |
+
from FlagEmbedding import FlagReranker
|
| 28 |
+
|
| 29 |
+
r = self.cfg.reranker
|
| 30 |
+
self._model = FlagReranker(r["model"], use_fp16=bool(r["use_fp16"]))
|
| 31 |
+
return self._model
|
| 32 |
+
|
| 33 |
+
def scores(self, query: str, passages: List[str]) -> List[float]:
|
| 34 |
+
"""Relevance score (0..1) for each passage against the query."""
|
| 35 |
+
if not passages:
|
| 36 |
+
return []
|
| 37 |
+
model = self._load()
|
| 38 |
+
pairs = [[query, p] for p in passages]
|
| 39 |
+
out = model.compute_score(
|
| 40 |
+
pairs, batch_size=self.cfg.reranker["batch_size"], normalize=True
|
| 41 |
+
)
|
| 42 |
+
if isinstance(out, (int, float)):
|
| 43 |
+
return [float(out)]
|
| 44 |
+
return [float(x) for x in out]
|
app/server.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Local FastAPI app: search box + clickable, seekable results.
|
| 2 |
+
|
| 3 |
+
Endpoints
|
| 4 |
+
GET / -> the search page
|
| 5 |
+
POST /api/search -> {query, top_k} -> ranked moments (JSON)
|
| 6 |
+
POST /api/query_audio -> multipart audio file -> {text, results}
|
| 7 |
+
GET /api/stats -> index / manifest stats
|
| 8 |
+
GET {audio_route}/... -> the original recordings, served with HTTP Range so the
|
| 9 |
+
<audio> element seeks instantly to a timestamp.
|
| 10 |
+
|
| 11 |
+
Run: python -m app.server (or) uvicorn app.server:app --host 127.0.0.1 --port 8000
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import sys
|
| 16 |
+
import tempfile
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from urllib.parse import quote
|
| 19 |
+
|
| 20 |
+
from fastapi import FastAPI, File, Form, UploadFile
|
| 21 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 22 |
+
from fastapi.staticfiles import StaticFiles
|
| 23 |
+
|
| 24 |
+
from app.config import get_config
|
| 25 |
+
from app.manifest import Manifest
|
| 26 |
+
from app.query import Searcher
|
| 27 |
+
from app import audio_source
|
| 28 |
+
|
| 29 |
+
if sys.platform == "win32": # make sure Devanagari prints fine in the console
|
| 30 |
+
try:
|
| 31 |
+
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
|
| 32 |
+
except Exception: # noqa: BLE001
|
| 33 |
+
pass
|
| 34 |
+
|
| 35 |
+
cfg = get_config()
|
| 36 |
+
cfg.ensure_dirs()
|
| 37 |
+
WEB_DIR = Path(__file__).parent / "web"
|
| 38 |
+
AUDIO_ROUTE = cfg.server["audio_route"]
|
| 39 |
+
AUDIO_MODE = (cfg.get("audio", {}) or {}).get("mode", "local")
|
| 40 |
+
|
| 41 |
+
app = FastAPI(title="SearchAudio", version="0.1.0")
|
| 42 |
+
_searcher = Searcher(cfg)
|
| 43 |
+
|
| 44 |
+
# Static assets. StaticFiles speaks HTTP Range, so seeking is instant.
|
| 45 |
+
app.mount("/static", StaticFiles(directory=str(WEB_DIR)), name="static")
|
| 46 |
+
# Local audio is served from disk only in local mode; archive mode streams from archive.org.
|
| 47 |
+
if AUDIO_MODE == "local":
|
| 48 |
+
app.mount(AUDIO_ROUTE, StaticFiles(directory=str(cfg.audio_dir)), name="audio")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _audio_url(source_file: str) -> str:
|
| 52 |
+
return audio_source.audio_url(source_file, cfg)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _result_payload(results) -> list:
|
| 56 |
+
out = []
|
| 57 |
+
for r in results:
|
| 58 |
+
d = r.to_dict()
|
| 59 |
+
d["audio_url"] = _audio_url(r.source_file)
|
| 60 |
+
out.append(d)
|
| 61 |
+
return out
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@app.get("/")
|
| 65 |
+
def index() -> FileResponse:
|
| 66 |
+
return FileResponse(str(WEB_DIR / "index.html"))
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@app.post("/api/search")
|
| 70 |
+
def api_search(payload: dict) -> JSONResponse:
|
| 71 |
+
query = (payload or {}).get("query", "")
|
| 72 |
+
top_k = (payload or {}).get("top_k")
|
| 73 |
+
results = _searcher.search_text(query, top_k=top_k)
|
| 74 |
+
return JSONResponse({"query": query, "count": len(results), "results": _result_payload(results)})
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
async def api_query_audio(file: UploadFile = File(...), top_k: int = Form(None)) -> JSONResponse:
|
| 78 |
+
suffix = Path(file.filename or "clip.webm").suffix or ".webm"
|
| 79 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
| 80 |
+
tmp.write(await file.read())
|
| 81 |
+
tmp_path = tmp.name
|
| 82 |
+
try:
|
| 83 |
+
text, results = _searcher.search_audio(tmp_path, top_k=top_k)
|
| 84 |
+
finally:
|
| 85 |
+
try:
|
| 86 |
+
Path(tmp_path).unlink(missing_ok=True)
|
| 87 |
+
except Exception: # noqa: BLE001
|
| 88 |
+
pass
|
| 89 |
+
return JSONResponse({"text": text, "count": len(results), "results": _result_payload(results)})
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# Register the audio-query route ONLY in local mode. In archive/online mode the route is
|
| 93 |
+
# absent (POST -> 404) and app.asr / whisperx are never imported (keeps the image slim).
|
| 94 |
+
if AUDIO_MODE == "local":
|
| 95 |
+
app.post("/api/query_audio")(api_query_audio)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@app.get("/api/config")
|
| 99 |
+
def api_config() -> JSONResponse:
|
| 100 |
+
return JSONResponse({"mode": AUDIO_MODE, "audio_query": AUDIO_MODE == "local"})
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@app.get("/api/stats")
|
| 104 |
+
def api_stats() -> JSONResponse:
|
| 105 |
+
manifest = Manifest(cfg.manifest_path)
|
| 106 |
+
counts = manifest.counts()
|
| 107 |
+
manifest.close()
|
| 108 |
+
try:
|
| 109 |
+
n_passages = _searcher.store.count()
|
| 110 |
+
except Exception: # noqa: BLE001
|
| 111 |
+
n_passages = 0
|
| 112 |
+
return JSONResponse(
|
| 113 |
+
{
|
| 114 |
+
"files": counts,
|
| 115 |
+
"passages": n_passages,
|
| 116 |
+
"reranker": bool(cfg.reranker["enabled"]),
|
| 117 |
+
"hybrid": bool(cfg.search["hybrid"]),
|
| 118 |
+
}
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def run() -> None:
|
| 123 |
+
import uvicorn
|
| 124 |
+
|
| 125 |
+
uvicorn.run(
|
| 126 |
+
"app.server:app",
|
| 127 |
+
host=cfg.server["host"],
|
| 128 |
+
port=int(cfg.server["port"]),
|
| 129 |
+
workers=1, # ONE GPU worker: never load the models N times into VRAM
|
| 130 |
+
log_level="info",
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
if __name__ == "__main__":
|
| 135 |
+
run()
|
app/store.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LanceDB vector store for transcript passages.
|
| 2 |
+
|
| 3 |
+
Design choices:
|
| 4 |
+
* **Vector search is the reliable core** (exact/flat search is sub-second well past 1M
|
| 5 |
+
rows). Cross-lingual recall comes entirely from the dense bge-m3 vectors.
|
| 6 |
+
* **Hybrid (BM25) is a best-effort boost.** BM25 can't bridge English->Devanagari, so
|
| 7 |
+
if the FTS index can't be built on this LanceDB version it's simply skipped and we
|
| 8 |
+
fall back to pure vector search — still fully functional. When available, dense and
|
| 9 |
+
sparse hits are fused with a dense-dominant RRF in Python (no reliance on the
|
| 10 |
+
version-sensitive LanceDB hybrid+reranker API).
|
| 11 |
+
* Upsert via ``merge_insert`` keyed on the passage ``id`` (idempotent re-ingest);
|
| 12 |
+
delete-by-recording for clean re-processing.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Any, Dict, List, Optional
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
|
| 21 |
+
from app.config import Config, get_config
|
| 22 |
+
from app.models import Passage
|
| 23 |
+
|
| 24 |
+
TABLE_NAME = "passages"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _schema(dim: int):
|
| 28 |
+
import pyarrow as pa
|
| 29 |
+
|
| 30 |
+
return pa.schema(
|
| 31 |
+
[
|
| 32 |
+
pa.field("id", pa.string()),
|
| 33 |
+
pa.field("recording_id", pa.string()),
|
| 34 |
+
pa.field("source_file", pa.string()),
|
| 35 |
+
pa.field("start_ms", pa.int64()),
|
| 36 |
+
pa.field("end_ms", pa.int64()),
|
| 37 |
+
pa.field("hindi_text", pa.string()),
|
| 38 |
+
pa.field("english_gloss", pa.string()),
|
| 39 |
+
pa.field("n_words", pa.int64()),
|
| 40 |
+
pa.field("vector", pa.list_(pa.float32(), dim)),
|
| 41 |
+
]
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class Store:
|
| 46 |
+
def __init__(self, cfg: Optional[Config] = None):
|
| 47 |
+
self.cfg = cfg or get_config()
|
| 48 |
+
self.dim = int(self.cfg.embedding["dim"])
|
| 49 |
+
self.uri = str(self.cfg.lancedb_dir)
|
| 50 |
+
Path(self.uri).mkdir(parents=True, exist_ok=True)
|
| 51 |
+
self._db = None
|
| 52 |
+
self._table = None
|
| 53 |
+
self._fts_ready = False
|
| 54 |
+
|
| 55 |
+
# ---- connection -----------------------------------------------------
|
| 56 |
+
def _connect(self):
|
| 57 |
+
if self._db is None:
|
| 58 |
+
import lancedb
|
| 59 |
+
|
| 60 |
+
self._db = lancedb.connect(self.uri)
|
| 61 |
+
return self._db
|
| 62 |
+
|
| 63 |
+
def table(self):
|
| 64 |
+
if self._table is not None:
|
| 65 |
+
return self._table
|
| 66 |
+
db = self._connect()
|
| 67 |
+
if TABLE_NAME in db.table_names():
|
| 68 |
+
self._table = db.open_table(TABLE_NAME)
|
| 69 |
+
else:
|
| 70 |
+
self._table = db.create_table(TABLE_NAME, schema=_schema(self.dim))
|
| 71 |
+
return self._table
|
| 72 |
+
|
| 73 |
+
# ---- writes ---------------------------------------------------------
|
| 74 |
+
def _rows(self, passages: List[Passage], vectors: np.ndarray) -> List[Dict[str, Any]]:
|
| 75 |
+
rows = []
|
| 76 |
+
for p, v in zip(passages, vectors):
|
| 77 |
+
row = p.to_row()
|
| 78 |
+
row["vector"] = np.asarray(v, dtype=np.float32).tolist()
|
| 79 |
+
rows.append(row)
|
| 80 |
+
return rows
|
| 81 |
+
|
| 82 |
+
def upsert_passages(self, passages: List[Passage], vectors: np.ndarray) -> int:
|
| 83 |
+
"""Insert or update passages keyed on id. Returns rows written."""
|
| 84 |
+
if not passages:
|
| 85 |
+
return 0
|
| 86 |
+
rows = self._rows(passages, vectors)
|
| 87 |
+
tbl = self.table()
|
| 88 |
+
try:
|
| 89 |
+
(
|
| 90 |
+
tbl.merge_insert("id")
|
| 91 |
+
.when_matched_update_all()
|
| 92 |
+
.when_not_matched_insert_all()
|
| 93 |
+
.execute(rows)
|
| 94 |
+
)
|
| 95 |
+
except Exception: # noqa: BLE001 — older LanceDB: emulate upsert
|
| 96 |
+
ids = [r["id"] for r in rows]
|
| 97 |
+
id_list = ",".join("'" + i.replace("'", "''") + "'" for i in ids)
|
| 98 |
+
try:
|
| 99 |
+
tbl.delete(f"id IN ({id_list})")
|
| 100 |
+
except Exception: # noqa: BLE001
|
| 101 |
+
pass
|
| 102 |
+
tbl.add(rows)
|
| 103 |
+
self._fts_ready = False # index needs refresh after writes
|
| 104 |
+
return len(rows)
|
| 105 |
+
|
| 106 |
+
def delete_recording(self, recording_id: str) -> None:
|
| 107 |
+
tbl = self.table()
|
| 108 |
+
safe = recording_id.replace("'", "''")
|
| 109 |
+
tbl.delete(f"recording_id = '{safe}'")
|
| 110 |
+
|
| 111 |
+
def optimize(self) -> None:
|
| 112 |
+
try:
|
| 113 |
+
self.table().optimize()
|
| 114 |
+
except Exception: # noqa: BLE001 — older versions: compact_files / no-op
|
| 115 |
+
try:
|
| 116 |
+
self.table().compact_files()
|
| 117 |
+
except Exception: # noqa: BLE001
|
| 118 |
+
pass
|
| 119 |
+
|
| 120 |
+
def count(self) -> int:
|
| 121 |
+
try:
|
| 122 |
+
return self.table().count_rows()
|
| 123 |
+
except Exception: # noqa: BLE001
|
| 124 |
+
return len(self.table().to_pandas())
|
| 125 |
+
|
| 126 |
+
def recording_ids(self) -> set:
|
| 127 |
+
try:
|
| 128 |
+
df = self.table().to_pandas()
|
| 129 |
+
return set(df["recording_id"].unique().tolist())
|
| 130 |
+
except Exception: # noqa: BLE001
|
| 131 |
+
return set()
|
| 132 |
+
|
| 133 |
+
# ---- full-text index (best effort) ----------------------------------
|
| 134 |
+
def ensure_fts_index(self) -> bool:
|
| 135 |
+
"""Create a BM25 FTS index on hindi_text (ICU tokenizer if supported)."""
|
| 136 |
+
if self._fts_ready:
|
| 137 |
+
return True
|
| 138 |
+
tbl = self.table()
|
| 139 |
+
attempts = [
|
| 140 |
+
dict(use_tantivy=False, tokenizer_name="icu", with_position=True, replace=True),
|
| 141 |
+
dict(use_tantivy=False, tokenizer_name="icu", replace=True),
|
| 142 |
+
dict(use_tantivy=False, replace=True),
|
| 143 |
+
dict(), # bare default
|
| 144 |
+
]
|
| 145 |
+
for kwargs in attempts:
|
| 146 |
+
try:
|
| 147 |
+
tbl.create_fts_index("hindi_text", **kwargs)
|
| 148 |
+
self._fts_ready = True
|
| 149 |
+
return True
|
| 150 |
+
except TypeError:
|
| 151 |
+
continue
|
| 152 |
+
except Exception: # noqa: BLE001
|
| 153 |
+
continue
|
| 154 |
+
return False
|
| 155 |
+
|
| 156 |
+
# ---- search ---------------------------------------------------------
|
| 157 |
+
def search_vector(self, query_vec: np.ndarray, k: int) -> List[Dict[str, Any]]:
|
| 158 |
+
tbl = self.table()
|
| 159 |
+
q = np.asarray(query_vec, dtype=np.float32)
|
| 160 |
+
rows = tbl.search(q).limit(k).to_list()
|
| 161 |
+
for r in rows:
|
| 162 |
+
r.pop("vector", None)
|
| 163 |
+
dist = r.get("_distance")
|
| 164 |
+
# bge-m3 vectors are unit-norm -> cosine = 1 - L2^2 / 2
|
| 165 |
+
r["score"] = float(1.0 - dist / 2.0) if isinstance(dist, (int, float)) else 0.0
|
| 166 |
+
return rows
|
| 167 |
+
|
| 168 |
+
def search_fts(self, query_text: str, k: int) -> List[Dict[str, Any]]:
|
| 169 |
+
if not query_text.strip():
|
| 170 |
+
return []
|
| 171 |
+
if not self.ensure_fts_index():
|
| 172 |
+
return []
|
| 173 |
+
try:
|
| 174 |
+
rows = self.table().search(query_text, query_type="fts").limit(k).to_list()
|
| 175 |
+
except Exception: # noqa: BLE001
|
| 176 |
+
return []
|
| 177 |
+
for r in rows:
|
| 178 |
+
r.pop("vector", None)
|
| 179 |
+
return rows
|
| 180 |
+
|
| 181 |
+
def search(
|
| 182 |
+
self,
|
| 183 |
+
query_vec: np.ndarray,
|
| 184 |
+
query_text: str = "",
|
| 185 |
+
k: int = 20,
|
| 186 |
+
hybrid: bool = True,
|
| 187 |
+
vector_weight: float = 0.7,
|
| 188 |
+
candidate_pool: Optional[int] = None,
|
| 189 |
+
) -> List[Dict[str, Any]]:
|
| 190 |
+
"""Return up to k candidate rows (no rerank). Dense-dominant RRF fusion."""
|
| 191 |
+
pool = candidate_pool or max(k, 80)
|
| 192 |
+
dense = self.search_vector(query_vec, pool)
|
| 193 |
+
if not hybrid or not query_text.strip():
|
| 194 |
+
return dense[:k]
|
| 195 |
+
sparse = self.search_fts(query_text, pool)
|
| 196 |
+
if not sparse:
|
| 197 |
+
return dense[:k]
|
| 198 |
+
return _rrf_fuse(dense, sparse, vector_weight=vector_weight)[:k]
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _rrf_fuse(
|
| 202 |
+
dense: List[Dict[str, Any]],
|
| 203 |
+
sparse: List[Dict[str, Any]],
|
| 204 |
+
rrf_k: int = 60,
|
| 205 |
+
vector_weight: float = 0.7,
|
| 206 |
+
) -> List[Dict[str, Any]]:
|
| 207 |
+
scores: Dict[str, float] = {}
|
| 208 |
+
rows: Dict[str, Dict[str, Any]] = {}
|
| 209 |
+
for rank, r in enumerate(dense):
|
| 210 |
+
rid = r["id"]
|
| 211 |
+
rows[rid] = r
|
| 212 |
+
scores[rid] = scores.get(rid, 0.0) + vector_weight / (rrf_k + rank + 1)
|
| 213 |
+
for rank, r in enumerate(sparse):
|
| 214 |
+
rid = r["id"]
|
| 215 |
+
rows.setdefault(rid, r)
|
| 216 |
+
scores[rid] = scores.get(rid, 0.0) + (1.0 - vector_weight) / (rrf_k + rank + 1)
|
| 217 |
+
ordered = sorted(scores.items(), key=lambda kv: kv[1], reverse=True)
|
| 218 |
+
out = []
|
| 219 |
+
for rid, s in ordered:
|
| 220 |
+
row = rows[rid]
|
| 221 |
+
row["fused_score"] = float(s)
|
| 222 |
+
out.append(row)
|
| 223 |
+
return out
|
app/translate.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Optional offline Hindi -> English translation via AI4Bharat IndicTrans2.
|
| 2 |
+
|
| 3 |
+
Used ONLY to (a) show a readable English snippet next to each Hindi result so a
|
| 4 |
+
non-Hindi reader can confirm relevance before listening, and (b) enable an English
|
| 5 |
+
BM25 fallback. It is NOT used for primary retrieval (bge-m3 already matches English
|
| 6 |
+
queries to Hindi text natively). Disabled by default; enable in config (``translation``)
|
| 7 |
+
once IndicTrans2 + IndicTransToolkit are installed.
|
| 8 |
+
|
| 9 |
+
Requires: ``transformers`` and ``IndicTransToolkit`` (``pip install IndicTransToolkit``).
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from typing import List, Optional
|
| 14 |
+
|
| 15 |
+
from app.config import Config, get_config
|
| 16 |
+
|
| 17 |
+
_SRC_LANG = "hin_Deva"
|
| 18 |
+
_TGT_LANG = "eng_Latn"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class Translator:
|
| 22 |
+
def __init__(self, cfg: Optional[Config] = None):
|
| 23 |
+
self.cfg = cfg or get_config()
|
| 24 |
+
self._model = None
|
| 25 |
+
self._tok = None
|
| 26 |
+
self._proc = None
|
| 27 |
+
|
| 28 |
+
@property
|
| 29 |
+
def enabled(self) -> bool:
|
| 30 |
+
return bool(self.cfg.translation["enabled"])
|
| 31 |
+
|
| 32 |
+
def _load(self):
|
| 33 |
+
if self._model is not None:
|
| 34 |
+
return
|
| 35 |
+
import torch
|
| 36 |
+
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
from IndicTransToolkit.processor import IndicProcessor
|
| 40 |
+
except Exception as exc: # noqa: BLE001
|
| 41 |
+
raise RuntimeError(
|
| 42 |
+
"IndicTrans2 translation needs IndicTransToolkit. "
|
| 43 |
+
"Install it: pip install IndicTransToolkit"
|
| 44 |
+
) from exc
|
| 45 |
+
|
| 46 |
+
name = self.cfg.translation["model"]
|
| 47 |
+
device = self.cfg.translation["device"]
|
| 48 |
+
self._tok = AutoTokenizer.from_pretrained(name, trust_remote_code=True)
|
| 49 |
+
model = AutoModelForSeq2SeqLM.from_pretrained(name, trust_remote_code=True)
|
| 50 |
+
if device == "cuda" and torch.cuda.is_available():
|
| 51 |
+
model = model.to("cuda").half()
|
| 52 |
+
self._model = model.eval()
|
| 53 |
+
self._proc = IndicProcessor(inference=True)
|
| 54 |
+
self._device = device if torch.cuda.is_available() else "cpu"
|
| 55 |
+
|
| 56 |
+
def translate(self, sentences: List[str], batch_size: int = 16) -> List[str]:
|
| 57 |
+
"""Translate a list of Hindi sentences/passages to English."""
|
| 58 |
+
if not sentences:
|
| 59 |
+
return []
|
| 60 |
+
self._load()
|
| 61 |
+
import torch
|
| 62 |
+
|
| 63 |
+
results: List[str] = []
|
| 64 |
+
for i in range(0, len(sentences), batch_size):
|
| 65 |
+
batch = sentences[i : i + batch_size]
|
| 66 |
+
pre = self._proc.preprocess_batch(batch, src_lang=_SRC_LANG, tgt_lang=_TGT_LANG)
|
| 67 |
+
inputs = self._tok(
|
| 68 |
+
pre, truncation=True, padding="longest", return_tensors="pt", max_length=256
|
| 69 |
+
).to(self._model.device)
|
| 70 |
+
with torch.no_grad():
|
| 71 |
+
generated = self._model.generate(
|
| 72 |
+
**inputs, max_length=256, num_beams=5, num_return_sequences=1
|
| 73 |
+
)
|
| 74 |
+
decoded = self._tok.batch_decode(
|
| 75 |
+
generated, skip_special_tokens=True, clean_up_tokenization_spaces=True
|
| 76 |
+
)
|
| 77 |
+
results.extend(self._proc.postprocess_batch(decoded, lang=_TGT_LANG))
|
| 78 |
+
return results
|
app/web/app.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
|
| 3 |
+
const $ = (id) => document.getElementById(id);
|
| 4 |
+
const qInput = $("q");
|
| 5 |
+
const statusEl = $("status");
|
| 6 |
+
const resultsEl = $("results");
|
| 7 |
+
const playerBar = $("playerBar");
|
| 8 |
+
const player = $("player");
|
| 9 |
+
const npTitle = $("npTitle");
|
| 10 |
+
|
| 11 |
+
// ---- helpers ----------------------------------------------------------
|
| 12 |
+
function fmtTime(sec) {
|
| 13 |
+
sec = Math.max(0, Math.floor(sec));
|
| 14 |
+
const h = Math.floor(sec / 3600);
|
| 15 |
+
const m = Math.floor((sec % 3600) / 60);
|
| 16 |
+
const s = sec % 60;
|
| 17 |
+
const mm = String(m).padStart(2, "0");
|
| 18 |
+
const ss = String(s).padStart(2, "0");
|
| 19 |
+
return h > 0 ? `${h}:${mm}:${ss}` : `${m}:${ss}`;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function baseName(path) {
|
| 23 |
+
const p = path.split("/");
|
| 24 |
+
return decodeURIComponent(p[p.length - 1]);
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function setStatus(html) { statusEl.innerHTML = html; }
|
| 28 |
+
function busy(html) { setStatus(`<span class="spinner"></span> ${html}`); }
|
| 29 |
+
|
| 30 |
+
// Escape user-/ASR-supplied text before putting it inside an innerHTML string.
|
| 31 |
+
function esc(s) {
|
| 32 |
+
return String(s == null ? "" : s)
|
| 33 |
+
.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
| 34 |
+
.replaceAll('"', """).replaceAll("'", "'");
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// ---- playback ---------------------------------------------------------
|
| 38 |
+
function playAt(audioUrl, startSeconds, label) {
|
| 39 |
+
// The #t media-fragment makes the browser seek on load; Range requests make it instant.
|
| 40 |
+
player.src = `${audioUrl}#t=${startSeconds}`;
|
| 41 |
+
npTitle.textContent = `${label} @ ${fmtTime(startSeconds)}`;
|
| 42 |
+
playerBar.classList.remove("hidden");
|
| 43 |
+
player.load();
|
| 44 |
+
player.play().catch(() => {/* user gesture already present from the click */});
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
// ---- rendering --------------------------------------------------------
|
| 48 |
+
function render(results) {
|
| 49 |
+
resultsEl.innerHTML = "";
|
| 50 |
+
if (!results || results.length === 0) {
|
| 51 |
+
resultsEl.innerHTML = `<div class="empty">No matching moments found. Try rephrasing the topic.</div>`;
|
| 52 |
+
return;
|
| 53 |
+
}
|
| 54 |
+
for (const r of results) {
|
| 55 |
+
const card = document.createElement("div");
|
| 56 |
+
card.className = "card";
|
| 57 |
+
|
| 58 |
+
const row1 = document.createElement("div");
|
| 59 |
+
row1.className = "row1";
|
| 60 |
+
|
| 61 |
+
const jump = document.createElement("button");
|
| 62 |
+
jump.className = "jump";
|
| 63 |
+
jump.textContent = `▶ ${fmtTime(r.start_seconds)}`;
|
| 64 |
+
jump.onclick = () => playAt(r.audio_url, r.start_seconds, baseName(r.source_file));
|
| 65 |
+
|
| 66 |
+
const file = document.createElement("span");
|
| 67 |
+
file.className = "file";
|
| 68 |
+
file.textContent = baseName(r.source_file);
|
| 69 |
+
|
| 70 |
+
const score = document.createElement("span");
|
| 71 |
+
score.className = "scorepill";
|
| 72 |
+
score.textContent = (r.score != null ? (r.score * 100).toFixed(0) : "--") + "%";
|
| 73 |
+
score.title = "relevance";
|
| 74 |
+
|
| 75 |
+
row1.append(jump, file, score);
|
| 76 |
+
|
| 77 |
+
const hi = document.createElement("div");
|
| 78 |
+
hi.className = "hi-text";
|
| 79 |
+
hi.textContent = r.hindi_text || "";
|
| 80 |
+
|
| 81 |
+
card.append(row1, hi);
|
| 82 |
+
|
| 83 |
+
if (r.english_gloss) {
|
| 84 |
+
const en = document.createElement("div");
|
| 85 |
+
en.className = "en-gloss";
|
| 86 |
+
en.textContent = r.english_gloss;
|
| 87 |
+
card.append(en);
|
| 88 |
+
}
|
| 89 |
+
// clicking anywhere on the card also jumps
|
| 90 |
+
card.onclick = (e) => { if (e.target !== jump) playAt(r.audio_url, r.start_seconds, baseName(r.source_file)); };
|
| 91 |
+
resultsEl.append(card);
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
// ---- text search ------------------------------------------------------
|
| 96 |
+
async function doSearch() {
|
| 97 |
+
const query = qInput.value.trim();
|
| 98 |
+
if (!query) { qInput.focus(); return; }
|
| 99 |
+
busy(`Searching for “${esc(query)}”…`);
|
| 100 |
+
resultsEl.innerHTML = "";
|
| 101 |
+
try {
|
| 102 |
+
const resp = await fetch("/api/search", {
|
| 103 |
+
method: "POST",
|
| 104 |
+
headers: { "Content-Type": "application/json" },
|
| 105 |
+
body: JSON.stringify({ query }),
|
| 106 |
+
});
|
| 107 |
+
const data = await resp.json();
|
| 108 |
+
setStatus(`Found <b>${data.count}</b> moment(s) for “${esc(query)}”.`);
|
| 109 |
+
render(data.results);
|
| 110 |
+
} catch (err) {
|
| 111 |
+
setStatus(`Search failed: ${err}`);
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
// ---- audio query (mic + upload) --------------------------------------
|
| 116 |
+
let mediaRecorder = null;
|
| 117 |
+
let chunks = [];
|
| 118 |
+
|
| 119 |
+
async function toggleMic() {
|
| 120 |
+
const micBtn = $("micBtn");
|
| 121 |
+
if (mediaRecorder && mediaRecorder.state === "recording") {
|
| 122 |
+
mediaRecorder.stop();
|
| 123 |
+
return;
|
| 124 |
+
}
|
| 125 |
+
try {
|
| 126 |
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
| 127 |
+
mediaRecorder = new MediaRecorder(stream);
|
| 128 |
+
chunks = [];
|
| 129 |
+
mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) chunks.push(e.data); };
|
| 130 |
+
mediaRecorder.onstop = async () => {
|
| 131 |
+
stream.getTracks().forEach((t) => t.stop());
|
| 132 |
+
micBtn.classList.remove("recording");
|
| 133 |
+
const blob = new Blob(chunks, { type: "audio/webm" });
|
| 134 |
+
await sendAudio(blob, "clip.webm");
|
| 135 |
+
};
|
| 136 |
+
mediaRecorder.start();
|
| 137 |
+
micBtn.classList.add("recording");
|
| 138 |
+
busy("Recording… click the mic again to stop and search.");
|
| 139 |
+
} catch (err) {
|
| 140 |
+
setStatus(`Microphone unavailable: ${err}`);
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
async function sendAudio(blob, filename) {
|
| 145 |
+
busy("Transcribing your Hindi clip and searching…");
|
| 146 |
+
resultsEl.innerHTML = "";
|
| 147 |
+
const fd = new FormData();
|
| 148 |
+
fd.append("file", blob, filename);
|
| 149 |
+
try {
|
| 150 |
+
const resp = await fetch("/api/query_audio", { method: "POST", body: fd });
|
| 151 |
+
const data = await resp.json();
|
| 152 |
+
const recog = data.text ? `Heard: <span class="recog">${esc(data.text)}</span> — ` : "";
|
| 153 |
+
setStatus(`${recog}found <b>${data.count}</b> moment(s).`);
|
| 154 |
+
if (data.text) qInput.value = data.text;
|
| 155 |
+
render(data.results);
|
| 156 |
+
} catch (err) {
|
| 157 |
+
setStatus(`Audio search failed: ${err}`);
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
// ---- stats ------------------------------------------------------------
|
| 162 |
+
async function loadStats() {
|
| 163 |
+
try {
|
| 164 |
+
const data = await (await fetch("/api/stats")).json();
|
| 165 |
+
const indexed = (data.files && data.files.indexed) || 0;
|
| 166 |
+
const failed = (data.files && data.files.failed) || 0;
|
| 167 |
+
let s = `<b>${data.passages.toLocaleString()}</b> passages · <b>${indexed}</b> recordings indexed`;
|
| 168 |
+
if (failed) s += ` · ${failed} failed`;
|
| 169 |
+
s += data.reranker ? " · reranker on" : "";
|
| 170 |
+
$("stats").innerHTML = s;
|
| 171 |
+
} catch { /* ignore */ }
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
// ---- deployment config (hide audio-query controls when text-only) -----
|
| 175 |
+
async function applyConfig() {
|
| 176 |
+
try {
|
| 177 |
+
const cfg = await (await fetch("/api/config")).json();
|
| 178 |
+
if (!cfg.audio_query) {
|
| 179 |
+
const mic = document.getElementById("micBtn");
|
| 180 |
+
const up = document.getElementById("uploadLabel");
|
| 181 |
+
if (mic) mic.style.display = "none";
|
| 182 |
+
if (up) up.style.display = "none";
|
| 183 |
+
}
|
| 184 |
+
} catch { /* default to showing controls */ }
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
// ---- wire up ----------------------------------------------------------
|
| 188 |
+
$("searchBtn").onclick = doSearch;
|
| 189 |
+
qInput.addEventListener("keydown", (e) => { if (e.key === "Enter") doSearch(); });
|
| 190 |
+
$("micBtn").onclick = toggleMic;
|
| 191 |
+
$("uploadInput").addEventListener("change", (e) => {
|
| 192 |
+
const f = e.target.files[0];
|
| 193 |
+
if (f) sendAudio(f, f.name);
|
| 194 |
+
});
|
| 195 |
+
applyConfig();
|
| 196 |
+
loadStats();
|
| 197 |
+
qInput.focus();
|
app/web/index.html
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>SearchAudio — find the moment</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/styles.css" />
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<header class="topbar">
|
| 11 |
+
<div class="brand">
|
| 12 |
+
<span class="logo">🎧</span>
|
| 13 |
+
<div>
|
| 14 |
+
<h1>SearchAudio</h1>
|
| 15 |
+
<p class="tagline">Search Hindi talks in English — jump straight to the moment.</p>
|
| 16 |
+
</div>
|
| 17 |
+
</div>
|
| 18 |
+
<div id="stats" class="stats"></div>
|
| 19 |
+
</header>
|
| 20 |
+
|
| 21 |
+
<main>
|
| 22 |
+
<section class="searchbar">
|
| 23 |
+
<input id="q" type="text" autocomplete="off"
|
| 24 |
+
placeholder="Describe a topic in English or Hindi… e.g. “What is the meaning of true happiness?”" />
|
| 25 |
+
<button id="searchBtn" class="primary">Search</button>
|
| 26 |
+
<button id="micBtn" title="Search by speaking a Hindi clip">🎤</button>
|
| 27 |
+
<label id="uploadLabel" class="ghost" title="Search by uploading a Hindi audio clip">
|
| 28 |
+
⬆ Audio
|
| 29 |
+
<input id="uploadInput" type="file" accept="audio/*" hidden />
|
| 30 |
+
</label>
|
| 31 |
+
</section>
|
| 32 |
+
|
| 33 |
+
<p id="status" class="status"></p>
|
| 34 |
+
<div id="results" class="results"></div>
|
| 35 |
+
</main>
|
| 36 |
+
|
| 37 |
+
<div id="playerBar" class="playerbar hidden">
|
| 38 |
+
<div class="np">
|
| 39 |
+
<strong>Now playing:</strong> <span id="npTitle">—</span>
|
| 40 |
+
</div>
|
| 41 |
+
<audio id="player" controls preload="none"></audio>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
<script src="/static/app.js"></script>
|
| 45 |
+
</body>
|
| 46 |
+
</html>
|