kumarakkiy commited on
Commit
4cdc522
·
verified ·
1 Parent(s): b7832be

Upload 143 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +13 -0
  2. Dockerfile +23 -0
  3. app/__init__.py +12 -0
  4. app/__pycache__/__init__.cpython-312.pyc +0 -0
  5. app/__pycache__/__init__.cpython-314.pyc +0 -0
  6. app/__pycache__/_cuda_bootstrap.cpython-312.pyc +0 -0
  7. app/__pycache__/_cuda_bootstrap.cpython-314.pyc +0 -0
  8. app/__pycache__/asr.cpython-312.pyc +0 -0
  9. app/__pycache__/asr.cpython-314.pyc +0 -0
  10. app/__pycache__/audio_source.cpython-312.pyc +0 -0
  11. app/__pycache__/audio_utils.cpython-312.pyc +0 -0
  12. app/__pycache__/audio_utils.cpython-314.pyc +0 -0
  13. app/__pycache__/chunking.cpython-312.pyc +0 -0
  14. app/__pycache__/chunking.cpython-314.pyc +0 -0
  15. app/__pycache__/config.cpython-312.pyc +0 -0
  16. app/__pycache__/config.cpython-314.pyc +0 -0
  17. app/__pycache__/embed.cpython-312.pyc +0 -0
  18. app/__pycache__/embed.cpython-314.pyc +0 -0
  19. app/__pycache__/ingest.cpython-312.pyc +0 -0
  20. app/__pycache__/ingest.cpython-314.pyc +0 -0
  21. app/__pycache__/manifest.cpython-312.pyc +0 -0
  22. app/__pycache__/manifest.cpython-314.pyc +0 -0
  23. app/__pycache__/models.cpython-312.pyc +0 -0
  24. app/__pycache__/models.cpython-314.pyc +0 -0
  25. app/__pycache__/query.cpython-312.pyc +0 -0
  26. app/__pycache__/query.cpython-314.pyc +0 -0
  27. app/__pycache__/rerank.cpython-312.pyc +0 -0
  28. app/__pycache__/rerank.cpython-314.pyc +0 -0
  29. app/__pycache__/server.cpython-312.pyc +0 -0
  30. app/__pycache__/server.cpython-314.pyc +0 -0
  31. app/__pycache__/store.cpython-312.pyc +0 -0
  32. app/__pycache__/store.cpython-314.pyc +0 -0
  33. app/__pycache__/translate.cpython-314.pyc +0 -0
  34. app/_cuda_bootstrap.py +88 -0
  35. app/asr.py +209 -0
  36. app/audio_source.py +36 -0
  37. app/audio_utils.py +56 -0
  38. app/chunking.py +124 -0
  39. app/config.py +190 -0
  40. app/embed.py +53 -0
  41. app/ingest.py +164 -0
  42. app/manifest.py +139 -0
  43. app/models.py +86 -0
  44. app/query.py +112 -0
  45. app/rerank.py +44 -0
  46. app/server.py +135 -0
  47. app/store.py +223 -0
  48. app/translate.py +78 -0
  49. app/web/app.js +197 -0
  50. 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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
34
+ .replaceAll('"', "&quot;").replaceAll("'", "&#39;");
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>