ahmed commited on
Commit
f15e49c
·
0 Parent(s):

Deploy Quran reciter Gradio app

Browse files
.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()