ahmed commited on
Commit ·
f15e49c
0
Parent(s):
Deploy Quran reciter Gradio app
Browse files- .gitignore +12 -0
- README.md +124 -0
- app.py +15 -0
- configs/ecapa_quran_md.yaml +22 -0
- gradio_app.py +106 -0
- pyproject.toml +25 -0
- requirements.txt +11 -0
- src/quran_reciter_id/__init__.py +3 -0
- src/quran_reciter_id/audio.py +89 -0
- src/quran_reciter_id/build_manifest.py +136 -0
- src/quran_reciter_id/config.py +72 -0
- src/quran_reciter_id/embeddings.py +67 -0
- src/quran_reciter_id/export_quran_md.py +197 -0
- src/quran_reciter_id/manifest.py +67 -0
- src/quran_reciter_id/model.py +28 -0
- src/quran_reciter_id/predict.py +97 -0
- src/quran_reciter_id/train.py +246 -0
.gitignore
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
*.egg-info/
|
| 4 |
+
|
| 5 |
+
.venv/
|
| 6 |
+
data/
|
| 7 |
+
runs/
|
| 8 |
+
pretrained_models/
|
| 9 |
+
test/
|
| 10 |
+
|
| 11 |
+
.env
|
| 12 |
+
.DS_Store
|
README.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Quran Reciter Identification
|
| 3 |
+
emoji: 🎙️
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 5.0.0
|
| 8 |
+
app_file: app.py
|
| 9 |
+
python_version: "3.11"
|
| 10 |
+
suggested_hardware: cpu-basic
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# Quran Reciter Identification
|
| 14 |
+
|
| 15 |
+
Identify Quran reciters from short unknown audio clips using a strong pretrained
|
| 16 |
+
speaker-recognition backbone.
|
| 17 |
+
|
| 18 |
+
## Approach
|
| 19 |
+
|
| 20 |
+
This project uses `speechbrain/spkrec-ecapa-voxceleb` as the default embedding
|
| 21 |
+
model. ECAPA-TDNN is a proven speaker-recognition architecture, and the
|
| 22 |
+
SpeechBrain checkpoint is trained on VoxCeleb1+VoxCeleb2. The local classifier
|
| 23 |
+
is trained on Quran reciter embeddings, so training is fast enough for a
|
| 24 |
+
moderate laptop dataset while still using a deep speaker model.
|
| 25 |
+
|
| 26 |
+
Recommended dataset:
|
| 27 |
+
|
| 28 |
+
- `Buraaq/quran-md-ayahs` on Hugging Face: 187,080 verse-level clips, 30
|
| 29 |
+
reciters, complete Quran coverage, about 450+ hours.
|
| 30 |
+
|
| 31 |
+
The code also supports any folder dataset shaped like:
|
| 32 |
+
|
| 33 |
+
```text
|
| 34 |
+
data/raw/
|
| 35 |
+
alafasy/
|
| 36 |
+
clip001.mp3
|
| 37 |
+
clip002.wav
|
| 38 |
+
abdul_basit/
|
| 39 |
+
clip001.mp3
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
## Setup
|
| 43 |
+
|
| 44 |
+
```powershell
|
| 45 |
+
python -m venv .venv
|
| 46 |
+
.\.venv\Scripts\Activate.ps1
|
| 47 |
+
pip install -r requirements.txt
|
| 48 |
+
pip install -e .
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
Install FFmpeg if your audio files are MP3 and `torchaudio` cannot decode them
|
| 52 |
+
on your machine.
|
| 53 |
+
|
| 54 |
+
## Build a Moderate Quran-MD Dataset
|
| 55 |
+
|
| 56 |
+
This exports a balanced subset from Hugging Face into local WAV clips and a
|
| 57 |
+
manifest. The default keeps all 30 reciters and about 500 ayahs per reciter,
|
| 58 |
+
spread across train/validation/test surah ranges.
|
| 59 |
+
|
| 60 |
+
```powershell
|
| 61 |
+
python -m quran_reciter_id.export_quran_md `
|
| 62 |
+
--output-dir data/quran_md `
|
| 63 |
+
--max-samples-per-reciter 500
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
For a larger run, increase `--max-samples-per-reciter`; use `0` for the full
|
| 67 |
+
dataset.
|
| 68 |
+
|
| 69 |
+
## Build a Manifest from Local Folders
|
| 70 |
+
|
| 71 |
+
```powershell
|
| 72 |
+
python -m quran_reciter_id.build_manifest `
|
| 73 |
+
--audio-root data/raw `
|
| 74 |
+
--output data/manifests/local.jsonl
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
## Train
|
| 78 |
+
|
| 79 |
+
```powershell
|
| 80 |
+
python -m quran_reciter_id.train `
|
| 81 |
+
--manifest data/quran_md/manifest.jsonl `
|
| 82 |
+
--output-dir runs/ecapa_quran_md
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
Training saves:
|
| 86 |
+
|
| 87 |
+
- `model.pt`: classifier head and normalization stats
|
| 88 |
+
- `labels.json`: reciter labels
|
| 89 |
+
- `metrics.json`: validation/test metrics
|
| 90 |
+
- `embeddings/*.npz`: cached ECAPA embeddings
|
| 91 |
+
|
| 92 |
+
## Predict an Unknown Reciter
|
| 93 |
+
|
| 94 |
+
```powershell
|
| 95 |
+
python -m quran_reciter_id.predict `
|
| 96 |
+
--run-dir runs/ecapa_quran_md `
|
| 97 |
+
--audio-file path\to\unknown_recitation.mp3 `
|
| 98 |
+
--top-k 5
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
The predictor returns top candidates, probabilities, and a centroid similarity
|
| 102 |
+
score. If confidence is below `--unknown-threshold`, it reports the clip as
|
| 103 |
+
unknown/out-of-distribution.
|
| 104 |
+
|
| 105 |
+
## Test in the Browser
|
| 106 |
+
|
| 107 |
+
Launch the Gradio interface to upload audio or record from a microphone:
|
| 108 |
+
|
| 109 |
+
```powershell
|
| 110 |
+
python gradio_app.py
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
Choose the checkpoint directory and device in the interface. Browser microphone
|
| 114 |
+
access requires localhost or an HTTPS connection.
|
| 115 |
+
|
| 116 |
+
## Notes
|
| 117 |
+
|
| 118 |
+
- Split by surah when possible. That prevents the model from memorizing a
|
| 119 |
+
specific verse recording pattern instead of the reciter voice.
|
| 120 |
+
- A 30-reciter Quran-MD subset is the best practical starting point today. If a
|
| 121 |
+
directly downloadable Tadabur release becomes available, use
|
| 122 |
+
`build_manifest.py` after arranging audio by reciter folder.
|
| 123 |
+
- For production, use longer clips when possible. A 10-30 second recitation
|
| 124 |
+
generally gives more stable speaker embeddings than a very short ayah.
|
app.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
# Hugging Face Spaces runs the repository directly rather than installing the
|
| 8 |
+
# src-layout package first.
|
| 9 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))
|
| 10 |
+
|
| 11 |
+
from gradio_app import demo # noqa: E402
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
if __name__ == "__main__":
|
| 15 |
+
demo.launch()
|
configs/ecapa_quran_md.yaml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
seed: 1337
|
| 2 |
+
sample_rate: 16000
|
| 3 |
+
embedding_model: speechbrain/spkrec-ecapa-voxceleb
|
| 4 |
+
device: auto
|
| 5 |
+
|
| 6 |
+
dataset:
|
| 7 |
+
min_duration_sec: 1.0
|
| 8 |
+
max_duration_sec: 45.0
|
| 9 |
+
split_strategy: per_reciter_order
|
| 10 |
+
|
| 11 |
+
classifier:
|
| 12 |
+
hidden_dim: 256
|
| 13 |
+
dropout: 0.2
|
| 14 |
+
batch_size: 128
|
| 15 |
+
epochs: 80
|
| 16 |
+
learning_rate: 0.001
|
| 17 |
+
weight_decay: 0.0001
|
| 18 |
+
patience: 10
|
| 19 |
+
|
| 20 |
+
inference:
|
| 21 |
+
unknown_threshold: 0.45
|
| 22 |
+
min_centroid_similarity: 0.25
|
gradio_app.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import gradio as gr
|
| 7 |
+
|
| 8 |
+
from quran_reciter_id.predict import predict
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _default_checkpoint_dir() -> str:
|
| 12 |
+
configured = os.getenv("CHECKPOINT_DIR")
|
| 13 |
+
if configured:
|
| 14 |
+
return configured
|
| 15 |
+
if Path("checkpoint/model.pt").is_file():
|
| 16 |
+
return "checkpoint"
|
| 17 |
+
local_runs = sorted(Path("runs").glob("*/model.pt"))
|
| 18 |
+
return str(local_runs[-1].parent) if local_runs else "checkpoint"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def identify_reciter(
|
| 22 |
+
audio_file: str | None,
|
| 23 |
+
video_file: str | None,
|
| 24 |
+
run_dir_text: str,
|
| 25 |
+
device: str,
|
| 26 |
+
top_k: int,
|
| 27 |
+
) -> tuple[str, list[list[object]]]:
|
| 28 |
+
media_file = audio_file or video_file
|
| 29 |
+
if not media_file:
|
| 30 |
+
raise gr.Error("Record audio, upload audio, or upload an MP4 video first.")
|
| 31 |
+
|
| 32 |
+
run_dir = Path(run_dir_text).expanduser()
|
| 33 |
+
checkpoint_path = run_dir / "model.pt"
|
| 34 |
+
if not checkpoint_path.is_file():
|
| 35 |
+
raise gr.Error(f"Checkpoint not found: {checkpoint_path}")
|
| 36 |
+
|
| 37 |
+
try:
|
| 38 |
+
result = predict(
|
| 39 |
+
run_dir=run_dir,
|
| 40 |
+
audio_file=Path(media_file),
|
| 41 |
+
top_k=int(top_k),
|
| 42 |
+
device=device.strip() or "auto",
|
| 43 |
+
)
|
| 44 |
+
except Exception as exc:
|
| 45 |
+
raise gr.Error(f"Prediction failed: {exc}") from exc
|
| 46 |
+
|
| 47 |
+
if result["is_unknown"]:
|
| 48 |
+
verdict = "### Unknown speaker\nNo known reciter passed the acceptance thresholds."
|
| 49 |
+
else:
|
| 50 |
+
verdict = f"### Prediction: `{result['prediction']}`"
|
| 51 |
+
|
| 52 |
+
candidates = [
|
| 53 |
+
[
|
| 54 |
+
candidate["reciter_id"],
|
| 55 |
+
round(candidate["probability"] * 100, 2),
|
| 56 |
+
round(candidate["centroid_similarity"], 4),
|
| 57 |
+
]
|
| 58 |
+
for candidate in result["top_candidates"]
|
| 59 |
+
]
|
| 60 |
+
return verdict, candidates
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
with gr.Blocks(title="Quran Reciter ID") as demo:
|
| 64 |
+
gr.Markdown(
|
| 65 |
+
"# Quran Reciter Identification\n"
|
| 66 |
+
"Record a recitation or upload audio to test a trained checkpoint."
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
with gr.Row():
|
| 70 |
+
with gr.Column(scale=2):
|
| 71 |
+
audio = gr.Audio(
|
| 72 |
+
label="Audio",
|
| 73 |
+
sources=["microphone", "upload"],
|
| 74 |
+
type="filepath",
|
| 75 |
+
)
|
| 76 |
+
video = gr.Video(
|
| 77 |
+
label="MP4 video (the audio track will be analyzed)",
|
| 78 |
+
sources=["upload"],
|
| 79 |
+
format="mp4",
|
| 80 |
+
)
|
| 81 |
+
with gr.Column(scale=1):
|
| 82 |
+
run_dir = gr.Textbox(
|
| 83 |
+
label="Checkpoint directory",
|
| 84 |
+
value=_default_checkpoint_dir(),
|
| 85 |
+
)
|
| 86 |
+
device = gr.Textbox(label="Device", value="auto")
|
| 87 |
+
top_k = gr.Slider(1, 10, value=5, step=1, label="Top candidates")
|
| 88 |
+
identify = gr.Button("Identify reciter", variant="primary")
|
| 89 |
+
|
| 90 |
+
verdict = gr.Markdown()
|
| 91 |
+
candidates = gr.Dataframe(
|
| 92 |
+
headers=["Reciter", "Probability (%)", "Centroid similarity"],
|
| 93 |
+
datatype=["str", "number", "number"],
|
| 94 |
+
interactive=False,
|
| 95 |
+
label="Top candidates",
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
identify.click(
|
| 99 |
+
fn=identify_reciter,
|
| 100 |
+
inputs=[audio, video, run_dir, device, top_k],
|
| 101 |
+
outputs=[verdict, candidates],
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
if __name__ == "__main__":
|
| 106 |
+
demo.launch(server_name="0.0.0.0")
|
pyproject.toml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=68", "wheel"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "quran-reciter-id"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "Quran reciter identification with deep speaker embeddings."
|
| 9 |
+
requires-python = ">=3.10"
|
| 10 |
+
dependencies = [
|
| 11 |
+
"datasets>=2.20.0",
|
| 12 |
+
"huggingface-hub>=0.24.0",
|
| 13 |
+
"gradio>=5.0.0",
|
| 14 |
+
"numpy>=1.24",
|
| 15 |
+
"PyYAML>=6.0",
|
| 16 |
+
"scikit-learn>=1.4",
|
| 17 |
+
"soundfile>=0.12",
|
| 18 |
+
"speechbrain>=1.0.0",
|
| 19 |
+
"torch>=2.2",
|
| 20 |
+
"torchaudio>=2.2",
|
| 21 |
+
"tqdm>=4.66",
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
[tool.setuptools.packages.find]
|
| 25 |
+
where = ["src"]
|
requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
datasets>=2.20.0
|
| 2 |
+
huggingface-hub>=0.24.0
|
| 3 |
+
gradio>=5.0.0
|
| 4 |
+
numpy>=1.24
|
| 5 |
+
PyYAML>=6.0
|
| 6 |
+
scikit-learn>=1.4
|
| 7 |
+
soundfile>=0.12
|
| 8 |
+
speechbrain>=1.0.0
|
| 9 |
+
torch>=2.2
|
| 10 |
+
torchaudio>=2.2
|
| 11 |
+
tqdm>=4.66
|
src/quran_reciter_id/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Quran reciter identification package."""
|
| 2 |
+
|
| 3 |
+
__version__ = "0.1.0"
|
src/quran_reciter_id/audio.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import subprocess
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
import torchaudio
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _load_audio_with_ffmpeg(
|
| 11 |
+
path: str | Path,
|
| 12 |
+
sample_rate: int,
|
| 13 |
+
max_duration_sec: float | None,
|
| 14 |
+
) -> torch.Tensor:
|
| 15 |
+
command = [
|
| 16 |
+
"ffmpeg",
|
| 17 |
+
"-v",
|
| 18 |
+
"error",
|
| 19 |
+
"-i",
|
| 20 |
+
str(path),
|
| 21 |
+
"-vn",
|
| 22 |
+
]
|
| 23 |
+
if max_duration_sec is not None:
|
| 24 |
+
command.extend(["-t", str(max_duration_sec)])
|
| 25 |
+
command.extend(
|
| 26 |
+
["-ac", "1", "-ar", str(sample_rate), "-f", "f32le", "pipe:1"]
|
| 27 |
+
)
|
| 28 |
+
completed = subprocess.run(command, capture_output=True, check=False)
|
| 29 |
+
if completed.returncode != 0 or not completed.stdout:
|
| 30 |
+
error = completed.stderr.decode("utf-8", errors="replace").strip()
|
| 31 |
+
raise RuntimeError(f"FFmpeg could not decode {path}: {error}")
|
| 32 |
+
|
| 33 |
+
waveform = torch.frombuffer(bytearray(completed.stdout), dtype=torch.float32)
|
| 34 |
+
return waveform.unsqueeze(0).clamp(-1.0, 1.0)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def audio_duration_sec(path: str | Path) -> float | None:
|
| 38 |
+
try:
|
| 39 |
+
info = torchaudio.info(str(path))
|
| 40 |
+
except Exception:
|
| 41 |
+
return None
|
| 42 |
+
if info.sample_rate <= 0:
|
| 43 |
+
return None
|
| 44 |
+
return float(info.num_frames) / float(info.sample_rate)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def load_audio(
|
| 48 |
+
path: str | Path,
|
| 49 |
+
sample_rate: int = 16000,
|
| 50 |
+
max_duration_sec: float | None = None,
|
| 51 |
+
) -> torch.Tensor:
|
| 52 |
+
try:
|
| 53 |
+
waveform, source_rate = torchaudio.load(str(path), backend="ffmpeg")
|
| 54 |
+
except Exception:
|
| 55 |
+
# The TorchAudio wheel may not include its optional FFmpeg backend even
|
| 56 |
+
# when the system ffmpeg executable supports M4A/MP4. Decode through the
|
| 57 |
+
# executable as a reliable fallback.
|
| 58 |
+
return _load_audio_with_ffmpeg(path, sample_rate, max_duration_sec)
|
| 59 |
+
if waveform.ndim == 2 and waveform.shape[0] > 1:
|
| 60 |
+
waveform = waveform.mean(dim=0, keepdim=True)
|
| 61 |
+
elif waveform.ndim == 1:
|
| 62 |
+
waveform = waveform.unsqueeze(0)
|
| 63 |
+
|
| 64 |
+
if source_rate != sample_rate:
|
| 65 |
+
waveform = torchaudio.functional.resample(waveform, source_rate, sample_rate)
|
| 66 |
+
|
| 67 |
+
if max_duration_sec is not None:
|
| 68 |
+
max_frames = int(sample_rate * max_duration_sec)
|
| 69 |
+
if waveform.shape[-1] > max_frames:
|
| 70 |
+
waveform = waveform[..., :max_frames]
|
| 71 |
+
|
| 72 |
+
waveform = waveform.clamp(-1.0, 1.0)
|
| 73 |
+
return waveform
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def normalize_array_audio(array, source_rate: int, target_rate: int = 16000) -> torch.Tensor:
|
| 77 |
+
waveform = torch.as_tensor(array).float()
|
| 78 |
+
if waveform.ndim == 1:
|
| 79 |
+
waveform = waveform.unsqueeze(0)
|
| 80 |
+
elif waveform.ndim == 2:
|
| 81 |
+
if waveform.shape[0] > waveform.shape[1]:
|
| 82 |
+
waveform = waveform.transpose(0, 1)
|
| 83 |
+
if waveform.shape[0] > 1:
|
| 84 |
+
waveform = waveform.mean(dim=0, keepdim=True)
|
| 85 |
+
|
| 86 |
+
if source_rate != target_rate:
|
| 87 |
+
waveform = torchaudio.functional.resample(waveform, source_rate, target_rate)
|
| 88 |
+
|
| 89 |
+
return waveform.clamp(-1.0, 1.0)
|
src/quran_reciter_id/build_manifest.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
from collections import defaultdict
|
| 6 |
+
from math import ceil
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from tqdm import tqdm
|
| 10 |
+
|
| 11 |
+
from .audio import audio_duration_sec
|
| 12 |
+
from .config import DEFAULT_CONFIG_PATH, load_config
|
| 13 |
+
from .manifest import iter_audio_files, summarize_manifest, write_jsonl
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _assign_per_reciter_order_splits(rows: list[dict]) -> None:
|
| 17 |
+
rows_by_reciter: dict[str, list[dict]] = defaultdict(list)
|
| 18 |
+
for row in rows:
|
| 19 |
+
rows_by_reciter[row["reciter_id"]].append(row)
|
| 20 |
+
|
| 21 |
+
for reciter_rows in rows_by_reciter.values():
|
| 22 |
+
sample_count = len(reciter_rows)
|
| 23 |
+
if sample_count < 3:
|
| 24 |
+
val_count = test_count = 0
|
| 25 |
+
else:
|
| 26 |
+
val_count = max(1, ceil(sample_count * 0.10))
|
| 27 |
+
test_count = max(1, ceil(sample_count * 0.10))
|
| 28 |
+
train_count = sample_count - val_count - test_count
|
| 29 |
+
|
| 30 |
+
for index, row in enumerate(reciter_rows):
|
| 31 |
+
if index < train_count:
|
| 32 |
+
row["split"] = "train"
|
| 33 |
+
elif index < train_count + val_count:
|
| 34 |
+
row["split"] = "val"
|
| 35 |
+
else:
|
| 36 |
+
row["split"] = "test"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _assign_surah_splits(
|
| 40 |
+
rows: list[dict],
|
| 41 |
+
train_surah_max: int,
|
| 42 |
+
val_surah_max: int,
|
| 43 |
+
) -> None:
|
| 44 |
+
for row in rows:
|
| 45 |
+
surah_id = row.get("surah_id")
|
| 46 |
+
if surah_id is None:
|
| 47 |
+
raise ValueError(
|
| 48 |
+
f"Cannot use the surah split strategy because {row['path']} "
|
| 49 |
+
"does not have a six-digit SSSAAA filename."
|
| 50 |
+
)
|
| 51 |
+
if surah_id <= train_surah_max:
|
| 52 |
+
row["split"] = "train"
|
| 53 |
+
elif surah_id <= val_surah_max:
|
| 54 |
+
row["split"] = "val"
|
| 55 |
+
else:
|
| 56 |
+
row["split"] = "test"
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def build_manifest(
|
| 60 |
+
audio_root: Path,
|
| 61 |
+
output: Path,
|
| 62 |
+
min_duration_sec: float = 1.0,
|
| 63 |
+
max_duration_sec: float = 60.0,
|
| 64 |
+
split_strategy: str = "per_reciter_order",
|
| 65 |
+
train_surah_max: int = 95,
|
| 66 |
+
val_surah_max: int = 105,
|
| 67 |
+
) -> dict:
|
| 68 |
+
rows: list[dict] = []
|
| 69 |
+
root = audio_root.resolve()
|
| 70 |
+
|
| 71 |
+
audio_paths = list(iter_audio_files(root))
|
| 72 |
+
for audio_path in tqdm(audio_paths, desc="Building manifest", unit="audio"):
|
| 73 |
+
try:
|
| 74 |
+
reciter_id = audio_path.relative_to(root).parts[0]
|
| 75 |
+
except IndexError:
|
| 76 |
+
continue
|
| 77 |
+
|
| 78 |
+
duration = audio_duration_sec(audio_path)
|
| 79 |
+
if duration is None:
|
| 80 |
+
continue
|
| 81 |
+
if duration < min_duration_sec or duration > max_duration_sec:
|
| 82 |
+
continue
|
| 83 |
+
|
| 84 |
+
row = {
|
| 85 |
+
"path": str(audio_path.resolve()),
|
| 86 |
+
"reciter_id": reciter_id,
|
| 87 |
+
"reciter_name": reciter_id,
|
| 88 |
+
"duration_sec": duration,
|
| 89 |
+
}
|
| 90 |
+
if len(audio_path.stem) == 6 and audio_path.stem.isdigit():
|
| 91 |
+
row["surah_id"] = int(audio_path.stem[:3])
|
| 92 |
+
row["ayah_id"] = int(audio_path.stem[3:])
|
| 93 |
+
rows.append(row)
|
| 94 |
+
|
| 95 |
+
if split_strategy == "per_reciter_order":
|
| 96 |
+
_assign_per_reciter_order_splits(rows)
|
| 97 |
+
elif split_strategy == "surah":
|
| 98 |
+
_assign_surah_splits(rows, train_surah_max, val_surah_max)
|
| 99 |
+
else:
|
| 100 |
+
raise ValueError(
|
| 101 |
+
f"Unknown split strategy {split_strategy!r}; expected "
|
| 102 |
+
"'per_reciter_order' or 'surah'."
|
| 103 |
+
)
|
| 104 |
+
write_jsonl(output, rows)
|
| 105 |
+
summary = summarize_manifest(rows)
|
| 106 |
+
summary_path = output.with_name("summary.json")
|
| 107 |
+
summary_path.write_text(json.dumps(summary, indent=2), encoding="utf-8")
|
| 108 |
+
return summary
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def main() -> None:
|
| 112 |
+
parser = argparse.ArgumentParser(description="Build a reciter manifest from audio folders.")
|
| 113 |
+
parser.add_argument("--audio-root", required=True, type=Path)
|
| 114 |
+
parser.add_argument("--output", required=True, type=Path)
|
| 115 |
+
parser.add_argument("--min-duration-sec", default=1.0, type=float)
|
| 116 |
+
parser.add_argument("--max-duration-sec", default=60.0, type=float)
|
| 117 |
+
parser.add_argument("--config", default=DEFAULT_CONFIG_PATH, type=Path)
|
| 118 |
+
args = parser.parse_args()
|
| 119 |
+
|
| 120 |
+
config = load_config(args.config)
|
| 121 |
+
dataset_config = config.get("dataset", {})
|
| 122 |
+
|
| 123 |
+
summary = build_manifest(
|
| 124 |
+
audio_root=args.audio_root,
|
| 125 |
+
output=args.output,
|
| 126 |
+
min_duration_sec=args.min_duration_sec,
|
| 127 |
+
max_duration_sec=args.max_duration_sec,
|
| 128 |
+
split_strategy=str(dataset_config.get("split_strategy", "per_reciter_order")),
|
| 129 |
+
train_surah_max=int(dataset_config.get("train_surah_max", 95)),
|
| 130 |
+
val_surah_max=int(dataset_config.get("val_surah_max", 105)),
|
| 131 |
+
)
|
| 132 |
+
print(json.dumps(summary, indent=2))
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
if __name__ == "__main__":
|
| 136 |
+
main()
|
src/quran_reciter_id/config.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
import yaml
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
DEFAULT_CONFIG_PATH = Path("configs/ecapa_quran_md.yaml")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def load_config(path: str | Path | None = None) -> dict[str, Any]:
|
| 13 |
+
config_path = Path(path) if path else DEFAULT_CONFIG_PATH
|
| 14 |
+
with config_path.open("r", encoding="utf-8") as handle:
|
| 15 |
+
return yaml.safe_load(handle)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def resolve_device(device: str) -> str:
|
| 19 |
+
if not device.startswith("cuda") and device != "auto":
|
| 20 |
+
return device
|
| 21 |
+
|
| 22 |
+
import torch
|
| 23 |
+
import torch.nn.functional as F
|
| 24 |
+
|
| 25 |
+
if not torch.cuda.is_available():
|
| 26 |
+
if device == "auto":
|
| 27 |
+
return "cpu"
|
| 28 |
+
raise RuntimeError("CUDA was requested, but PyTorch cannot access a CUDA GPU")
|
| 29 |
+
|
| 30 |
+
if device == "auto":
|
| 31 |
+
candidates = list(range(torch.cuda.device_count()))
|
| 32 |
+
candidates.sort(key=lambda index: torch.cuda.mem_get_info(index)[0], reverse=True)
|
| 33 |
+
else:
|
| 34 |
+
try:
|
| 35 |
+
requested_index = int(device.split(":", 1)[1]) if ":" in device else 0
|
| 36 |
+
except ValueError as exc:
|
| 37 |
+
raise ValueError(f"Invalid CUDA device: {device}") from exc
|
| 38 |
+
if requested_index >= torch.cuda.device_count():
|
| 39 |
+
raise RuntimeError(
|
| 40 |
+
f"{device} was requested, but only {torch.cuda.device_count()} CUDA "
|
| 41 |
+
"device(s) are visible. Use 'device: auto' or a valid logical index."
|
| 42 |
+
)
|
| 43 |
+
candidates = [requested_index]
|
| 44 |
+
|
| 45 |
+
failures: list[str] = []
|
| 46 |
+
for index in candidates:
|
| 47 |
+
candidate = f"cuda:{index}"
|
| 48 |
+
try:
|
| 49 |
+
x = torch.zeros(1, 1, 32, device=candidate)
|
| 50 |
+
weight = torch.zeros(1, 1, 3, device=candidate)
|
| 51 |
+
F.conv1d(x, weight)
|
| 52 |
+
torch.cuda.synchronize(index)
|
| 53 |
+
return candidate
|
| 54 |
+
except Exception as exc:
|
| 55 |
+
failures.append(f"{candidate}: {exc}")
|
| 56 |
+
|
| 57 |
+
# Keep training on the GPU if only the optional cuDNN acceleration layer is
|
| 58 |
+
# broken or incompatible with the installed CUDA/PyTorch runtime.
|
| 59 |
+
torch.backends.cudnn.enabled = False
|
| 60 |
+
for index in candidates:
|
| 61 |
+
candidate = f"cuda:{index}"
|
| 62 |
+
try:
|
| 63 |
+
x = torch.zeros(1, 1, 32, device=candidate)
|
| 64 |
+
weight = torch.zeros(1, 1, 3, device=candidate)
|
| 65 |
+
F.conv1d(x, weight)
|
| 66 |
+
torch.cuda.synchronize(index)
|
| 67 |
+
print(f"Warning: cuDNN initialization failed; using {candidate} without cuDNN.")
|
| 68 |
+
return candidate
|
| 69 |
+
except Exception as exc:
|
| 70 |
+
failures.append(f"{candidate} without cuDNN: {exc}")
|
| 71 |
+
|
| 72 |
+
raise RuntimeError("No working CUDA device was found. " + " | ".join(failures))
|
src/quran_reciter_id/embeddings.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import torch
|
| 7 |
+
from speechbrain.inference.speaker import EncoderClassifier
|
| 8 |
+
from tqdm import tqdm
|
| 9 |
+
|
| 10 |
+
from .audio import load_audio
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class EcapaEmbedder:
|
| 14 |
+
def __init__(
|
| 15 |
+
self,
|
| 16 |
+
source: str = "speechbrain/spkrec-ecapa-voxceleb",
|
| 17 |
+
device: str = "cpu",
|
| 18 |
+
savedir: str | Path = "pretrained_models/spkrec-ecapa-voxceleb",
|
| 19 |
+
sample_rate: int = 16000,
|
| 20 |
+
) -> None:
|
| 21 |
+
self.sample_rate = sample_rate
|
| 22 |
+
self.device = device
|
| 23 |
+
self.classifier = EncoderClassifier.from_hparams(
|
| 24 |
+
source=source,
|
| 25 |
+
savedir=str(savedir),
|
| 26 |
+
run_opts={"device": device},
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
@torch.inference_mode()
|
| 30 |
+
def encode_file(self, path: str | Path, max_duration_sec: float | None = 45.0) -> np.ndarray:
|
| 31 |
+
waveform = load_audio(path, sample_rate=self.sample_rate, max_duration_sec=max_duration_sec)
|
| 32 |
+
waveform = waveform.to(self.device)
|
| 33 |
+
embedding = self.classifier.encode_batch(waveform)
|
| 34 |
+
return embedding.squeeze().detach().cpu().numpy().astype(np.float32)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def extract_embeddings(
|
| 38 |
+
rows: list[dict],
|
| 39 |
+
embedder: EcapaEmbedder,
|
| 40 |
+
label_to_idx: dict[str, int],
|
| 41 |
+
max_duration_sec: float | None = 45.0,
|
| 42 |
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
| 43 |
+
embeddings: list[np.ndarray] = []
|
| 44 |
+
labels: list[int] = []
|
| 45 |
+
keep_indices: list[int] = []
|
| 46 |
+
|
| 47 |
+
for index, row in enumerate(tqdm(rows, desc="Extracting ECAPA embeddings")):
|
| 48 |
+
try:
|
| 49 |
+
embedding = embedder.encode_file(row["path"], max_duration_sec=max_duration_sec)
|
| 50 |
+
except Exception as exc:
|
| 51 |
+
error_message = str(exc)
|
| 52 |
+
if "CUDA" in error_message.upper() or "CUDNN" in error_message.upper():
|
| 53 |
+
raise RuntimeError(
|
| 54 |
+
f"GPU embedding inference failed on {embedder.device}: {exc}. "
|
| 55 |
+
"Set 'device: cpu' in the config, or select a working CUDA device."
|
| 56 |
+
) from exc
|
| 57 |
+
print(f"Skipping {row['path']}: {exc}")
|
| 58 |
+
continue
|
| 59 |
+
embeddings.append(embedding)
|
| 60 |
+
labels.append(label_to_idx[row["reciter_id"]])
|
| 61 |
+
keep_indices.append(index)
|
| 62 |
+
|
| 63 |
+
return (
|
| 64 |
+
np.stack(embeddings).astype(np.float32),
|
| 65 |
+
np.asarray(labels, dtype=np.int64),
|
| 66 |
+
np.asarray(keep_indices, dtype=np.int64),
|
| 67 |
+
)
|
src/quran_reciter_id/export_quran_md.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import io
|
| 5 |
+
import json
|
| 6 |
+
from collections import Counter
|
| 7 |
+
from math import ceil
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
import soundfile as sf
|
| 11 |
+
import torchaudio
|
| 12 |
+
from datasets import Audio, load_dataset
|
| 13 |
+
from tqdm import tqdm
|
| 14 |
+
|
| 15 |
+
from .audio import normalize_array_audio
|
| 16 |
+
from .manifest import split_from_surah, summarize_manifest, write_jsonl
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _decode_audio(audio: dict) -> tuple[object, int]:
|
| 20 |
+
"""Decode a raw Hugging Face audio record with FFmpeg when available."""
|
| 21 |
+
if audio.get("bytes") is not None:
|
| 22 |
+
source = io.BytesIO(audio["bytes"])
|
| 23 |
+
elif audio.get("path"):
|
| 24 |
+
source = audio["path"]
|
| 25 |
+
else:
|
| 26 |
+
raise ValueError("Audio record contains neither bytes nor a path")
|
| 27 |
+
|
| 28 |
+
# Explicitly avoid the SoundFile backend for MP3. Its mpg123 decoder emits
|
| 29 |
+
# errors directly to stderr for damaged frames and rejects files that FFmpeg
|
| 30 |
+
# can often recover partially.
|
| 31 |
+
backends = torchaudio.list_audio_backends()
|
| 32 |
+
backend = "ffmpeg" if "ffmpeg" in backends else None
|
| 33 |
+
waveform, sampling_rate = torchaudio.load(source, backend=backend)
|
| 34 |
+
return waveform, sampling_rate
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def export_quran_md(
|
| 38 |
+
output_dir: Path,
|
| 39 |
+
max_samples_per_reciter: int | None = 10,
|
| 40 |
+
sample_rate: int = 16000,
|
| 41 |
+
streaming: bool = True,
|
| 42 |
+
reciters: set[str] | None = None,
|
| 43 |
+
verbose_skips: bool = False,
|
| 44 |
+
expected_reciters: int = 30,
|
| 45 |
+
) -> dict:
|
| 46 |
+
if max_samples_per_reciter is not None and max_samples_per_reciter <= 0:
|
| 47 |
+
max_samples_per_reciter = None
|
| 48 |
+
|
| 49 |
+
target_by_split: dict[str, int] | None = None
|
| 50 |
+
if max_samples_per_reciter is not None:
|
| 51 |
+
if max_samples_per_reciter < 3:
|
| 52 |
+
val_target = 0
|
| 53 |
+
test_target = 0
|
| 54 |
+
else:
|
| 55 |
+
val_target = max(1, ceil(max_samples_per_reciter * 0.10))
|
| 56 |
+
test_target = max(1, ceil(max_samples_per_reciter * 0.10))
|
| 57 |
+
train_target = max(1, max_samples_per_reciter - val_target - test_target)
|
| 58 |
+
target_by_split = {"train": train_target, "val": val_target, "test": test_target}
|
| 59 |
+
|
| 60 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 61 |
+
clips_dir = output_dir / "clips"
|
| 62 |
+
manifest_path = output_dir / "manifest.jsonl"
|
| 63 |
+
summary_path = output_dir / "summary.json"
|
| 64 |
+
|
| 65 |
+
dataset = load_dataset("Buraaq/quran-md-ayahs", split="train", streaming=streaming)
|
| 66 |
+
# Some records in this dataset cannot be decoded by libsndfile. Keeping the
|
| 67 |
+
# encoded bytes lets us handle failures per record instead of crashing while
|
| 68 |
+
# IterableDataset is constructing the next row.
|
| 69 |
+
dataset = dataset.cast_column("audio", Audio(decode=False))
|
| 70 |
+
counts: Counter[str] = Counter()
|
| 71 |
+
split_counts: Counter[tuple[str, str]] = Counter()
|
| 72 |
+
rows: list[dict] = []
|
| 73 |
+
skipped_audio = 0
|
| 74 |
+
|
| 75 |
+
dataset_iterator = iter(dataset)
|
| 76 |
+
progress = tqdm(dataset_iterator, desc="Exporting Quran-MD", unit="audio")
|
| 77 |
+
for row in progress:
|
| 78 |
+
reciter_id = row["reciter_id"]
|
| 79 |
+
if reciters and reciter_id not in reciters:
|
| 80 |
+
continue
|
| 81 |
+
|
| 82 |
+
if max_samples_per_reciter is not None and counts[reciter_id] >= max_samples_per_reciter:
|
| 83 |
+
continue
|
| 84 |
+
|
| 85 |
+
surah_id = int(row["surah_id"])
|
| 86 |
+
ayah_id = int(row["ayah_id"])
|
| 87 |
+
if target_by_split is None:
|
| 88 |
+
split = split_from_surah(surah_id)
|
| 89 |
+
else:
|
| 90 |
+
# Assign consecutive valid clips to the requested 80/10/10 quota.
|
| 91 |
+
# This avoids scanning most of the 35 GB dataset just to reach the
|
| 92 |
+
# late Surahs used by split_from_surah.
|
| 93 |
+
reciter_position = counts[reciter_id]
|
| 94 |
+
if reciter_position < target_by_split["train"]:
|
| 95 |
+
split = "train"
|
| 96 |
+
elif reciter_position < target_by_split["train"] + target_by_split["val"]:
|
| 97 |
+
split = "val"
|
| 98 |
+
else:
|
| 99 |
+
split = "test"
|
| 100 |
+
|
| 101 |
+
try:
|
| 102 |
+
audio_array, source_rate = _decode_audio(row["audio"])
|
| 103 |
+
except Exception as exc:
|
| 104 |
+
skipped_audio += 1
|
| 105 |
+
if verbose_skips:
|
| 106 |
+
tqdm.write(
|
| 107 |
+
f"Skipping unreadable audio for {reciter_id} "
|
| 108 |
+
f"{surah_id:03d}:{ayah_id:03d}: {exc}"
|
| 109 |
+
)
|
| 110 |
+
continue
|
| 111 |
+
|
| 112 |
+
waveform = normalize_array_audio(audio_array, source_rate, sample_rate)
|
| 113 |
+
duration = waveform.shape[-1] / sample_rate
|
| 114 |
+
if duration < 1.0:
|
| 115 |
+
continue
|
| 116 |
+
|
| 117 |
+
rel_path = Path("clips") / reciter_id / f"{surah_id:03d}{ayah_id:03d}.wav"
|
| 118 |
+
audio_path = output_dir / rel_path
|
| 119 |
+
audio_path.parent.mkdir(parents=True, exist_ok=True)
|
| 120 |
+
sf.write(str(audio_path), waveform.squeeze(0).numpy(), sample_rate)
|
| 121 |
+
|
| 122 |
+
counts[reciter_id] += 1
|
| 123 |
+
split_counts[(reciter_id, split)] += 1
|
| 124 |
+
rows.append(
|
| 125 |
+
{
|
| 126 |
+
"path": str(audio_path.resolve()),
|
| 127 |
+
"reciter_id": reciter_id,
|
| 128 |
+
"reciter_name": row["reciter_name"],
|
| 129 |
+
"surah_id": surah_id,
|
| 130 |
+
"ayah_id": ayah_id,
|
| 131 |
+
"duration_sec": duration,
|
| 132 |
+
"split": split,
|
| 133 |
+
}
|
| 134 |
+
)
|
| 135 |
+
progress.set_postfix(
|
| 136 |
+
reciters=len(counts),
|
| 137 |
+
exported=len(rows),
|
| 138 |
+
skipped=skipped_audio,
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
target_reciter_count = len(reciters) if reciters else expected_reciters
|
| 142 |
+
if (
|
| 143 |
+
max_samples_per_reciter is not None
|
| 144 |
+
and len(counts) >= target_reciter_count
|
| 145 |
+
and all(count >= max_samples_per_reciter for count in counts.values())
|
| 146 |
+
):
|
| 147 |
+
break
|
| 148 |
+
|
| 149 |
+
# Breaking a streaming Hugging Face iterator can leave its download worker
|
| 150 |
+
# alive until interpreter shutdown, which may trigger a PyGILState fatal
|
| 151 |
+
# error. Close both wrappers while Python is still fully initialized.
|
| 152 |
+
progress.close()
|
| 153 |
+
close_iterator = getattr(dataset_iterator, "close", None)
|
| 154 |
+
if close_iterator is not None:
|
| 155 |
+
close_iterator()
|
| 156 |
+
|
| 157 |
+
write_jsonl(manifest_path, rows)
|
| 158 |
+
summary = summarize_manifest(rows)
|
| 159 |
+
summary["skipped_unreadable_audio"] = skipped_audio
|
| 160 |
+
summary_path.write_text(json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8")
|
| 161 |
+
return summary
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def main() -> None:
|
| 165 |
+
parser = argparse.ArgumentParser(description="Export a balanced Quran-MD subset.")
|
| 166 |
+
parser.add_argument("--output-dir", default=Path("data/quran_md"), type=Path)
|
| 167 |
+
parser.add_argument("--max-samples-per-reciter", default=10, type=int)
|
| 168 |
+
parser.add_argument("--sample-rate", default=16000, type=int)
|
| 169 |
+
parser.add_argument("--no-streaming", action="store_true")
|
| 170 |
+
parser.add_argument("--reciters", nargs="*", default=None)
|
| 171 |
+
parser.add_argument(
|
| 172 |
+
"--expected-reciters",
|
| 173 |
+
default=30,
|
| 174 |
+
type=int,
|
| 175 |
+
help="Stop after this many reciters reach the sample limit (default: 30).",
|
| 176 |
+
)
|
| 177 |
+
parser.add_argument(
|
| 178 |
+
"--verbose-skips",
|
| 179 |
+
action="store_true",
|
| 180 |
+
help="Print one message for every unreadable dataset record.",
|
| 181 |
+
)
|
| 182 |
+
args = parser.parse_args()
|
| 183 |
+
|
| 184 |
+
summary = export_quran_md(
|
| 185 |
+
output_dir=args.output_dir,
|
| 186 |
+
max_samples_per_reciter=args.max_samples_per_reciter,
|
| 187 |
+
sample_rate=args.sample_rate,
|
| 188 |
+
streaming=not args.no_streaming,
|
| 189 |
+
reciters=set(args.reciters) if args.reciters else None,
|
| 190 |
+
verbose_skips=args.verbose_skips,
|
| 191 |
+
expected_reciters=args.expected_reciters,
|
| 192 |
+
)
|
| 193 |
+
print(json.dumps(summary, indent=2, ensure_ascii=False))
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
if __name__ == "__main__":
|
| 197 |
+
main()
|
src/quran_reciter_id/manifest.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
from collections import Counter
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Iterable
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
AUDIO_EXTENSIONS = {".flac", ".m4a", ".mp3", ".ogg", ".wav"}
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def read_jsonl(path: str | Path) -> list[dict]:
|
| 14 |
+
rows: list[dict] = []
|
| 15 |
+
with Path(path).open("r", encoding="utf-8") as handle:
|
| 16 |
+
for line in handle:
|
| 17 |
+
line = line.strip()
|
| 18 |
+
if line:
|
| 19 |
+
rows.append(json.loads(line))
|
| 20 |
+
return rows
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def write_jsonl(path: str | Path, rows: Iterable[dict]) -> None:
|
| 24 |
+
output = Path(path)
|
| 25 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 26 |
+
with output.open("w", encoding="utf-8") as handle:
|
| 27 |
+
for row in rows:
|
| 28 |
+
handle.write(json.dumps(row, ensure_ascii=False) + "\n")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def iter_audio_files(audio_root: str | Path) -> Iterable[Path]:
|
| 32 |
+
root = Path(audio_root)
|
| 33 |
+
for path in sorted(root.rglob("*")):
|
| 34 |
+
if path.is_file() and path.suffix.lower() in AUDIO_EXTENSIONS:
|
| 35 |
+
yield path
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def split_from_surah(surah_id: int | None) -> str:
|
| 39 |
+
if surah_id is None:
|
| 40 |
+
return "train"
|
| 41 |
+
if surah_id <= 95:
|
| 42 |
+
return "train"
|
| 43 |
+
if surah_id <= 105:
|
| 44 |
+
return "val"
|
| 45 |
+
return "test"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def stable_hash_split(value: str, train_pct: int = 80, val_pct: int = 10) -> str:
|
| 49 |
+
digest = hashlib.sha1(value.encode("utf-8")).hexdigest()
|
| 50 |
+
bucket = int(digest[:8], 16) % 100
|
| 51 |
+
if bucket < train_pct:
|
| 52 |
+
return "train"
|
| 53 |
+
if bucket < train_pct + val_pct:
|
| 54 |
+
return "val"
|
| 55 |
+
return "test"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def summarize_manifest(rows: Iterable[dict]) -> dict:
|
| 59 |
+
rows = list(rows)
|
| 60 |
+
split_counts = Counter(row.get("split", "train") for row in rows)
|
| 61 |
+
reciter_counts = Counter(row["reciter_id"] for row in rows)
|
| 62 |
+
return {
|
| 63 |
+
"num_samples": len(rows),
|
| 64 |
+
"num_reciters": len(reciter_counts),
|
| 65 |
+
"splits": dict(sorted(split_counts.items())),
|
| 66 |
+
"samples_per_reciter": dict(sorted(reciter_counts.items())),
|
| 67 |
+
}
|
src/quran_reciter_id/model.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
from torch import nn
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class EmbeddingClassifier(nn.Module):
|
| 8 |
+
def __init__(
|
| 9 |
+
self,
|
| 10 |
+
input_dim: int,
|
| 11 |
+
num_classes: int,
|
| 12 |
+
hidden_dim: int = 256,
|
| 13 |
+
dropout: float = 0.2,
|
| 14 |
+
) -> None:
|
| 15 |
+
super().__init__()
|
| 16 |
+
self.net = nn.Sequential(
|
| 17 |
+
nn.Linear(input_dim, hidden_dim),
|
| 18 |
+
nn.ReLU(),
|
| 19 |
+
nn.Dropout(dropout),
|
| 20 |
+
nn.Linear(hidden_dim, num_classes),
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
def forward(self, embeddings: torch.Tensor) -> torch.Tensor:
|
| 24 |
+
return self.net(embeddings)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def l2_normalize(x: torch.Tensor, eps: float = 1e-12) -> torch.Tensor:
|
| 28 |
+
return x / x.norm(dim=-1, keepdim=True).clamp_min(eps)
|
src/quran_reciter_id/predict.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
|
| 9 |
+
from .config import resolve_device
|
| 10 |
+
from .embeddings import EcapaEmbedder
|
| 11 |
+
from .model import EmbeddingClassifier, l2_normalize
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def load_checkpoint(run_dir: Path, device: str) -> dict:
|
| 15 |
+
try:
|
| 16 |
+
return torch.load(run_dir / "model.pt", map_location=device, weights_only=False)
|
| 17 |
+
except TypeError:
|
| 18 |
+
return torch.load(run_dir / "model.pt", map_location=device)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@torch.inference_mode()
|
| 22 |
+
def predict(run_dir: Path, audio_file: Path, top_k: int = 5, device: str = "auto") -> dict:
|
| 23 |
+
resolved_device = resolve_device(device)
|
| 24 |
+
checkpoint = load_checkpoint(run_dir, resolved_device)
|
| 25 |
+
labels = checkpoint["labels"]
|
| 26 |
+
|
| 27 |
+
embedder = EcapaEmbedder(
|
| 28 |
+
source=checkpoint["embedding_model"],
|
| 29 |
+
device=resolved_device,
|
| 30 |
+
sample_rate=int(checkpoint["sample_rate"]),
|
| 31 |
+
)
|
| 32 |
+
embedding = (
|
| 33 |
+
torch.from_numpy(embedder.encode_file(audio_file))
|
| 34 |
+
.float()
|
| 35 |
+
.unsqueeze(0)
|
| 36 |
+
.to(resolved_device)
|
| 37 |
+
)
|
| 38 |
+
mean = checkpoint["mean"].to(resolved_device)
|
| 39 |
+
std = checkpoint["std"].to(resolved_device)
|
| 40 |
+
embedding = (embedding - mean) / std
|
| 41 |
+
|
| 42 |
+
model = EmbeddingClassifier(
|
| 43 |
+
input_dim=int(checkpoint["input_dim"]),
|
| 44 |
+
num_classes=len(labels),
|
| 45 |
+
hidden_dim=int(checkpoint["classifier"]["hidden_dim"]),
|
| 46 |
+
dropout=float(checkpoint["classifier"]["dropout"]),
|
| 47 |
+
).to(resolved_device)
|
| 48 |
+
model.load_state_dict(checkpoint["state_dict"])
|
| 49 |
+
model.eval()
|
| 50 |
+
|
| 51 |
+
probs = torch.softmax(model(embedding), dim=-1).squeeze(0).cpu()
|
| 52 |
+
normalized = l2_normalize(embedding.cpu())
|
| 53 |
+
centroid_scores = (normalized @ checkpoint["centroids"].cpu().T).squeeze(0)
|
| 54 |
+
|
| 55 |
+
k = min(top_k, len(labels))
|
| 56 |
+
top_probs, top_indices = probs.topk(k)
|
| 57 |
+
candidates = []
|
| 58 |
+
for probability, index in zip(top_probs.tolist(), top_indices.tolist(), strict=True):
|
| 59 |
+
candidates.append(
|
| 60 |
+
{
|
| 61 |
+
"reciter_id": labels[index],
|
| 62 |
+
"probability": float(probability),
|
| 63 |
+
"centroid_similarity": float(centroid_scores[index].item()),
|
| 64 |
+
}
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
inference_cfg = checkpoint.get("inference", {})
|
| 68 |
+
unknown_threshold = float(inference_cfg.get("unknown_threshold", 0.45))
|
| 69 |
+
min_centroid_similarity = float(inference_cfg.get("min_centroid_similarity", 0.25))
|
| 70 |
+
best = candidates[0]
|
| 71 |
+
is_unknown = (
|
| 72 |
+
best["probability"] < unknown_threshold
|
| 73 |
+
or best["centroid_similarity"] < min_centroid_similarity
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
return {
|
| 77 |
+
"audio_file": str(audio_file.resolve()),
|
| 78 |
+
"prediction": None if is_unknown else best["reciter_id"],
|
| 79 |
+
"is_unknown": is_unknown,
|
| 80 |
+
"top_candidates": candidates,
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def main() -> None:
|
| 85 |
+
parser = argparse.ArgumentParser(description="Predict the reciter for an audio file.")
|
| 86 |
+
parser.add_argument("--run-dir", required=True, type=Path)
|
| 87 |
+
parser.add_argument("--audio-file", required=True, type=Path)
|
| 88 |
+
parser.add_argument("--top-k", default=5, type=int)
|
| 89 |
+
parser.add_argument("--device", default="auto")
|
| 90 |
+
args = parser.parse_args()
|
| 91 |
+
|
| 92 |
+
result = predict(args.run_dir, args.audio_file, top_k=args.top_k, device=args.device)
|
| 93 |
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
if __name__ == "__main__":
|
| 97 |
+
main()
|
src/quran_reciter_id/train.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import random
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import torch
|
| 10 |
+
from sklearn.metrics import accuracy_score, classification_report
|
| 11 |
+
from torch.utils.data import DataLoader, TensorDataset
|
| 12 |
+
|
| 13 |
+
from .config import load_config, resolve_device
|
| 14 |
+
from .embeddings import EcapaEmbedder, extract_embeddings
|
| 15 |
+
from .manifest import read_jsonl
|
| 16 |
+
from .model import EmbeddingClassifier, l2_normalize
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def set_seed(seed: int) -> None:
|
| 20 |
+
random.seed(seed)
|
| 21 |
+
np.random.seed(seed)
|
| 22 |
+
torch.manual_seed(seed)
|
| 23 |
+
if torch.cuda.is_available():
|
| 24 |
+
torch.cuda.manual_seed_all(seed)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def split_masks(splits: np.ndarray) -> dict[str, np.ndarray]:
|
| 28 |
+
return {name: splits == name for name in ["train", "val", "test"]}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def make_loaders(
|
| 32 |
+
x: np.ndarray,
|
| 33 |
+
y: np.ndarray,
|
| 34 |
+
train_mask: np.ndarray,
|
| 35 |
+
val_mask: np.ndarray,
|
| 36 |
+
batch_size: int,
|
| 37 |
+
) -> tuple[DataLoader, DataLoader | None]:
|
| 38 |
+
train_ds = TensorDataset(torch.from_numpy(x[train_mask]), torch.from_numpy(y[train_mask]))
|
| 39 |
+
train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True)
|
| 40 |
+
|
| 41 |
+
if val_mask.any():
|
| 42 |
+
val_ds = TensorDataset(torch.from_numpy(x[val_mask]), torch.from_numpy(y[val_mask]))
|
| 43 |
+
val_loader = DataLoader(val_ds, batch_size=batch_size, shuffle=False)
|
| 44 |
+
else:
|
| 45 |
+
val_loader = None
|
| 46 |
+
|
| 47 |
+
return train_loader, val_loader
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@torch.inference_mode()
|
| 51 |
+
def predict_logits(model: torch.nn.Module, x: np.ndarray, device: str, batch_size: int = 256) -> np.ndarray:
|
| 52 |
+
model.eval()
|
| 53 |
+
preds: list[np.ndarray] = []
|
| 54 |
+
for start in range(0, len(x), batch_size):
|
| 55 |
+
batch = torch.from_numpy(x[start : start + batch_size]).to(device)
|
| 56 |
+
preds.append(model(batch).detach().cpu().numpy())
|
| 57 |
+
return np.concatenate(preds, axis=0)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def evaluate_split(
|
| 61 |
+
model: torch.nn.Module,
|
| 62 |
+
x: np.ndarray,
|
| 63 |
+
y: np.ndarray,
|
| 64 |
+
mask: np.ndarray,
|
| 65 |
+
labels: list[str],
|
| 66 |
+
device: str,
|
| 67 |
+
) -> dict:
|
| 68 |
+
if not mask.any():
|
| 69 |
+
return {"samples": 0}
|
| 70 |
+
logits = predict_logits(model, x[mask], device)
|
| 71 |
+
pred = logits.argmax(axis=1)
|
| 72 |
+
result = {
|
| 73 |
+
"samples": int(mask.sum()),
|
| 74 |
+
"accuracy": float(accuracy_score(y[mask], pred)),
|
| 75 |
+
}
|
| 76 |
+
if len(labels) <= 50:
|
| 77 |
+
result["classification_report"] = classification_report(
|
| 78 |
+
y[mask],
|
| 79 |
+
pred,
|
| 80 |
+
labels=list(range(len(labels))),
|
| 81 |
+
target_names=labels,
|
| 82 |
+
zero_division=0,
|
| 83 |
+
output_dict=True,
|
| 84 |
+
)
|
| 85 |
+
return result
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def train_classifier(
|
| 89 |
+
x: np.ndarray,
|
| 90 |
+
y: np.ndarray,
|
| 91 |
+
splits: np.ndarray,
|
| 92 |
+
labels: list[str],
|
| 93 |
+
config: dict,
|
| 94 |
+
output_dir: Path,
|
| 95 |
+
device: str,
|
| 96 |
+
) -> dict:
|
| 97 |
+
classifier_cfg = config["classifier"]
|
| 98 |
+
masks = split_masks(splits)
|
| 99 |
+
train_mask = masks["train"]
|
| 100 |
+
val_mask = masks["val"]
|
| 101 |
+
|
| 102 |
+
mean = x[train_mask].mean(axis=0, keepdims=True).astype(np.float32)
|
| 103 |
+
std = (x[train_mask].std(axis=0, keepdims=True) + 1e-6).astype(np.float32)
|
| 104 |
+
x_norm = ((x - mean) / std).astype(np.float32)
|
| 105 |
+
|
| 106 |
+
train_loader, val_loader = make_loaders(
|
| 107 |
+
x_norm,
|
| 108 |
+
y,
|
| 109 |
+
train_mask,
|
| 110 |
+
val_mask,
|
| 111 |
+
batch_size=int(classifier_cfg["batch_size"]),
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
model = EmbeddingClassifier(
|
| 115 |
+
input_dim=x.shape[1],
|
| 116 |
+
num_classes=len(labels),
|
| 117 |
+
hidden_dim=int(classifier_cfg["hidden_dim"]),
|
| 118 |
+
dropout=float(classifier_cfg["dropout"]),
|
| 119 |
+
).to(device)
|
| 120 |
+
|
| 121 |
+
optimizer = torch.optim.AdamW(
|
| 122 |
+
model.parameters(),
|
| 123 |
+
lr=float(classifier_cfg["learning_rate"]),
|
| 124 |
+
weight_decay=float(classifier_cfg["weight_decay"]),
|
| 125 |
+
)
|
| 126 |
+
loss_fn = torch.nn.CrossEntropyLoss()
|
| 127 |
+
|
| 128 |
+
best_state = None
|
| 129 |
+
best_val = -1.0
|
| 130 |
+
patience_left = int(classifier_cfg["patience"])
|
| 131 |
+
|
| 132 |
+
for epoch in range(1, int(classifier_cfg["epochs"]) + 1):
|
| 133 |
+
model.train()
|
| 134 |
+
total_loss = 0.0
|
| 135 |
+
for batch_x, batch_y in train_loader:
|
| 136 |
+
batch_x = batch_x.to(device)
|
| 137 |
+
batch_y = batch_y.to(device)
|
| 138 |
+
optimizer.zero_grad(set_to_none=True)
|
| 139 |
+
loss = loss_fn(model(batch_x), batch_y)
|
| 140 |
+
loss.backward()
|
| 141 |
+
optimizer.step()
|
| 142 |
+
total_loss += float(loss.item()) * batch_x.shape[0]
|
| 143 |
+
|
| 144 |
+
if val_loader is not None:
|
| 145 |
+
val_logits = predict_logits(model, x_norm[val_mask], device)
|
| 146 |
+
val_score = float(accuracy_score(y[val_mask], val_logits.argmax(axis=1)))
|
| 147 |
+
else:
|
| 148 |
+
train_logits = predict_logits(model, x_norm[train_mask], device)
|
| 149 |
+
val_score = float(accuracy_score(y[train_mask], train_logits.argmax(axis=1)))
|
| 150 |
+
|
| 151 |
+
avg_loss = total_loss / max(1, int(train_mask.sum()))
|
| 152 |
+
print(f"epoch={epoch:03d} loss={avg_loss:.4f} score={val_score:.4f}")
|
| 153 |
+
|
| 154 |
+
if val_score > best_val:
|
| 155 |
+
best_val = val_score
|
| 156 |
+
best_state = {key: value.detach().cpu().clone() for key, value in model.state_dict().items()}
|
| 157 |
+
patience_left = int(classifier_cfg["patience"])
|
| 158 |
+
else:
|
| 159 |
+
patience_left -= 1
|
| 160 |
+
if patience_left <= 0:
|
| 161 |
+
break
|
| 162 |
+
|
| 163 |
+
if best_state is not None:
|
| 164 |
+
model.load_state_dict(best_state)
|
| 165 |
+
|
| 166 |
+
x_train_tensor = torch.from_numpy(x_norm[train_mask])
|
| 167 |
+
y_train_tensor = torch.from_numpy(y[train_mask])
|
| 168 |
+
centroids = []
|
| 169 |
+
for class_index in range(len(labels)):
|
| 170 |
+
class_embeddings = x_train_tensor[y_train_tensor == class_index]
|
| 171 |
+
centroid = class_embeddings.mean(dim=0)
|
| 172 |
+
centroids.append(centroid)
|
| 173 |
+
centroids_tensor = l2_normalize(torch.stack(centroids))
|
| 174 |
+
|
| 175 |
+
metrics = {
|
| 176 |
+
"train": evaluate_split(model, x_norm, y, train_mask, labels, device),
|
| 177 |
+
"val": evaluate_split(model, x_norm, y, masks["val"], labels, device),
|
| 178 |
+
"test": evaluate_split(model, x_norm, y, masks["test"], labels, device),
|
| 179 |
+
"best_validation_score": best_val,
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
checkpoint = {
|
| 183 |
+
"state_dict": model.state_dict(),
|
| 184 |
+
"labels": labels,
|
| 185 |
+
"mean": torch.from_numpy(mean),
|
| 186 |
+
"std": torch.from_numpy(std),
|
| 187 |
+
"centroids": centroids_tensor,
|
| 188 |
+
"input_dim": x.shape[1],
|
| 189 |
+
"classifier": classifier_cfg,
|
| 190 |
+
"embedding_model": config["embedding_model"],
|
| 191 |
+
"sample_rate": config["sample_rate"],
|
| 192 |
+
"inference": config.get("inference", {}),
|
| 193 |
+
}
|
| 194 |
+
torch.save(checkpoint, output_dir / "model.pt")
|
| 195 |
+
(output_dir / "labels.json").write_text(json.dumps(labels, indent=2), encoding="utf-8")
|
| 196 |
+
(output_dir / "metrics.json").write_text(json.dumps(metrics, indent=2), encoding="utf-8")
|
| 197 |
+
return metrics
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def main() -> None:
|
| 201 |
+
parser = argparse.ArgumentParser(description="Train a Quran reciter identification model.")
|
| 202 |
+
parser.add_argument("--manifest", required=True, type=Path)
|
| 203 |
+
parser.add_argument("--output-dir", default=Path("runs/ecapa_quran"), type=Path)
|
| 204 |
+
parser.add_argument("--config", default=Path("configs/ecapa_quran_md.yaml"), type=Path)
|
| 205 |
+
parser.add_argument("--recompute-embeddings", action="store_true")
|
| 206 |
+
args = parser.parse_args()
|
| 207 |
+
|
| 208 |
+
config = load_config(args.config)
|
| 209 |
+
set_seed(int(config["seed"]))
|
| 210 |
+
device = resolve_device(str(config.get("device", "auto")))
|
| 211 |
+
args.output_dir.mkdir(parents=True, exist_ok=True)
|
| 212 |
+
embeddings_dir = args.output_dir / "embeddings"
|
| 213 |
+
embeddings_dir.mkdir(parents=True, exist_ok=True)
|
| 214 |
+
cache_path = embeddings_dir / "all_embeddings.npz"
|
| 215 |
+
|
| 216 |
+
rows = read_jsonl(args.manifest)
|
| 217 |
+
labels = sorted({row["reciter_id"] for row in rows})
|
| 218 |
+
label_to_idx = {label: index for index, label in enumerate(labels)}
|
| 219 |
+
|
| 220 |
+
if cache_path.exists() and not args.recompute_embeddings:
|
| 221 |
+
cached = np.load(cache_path, allow_pickle=True)
|
| 222 |
+
x = cached["embeddings"]
|
| 223 |
+
y = cached["labels"]
|
| 224 |
+
keep_indices = cached["keep_indices"]
|
| 225 |
+
else:
|
| 226 |
+
embedder = EcapaEmbedder(
|
| 227 |
+
source=config["embedding_model"],
|
| 228 |
+
device=device,
|
| 229 |
+
sample_rate=int(config["sample_rate"]),
|
| 230 |
+
)
|
| 231 |
+
x, y, keep_indices = extract_embeddings(
|
| 232 |
+
rows,
|
| 233 |
+
embedder,
|
| 234 |
+
label_to_idx,
|
| 235 |
+
max_duration_sec=float(config["dataset"]["max_duration_sec"]),
|
| 236 |
+
)
|
| 237 |
+
np.savez_compressed(cache_path, embeddings=x, labels=y, keep_indices=keep_indices)
|
| 238 |
+
|
| 239 |
+
kept_rows = [rows[int(index)] for index in keep_indices]
|
| 240 |
+
splits = np.asarray([row.get("split", "train") for row in kept_rows])
|
| 241 |
+
metrics = train_classifier(x, y, splits, labels, config, args.output_dir, device)
|
| 242 |
+
print(json.dumps(metrics, indent=2))
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
if __name__ == "__main__":
|
| 246 |
+
main()
|