diff --git a/README.md b/README.md
index 6ac880de60f32c18e8179a0e21873d5d85dc8a23..80995deef6567c8a5a020efe7f7ba47e67202b4c 100644
--- a/README.md
+++ b/README.md
@@ -1,147 +1,178 @@
----
-language:
- - ur
- - sd
-license: apache-2.0
-pretty_name: Canopy-M
-pipeline_tag: automatic-speech-recognition
-library_name: pytorch
-tags:
- - automatic-speech-recognition
- - speech
- - ctc
- - conformer
- - urdu
- - sindhi
----
-
-# Canopy-M
-
-
-
-

-
-
-
-Canopy-M was trained on Urdu and Sindhi. The numbers below are the evaluation.
-
-
-
-
-
-## Use
-
-```python
-from canopy.inference.transcriber import Transcriber
-
-asr = Transcriber.from_checkpoint("canopy-m.pt", device="cuda")
-print(asr.transcribe("urdu.wav", language="urd"))
-print(asr.transcribe("sindhi.wav", language="snd"))
-```
-
-Pass `urd` or `snd`. That selects the language embedding and restricts the CTC vocabulary to that language.
-
-## Training data
-
-About **186.7 hours** used in training.
-
-| Language | Source | Train hours | Dev hours | Labels |
-|---|---|---:|---:|---|
-| Urdu | Common Voice + mahwiz + UrduSpeech | ~79.6 | ~17 | human |
-| Sindhi | Common Voice | 71.5 | 0.10 | human; mostly unvalidated `other` |
-| Sindhi | FLEURS `sd_in` | 12.3 | 1.33 | human, CC-BY-4.0 |
-
-## Benchmark
-
-Same development clips and the same `eval_norm` for every system. Whisper is forced to the `urdu` or `sindhi` token, fp16, greedy, `max_new_tokens=225`.
-
-| System | Params | Urdu CER | Urdu WER | Sindhi CER | Sindhi WER | Macro CER |
-|---|---:|---:|---:|---:|---:|---:|
-| **Canopy-M** | 57.9M | **0.132** | **0.267** | **0.107** | **0.271** | **0.119** |
-| Whisper-tiny | 39M | 1.024 | 1.477 | 1.430 | 1.599 | 1.227 |
-| Whisper-base | 74M | 0.855 | 1.247 | 0.624 | 1.083 | 0.739 |
-| whisper-small-urdu | 244M | 0.177 | 0.330 | not run | not run | — |
-
-`whisper-small-urdu` is `khawajaaliarshad/whisper-small-urdu`, an Urdu-only fine-tune, so it was not run on Sindhi. A value above 1.0 means the output is longer than the reference, usually a repetition or the wrong script.
-
-
-
-### Urdu by source
-
-14,681 utterances, 63 speakers: Common Voice 7,772 / 54 speakers, UrduSpeech 4,296 / 7, mahwiz 2,613 / 2.
-
-| System | CV CER | UrduSpeech CER | mahwiz CER |
-|---|---:|---:|---:|
-| Canopy-M | 0.248 | 0.022 | 0.027 |
-| Whisper-tiny | 1.505 | 0.642 | 0.451 |
-| Whisper-base | 1.520 | 0.209 | 0.283 |
-| whisper-small-urdu | 0.278 | 0.069 | 0.111 |
-
-
-
-Paired speaker-cluster test, Δ = whisper-small-urdu minus Canopy-M. Positive means Canopy-M makes fewer errors. “No clear difference” means the 95% interval includes zero.
-
-| Subset | ΔCER [95% CI] | Verdict |
-|---|---|---|
-| All Urdu | +0.046 [−0.003, +0.118] | no clear difference |
-| Common Voice | +0.030 [−0.042, +0.184] | no clear difference |
-| UrduSpeech | +0.047 [+0.021, +0.090] | Canopy-M lower CER |
-| mahwiz | +0.084 [+0.074, +0.099] | Canopy-M lower CER |
-
-whisper-small-urdu was fine-tuned on Common Voice Urdu 24.0 with its own split, so its Common Voice number may include clips it trained on. Canopy-M trained on the UrduSpeech and mahwiz training splits, so those two rows are in-domain for Canopy-M.
-
-### Sindhi by source
-
-496 utterances, 164 speakers: Common Voice 70 / 14 speakers, FLEURS 426 / 150 speakers. FLEURS `sd_in` is India-recorded Sindhi.
-
-| System | CV CER | FLEURS CER |
-|---|---:|---:|
-| Canopy-M | 0.286 | 0.095 |
-| Whisper-tiny | 1.911 | 1.399 |
-| Whisper-base | 0.690 | 0.619 |
-
-
-
-Against zero-shot Whisper-base, Canopy-M is lower on every Sindhi subset. All Sindhi ΔCER is +0.517 [+0.470, +0.568]. Whisper-base did not train on this Sindhi data. Canopy-M did.
-
-### Robustness
-
-Median utterance CER is 0.059 on Urdu and 0.078 on Sindhi. Canopy-M produced no runaway outputs (hypothesis longer than 3× the reference) and no empty outputs. Whisper-tiny runaway rate is 10.3% on Urdu and 9.1% on Sindhi.
-
-
-
-
-
-
-
-Decode real-time factor on an RTX 3090 is about 0.0006 for Canopy-M, 0.0218 for Whisper-tiny, 0.0100 for Whisper-base, and 0.0069 for whisper-small-urdu. Batch sizes differed, so treat the speed column as indicative.
-
-## Intended use
-
-Research transcription of read Urdu and Sindhi at 16 kHz, one language per utterance, with the language given.
-
-## Out of scope
-
-Surveillance, speaker identification, and any decision about a person made without review. Production deployment while the test split is unscored. Telephone, broadcast, conversational, and code-switched speech.
-
-## Limitations
-
-- These scores are the development split. The test split (Urdu ~37 h, Sindhi FLEURS ~3.3 h plus a small Common Voice test) has not been opened.
-- Sindhi Common Voice development audio is 0.10 hours (70 utterances). Most of the Sindhi score is FLEURS.
-- Common Voice Sindhi training audio is mostly unvalidated `other`, included with `--include-unvalidated`.
-- Urdu Common Voice CER is 0.248. The headline 0.132 is pulled down by UrduSpeech and mahwiz.
-- A wrong language id can cross Urdu and Sindhi, which share a Perso-Arabic script.
-- No word n-gram was used in this table. Fusion can still overwrite a correct acoustic hypothesis.
-
-## Citation
-
-```bibtex
-@software{canopy_m_2026,
- title = {Canopy-M: Conformer-CTC speech recognition for Urdu and Sindhi},
- year = {2026},
- url = {https://github.com/mwzkhalil/canopy},
- license = {Apache-2.0}
-}
-```
-
-Also cite Common Voice, FLEURS, and the Metro-ASR / Conformer designs the block follows.
+---
+language:
+ - ur
+ - sd
+license: apache-2.0
+pretty_name: Canopy-M
+pipeline_tag: automatic-speech-recognition
+library_name: pytorch
+tags:
+ - automatic-speech-recognition
+ - speech
+ - ctc
+ - conformer
+ - urdu
+ - sindhi
+---
+
+# Canopy-M
+
+Canopy-M was trained on **Urdu** and **Sindhi**. The numbers below are the full **development** evaluation (greedy CTC, language id on, no language model). The held-out test split has not been scored.
+
+| | Urdu `urd` | Sindhi `snd` | Macro |
+|---|---:|---:|---:|
+| CER | 0.132 [0.065, 0.211] | 0.107 [0.091, 0.123] | 0.119 |
+| WER | 0.267 [0.166, 0.383] | 0.271 [0.240, 0.304] | 0.269 |
+| Utterances | 14,681 | 496 | |
+| Speakers | 63 | 164 | |
+
+Intervals are 95% speaker-cluster bootstrap. Lower is better. Run `canopy_exp_002_urd_snd_20261005-120848`, checkpoint selected at step 40,000, evaluated 2026-10-06.
+
+
+
+## Model
+
+| | |
+|---|---|
+| Architecture | Conformer-CTC, Metro-style Macaron blocks |
+| Size | 57.9M inference parameters, d=384, 12 layers, 6 heads |
+| Languages | Urdu (id 1), Sindhi (id 2). Id 0 is unconditioned |
+| Conditioning | language embedding and a language-ID head |
+| Tokenizer | character, `char_urd_snd` |
+| Frontend | 16 kHz, 80-bin log-mel, 4× conv subsampling, 25 frames/s |
+| Objective | `(1 − 0.3) · CTC + 0.3 · auxiliary CTC` at layer 6 |
+| Decoder in this table | greedy CTC, no n-gram |
+| Training | 40,000 steps, bf16, RTX 3090, about 9,114 hours of audio seen, ~50/50 Urdu/Sindhi |
+| Code | Apache-2.0 |
+
+One block is half SwiGLU, rotary attention, a squeeze-excite depthwise convolution (kernel 31), and a second half SwiGLU, then RMSNorm. The auxiliary head is dropped at export. Sindhi keeps its own letters: Urdu yeh, kaf, and heh mappings are not applied to Sindhi.
+
+## Use
+
+The model repository is private. Log in once with a token that can read `ProximaAI/Canopy-M`.
+
+```bash
+pip install torch torchaudio soundfile numpy pyyaml huggingface_hub
+hf auth login
+hf download ProximaAI/Canopy-M --local-dir Canopy-M
+python Canopy-M/canopy_m.py clip.wav --language urd
+python Canopy-M/canopy_m.py clip.wav --language snd --device cpu
+```
+
+`--language` accepts `urd`, `ur`, `urdu`, `snd`, `sd`, or `sindhi`. That selects the language embedding and keeps the CTC output inside that language’s letters. Decoding is greedy CTC, the same setup as the table above.
+
+```python
+from canopy_m import CanopyM
+
+asr = CanopyM.from_pretrained("ProximaAI/Canopy-M") # cuda when available, otherwise cpu
+print(asr.transcribe("clip.wav", language="urd"))
+print(asr.transcribe("clip.wav", language="snd"))
+```
+
+Run that from the `Canopy-M` directory, or add it to `PYTHONPATH`. `canopy_m.py` and the `canopy` package are both in this repository, next to `canopy-m.pt`. A 16 kHz mono file is the expected input. Other sample rates are resampled. Files longer than 30 seconds are transcribed in chunks.
+
+## Training data
+
+About **186.7 hours** used in training. YouTube Sindhi is train-only and is excluded from the scores.
+
+| Language | Source | Train hours | Dev hours | Labels |
+|---|---|---:|---:|---|
+| Urdu | Common Voice + mahwiz + UrduSpeech | ~79.6 | ~17 | human |
+| Sindhi | Common Voice | 71.5 | 0.10 | human; mostly unvalidated `other` |
+| Sindhi | FLEURS `sd_in` | 12.3 | 1.33 | human, CC-BY-4.0 |
+| Sindhi | YouTube | 23.7 | — | Whisper pseudo-labels, train only |
+
+## Benchmark
+
+Same development clips and the same `eval_norm` for every system. Whisper is forced to the `urdu` or `sindhi` token, fp16, greedy, `max_new_tokens=225`.
+
+| System | Params | Urdu CER | Urdu WER | Sindhi CER | Sindhi WER | Macro CER |
+|---|---:|---:|---:|---:|---:|---:|
+| **Canopy-M** | 57.9M | **0.132** | **0.267** | **0.107** | **0.271** | **0.119** |
+| Whisper-tiny | 39M | 1.024 | 1.477 | 1.430 | 1.599 | 1.227 |
+| Whisper-base | 74M | 0.855 | 1.247 | 0.624 | 1.083 | 0.739 |
+| whisper-small-urdu | 244M | 0.177 | 0.330 | not run | not run | — |
+
+`whisper-small-urdu` is `khawajaaliarshad/whisper-small-urdu`, an Urdu-only fine-tune, so it was not run on Sindhi. A value above 1.0 means the output is longer than the reference, usually a repetition or the wrong script.
+
+
+
+### Urdu by source
+
+14,681 utterances, 63 speakers: Common Voice 7,772 / 54 speakers, UrduSpeech 4,296 / 7, mahwiz 2,613 / 2.
+
+| System | CV CER | UrduSpeech CER | mahwiz CER |
+|---|---:|---:|---:|
+| Canopy-M | 0.248 | 0.022 | 0.027 |
+| Whisper-tiny | 1.505 | 0.642 | 0.451 |
+| Whisper-base | 1.520 | 0.209 | 0.283 |
+| whisper-small-urdu | 0.278 | 0.069 | 0.111 |
+
+
+
+Paired speaker-cluster test, Δ = whisper-small-urdu minus Canopy-M. Positive means Canopy-M makes fewer errors. “No clear difference” means the 95% interval includes zero.
+
+| Subset | ΔCER [95% CI] | Verdict |
+|---|---|---|
+| All Urdu | +0.046 [−0.003, +0.118] | no clear difference |
+| Common Voice | +0.030 [−0.042, +0.184] | no clear difference |
+| UrduSpeech | +0.047 [+0.021, +0.090] | Canopy-M lower CER |
+| mahwiz | +0.084 [+0.074, +0.099] | Canopy-M lower CER |
+
+whisper-small-urdu was fine-tuned on Common Voice Urdu 24.0 with its own split, so its Common Voice number may include clips it trained on. Canopy-M trained on the UrduSpeech and mahwiz training splits, so those two rows are in-domain for Canopy-M.
+
+### Sindhi by source
+
+496 utterances, 164 speakers: Common Voice 70 / 14 speakers, FLEURS 426 / 150 speakers. FLEURS `sd_in` is India-recorded Sindhi.
+
+| System | CV CER | FLEURS CER |
+|---|---:|---:|
+| Canopy-M | 0.286 | 0.095 |
+| Whisper-tiny | 1.911 | 1.399 |
+| Whisper-base | 0.690 | 0.619 |
+
+
+
+Against zero-shot Whisper-base, Canopy-M is lower on every Sindhi subset. All Sindhi ΔCER is +0.517 [+0.470, +0.568]. Whisper-base did not train on this Sindhi data. Canopy-M did.
+
+### Robustness
+
+Median utterance CER is 0.059 on Urdu and 0.078 on Sindhi. Canopy-M produced no runaway outputs (hypothesis longer than 3× the reference) and no empty outputs. Whisper-tiny runaway rate is 10.3% on Urdu and 9.1% on Sindhi.
+
+
+
+
+
+
+
+Decode real-time factor on an RTX 3090 is about 0.0006 for Canopy-M, 0.0218 for Whisper-tiny, 0.0100 for Whisper-base, and 0.0069 for whisper-small-urdu. Batch sizes differed, so treat the speed column as indicative.
+
+## Intended use
+
+Research transcription of read Urdu and Sindhi at 16 kHz, one language per utterance, with the language given.
+
+## Out of scope
+
+Surveillance, speaker identification, and any decision about a person made without review. Production deployment while the test split is unscored. Telephone, broadcast, conversational, and code-switched speech.
+
+## Limitations
+
+- These scores are the development split. The test split (Urdu ~37 h, Sindhi FLEURS ~3.3 h plus a small Common Voice test) has not been opened.
+- Sindhi Common Voice development audio is 0.10 hours (70 utterances). Most of the Sindhi score is FLEURS.
+- Common Voice Sindhi training audio is mostly unvalidated `other`, included with `--include-unvalidated`.
+- Urdu Common Voice CER is 0.248. The headline 0.132 is pulled down by UrduSpeech and mahwiz.
+- A wrong language id can cross Urdu and Sindhi, which share a Perso-Arabic script.
+- No word n-gram was used in this table. Fusion can still overwrite a correct acoustic hypothesis.
+
+## Citation
+
+```bibtex
+@software{canopy_m_2026,
+ title = {Canopy-M: Conformer-CTC speech recognition for Urdu and Sindhi},
+ year = {2026},
+ url = {https://github.com/mwzkhalil/canopy},
+ license = {Apache-2.0}
+}
+```
+
+Also cite Common Voice, FLEURS, and the Metro-ASR / Conformer designs the block follows.
diff --git a/canopy/__init__.py b/canopy/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..01d06341c59da2ac14ca53331fb023023a9a7c17
--- /dev/null
+++ b/canopy/__init__.py
@@ -0,0 +1,6 @@
+"""Canopy: multilingual non-autoregressive CTC ASR for Pakistani languages."""
+
+__version__ = "0.1.0.dev0"
+
+BLANK_ID = 0
+SAMPLE_RATE = 16000
diff --git a/canopy/data/__init__.py b/canopy/data/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..c5141f3ede45d680fadae137b3e21064d1aeb5b8
--- /dev/null
+++ b/canopy/data/__init__.py
@@ -0,0 +1,3 @@
+from canopy.data.manifest import PROVENANCES, REQUIRED_FIELDS, load_manifest, make_row, validate_row
+
+__all__ = ["PROVENANCES", "REQUIRED_FIELDS", "load_manifest", "make_row", "validate_row"]
diff --git a/canopy/data/commonvoice.py b/canopy/data/commonvoice.py
new file mode 100644
index 0000000000000000000000000000000000000000..0afe84aa189798a276271f71b8704e519f3ae9c2
--- /dev/null
+++ b/canopy/data/commonvoice.py
@@ -0,0 +1,113 @@
+"""Import a locally downloaded Common Voice locale into a Canopy manifest.
+
+Common Voice is obtained by each user from Mozilla Data Collective under the
+platform terms, which forbid re-hosting any part of the dataset and forbid
+attempting to identify speakers. This importer only reads local files. The
+CV ``client_id`` is re-hashed into an opaque ``speaker_id`` used solely for
+split construction.
+"""
+
+from __future__ import annotations
+
+import csv
+import hashlib
+import os
+import sys
+from pathlib import Path
+
+from canopy.data.manifest import make_row
+
+CV_LICENSE = "CC0-1.0 (Mozilla Data Collective terms apply: no redistribution)"
+CV_SOURCE = "cv_scripted_speech"
+
+# Which TSV files map to which validation status. `invalidated.tsv` is never imported.
+CV_TABLES = {"validated": "validated.tsv", "other": "other.tsv"}
+
+csv.field_size_limit(min(sys.maxsize, 2**31 - 1))
+
+
+def _speaker_hash(client_id: str) -> str:
+ return "cv:" + hashlib.sha256(client_id.encode("utf-8")).hexdigest()[:16]
+
+
+def _read_tsv(path: Path) -> list[dict]:
+ with open(path, encoding="utf-8", newline="") as f:
+ return list(csv.DictReader(f, delimiter="\t", quoting=csv.QUOTE_NONE))
+
+
+def read_clip_durations(locale_dir: Path) -> dict[str, float]:
+ p = locale_dir / "clip_durations.tsv"
+ if not p.exists():
+ return {}
+ out = {}
+ for row in _read_tsv(p):
+ clip = row.get("clip")
+ dur = row.get("duration[ms]") or row.get("duration")
+ if clip and dur:
+ out[clip] = float(dur) / 1000.0
+ return out
+
+
+def import_locale(locale_dir: str | os.PathLike, *, language: str, version: str, tables: tuple[str, ...] = ("validated",),
+ probe_missing_durations: bool = True, audio_root: str | os.PathLike | None = None) -> tuple[list[dict], dict]:
+ """Return (rows, stats) for one CV locale directory (the one containing ``clips/``)."""
+ from canopy.data.generic import check_importable
+
+ check_importable(CV_SOURCE)
+ locale_dir = Path(locale_dir)
+ clips_dir = Path(audio_root) if audio_root else locale_dir / "clips"
+ durations = read_clip_durations(locale_dir)
+ stats = {"locale_dir": str(locale_dir), "language": language, "version": version, "tables": {},
+ "missing_audio": 0, "missing_duration": 0, "empty_text": 0}
+ rows: list[dict] = []
+ seen: set[str] = set()
+ for table in tables:
+ if table not in CV_TABLES:
+ raise ValueError(f"unknown CV table {table!r}; choose from {list(CV_TABLES)} ('invalidated' is never imported)")
+ path = locale_dir / CV_TABLES[table]
+ if not path.exists():
+ raise FileNotFoundError(f"{path} not found; is {locale_dir} an extracted CV locale directory?")
+ n = 0
+ for rec in _read_tsv(path):
+ clip = rec["path"]
+ if clip in seen:
+ continue
+ seen.add(clip)
+ text = (rec.get("sentence") or "").strip()
+ if not text:
+ stats["empty_text"] += 1
+ continue
+ audio = clips_dir / clip
+ if not audio.exists():
+ stats["missing_audio"] += 1
+ continue
+ dur = durations.get(clip)
+ if dur is None and probe_missing_durations:
+ try:
+ from canopy.frontend.audio import audio_info
+
+ dur = audio_info(audio).duration
+ except Exception:
+ dur = None
+ if not dur:
+ stats["missing_duration"] += 1
+ continue
+ extra = {k: rec[k] for k in ("sentence_id", "variant", "accents", "segment") if rec.get(k)}
+ extra["cv_locale"] = rec.get("locale") or locale_dir.name
+ rows.append(make_row(
+ id=f"cv{version}:{language}:{Path(clip).stem}",
+ audio_path=str(audio.resolve()),
+ duration=dur,
+ language=language,
+ speaker_id=_speaker_hash(rec["client_id"]),
+ text_raw=text,
+ source=CV_SOURCE,
+ source_version=version,
+ license=CV_LICENSE,
+ provenance="human",
+ validation="validated" if table == "validated" else "unvalidated",
+ extra=extra,
+ ))
+ n += 1
+ stats["tables"][table] = n
+ return rows, stats
diff --git a/canopy/data/dataset.py b/canopy/data/dataset.py
new file mode 100644
index 0000000000000000000000000000000000000000..f76a74b0e9fe25c7fd7939603ca658f968a7129d
--- /dev/null
+++ b/canopy/data/dataset.py
@@ -0,0 +1,104 @@
+from __future__ import annotations
+
+import random
+from dataclasses import dataclass
+
+import torch
+from torch.utils.data import Dataset
+
+from canopy.frontend.audio import load_audio
+from canopy.frontend.augment import speed_perturb
+
+
+def ctc_required_frames(tokens: list[int]) -> int:
+ """Minimum CTC frames: one per token plus a blank between adjacent repeats."""
+ repeats = sum(1 for a, b in zip(tokens, tokens[1:]) if a == b)
+ return len(tokens) + repeats
+
+
+@dataclass
+class FilterStats:
+ kept: int = 0
+ empty_target: int = 0
+ ctc_infeasible: int = 0
+ unknown_language: int = 0
+
+ def as_dict(self) -> dict:
+ return dict(self.__dict__)
+
+
+class ASRDataset(Dataset):
+ """Utterance dataset. Features are computed on the device in the trainer.
+
+ Rows whose target cannot be emitted by CTC at the encoder frame rate (even
+ at the fastest speed perturbation) are removed up front and counted.
+ """
+
+ def __init__(self, rows: list[dict], tokenizer, lang2id: dict[str, int], *, encoder_fps: float = 25.0,
+ speed_factors: tuple[float, ...] = (1.0,), train: bool = False, filter_targets: bool = True):
+ self.tokenizer = tokenizer
+ self.lang2id = lang2id
+ self.train = train
+ self.speed_factors = tuple(speed_factors) if train else (1.0,)
+ self.stats = FilterStats()
+ fastest = max(self.speed_factors)
+ self.rows: list[dict] = []
+ self.targets: list[list[int]] = []
+ for r in rows:
+ if r["language"] not in lang2id:
+ self.stats.unknown_language += 1
+ continue
+ ids = tokenizer.encode(r["text_train"], language=r["language"])
+ if filter_targets:
+ if not ids:
+ self.stats.empty_target += 1
+ continue
+ frames = int(r["duration"] / fastest * encoder_fps)
+ if ctc_required_frames(ids) > frames:
+ self.stats.ctc_infeasible += 1
+ continue
+ self.rows.append(r)
+ self.targets.append(ids)
+ self.stats.kept = len(self.rows)
+
+ def __len__(self) -> int:
+ return len(self.rows)
+
+ def duration(self, i: int) -> float:
+ return self.rows[i]["duration"]
+
+ def __getitem__(self, i: int) -> dict:
+ r = self.rows[i]
+ wav = load_audio(r["audio_path"])
+ if self.train and len(self.speed_factors) > 1:
+ f = random.choice(self.speed_factors)
+ wav = speed_perturb(wav, f)
+ return {
+ "id": r["id"],
+ "wav": wav,
+ "tokens": torch.tensor(self.targets[i], dtype=torch.long),
+ "lang_id": self.lang2id[r["language"]],
+ "language": r["language"],
+ "speaker_id": r["speaker_id"],
+ }
+
+
+def collate(batch: list[dict]) -> dict:
+ B = len(batch)
+ wav_lens = torch.tensor([b["wav"].numel() for b in batch], dtype=torch.long)
+ tgt_lens = torch.tensor([b["tokens"].numel() for b in batch], dtype=torch.long)
+ wavs = torch.zeros(B, int(wav_lens.max()))
+ tgts = torch.zeros(B, int(tgt_lens.max()), dtype=torch.long)
+ for i, b in enumerate(batch):
+ wavs[i, : b["wav"].numel()] = b["wav"]
+ tgts[i, : b["tokens"].numel()] = b["tokens"]
+ return {
+ "ids": [b["id"] for b in batch],
+ "languages": [b["language"] for b in batch],
+ "speaker_ids": [b["speaker_id"] for b in batch],
+ "wav": wavs,
+ "wav_lengths": wav_lens,
+ "targets": tgts,
+ "target_lengths": tgt_lens,
+ "lang_ids": torch.tensor([b["lang_id"] for b in batch], dtype=torch.long),
+ }
diff --git a/canopy/data/generic.py b/canopy/data/generic.py
new file mode 100644
index 0000000000000000000000000000000000000000..cc9f37aa9a51a58f06a2bd287eff407c603c2613
--- /dev/null
+++ b/canopy/data/generic.py
@@ -0,0 +1,75 @@
+"""Import a corpus described by a simple TSV (path, text, speaker[, duration][, split]).
+
+Used for corpora other than Common Voice (e.g. FLEURS exported locally). The
+caller must supply the dataset id, version and licence exactly as recorded in
+data/LICENSES.json; the importer refuses datasets whose release status there is
+blocked or excluded.
+"""
+
+from __future__ import annotations
+
+import csv
+import json
+import os
+from pathlib import Path
+
+from canopy.data.manifest import make_row
+
+BLOCKED_STATUSES = {"blocked_pending_review", "excluded", "eval_only_pending_review"}
+DEFAULT_LICENSES = Path(__file__).resolve().parents[2] / "data" / "LICENSES.json"
+
+
+def license_record(dataset_id: str, licenses_path: str | os.PathLike = DEFAULT_LICENSES) -> dict:
+ with open(licenses_path, encoding="utf-8") as f:
+ db = json.load(f)
+ datasets = db["datasets"]
+ if isinstance(datasets, dict):
+ rec = datasets.get(dataset_id)
+ else:
+ rec = next((d for d in datasets if d.get("id") == dataset_id), None)
+ if rec is None:
+ raise KeyError(f"dataset {dataset_id!r} is not in {licenses_path}; audit its licence before importing")
+ return rec
+
+
+def check_importable(dataset_id: str, licenses_path: str | os.PathLike = DEFAULT_LICENSES) -> dict:
+ rec = license_record(dataset_id, licenses_path)
+ status = rec.get("release_status")
+ if status in BLOCKED_STATUSES:
+ raise PermissionError(f"dataset {dataset_id!r} has release_status={status!r} in {licenses_path}; "
+ "it must not be used until the licence review is resolved")
+ return rec
+
+
+def import_tsv(tsv: str | os.PathLike, *, dataset_id: str, language: str, version: str, audio_root: str | os.PathLike | None = None,
+ licenses_path: str | os.PathLike = DEFAULT_LICENSES, provenance: str = "human") -> list[dict]:
+ rec = check_importable(dataset_id, licenses_path)
+ license_str = rec.get("license", "unknown")
+ tsv = Path(tsv)
+ root = Path(audio_root) if audio_root else tsv.parent
+ rows = []
+ with open(tsv, encoding="utf-8", newline="") as f:
+ for i, r in enumerate(csv.DictReader(f, delimiter="\t", quoting=csv.QUOTE_NONE)):
+ audio = Path(r["path"])
+ if not audio.is_absolute():
+ audio = root / audio
+ dur = r.get("duration")
+ if not dur:
+ from canopy.frontend.audio import audio_info
+
+ dur = audio_info(audio).duration
+ extra = {"split": r["split"]} if r.get("split") else None
+ rows.append(make_row(
+ id=f"{dataset_id}:{language}:{r.get('id') or i}",
+ audio_path=str(audio.resolve()),
+ duration=float(dur),
+ language=language,
+ speaker_id=f"{dataset_id}:{r['speaker']}",
+ text_raw=r["text"],
+ source=dataset_id,
+ source_version=version,
+ license=license_str,
+ provenance=provenance,
+ extra=extra,
+ ))
+ return rows
diff --git a/canopy/data/manifest.py b/canopy/data/manifest.py
new file mode 100644
index 0000000000000000000000000000000000000000..f8582acea16ad7ed79e3bea2ce5d2db9872ef9d6
--- /dev/null
+++ b/canopy/data/manifest.py
@@ -0,0 +1,124 @@
+"""Manifest schema.
+
+A manifest is a JSONL file with one utterance per line. Manifests reference
+audio on the local disk and are never committed or published with text:
+Common Voice terms forbid re-hosting any part of the corpus.
+"""
+
+from __future__ import annotations
+
+import os
+from collections.abc import Iterable
+
+from canopy.text.normalize import get_normalizer
+from canopy.utils.io import read_jsonl, sha256_text
+
+REQUIRED_FIELDS = (
+ "id", "audio_path", "duration", "language", "speaker_id", "text_raw", "text_train", "text_eval",
+ "sentence_key", "source", "source_version", "license", "provenance",
+)
+# `human`: human transcript; `pseudo`: model-generated label; `synthetic`: synthetic audio (e.g. TTS).
+PROVENANCES = ("human", "pseudo", "synthetic")
+SPLITS = ("train", "dev", "test")
+
+
+def sentence_key(language: str, text_eval: str) -> str:
+ """Identity of a sentence for leakage control: same language + same scoring text."""
+ return sha256_text(f"{language}\t{text_eval}")[:20]
+
+
+def make_row(*, id: str, audio_path: str, duration: float, language: str, speaker_id: str, text_raw: str,
+ source: str, source_version: str, license: str, provenance: str = "human",
+ validation: str | None = None, extra: dict | None = None) -> dict:
+ if provenance not in PROVENANCES:
+ raise ValueError(f"provenance must be one of {PROVENANCES}, got {provenance!r}")
+ norm = get_normalizer(language)
+ text_train = norm.train_norm(text_raw)
+ text_eval = norm.eval_norm(text_raw)
+ row = {
+ "id": id,
+ "audio_path": audio_path,
+ "duration": round(float(duration), 4),
+ "language": language,
+ "speaker_id": speaker_id,
+ "text_raw": text_raw,
+ "text_train": text_train,
+ "text_eval": text_eval,
+ "sentence_key": sentence_key(language, text_eval),
+ "source": source,
+ "source_version": source_version,
+ "license": license,
+ "provenance": provenance,
+ }
+ if validation is not None:
+ row["validation"] = validation
+ if extra:
+ row.update(extra)
+ return row
+
+
+def validate_row(row: dict) -> list[str]:
+ errors = [f"missing field {k!r}" for k in REQUIRED_FIELDS if k not in row]
+ if errors:
+ return errors
+ if row["provenance"] not in PROVENANCES:
+ errors.append(f"bad provenance {row['provenance']!r}")
+ if "split" in row and row["split"] not in SPLITS and not str(row["split"]).startswith("fold"):
+ errors.append(f"bad split {row['split']!r}")
+ if not isinstance(row["duration"], (int, float)) or row["duration"] <= 0:
+ errors.append("non-positive duration")
+ return errors
+
+
+def load_manifest(paths: str | os.PathLike | Iterable[str | os.PathLike], *, languages: Iterable[str] | None = None,
+ splits: Iterable[str] | None = None, provenances: Iterable[str] | None = ("human",),
+ include_unvalidated: bool = False,
+ min_duration: float = 0.0, max_duration: float | None = None) -> list[dict]:
+ """Load and filter one or more manifests.
+
+ ``provenances`` defaults to human-labelled data only; pseudo-labelled or
+ synthetic rows must be requested explicitly. Rows whose audio failed or
+ lacks community validation (``validation == "unvalidated"``) are excluded
+ unless ``include_unvalidated`` is set.
+ """
+ if isinstance(paths, (str, os.PathLike)):
+ paths = [paths]
+ langs = set(languages) if languages else None
+ spl = set(splits) if splits else None
+ prov = set(provenances) if provenances else None
+ rows = []
+ for p in paths:
+ for row in read_jsonl(p):
+ if langs is not None and row["language"] not in langs:
+ continue
+ if spl is not None and row.get("split") not in spl:
+ continue
+ if prov is not None and row.get("provenance") not in prov:
+ continue
+ if not include_unvalidated and row.get("validation") == "unvalidated":
+ continue
+ d = row["duration"]
+ if d < min_duration or (max_duration is not None and d > max_duration):
+ continue
+ rows.append(row)
+ return rows
+
+
+def summarize(rows: Iterable[dict]) -> dict:
+ out: dict = {}
+ for r in rows:
+ key = (r["language"], r.get("split", "unsplit"))
+ s = out.setdefault(key, {"utterances": 0, "hours": 0.0, "speakers": set(), "sentences": set()})
+ s["utterances"] += 1
+ s["hours"] += r["duration"] / 3600
+ s["speakers"].add(r["speaker_id"])
+ s["sentences"].add(r["sentence_key"])
+ result: dict = {}
+ for (lang, split), s in sorted(out.items()):
+ result.setdefault(lang, {})[split] = {
+ "utterances": s["utterances"],
+ "hours": round(s["hours"], 4),
+ "speakers": len(s["speakers"]),
+ "sentences": len(s["sentences"]),
+ }
+ return result
diff --git a/canopy/data/sampler.py b/canopy/data/sampler.py
new file mode 100644
index 0000000000000000000000000000000000000000..650677ff1b21b0c526018b573035a7ac08eb6948
--- /dev/null
+++ b/canopy/data/sampler.py
@@ -0,0 +1,128 @@
+"""Duration-bucketed batching and temperature-balanced multilingual sampling.
+
+Language ``l`` is drawn with probability ``p_l ∝ min(h_l, cap_l) ** (1/T)``
+where ``h_l`` is its training hours. T=1 is proportional sampling, T→∞ is
+uniform. ``cap_l`` bounds the effective size of dominant languages (Pashto has
+>3,000 validated hours against ~10 h for most CV-Pakistan languages).
+Each virtual epoch draws ``epoch_hours`` of audio in total.
+"""
+
+from __future__ import annotations
+
+import math
+import random
+from collections import defaultdict
+from collections.abc import Iterator, Sequence
+
+from torch.utils.data import Sampler
+
+
+def language_probabilities(hours: dict[str, float], temperature: float, caps: dict[str, float] | None = None,
+ default_cap: float | None = None) -> dict[str, float]:
+ caps = caps or {}
+ eff = {}
+ for lang, h in hours.items():
+ cap = caps.get(lang, default_cap)
+ eff[lang] = min(h, cap) if cap is not None else h
+ if math.isinf(temperature):
+ w = {lang: 1.0 for lang in eff}
+ else:
+ w = {lang: h ** (1.0 / temperature) for lang, h in eff.items() if h > 0}
+ z = sum(w.values())
+ return {lang: v / z for lang, v in w.items()}
+
+
+def _pack(indices: list[int], durations: Sequence[float], max_batch_seconds: float, max_batch_size: int) -> list[list[int]]:
+ batches, cur, cur_max = [], [], 0.0
+ for i in indices:
+ d = durations[i]
+ new_max = max(cur_max, d)
+ if cur and (new_max * (len(cur) + 1) > max_batch_seconds or len(cur) >= max_batch_size):
+ batches.append(cur)
+ cur, new_max = [], d
+ cur.append(i)
+ cur_max = new_max
+ if cur:
+ batches.append(cur)
+ return batches
+
+
+class DurationBatchSampler(Sampler[list[int]]):
+ """Deterministic, sorted batches for evaluation (padding-efficient)."""
+
+ def __init__(self, durations: Sequence[float], max_batch_seconds: float, max_batch_size: int = 256):
+ order = sorted(range(len(durations)), key=lambda i: durations[i])
+ self.batches = _pack(order, durations, max_batch_seconds, max_batch_size)
+
+ def __iter__(self) -> Iterator[list[int]]:
+ return iter(self.batches)
+
+ def __len__(self) -> int:
+ return len(self.batches)
+
+
+class BalancedBatchSampler(Sampler[list[int]]):
+ def __init__(self, languages: Sequence[str], durations: Sequence[float], *, max_batch_seconds: float,
+ max_batch_size: int = 128, temperature: float = 5.0, caps_hours: dict[str, float] | None = None,
+ default_cap_hours: float | None = None, epoch_hours: float | None = None, bucket_size: int = 64,
+ seed: int = 0):
+ self.languages = list(languages)
+ self.durations = list(durations)
+ self.max_batch_seconds = max_batch_seconds
+ self.max_batch_size = max_batch_size
+ self.bucket_size = bucket_size
+ self.seed = seed
+ self.by_lang: dict[str, list[int]] = defaultdict(list)
+ for i, lang in enumerate(self.languages):
+ self.by_lang[lang].append(i)
+ self.hours = {lang: sum(self.durations[i] for i in idx) / 3600 for lang, idx in self.by_lang.items()}
+ self.probs = language_probabilities(self.hours, temperature, caps_hours, default_cap_hours)
+ total_h = sum(self.hours.values())
+ self.epoch_hours = epoch_hours if epoch_hours is not None else total_h
+ self.epoch = 0
+ self.start_batch = 0
+ self._batches: list[list[int]] = []
+ self._audit: dict = {}
+ self.set_epoch(0)
+
+ def set_epoch(self, epoch: int) -> None:
+ self.epoch = epoch
+ rng = random.Random(f"{self.seed}:{epoch}")
+ drawn: list[int] = []
+ audit = {}
+ for lang in sorted(self.by_lang):
+ p = self.probs.get(lang, 0.0)
+ budget = p * self.epoch_hours * 3600
+ pool = self.by_lang[lang]
+ got, secs, cycles = [], 0.0, 0
+ order: list[int] = []
+ while secs < budget and pool:
+ if not order:
+ order = pool[:]
+ rng.shuffle(order)
+ cycles += 1
+ i = order.pop()
+ got.append(i)
+ secs += self.durations[i]
+ drawn.extend(got)
+ audit[lang] = {"probability": round(p, 6), "utterances": len(got), "seconds": round(secs, 2),
+ "passes_over_data": round(secs / max(1e-9, self.hours[lang] * 3600), 3)}
+ rng.shuffle(drawn)
+ batches: list[list[int]] = []
+ chunk = self.bucket_size * max(1, self.max_batch_size // 4)
+ for s in range(0, len(drawn), chunk):
+ part = sorted(drawn[s : s + chunk], key=lambda i: self.durations[i])
+ batches.extend(_pack(part, self.durations, self.max_batch_seconds, self.max_batch_size))
+ rng.shuffle(batches)
+ self._batches = batches
+ self._audit = {"epoch": epoch, "languages": audit, "batches": len(batches)}
+
+ def audit(self) -> dict:
+ return self._audit
+
+ def __iter__(self) -> Iterator[list[int]]:
+ start, self.start_batch = self.start_batch, 0
+ return iter(self._batches[start:])
+
+ def __len__(self) -> int:
+ return len(self._batches)
diff --git a/canopy/data/splits.py b/canopy/data/splits.py
new file mode 100644
index 0000000000000000000000000000000000000000..60ba47476e8f48f91f132c2512683598035025b3
--- /dev/null
+++ b/canopy/data/splits.py
@@ -0,0 +1,188 @@
+"""Speaker- AND sentence-disjoint split construction.
+
+Common Voice re-records each sentence by several speakers, so speaker-disjoint
+splits alone leak transcripts from train into test. The procedure, per
+language:
+
+1. Assign whole speakers to ``test``, then ``dev``, until each reaches its
+ target share of hours and minimum speaker count; the rest go to ``train``.
+2. Drop dev rows whose sentence occurs in test, and train rows whose sentence
+ occurs in dev or test. Dropped rows are returned and counted, never
+ silently discarded.
+
+Languages with too few speakers for a fixed held-out set get k speaker folds
+instead (``fold`` field); evaluation then cross-validates over folds.
+"""
+
+from __future__ import annotations
+
+import random
+from collections import defaultdict
+from collections.abc import Iterable
+from dataclasses import dataclass, field
+
+
+@dataclass
+class SplitConfig:
+ test_fraction: float = 0.10
+ dev_fraction: float = 0.10
+ min_test_speakers: int = 3
+ min_dev_speakers: int = 2
+ # One speaker may provide at most this share of a held-out split's target hours
+ # (unless no other speaker is left).
+ max_speaker_share: float = 0.34
+ # Below this many speakers a language gets speaker folds instead of fixed splits.
+ min_speakers_for_fixed: int = 10
+ n_folds: int = 5
+ seed: int = 1234
+
+
+@dataclass
+class SplitResult:
+ rows: list[dict]
+ dropped: list[dict]
+ report: dict = field(default_factory=dict)
+
+
+def _by(rows: Iterable[dict], key: str) -> dict[str, list[dict]]:
+ out: dict[str, list[dict]] = defaultdict(list)
+ for r in rows:
+ out[r[key]].append(r)
+ return out
+
+
+def _hours(rows: Iterable[dict]) -> float:
+ return sum(r["duration"] for r in rows) / 3600
+
+
+def _pick_speakers(speakers: list[str], spk_hours: dict[str, float], target_h: float, min_spk: int,
+ max_share: float) -> list[str]:
+ chosen: list[str] = []
+ total = 0.0
+ cap = max_share * target_h
+ for s in list(speakers):
+ if total >= target_h and len(chosen) >= min_spk:
+ break
+ if spk_hours[s] > cap and len(speakers) - len(chosen) > min_spk:
+ continue
+ chosen.append(s)
+ total += spk_hours[s]
+ if len(chosen) < min_spk:
+ for s in speakers:
+ if s not in chosen:
+ chosen.append(s)
+ if len(chosen) >= min_spk:
+ break
+ return chosen
+
+
+def _fixed_split(rows: list[dict], cfg: SplitConfig, rng: random.Random) -> tuple[list[dict], list[dict], dict]:
+ spk_rows = _by(rows, "speaker_id")
+ spk_hours = {s: _hours(rs) for s, rs in spk_rows.items()}
+ speakers = sorted(spk_rows)
+ rng.shuffle(speakers)
+ total_h = _hours(rows)
+
+ test_spk = _pick_speakers(speakers, spk_hours, cfg.test_fraction * total_h, cfg.min_test_speakers, cfg.max_speaker_share)
+ rest = [s for s in speakers if s not in set(test_spk)]
+ dev_spk = _pick_speakers(rest, spk_hours, cfg.dev_fraction * total_h, cfg.min_dev_speakers, cfg.max_speaker_share)
+ assign = {s: "train" for s in speakers}
+ assign.update({s: "test" for s in test_spk})
+ assign.update({s: "dev" for s in dev_spk})
+
+ test_sent = {r["sentence_key"] for r in rows if assign[r["speaker_id"]] == "test"}
+ dev_sent = {r["sentence_key"] for r in rows if assign[r["speaker_id"]] == "dev"}
+ kept, dropped = [], []
+ for r in rows:
+ split = assign[r["speaker_id"]]
+ leak = (split == "dev" and r["sentence_key"] in test_sent) or (
+ split == "train" and (r["sentence_key"] in test_sent or r["sentence_key"] in dev_sent))
+ out = dict(r, split=split)
+ if leak:
+ out["drop_reason"] = "sentence_in_heldout"
+ dropped.append(out)
+ else:
+ kept.append(out)
+ return kept, dropped, {"mode": "fixed", "test_speakers": len(test_spk), "dev_speakers": len(dev_spk)}
+
+
+def _fold_split(rows: list[dict], cfg: SplitConfig, rng: random.Random) -> tuple[list[dict], list[dict], dict]:
+ spk_rows = _by(rows, "speaker_id")
+ speakers = sorted(spk_rows)
+ rng.shuffle(speakers)
+ speakers.sort(key=lambda s: -_hours(spk_rows[s]))
+ k = max(2, min(cfg.n_folds, len(speakers)))
+ load = [0.0] * k
+ fold_of: dict[str, int] = {}
+ for s in speakers:
+ i = min(range(k), key=lambda j: load[j])
+ fold_of[s] = i
+ load[i] += _hours(spk_rows[s])
+ kept = [dict(r, split=f"fold{fold_of[r['speaker_id']]}", fold=fold_of[r["speaker_id"]]) for r in rows]
+ return kept, [], {"mode": "speaker_folds", "n_folds": k, "fold_hours": [round(x, 3) for x in load]}
+
+
+def make_splits(rows: list[dict], cfg: SplitConfig | None = None, respect_existing: bool = True) -> SplitResult:
+ """Assign ``split`` to every row. Rows that already carry a split (e.g. FLEURS
+ official dev/test) keep it when ``respect_existing`` is set."""
+ cfg = cfg or SplitConfig()
+ fixed_rows = [r for r in rows if respect_existing and r.get("split")]
+ todo = [r for r in rows if not (respect_existing and r.get("split"))]
+ out_rows, dropped = list(fixed_rows), []
+ report: dict = {"config": cfg.__dict__, "languages": {}}
+ for lang, lrows in sorted(_by(todo, "language").items()):
+ rng = random.Random(f"{cfg.seed}:{lang}")
+ n_spk = len({r["speaker_id"] for r in lrows})
+ if n_spk < cfg.min_speakers_for_fixed:
+ kept, drop, info = _fold_split(lrows, cfg, rng)
+ else:
+ kept, drop, info = _fixed_split(lrows, cfg, rng)
+ out_rows.extend(kept)
+ dropped.extend(drop)
+ info.update({
+ "speakers": n_spk,
+ "input_hours": round(_hours(lrows), 4),
+ "dropped_rows": len(drop),
+ "dropped_hours": round(_hours(drop), 4),
+ "splits": {s: {"rows": len(rs), "hours": round(_hours(rs), 4), "speakers": len({r['speaker_id'] for r in rs})}
+ for s, rs in sorted(_by(kept, "split").items())},
+ })
+ report["languages"][lang] = info
+ report["leakage"] = check_disjoint(out_rows)
+ return SplitResult(rows=out_rows, dropped=dropped, report=report)
+
+
+def check_disjoint(rows: Iterable[dict]) -> dict:
+ """Count speaker and sentence overlaps between splits, per language. All counts must be 0."""
+ per: dict[str, dict[str, dict[str, set]]] = defaultdict(lambda: defaultdict(lambda: {"spk": set(), "sent": set()}))
+ for r in rows:
+ split = r.get("split")
+ if split is None:
+ continue
+ group = "fold" if str(split).startswith("fold") else "fixed"
+ per[(r["language"], group)][split]["spk"].add(r["speaker_id"])
+ per[(r["language"], group)][split]["sent"].add(r["sentence_key"])
+ out: dict = {"speaker_overlaps": 0, "sentence_overlaps": 0, "details": []}
+ for (lang, group), splits in per.items():
+ names = sorted(splits)
+ for i, a in enumerate(names):
+ for b in names[i + 1:]:
+ s = len(splits[a]["spk"] & splits[b]["spk"])
+ t = len(splits[a]["sent"] & splits[b]["sent"])
+ # Folds share sentences by design; the train view drops them (fold_view).
+ if group == "fold":
+ t = 0
+ if s or t:
+ out["details"].append({"language": lang, "a": a, "b": b, "speakers": s, "sentences": t})
+ out["speaker_overlaps"] += s
+ out["sentence_overlaps"] += t
+ return out
+
+
+def fold_view(rows: list[dict], language: str, fold: int) -> tuple[list[dict], list[dict]]:
+ """(train, test) for one speaker fold, with test sentences removed from train."""
+ lrows = [r for r in rows if r["language"] == language and "fold" in r]
+ test = [r for r in lrows if r["fold"] == fold]
+ test_sent = {r["sentence_key"] for r in test}
+ train = [r for r in lrows if r["fold"] != fold and r["sentence_key"] not in test_sent]
+ return train, test
diff --git a/canopy/data/validation.py b/canopy/data/validation.py
new file mode 100644
index 0000000000000000000000000000000000000000..ddc8c27e68d45727b0ce5318371f97acc87c0fa9
--- /dev/null
+++ b/canopy/data/validation.py
@@ -0,0 +1,193 @@
+"""Automated data-quality checks producing ``data_quality_report.json``.
+
+Examples in the report are utterance IDs only (no transcripts), so the report
+can be shared without redistributing corpus text.
+
+Speaker near-duplicate detection is deliberately NOT implemented for Common
+Voice: its terms forbid attempts to determine speaker identity.
+"""
+
+from __future__ import annotations
+
+import unicodedata
+from collections import Counter, defaultdict
+from collections.abc import Callable
+
+from canopy.data.manifest import validate_row
+from canopy.data.splits import check_disjoint
+from canopy.languages import LanguageRegistry, get_registry
+
+MAX_EXAMPLES = 20
+
+
+def char_script(ch: str) -> str:
+ if ch.isspace():
+ return "space"
+ cat = unicodedata.category(ch)
+ if cat == "Nd":
+ return "digit"
+ if cat[0] == "M":
+ return "mark"
+ name = unicodedata.name(ch, "")
+ if name.startswith("ARABIC"):
+ return "Arab"
+ if name.startswith("LATIN"):
+ return "Latn"
+ if name.startswith(("DEVANAGARI",)):
+ return "Deva"
+ if name.startswith(("GURMUKHI",)):
+ return "Guru"
+ if name.startswith(("TIBETAN",)):
+ return "Tibt"
+ if ch in "'\u2019\u02bc\u200c":
+ return "joiner_or_apostrophe"
+ return "other"
+
+
+class _Issues:
+ def __init__(self):
+ self.counts: dict[str, Counter] = defaultdict(Counter)
+ self.examples: dict[str, dict[str, list[str]]] = defaultdict(lambda: defaultdict(list))
+
+ def add(self, lang: str, issue: str, utt_id: str):
+ self.counts[lang][issue] += 1
+ ex = self.examples[lang][issue]
+ if len(ex) < MAX_EXAMPLES:
+ ex.append(utt_id)
+
+
+def validate_manifest(rows: list[dict], *, registry: LanguageRegistry | None = None, check_audio: bool = False,
+ expected_sr: int = 16000, min_duration: float = 0.3, max_duration: float = 30.0,
+ max_chars_per_second: float = 30.0, min_chars_per_second: float = 0.3,
+ token_counter: Callable[[str], tuple[int, int]] | None = None, frames_per_second: float = 25.0) -> dict:
+ """Return the data-quality report dict.
+
+ ``token_counter(text) -> (n_tokens, n_adjacent_repeats)`` enables the CTC
+ feasibility check at ``frames_per_second`` (encoder output rate).
+ """
+ registry = registry or get_registry()
+ issues = _Issues()
+ ids = Counter(r.get("id") for r in rows)
+ audio_hashes: dict[str, str] = {}
+ sent_counts: dict[str, Counter] = defaultdict(Counter)
+ char_scripts: dict[str, Counter] = defaultdict(Counter)
+
+ for r in rows:
+ lang = r.get("language", "")
+ uid = r.get("id", "")
+ for e in validate_row(r):
+ issues.add(lang, f"schema:{e}", uid)
+ if ids[uid] > 1:
+ issues.add(lang, "duplicate_id", uid)
+ if lang not in registry:
+ issues.add(lang, "unknown_language_code", uid)
+ allowed_scripts = set()
+ else:
+ L = registry[lang]
+ allowed_scripts = {L.primary_script, *L.alternate_scripts}
+ text = r.get("text_train", "")
+ raw = r.get("text_raw", "")
+ dur = float(r.get("duration", 0) or 0)
+ if not text.strip():
+ issues.add(lang, "empty_transcript", uid)
+ if dur < min_duration:
+ issues.add(lang, "too_short", uid)
+ if dur > max_duration:
+ issues.add(lang, "too_long", uid)
+ if dur > 0 and text:
+ cps = len(text) / dur
+ if cps > max_chars_per_second:
+ issues.add(lang, "impossible_ratio_too_many_chars", uid)
+ elif cps < min_chars_per_second:
+ issues.add(lang, "impossible_ratio_too_few_chars", uid)
+ if token_counter is not None and text and dur > 0:
+ n_tok, n_rep = token_counter(text)
+ if n_tok + n_rep > int(dur * frames_per_second):
+ issues.add(lang, "ctc_infeasible", uid)
+ sent_counts[lang][r.get("sentence_key")] += 1
+
+ for ch in raw:
+ cat = unicodedata.category(ch)
+ o = ord(ch)
+ if cat in ("Co", "Cn", "Cs"):
+ issues.add(lang, "unicode_unassigned_or_private", uid)
+ elif 0xFB50 <= o <= 0xFDFF or 0xFE70 <= o <= 0xFEFE:
+ issues.add(lang, "unicode_presentation_form_in_raw", uid)
+ elif cat == "Cf" and ch != "\u200c":
+ issues.add(lang, "unicode_invisible_format_char_in_raw", uid)
+ word_scripts_mixed = False
+ for word in text.split():
+ s = {char_script(c) for c in word} - {"mark", "digit", "joiner_or_apostrophe"}
+ if len(s) > 1:
+ word_scripts_mixed = True
+ if word_scripts_mixed:
+ issues.add(lang, "mixed_script_word", uid)
+ for ch in text:
+ sc = char_script(ch)
+ char_scripts[lang][sc] += 1
+ if sc in ("Arab", "Latn", "Deva", "Guru", "Tibt") and allowed_scripts and sc not in allowed_scripts:
+ issues.add(lang, f"char_outside_registered_scripts:{sc}", uid)
+ if sc == "other":
+ issues.add(lang, "unsupported_character", uid)
+
+ if check_audio:
+ _check_audio(r, lang, uid, issues, expected_sr, audio_hashes)
+
+ per_lang: dict[str, dict] = {}
+ for lang in sorted({r.get("language", "") for r in rows}):
+ lrows = [r for r in rows if r.get("language") == lang]
+ sc = sent_counts[lang]
+ per_lang[lang] = {
+ "utterances": len(lrows),
+ "hours": round(sum(r["duration"] for r in lrows) / 3600, 4),
+ "speakers": len({r["speaker_id"] for r in lrows}),
+ "unique_sentences": len(sc),
+ "max_recordings_per_sentence": max(sc.values()) if sc else 0,
+ "mean_recordings_per_sentence": round(len(lrows) / max(1, len(sc)), 3),
+ "duplicate_transcript_rows": sum(c - 1 for c in sc.values() if c > 1),
+ "char_scripts": dict(char_scripts[lang]),
+ "issues": dict(issues.counts.get(lang, {})),
+ "issue_examples": {k: v for k, v in issues.examples.get(lang, {}).items()},
+ }
+ leakage = check_disjoint(rows)
+ total_issues = sum(sum(c.values()) for c in issues.counts.values())
+ return {
+ "n_utterances": len(rows),
+ "checks": {
+ "audio_checked": check_audio,
+ "near_duplicate_speakers": "not performed: Common Voice terms prohibit attempts to identify speakers",
+ "thresholds": {"min_duration": min_duration, "max_duration": max_duration,
+ "max_chars_per_second": max_chars_per_second, "min_chars_per_second": min_chars_per_second,
+ "ctc_frames_per_second": frames_per_second if token_counter else None},
+ },
+ "speaker_leakage": {"speaker_overlaps": leakage["speaker_overlaps"], "details": leakage["details"]},
+ "sentence_leakage": {"sentence_overlaps": leakage["sentence_overlaps"]},
+ "total_issue_count": total_issues,
+ "languages": per_lang,
+ }
+
+
+def _check_audio(r: dict, lang: str, uid: str, issues: _Issues, expected_sr: int, audio_hashes: dict[str, str]):
+ from canopy.frontend.audio import audio_info
+ from canopy.utils.io import sha256_file
+
+ path = r.get("audio_path")
+ try:
+ info = audio_info(path)
+ except Exception:
+ issues.add(lang, "broken_audio", uid)
+ return
+ if info.frames == 0:
+ issues.add(lang, "broken_audio", uid)
+ return
+ if info.sample_rate != expected_sr:
+ issues.add(lang, "sample_rate_not_16k", uid)
+ if info.channels != 1:
+ issues.add(lang, "not_mono", uid)
+ if abs(info.duration - float(r["duration"])) > 0.1:
+ issues.add(lang, "duration_mismatch", uid)
+ h = sha256_file(path)
+ if h in audio_hashes:
+ issues.add(lang, "duplicate_audio", uid)
+ else:
+ audio_hashes[h] = uid
diff --git a/canopy/decoding/__init__.py b/canopy/decoding/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..6411aa65da2a9c0011db71605f6f45076a48ecb0
--- /dev/null
+++ b/canopy/decoding/__init__.py
@@ -0,0 +1,4 @@
+from canopy.decoding.beam import prefix_beam_search
+from canopy.decoding.greedy import ctc_collapse, greedy_decode, language_mask_bias
+
+__all__ = ["ctc_collapse", "greedy_decode", "language_mask_bias", "prefix_beam_search"]
diff --git a/canopy/decoding/beam.py b/canopy/decoding/beam.py
new file mode 100644
index 0000000000000000000000000000000000000000..e6e999b8039df6912cb5cc2aa8671e0b54645819
--- /dev/null
+++ b/canopy/decoding/beam.py
@@ -0,0 +1,132 @@
+"""CTC prefix beam search with optional word-level n-gram shallow fusion.
+
+score(prefix) = log P_ctc(prefix) + alpha * log P_lm(words) + beta * |words|
+
+A word is scored by the LM when it is completed, i.e. when the next emitted
+token string contains a space (character tokenizers emit " "; SentencePiece
+pieces that start a word begin with "▁", rendered as " ").
+"""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Sequence
+
+import numpy as np
+
+NEG_INF = -math.inf
+
+
+def _lse(a: float, b: float) -> float:
+ if a == NEG_INF:
+ return b
+ if b == NEG_INF:
+ return a
+ m = max(a, b)
+ return m + math.log(math.exp(a - m) + math.exp(b - m))
+
+
+class _LMState:
+ __slots__ = ("raw", "history", "lm", "words")
+
+ def __init__(self, raw: str, history: tuple[str, ...], lm: float, words: int):
+ self.raw, self.history, self.lm, self.words = raw, history, lm, words
+
+
+def _pending_word(raw: str) -> str | None:
+ if not raw or raw.endswith(" "):
+ return None
+ parts = raw.split()
+ return parts[-1] if parts else None
+
+
+def prefix_beam_search(log_probs: np.ndarray, token_strings: Sequence[str], *, beam: int = 10, lm=None,
+ alpha: float = 0.5, beta: float = 1.0, token_prune: int = 20, prune_logp: float = -12.0,
+ allowed: np.ndarray | None = None, blank: int = 0) -> list[int]:
+ """Return the best token-id sequence for one utterance.
+
+ ``log_probs``: (T, V) natural-log posteriors. ``token_strings[i]``: surface
+ string of token i ("" for blank). ``lm``: object with
+ ``score(history, word) -> ln prob``, ``score_end(history) -> ln prob`` and
+ ``order``. ``allowed``: optional bool (V,) output mask.
+ """
+ T, V = log_probs.shape
+ lp = log_probs.copy()
+ if allowed is not None:
+ lp[:, ~allowed] = NEG_INF
+ use_lm = lm is not None and alpha != 0.0
+ hist_len = max(0, (lm.order - 1)) if use_lm else 0
+
+ beams: dict[tuple[int, ...], tuple[float, float]] = {(): (0.0, NEG_INF)}
+ lm_states: dict[tuple[int, ...], _LMState] = {(): _LMState("", (), 0.0, 0)}
+
+ def lm_state(prefix: tuple[int, ...]) -> _LMState:
+ st = lm_states.get(prefix)
+ if st is not None:
+ return st
+ parent = lm_state(prefix[:-1])
+ s = token_strings[prefix[-1]]
+ raw = parent.raw + s
+ history, lm_score, words = parent.history, parent.lm, parent.words
+ if " " in s:
+ w = _pending_word(parent.raw)
+ if w is not None:
+ words += 1
+ if use_lm:
+ lm_score += lm.score(history, w)
+ history = (history + (w,))[-hist_len:] if hist_len else ()
+ st = _LMState(raw, history, lm_score, words)
+ lm_states[prefix] = st
+ return st
+
+ def total(prefix, pb, pnb):
+ st = lm_state(prefix)
+ return _lse(pb, pnb) + alpha * st.lm + beta * st.words
+
+ for t in range(T):
+ row = lp[t]
+ cand = np.argsort(row)[::-1][:token_prune]
+ cand = [int(c) for c in cand if row[c] > prune_logp or c == blank]
+ nxt: dict[tuple[int, ...], list[float]] = {}
+
+ def add(p, b, nb, nxt=nxt):
+ cur = nxt.get(p)
+ if cur is None:
+ nxt[p] = [b, nb]
+ else:
+ cur[0] = _lse(cur[0], b)
+ cur[1] = _lse(cur[1], nb)
+
+ for prefix, (pb, pnb) in beams.items():
+ for c in cand:
+ p = float(row[c])
+ if c == blank:
+ add(prefix, _lse(pb, pnb) + p, NEG_INF)
+ continue
+ last = prefix[-1] if prefix else None
+ ext = prefix + (c,)
+ if c == last:
+ add(ext, NEG_INF, pb + p)
+ add(prefix, NEG_INF, pnb + p)
+ else:
+ add(ext, NEG_INF, _lse(pb, pnb) + p)
+ scored = sorted(nxt.items(), key=lambda kv: total(kv[0], kv[1][0], kv[1][1]), reverse=True)[:beam]
+ beams = {k: (v[0], v[1]) for k, v in scored}
+
+ best, best_score = (), NEG_INF
+ for prefix, (pb, pnb) in beams.items():
+ st = lm_state(prefix)
+ lm_score, words = st.lm, st.words
+ w = _pending_word(st.raw)
+ history = st.history
+ if w is not None:
+ words += 1
+ if use_lm:
+ lm_score += lm.score(history, w)
+ history = (history + (w,))[-hist_len:] if hist_len else ()
+ if use_lm:
+ lm_score += lm.score_end(history)
+ s = _lse(pb, pnb) + alpha * lm_score + beta * words
+ if s > best_score:
+ best, best_score = prefix, s
+ return list(best)
diff --git a/canopy/decoding/greedy.py b/canopy/decoding/greedy.py
new file mode 100644
index 0000000000000000000000000000000000000000..8eba7fc40790353b68f58c365dcd17a67c2a900e
--- /dev/null
+++ b/canopy/decoding/greedy.py
@@ -0,0 +1,43 @@
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import torch
+
+from canopy.tokenizer.base import BLANK_ID
+
+
+def ctc_collapse(ids: Sequence[int], blank: int = BLANK_ID) -> list[int]:
+ """Merge repeats, then remove blanks."""
+ out, prev = [], None
+ for i in ids:
+ if i != prev and i != blank:
+ out.append(int(i))
+ prev = i
+ return out
+
+
+def language_mask_bias(tokenizer, languages: Sequence[str | None], vocab_size: int, device=None) -> torch.Tensor | None:
+ """(B, V) additive bias: 0 for allowed tokens, -inf for tokens never seen in that
+ language's training text (output design O2). Rows for ``None`` or unknown
+ languages are all zeros (unrestricted)."""
+ if not any(languages):
+ return None
+ bias = torch.zeros(len(languages), vocab_size, device=device)
+ for b, lang in enumerate(languages):
+ if not lang:
+ continue
+ mask = tokenizer.language_mask(lang)
+ if mask is None:
+ continue
+ m = torch.tensor(mask, dtype=torch.bool, device=device)
+ bias[b, ~m] = float("-inf")
+ return bias
+
+
+@torch.no_grad()
+def greedy_decode(log_probs: torch.Tensor, lengths: torch.Tensor, mask_bias: torch.Tensor | None = None) -> list[list[int]]:
+ if mask_bias is not None:
+ log_probs = log_probs + mask_bias.unsqueeze(1)
+ best = log_probs.argmax(dim=-1).cpu()
+ return [ctc_collapse(best[b, : int(lengths[b])].tolist()) for b in range(best.shape[0])]
diff --git a/canopy/evaluation/__init__.py b/canopy/evaluation/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..d7b29ced37a57dfe7b9856d5533a9bc235b22823
--- /dev/null
+++ b/canopy/evaluation/__init__.py
@@ -0,0 +1,3 @@
+from canopy.evaluation.metrics import aggregate, corpus_error_rate, edit_counts, score_utterances
+
+__all__ = ["aggregate", "corpus_error_rate", "edit_counts", "score_utterances"]
diff --git a/canopy/evaluation/metrics.py b/canopy/evaluation/metrics.py
new file mode 100644
index 0000000000000000000000000000000000000000..55cbe72c1c97d9d4a0665ca1af1332145a102257
--- /dev/null
+++ b/canopy/evaluation/metrics.py
@@ -0,0 +1,135 @@
+"""WER / CER with per-language, macro, weighted and worst-k aggregates and
+speaker-cluster bootstrap confidence intervals.
+
+Both reference and hypothesis are scored in the language's ``eval_norm`` view.
+CER counts characters of the eval_norm string including single spaces between
+words.
+"""
+
+from __future__ import annotations
+
+import random
+from collections import defaultdict
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+
+@dataclass
+class EditCounts:
+ sub: int = 0
+ dele: int = 0
+ ins: int = 0
+ ref_len: int = 0
+
+ @property
+ def errors(self) -> int:
+ return self.sub + self.dele + self.ins
+
+ def __add__(self, o: EditCounts) -> EditCounts:
+ return EditCounts(self.sub + o.sub, self.dele + o.dele, self.ins + o.ins, self.ref_len + o.ref_len)
+
+
+def edit_counts(ref: Sequence, hyp: Sequence) -> EditCounts:
+ """Levenshtein alignment counts (substitutions, deletions, insertions)."""
+ n, m = len(ref), len(hyp)
+ if n == 0:
+ return EditCounts(0, 0, m, 0)
+ if m == 0:
+ return EditCounts(0, n, 0, n)
+ # Each cell holds (cost, sub, del, ins); ties prefer substitution, then deletion.
+ prev = [(j, 0, 0, j) for j in range(m + 1)]
+ for i in range(1, n + 1):
+ cur = [(i, 0, i, 0)]
+ ri = ref[i - 1]
+ for j in range(1, m + 1):
+ d = prev[j - 1]
+ if ri == hyp[j - 1]:
+ best = d
+ else:
+ best = (d[0] + 1, d[1] + 1, d[2], d[3])
+ u = prev[j]
+ if u[0] + 1 < best[0]:
+ best = (u[0] + 1, u[1], u[2] + 1, u[3])
+ left = cur[j - 1]
+ if left[0] + 1 < best[0]:
+ best = (left[0] + 1, left[1], left[2], left[3] + 1)
+ cur.append(best)
+ prev = cur
+ c = prev[m]
+ return EditCounts(c[1], c[2], c[3], n)
+
+
+def corpus_error_rate(counts: Sequence[EditCounts]) -> float:
+ total = EditCounts()
+ for c in counts:
+ total = total + c
+ return total.errors / total.ref_len if total.ref_len else 0.0
+
+
+def score_utterances(items: Sequence[dict]) -> list[dict]:
+ """``items``: dicts with language, speaker_id, ref (eval_norm), hyp (eval_norm)."""
+ out = []
+ for it in items:
+ w = edit_counts(it["ref"].split(), it["hyp"].split())
+ c = edit_counts(list(it["ref"]), list(it["hyp"]))
+ out.append({**it, "word": w, "char": c})
+ return out
+
+
+def _bootstrap(scored: list[dict], unit: str, n_boot: int, seed: int) -> tuple[float, float] | None:
+ by_spk: dict[str, list[EditCounts]] = defaultdict(list)
+ for s in scored:
+ by_spk[s["speaker_id"]].append(s[unit])
+ speakers = list(by_spk)
+ if len(speakers) < 2:
+ return None
+ totals = {k: sum(v, EditCounts()) for k, v in by_spk.items()}
+ rng = random.Random(seed)
+ vals = []
+ for _ in range(n_boot):
+ e = r = 0
+ for _k in range(len(speakers)):
+ t = totals[speakers[rng.randrange(len(speakers))]]
+ e += t.errors
+ r += t.ref_len
+ vals.append(e / r if r else 0.0)
+ vals.sort()
+ return vals[int(0.025 * (n_boot - 1))], vals[int(0.975 * (n_boot - 1))]
+
+
+def aggregate(scored: list[dict], n_boot: int = 1000, seed: int = 0, worst_k: int = 5) -> dict:
+ per_lang: dict[str, list[dict]] = defaultdict(list)
+ for s in scored:
+ per_lang[s["language"]].append(s)
+ langs = {}
+ for lang, rows in sorted(per_lang.items()):
+ w = sum((r["word"] for r in rows), EditCounts())
+ c = sum((r["char"] for r in rows), EditCounts())
+ entry = {
+ "utterances": len(rows),
+ "speakers": len({r["speaker_id"] for r in rows}),
+ "ref_words": w.ref_len,
+ "ref_chars": c.ref_len,
+ "wer": w.errors / w.ref_len if w.ref_len else 0.0,
+ "cer": c.errors / c.ref_len if c.ref_len else 0.0,
+ "word_edits": {"sub": w.sub, "del": w.dele, "ins": w.ins},
+ "char_edits": {"sub": c.sub, "del": c.dele, "ins": c.ins},
+ }
+ if n_boot:
+ entry["wer_ci95"] = _bootstrap(rows, "word", n_boot, seed)
+ entry["cer_ci95"] = _bootstrap(rows, "char", n_boot, seed)
+ entry["insufficient_evidence"] = entry["speakers"] < 5
+ langs[lang] = entry
+ all_w = sum((s["word"] for s in scored), EditCounts())
+ all_c = sum((s["char"] for s in scored), EditCounts())
+ macro_cer = sum(v["cer"] for v in langs.values()) / len(langs) if langs else 0.0
+ macro_wer = sum(v["wer"] for v in langs.values()) / len(langs) if langs else 0.0
+ worst = sorted(langs.items(), key=lambda kv: -kv[1]["cer"])[:worst_k]
+ return {
+ "languages": langs,
+ "macro": {"cer": macro_cer, "wer": macro_wer, "n_languages": len(langs)},
+ "weighted": {"cer": all_c.errors / all_c.ref_len if all_c.ref_len else 0.0,
+ "wer": all_w.errors / all_w.ref_len if all_w.ref_len else 0.0},
+ f"worst_{worst_k}_by_cer": [{"language": k, "cer": v["cer"], "wer": v["wer"]} for k, v in worst],
+ "bootstrap": {"unit": "speaker", "n": n_boot, "seed": seed} if n_boot else None,
+ }
diff --git a/canopy/evaluation/runner.py b/canopy/evaluation/runner.py
new file mode 100644
index 0000000000000000000000000000000000000000..abc666c5e48f45035a12ff0b089499aea413fa44
--- /dev/null
+++ b/canopy/evaluation/runner.py
@@ -0,0 +1,98 @@
+"""Run a model over manifest rows and score the output."""
+
+from __future__ import annotations
+
+import time
+from contextlib import nullcontext
+
+import numpy as np
+import torch
+from torch.utils.data import DataLoader
+
+from canopy.data.dataset import ASRDataset, collate
+from canopy.data.sampler import DurationBatchSampler
+from canopy.decoding.beam import prefix_beam_search
+from canopy.decoding.greedy import greedy_decode, language_mask_bias
+from canopy.evaluation.metrics import aggregate, score_utterances
+from canopy.text.normalize import get_normalizer
+
+
+@torch.no_grad()
+def transcribe_rows(model, tokenizer, rows: list[dict], *, lang2id: dict[str, int], device: torch.device,
+ max_batch_seconds: float = 300.0, num_workers: int = 0, use_language: bool = True,
+ use_masks: bool = True, decoder: str = "greedy", beam: int = 10, lms: dict | None = None,
+ alpha: float = 0.5, beta: float = 1.0, amp_dtype: torch.dtype | None = None) -> list[dict]:
+ """Returns one dict per row with id, language, speaker_id, hyp_train (model output text).
+
+ ``use_language``: pass the reference language to the model (conditioning)
+ and to the output mask. With ``use_language=False`` the model runs
+ language-agnostic, as for ``language=None`` at inference.
+ """
+ model.eval()
+ ds = ASRDataset(rows, tokenizer, {**lang2id, **{r["language"]: 0 for r in rows if r["language"] not in lang2id}},
+ filter_targets=False)
+ sampler = DurationBatchSampler([r["duration"] for r in ds.rows], max_batch_seconds)
+ dl = DataLoader(ds, batch_sampler=sampler, collate_fn=collate, num_workers=num_workers)
+ token_strings = None
+ if decoder == "beam":
+ labels = tokenizer.labels_for_pyctcdecode()
+ token_strings = [""] + labels[1:]
+ out = []
+ for batch in dl:
+ wav = batch["wav"].to(device)
+ wl = batch["wav_lengths"].to(device)
+ lids = batch["lang_ids"].to(device) if use_language else None
+ ctx = torch.autocast(device.type, dtype=amp_dtype) if amp_dtype is not None else nullcontext()
+ with ctx:
+ res = model(wav, wl, lids)
+ lp = res["log_probs"]
+ langs = batch["languages"] if (use_language and use_masks) else [None] * len(batch["ids"])
+ bias = language_mask_bias(tokenizer, langs, lp.shape[-1], device=lp.device)
+ if decoder == "greedy":
+ seqs = greedy_decode(lp, res["lengths"], bias)
+ elif decoder == "beam":
+ seqs = []
+ lpn = lp.float().cpu().numpy()
+ for b in range(lp.shape[0]):
+ allowed = None
+ if bias is not None:
+ allowed = np.isfinite(bias[b].cpu().numpy())
+ lm = (lms or {}).get(batch["languages"][b]) if use_language else (lms or {}).get("*")
+ seqs.append(prefix_beam_search(lpn[b, : int(res["lengths"][b])], token_strings, beam=beam, lm=lm,
+ alpha=alpha, beta=beta, allowed=allowed))
+ else:
+ raise ValueError(f"unknown decoder {decoder!r}")
+ blank_rate = (lp.argmax(-1) == 0).float()
+ for b, seq in enumerate(seqs):
+ L = int(res["lengths"][b])
+ out.append({
+ "id": batch["ids"][b],
+ "language": batch["languages"][b],
+ "speaker_id": batch["speaker_ids"][b],
+ "hyp_train": tokenizer.decode(seq),
+ "frames": L,
+ "blank_rate": float(blank_rate[b, :L].mean()),
+ })
+ return out
+
+
+def evaluate_rows(model, tokenizer, rows: list[dict], *, n_boot: int = 1000, **kw) -> tuple[dict, list[dict]]:
+ by_id = {r["id"]: r for r in rows}
+ t0 = time.perf_counter()
+ hyps = transcribe_rows(model, tokenizer, rows, **kw)
+ elapsed = time.perf_counter() - t0
+ items = []
+ for h in hyps:
+ r = by_id[h["id"]]
+ norm = get_normalizer(r["language"])
+ items.append({"id": h["id"], "language": r["language"], "speaker_id": r["speaker_id"],
+ "ref": r["text_eval"], "hyp": norm.eval_norm(h["hyp_train"])})
+ scored = score_utterances(items)
+ metrics = aggregate(scored, n_boot=n_boot)
+ audio_s = sum(r["duration"] for r in rows)
+ metrics["decode"] = {"seconds": elapsed, "audio_seconds": audio_s, "rtf": elapsed / max(audio_s, 1e-9),
+ "mean_blank_rate": float(np.mean([h["blank_rate"] for h in hyps])) if hyps else None}
+ for h, it in zip(hyps, items):
+ h["ref_eval"] = it["ref"]
+ h["hyp_eval"] = it["hyp"]
+ return metrics, hyps
diff --git a/canopy/evaluation/status.py b/canopy/evaluation/status.py
new file mode 100644
index 0000000000000000000000000000000000000000..98c2e0e2da9ca1923b7d02f3ce1d2957545111ee
--- /dev/null
+++ b/canopy/evaluation/status.py
@@ -0,0 +1,79 @@
+"""Language support status from evidence (RESEARCH_PLAN.md section 12.3).
+
+Statuses are computed, never assigned by hand. Thresholds are the proposed
+values and must be ratified before release 0.2.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class Thresholds:
+ prod_train_hours: float = 50.0
+ prod_test_speakers: int = 20
+ prod_test_hours: float = 2.0
+ prod_cer: float = 0.10
+ prod_wer: float = 0.25
+ prod_min_buckets: int = 2
+ prod_ci_halfwidth: float = 0.02
+ prod_int8_shift: float = 0.01
+ sup_train_hours: float = 10.0
+ sup_test_speakers: int = 5
+ sup_test_hours: float = 0.5
+ sup_cer: float = 0.15
+ sup_wer: float = 0.40
+ limited_train_hours: float = 5.0
+ limited_speakers: int = 10
+
+
+def compute_status(*, has_clean_data: bool, evaluated: bool, train_hours: float, total_speakers: int,
+ test_speakers: int = 0, test_hours: float = 0.0, cer: float | None = None, wer: float | None = None,
+ cer_ci95: tuple[float, float] | None = None, buckets_passing: int = 0,
+ int8_cer_shift: float | None = None, native_review_done: bool = False,
+ second_seed_within_ci: bool = False, auxiliary: bool = False, th: Thresholds = Thresholds()) -> dict:
+ reasons: list[str] = []
+ if auxiliary:
+ return {"status": "auxiliary", "reasons": ["auxiliary training language"]}
+ if not has_clean_data:
+ return {"status": "not-currently-supported", "reasons": ["no licence-clean transcribed data"]}
+ if train_hours < th.limited_train_hours or total_speakers < th.limited_speakers:
+ return {"status": "data-limited",
+ "reasons": [f"train_hours={train_hours:.2f} (<{th.limited_train_hours}) or "
+ f"speakers={total_speakers} (<{th.limited_speakers})"]}
+ if not evaluated or cer is None or wer is None:
+ return {"status": "evaluation-pending", "reasons": ["not yet evaluated under the Canopy protocol"]}
+
+ ci_half = (cer_ci95[1] - cer_ci95[0]) / 2 if cer_ci95 else None
+ prod_checks = {
+ "train_hours": train_hours >= th.prod_train_hours,
+ "test_speakers": test_speakers >= th.prod_test_speakers,
+ "test_hours": test_hours >= th.prod_test_hours,
+ "cer": cer <= th.prod_cer,
+ "wer": wer <= th.prod_wer,
+ "buckets": buckets_passing >= th.prod_min_buckets,
+ "ci": ci_half is not None and ci_half <= th.prod_ci_halfwidth,
+ "int8": int8_cer_shift is not None and int8_cer_shift <= th.prod_int8_shift,
+ "native_review": native_review_done,
+ "second_seed": second_seed_within_ci,
+ }
+ if all(prod_checks.values()):
+ return {"status": "production", "reasons": ["all production criteria met"], "checks": prod_checks}
+ sup_checks = {
+ "train_hours": train_hours >= th.sup_train_hours,
+ "test_speakers": test_speakers >= th.sup_test_speakers,
+ "test_hours": test_hours >= th.sup_test_hours,
+ "cer": cer <= th.sup_cer,
+ "wer": wer <= th.sup_wer,
+ "ci_reported": cer_ci95 is not None,
+ "second_seed": second_seed_within_ci,
+ }
+ if all(sup_checks.values()):
+ failed = [k for k, v in prod_checks.items() if not v]
+ return {"status": "supported", "reasons": [f"production criteria not met: {failed}"], "checks": sup_checks}
+ failed = [k for k, v in sup_checks.items() if not v]
+ reasons.append(f"supported criteria not met: {failed}")
+ if test_speakers < th.sup_test_speakers:
+ reasons.append("insufficient evidence: fewer than 5 test speakers")
+ return {"status": "experimental", "reasons": reasons, "checks": sup_checks}
diff --git a/canopy/frontend/__init__.py b/canopy/frontend/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..5569365d2458dabd2209c057a5d99bf9520f72fd
--- /dev/null
+++ b/canopy/frontend/__init__.py
@@ -0,0 +1,5 @@
+from canopy.frontend.audio import load_audio, resample
+from canopy.frontend.augment import SpecAugment, speed_perturb
+from canopy.frontend.features import LogMelFrontend, feature_lengths, mel_filterbank
+
+__all__ = ["LogMelFrontend", "SpecAugment", "feature_lengths", "load_audio", "mel_filterbank", "resample", "speed_perturb"]
diff --git a/canopy/frontend/audio.py b/canopy/frontend/audio.py
new file mode 100644
index 0000000000000000000000000000000000000000..fcea7a922574ec3dbc3c2132df12a95c41abefc6
--- /dev/null
+++ b/canopy/frontend/audio.py
@@ -0,0 +1,69 @@
+"""Audio loading and resampling. All model inputs are 16 kHz mono float32."""
+
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+
+import numpy as np
+import torch
+
+from canopy import SAMPLE_RATE
+
+
+@dataclass
+class AudioInfo:
+ sample_rate: int
+ channels: int
+ frames: int
+
+ @property
+ def duration(self) -> float:
+ return self.frames / self.sample_rate if self.sample_rate else 0.0
+
+
+def audio_info(path: str | os.PathLike) -> AudioInfo:
+ import soundfile as sf
+
+ i = sf.info(str(path))
+ return AudioInfo(sample_rate=int(i.samplerate), channels=int(i.channels), frames=int(i.frames))
+
+
+def _read(path: str) -> tuple[np.ndarray, int]:
+ try:
+ import soundfile as sf
+
+ data, sr = sf.read(path, dtype="float32", always_2d=True)
+ return data, int(sr)
+ except Exception as sf_err:
+ try:
+ import torchaudio
+
+ wav, sr = torchaudio.load(path)
+ return wav.numpy().T.astype(np.float32), int(sr)
+ except Exception as ta_err:
+ raise RuntimeError(
+ f"could not decode {path!r} (soundfile: {sf_err}; torchaudio: {ta_err}). "
+ "For MP3 input use libsndfile >= 1.1 (soundfile >= 0.12) or convert with scripts/prepare_data.py --convert."
+ ) from ta_err
+
+
+def resample(wav: torch.Tensor, orig_sr: int, target_sr: int = SAMPLE_RATE) -> torch.Tensor:
+ if orig_sr == target_sr:
+ return wav
+ import torchaudio.functional as taf
+
+ return taf.resample(wav, orig_sr, target_sr)
+
+
+def load_audio(path: str | os.PathLike, target_sr: int = SAMPLE_RATE) -> torch.Tensor:
+ """Load an audio file as a 1-D float32 tensor at ``target_sr``, downmixed to mono."""
+ data, sr = _read(str(path))
+ wav = torch.from_numpy(np.ascontiguousarray(data.mean(axis=1)))
+ return resample(wav, sr, target_sr)
+
+
+def save_audio(path: str | os.PathLike, wav: torch.Tensor, sr: int = SAMPLE_RATE) -> None:
+ import soundfile as sf
+
+ sf.write(str(path), wav.detach().cpu().numpy().astype(np.float32), sr)
diff --git a/canopy/frontend/augment.py b/canopy/frontend/augment.py
new file mode 100644
index 0000000000000000000000000000000000000000..141a09d908ac06a2aac7b884654226cc46a47b51
--- /dev/null
+++ b/canopy/frontend/augment.py
@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+import random
+
+import torch
+from torch import nn
+
+from canopy import SAMPLE_RATE
+
+
+def speed_perturb(wav: torch.Tensor, factor: float, sample_rate: int = SAMPLE_RATE) -> torch.Tensor:
+ """SoX-style speed change (tempo and pitch together) by resampling."""
+ if factor == 1.0:
+ return wav
+ import torchaudio.functional as taf
+
+ src = int(round(sample_rate * factor))
+ return taf.resample(wav, src, sample_rate)
+
+
+class SpecAugment(nn.Module):
+ """Batched SpecAugment on (B, T, F) features.
+
+ Time-mask width is adaptive: at most ``time_mask_ratio`` of each
+ utterance's valid length, because Canopy clips average ~4 s (400 frames at
+ 100 fps), where a fixed 50-100 frame mask would erase whole words.
+ """
+
+ def __init__(self, freq_masks: int = 2, freq_width: int = 27, time_masks: int = 2,
+ time_mask_ratio: float = 0.05, min_time_width: int = 1):
+ super().__init__()
+ self.freq_masks = freq_masks
+ self.freq_width = freq_width
+ self.time_masks = time_masks
+ self.time_mask_ratio = time_mask_ratio
+ self.min_time_width = min_time_width
+
+ def forward(self, feats: torch.Tensor, lengths: torch.Tensor) -> torch.Tensor:
+ if not self.training:
+ return feats
+ B, T, F = feats.shape
+ mask = torch.zeros(B, T, F, dtype=torch.bool, device=feats.device)
+ for b in range(B):
+ L = int(lengths[b])
+ for _ in range(self.freq_masks):
+ w = random.randint(0, min(self.freq_width, F))
+ f0 = random.randint(0, F - w)
+ mask[b, :, f0 : f0 + w] = True
+ max_w = max(self.min_time_width, int(L * self.time_mask_ratio))
+ for _ in range(self.time_masks):
+ w = random.randint(0, max_w)
+ if L - w <= 0:
+ continue
+ t0 = random.randint(0, L - w)
+ mask[b, t0 : t0 + w, :] = True
+ return feats.masked_fill(mask, 0.0)
diff --git a/canopy/frontend/features.py b/canopy/frontend/features.py
new file mode 100644
index 0000000000000000000000000000000000000000..22cf96687d96647420ec6210952941d27f731254
--- /dev/null
+++ b/canopy/frontend/features.py
@@ -0,0 +1,111 @@
+"""Log-mel frontend implemented in plain PyTorch (ONNX-exportable, no torchaudio needed).
+
+Defaults: 25 ms Hann window, 10 ms hop, 512-point FFT, 80 Slaney mel bins, 100 fps.
+"""
+
+from __future__ import annotations
+
+import math
+
+import torch
+from torch import nn
+
+from canopy import SAMPLE_RATE
+
+
+def _hz_to_mel_slaney(f: torch.Tensor) -> torch.Tensor:
+ f_sp = 200.0 / 3
+ min_log_hz = 1000.0
+ min_log_mel = min_log_hz / f_sp
+ logstep = math.log(6.4) / 27.0
+ mel = f / f_sp
+ return torch.where(f >= min_log_hz, min_log_mel + torch.log(f.clamp(min=1e-10) / min_log_hz) / logstep, mel)
+
+
+def _mel_to_hz_slaney(m: torch.Tensor) -> torch.Tensor:
+ f_sp = 200.0 / 3
+ min_log_hz = 1000.0
+ min_log_mel = min_log_hz / f_sp
+ logstep = math.log(6.4) / 27.0
+ return torch.where(m >= min_log_mel, min_log_hz * torch.exp(logstep * (m - min_log_mel)), f_sp * m)
+
+
+def mel_filterbank(n_freqs: int, n_mels: int, sample_rate: int, f_min: float = 0.0, f_max: float | None = None) -> torch.Tensor:
+ """Slaney-scale, Slaney-normalised triangular filterbank of shape (n_freqs, n_mels)."""
+ f_max = f_max if f_max is not None else sample_rate / 2
+ all_freqs = torch.linspace(0, sample_rate // 2, n_freqs, dtype=torch.float64)
+ m_pts = torch.linspace(_hz_to_mel_slaney(torch.tensor(f_min, dtype=torch.float64)).item(),
+ _hz_to_mel_slaney(torch.tensor(f_max, dtype=torch.float64)).item(), n_mels + 2,
+ dtype=torch.float64)
+ f_pts = _mel_to_hz_slaney(m_pts)
+ f_diff = f_pts[1:] - f_pts[:-1]
+ slopes = f_pts.unsqueeze(0) - all_freqs.unsqueeze(1)
+ down = -slopes[:, :-2] / f_diff[:-1]
+ up = slopes[:, 2:] / f_diff[1:]
+ fb = torch.clamp(torch.minimum(down, up), min=0.0)
+ enorm = 2.0 / (f_pts[2 : n_mels + 2] - f_pts[:n_mels])
+ return (fb * enorm.unsqueeze(0)).float()
+
+
+def feature_lengths(num_samples: torch.Tensor, hop_length: int = 160) -> torch.Tensor:
+ """Frame count for a centred STFT."""
+ return torch.div(num_samples, hop_length, rounding_mode="floor") + 1
+
+
+class LogMelFrontend(nn.Module):
+ def __init__(
+ self,
+ sample_rate: int = SAMPLE_RATE,
+ n_fft: int = 512,
+ win_length: int = 400,
+ hop_length: int = 160,
+ n_mels: int = 80,
+ normalize: str = "utterance",
+ eps: float = 1e-10,
+ ):
+ super().__init__()
+ if normalize not in ("utterance", "per_bin", "none"):
+ raise ValueError(f"normalize must be utterance|per_bin|none, got {normalize!r}")
+ self.sample_rate = sample_rate
+ self.n_fft = n_fft
+ self.win_length = win_length
+ self.hop_length = hop_length
+ self.n_mels = n_mels
+ self.normalize = normalize
+ self.eps = eps
+ self.register_buffer("window", torch.hann_window(win_length), persistent=False)
+ self.register_buffer("fbank", mel_filterbank(n_fft // 2 + 1, n_mels, sample_rate), persistent=False)
+
+ @torch.no_grad()
+ def forward(self, wav: torch.Tensor, lengths: torch.Tensor | None = None) -> tuple[torch.Tensor, torch.Tensor]:
+ """wav (B, S) float -> features (B, T, n_mels), lengths (B,)."""
+ with torch.autocast(device_type=wav.device.type, enabled=False):
+ return self._forward(wav, lengths)
+
+ def _forward(self, wav: torch.Tensor, lengths: torch.Tensor | None) -> tuple[torch.Tensor, torch.Tensor]:
+ if wav.dim() == 1:
+ wav = wav.unsqueeze(0)
+ wav = wav.float()
+ B, S = wav.shape
+ if lengths is None:
+ lengths = torch.full((B,), S, dtype=torch.long, device=wav.device)
+ spec = torch.stft(wav, n_fft=self.n_fft, hop_length=self.hop_length, win_length=self.win_length,
+ window=self.window, center=True, pad_mode="reflect", return_complex=True)
+ power = spec.real.pow(2) + spec.imag.pow(2) # (B, F, T)
+ mel = torch.matmul(power.transpose(1, 2), self.fbank) # (B, T, M)
+ logmel = torch.log(mel.clamp(min=self.eps))
+ feat_len = feature_lengths(lengths, self.hop_length).clamp(max=logmel.shape[1])
+ mask = torch.arange(logmel.shape[1], device=wav.device).unsqueeze(0) < feat_len.unsqueeze(1)
+ m = mask.unsqueeze(-1).float()
+ if self.normalize == "utterance":
+ n = (feat_len.float() * self.n_mels).view(B, 1, 1)
+ mean = (logmel * m).sum(dim=(1, 2), keepdim=True) / n
+ var = (((logmel - mean) * m) ** 2).sum(dim=(1, 2), keepdim=True) / n
+ logmel = (logmel - mean) / (var.sqrt() + 1e-6)
+ elif self.normalize == "per_bin":
+ n = feat_len.float().view(B, 1, 1)
+ mean = (logmel * m).sum(dim=1, keepdim=True) / n
+ var = (((logmel - mean) * m) ** 2).sum(dim=1, keepdim=True) / n
+ logmel = (logmel - mean) / (var.sqrt() + 1e-6)
+ logmel = logmel * m
+ return logmel, feat_len
diff --git a/canopy/inference/__init__.py b/canopy/inference/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..5e2d2febd99d3275228390eb5982d9985be4faad
--- /dev/null
+++ b/canopy/inference/__init__.py
@@ -0,0 +1,3 @@
+from canopy.inference.transcriber import Transcriber
+
+__all__ = ["Transcriber"]
diff --git a/canopy/inference/benchmark.py b/canopy/inference/benchmark.py
new file mode 100644
index 0000000000000000000000000000000000000000..537137eacff2c5304f4eba679d7a6aa7c82ede84
--- /dev/null
+++ b/canopy/inference/benchmark.py
@@ -0,0 +1,64 @@
+"""CPU real-time-factor protocol (docs/BENCHMARK_PLAN.md).
+
+Fixed durations, fixed thread count, warm-up runs excluded, median and p90
+over repeats. Synthetic noise input is used for latency only (never for
+accuracy), so the benchmark needs no dataset.
+"""
+
+from __future__ import annotations
+
+import os
+import platform
+import statistics
+import time
+
+import torch
+
+from canopy import SAMPLE_RATE
+
+
+def _rss_mb() -> float | None:
+ try:
+ import psutil
+
+ return psutil.Process(os.getpid()).memory_info().rss / 2**20
+ except Exception:
+ return None
+
+
+@torch.no_grad()
+def measure_rtf(model, durations=(1.0, 5.0, 10.0, 30.0), threads: int = 4, warmup: int = 3, repeats: int = 15,
+ device: str = "cpu", seed: int = 0) -> dict:
+ torch.set_num_threads(threads)
+ model = model.to(device).eval()
+ g = torch.Generator().manual_seed(seed)
+ results = {}
+ for d in durations:
+ n = int(d * SAMPLE_RATE)
+ wav = (0.05 * torch.randn(1, n, generator=g)).to(device)
+ lens = torch.tensor([n], device=device)
+ for _ in range(warmup):
+ model(wav, lens)
+ times = []
+ for _ in range(repeats):
+ t0 = time.perf_counter()
+ model(wav, lens)
+ if device.startswith("cuda"):
+ torch.cuda.synchronize()
+ times.append(time.perf_counter() - t0)
+ times.sort()
+ med = statistics.median(times)
+ results[f"{d:g}s"] = {"median_s": med, "p90_s": times[int(0.9 * (len(times) - 1))], "rtf_median": med / d,
+ "rtf_p90": times[int(0.9 * (len(times) - 1))] / d}
+ return {
+ "device": device,
+ "threads": threads,
+ "warmup": warmup,
+ "repeats": repeats,
+ "torch": torch.__version__,
+ "cpu": platform.processor() or platform.machine(),
+ "platform": platform.platform(),
+ "rss_mb": _rss_mb(),
+ "parameters": sum(p.numel() for p in model.parameters()),
+ "results": results,
+ }
diff --git a/canopy/inference/quantize.py b/canopy/inference/quantize.py
new file mode 100644
index 0000000000000000000000000000000000000000..83278e08c785463a6b8a2baf1d4a576a785a7091
--- /dev/null
+++ b/canopy/inference/quantize.py
@@ -0,0 +1,36 @@
+"""Dynamic INT8 quantization of Linear layers for CPU inference.
+
+INT8 must pass the per-language regression gate (docs/BENCHMARK_PLAN.md)
+before any INT8 artifact is released: evaluate FP32 and INT8 on the same dev
+set and compare per-language CER.
+"""
+
+from __future__ import annotations
+
+import copy
+
+import torch
+from torch import nn
+
+
+def quantize_dynamic_int8(model: nn.Module) -> nn.Module:
+ m = copy.deepcopy(model).cpu().eval()
+ try:
+ from torch.ao.quantization import quantize_dynamic
+ except ImportError: # pragma: no cover
+ from torch.quantization import quantize_dynamic
+ return quantize_dynamic(m, {nn.Linear}, dtype=torch.qint8)
+
+
+def int8_regression(fp32_metrics: dict, int8_metrics: dict, max_abs_cer_shift: float = 0.01) -> dict:
+ rows = {}
+ worst = 0.0
+ for lang, m in fp32_metrics["languages"].items():
+ q = int8_metrics["languages"].get(lang)
+ if q is None:
+ continue
+ shift = q["cer"] - m["cer"]
+ worst = max(worst, shift)
+ rows[lang] = {"fp32_cer": m["cer"], "int8_cer": q["cer"], "shift": shift, "pass": shift <= max_abs_cer_shift}
+ return {"max_abs_cer_shift": max_abs_cer_shift, "worst_shift": worst,
+ "passed": all(r["pass"] for r in rows.values()), "languages": rows}
diff --git a/canopy/inference/transcriber.py b/canopy/inference/transcriber.py
new file mode 100644
index 0000000000000000000000000000000000000000..5556526d0985f674d8273bae996c1a489179ccfc
--- /dev/null
+++ b/canopy/inference/transcriber.py
@@ -0,0 +1,101 @@
+from __future__ import annotations
+
+import json
+import tempfile
+from pathlib import Path
+
+import numpy as np
+import torch
+
+from canopy import SAMPLE_RATE
+from canopy.decoding.beam import prefix_beam_search
+from canopy.decoding.greedy import greedy_decode, language_mask_bias
+from canopy.frontend.audio import load_audio
+from canopy.tokenizer.base import load_tokenizer
+from canopy.training.checkpoint import load_checkpoint, model_from_checkpoint
+
+
+def _tokenizer_from_checkpoint(ck: dict, fallback_dir: str | None = None):
+ if "tokenizer_payload" in ck:
+ tmp = Path(tempfile.mkdtemp(prefix="canopy_tok_"))
+ (tmp / "tokenizer.json").write_text(json.dumps(ck["tokenizer_payload"], ensure_ascii=False), encoding="utf-8")
+ if ck.get("spm_model"):
+ (tmp / "spm.model").write_bytes(ck["spm_model"])
+ return load_tokenizer(tmp)
+ return load_tokenizer(fallback_dir or ck["tokenizer_dir"])
+
+
+class Transcriber:
+ """Load a checkpoint and transcribe audio.
+
+ ``language``: ISO 639-3 code. When given, the model is conditioned on it
+ (if trained with a language embedding), the output is restricted to that
+ language's tokens, and that language's LM is used. ``None`` runs
+ language-agnostic decoding.
+ """
+
+ def __init__(self, model, tokenizer, device: str = "cpu", max_chunk_seconds: float = 30.0):
+ self.model = model.to(device).eval()
+ self.tokenizer = tokenizer
+ self.device = torch.device(device)
+ self.languages = list(model.cfg.languages)
+ self.lang2id = {lang: i + 1 for i, lang in enumerate(self.languages)}
+ self.max_chunk = int(max_chunk_seconds * SAMPLE_RATE)
+ self.lms: dict[str, object] = {}
+
+ @classmethod
+ def from_checkpoint(cls, path: str | Path, device: str = "cpu", tokenizer_dir: str | None = None, **kw) -> Transcriber:
+ ck = load_checkpoint(path)
+ model = model_from_checkpoint(ck)
+ return cls(model, _tokenizer_from_checkpoint(ck, tokenizer_dir), device=device, **kw)
+
+ @classmethod
+ def from_pretrained(cls, repo_id: str = "ProximaAI/Canopy-M", *, filename: str = "canopy-m.pt",
+ device: str = "cpu", revision: str | None = None, token: str | None = None, **kw) -> Transcriber:
+ """Download an inference checkpoint from the Hugging Face Hub and load it."""
+ try:
+ from huggingface_hub import hf_hub_download
+ except ImportError as exc:
+ raise ImportError("pip install huggingface_hub") from exc
+ path = hf_hub_download(repo_id=repo_id, filename=filename, revision=revision, token=token)
+ return cls.from_checkpoint(path, device=device, **kw)
+
+ def attach_lm(self, language: str, lm) -> None:
+ """``lm``: ArpaLM or KenLMScorer. Use language "*" for a language-agnostic LM."""
+ self.lms[language] = lm
+
+ @torch.no_grad()
+ def log_probs(self, wav: torch.Tensor, language: str | None = None) -> torch.Tensor:
+ lid = None
+ if language is not None and self.model.lang_embed is not None:
+ lid = torch.tensor([self.lang2id.get(language, 0)], device=self.device)
+ chunks = [wav[i : i + self.max_chunk] for i in range(0, max(1, wav.numel()), self.max_chunk)]
+ outs = []
+ for c in chunks:
+ if c.numel() < 400:
+ continue
+ x = c.unsqueeze(0).to(self.device)
+ res = self.model(x, torch.tensor([c.numel()], device=self.device), lid)
+ outs.append(res["log_probs"][0, : int(res["lengths"][0])])
+ if not outs:
+ return torch.zeros(0, self.tokenizer.vocab_size)
+ return torch.cat(outs, dim=0)
+
+ def transcribe(self, audio, language: str | None = None, decoder: str = "greedy", beam: int = 10,
+ alpha: float = 0.5, beta: float = 1.0) -> str:
+ wav = load_audio(audio) if isinstance(audio, (str, Path)) else torch.as_tensor(audio, dtype=torch.float32)
+ lp = self.log_probs(wav, language)
+ if lp.shape[0] == 0:
+ return ""
+ bias = language_mask_bias(self.tokenizer, [language], lp.shape[-1], device=lp.device) if language else None
+ if decoder == "greedy":
+ ids = greedy_decode(lp.unsqueeze(0), torch.tensor([lp.shape[0]]), bias)[0]
+ elif decoder == "beam":
+ labels = self.tokenizer.labels_for_pyctcdecode()
+ allowed = np.isfinite(bias[0].cpu().numpy()) if bias is not None else None
+ lm = self.lms.get(language or "*") or self.lms.get("*")
+ ids = prefix_beam_search(lp.float().cpu().numpy(), [""] + labels[1:], beam=beam, lm=lm, alpha=alpha,
+ beta=beta, allowed=allowed)
+ else:
+ raise ValueError(f"unknown decoder {decoder!r}")
+ return self.tokenizer.decode(ids)
diff --git a/canopy/language_model/__init__.py b/canopy/language_model/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..22ac9507d3d30ef82b0ab027a034ff6bce15674a
--- /dev/null
+++ b/canopy/language_model/__init__.py
@@ -0,0 +1,4 @@
+from canopy.language_model.arpa import ArpaLM, build_arpa, write_arpa
+from canopy.language_model.text import filter_heldout, lm_sentences
+
+__all__ = ["ArpaLM", "build_arpa", "filter_heldout", "lm_sentences", "write_arpa"]
diff --git a/canopy/language_model/arpa.py b/canopy/language_model/arpa.py
new file mode 100644
index 0000000000000000000000000000000000000000..cd99bf0b9b5ab9b5b075e537893a2f8c08dc6684
--- /dev/null
+++ b/canopy/language_model/arpa.py
@@ -0,0 +1,188 @@
+"""Word n-gram LMs in ARPA format: a pure-Python reader/scorer and a small
+builder (interpolated absolute discounting, written as a back-off ARPA).
+
+For large corpora build with KenLM's ``lmplz`` (scripts/train_lm.py
+--backend kenlm); the pure-Python builder is intended for the small
+per-language CV-Pakistan corpora and for platforms without KenLM binaries.
+"""
+
+from __future__ import annotations
+
+import gzip
+import math
+from collections import Counter, defaultdict
+from collections.abc import Iterable
+from pathlib import Path
+
+LN10 = math.log(10.0)
+BOS, EOS, UNK = "", "", ""
+
+
+class ArpaLM:
+ def __init__(self, order: int, probs: dict[tuple[str, ...], float], backoffs: dict[tuple[str, ...], float],
+ unk_logp10: float = -7.0):
+ self.order = order
+ self.probs = probs
+ self.backoffs = backoffs
+ self.unk_logp10 = probs.get((UNK,), unk_logp10)
+
+ @classmethod
+ def load(cls, path: str | Path) -> ArpaLM:
+ path = Path(path)
+ opener = gzip.open if path.suffix == ".gz" else open
+ probs: dict[tuple[str, ...], float] = {}
+ backoffs: dict[tuple[str, ...], float] = {}
+ order = 0
+ section = None
+ with opener(path, "rt", encoding="utf-8") as f:
+ for line in f:
+ line = line.strip()
+ if not line:
+ continue
+ if line == "\\data\\":
+ section = "data"
+ continue
+ if line == "\\end\\":
+ break
+ if line.startswith("\\") and line.endswith("-grams:"):
+ section = int(line[1:].split("-")[0])
+ order = max(order, section)
+ continue
+ if section == "data" or section is None:
+ continue
+ parts = line.split("\t")
+ if len(parts) == 1:
+ parts = line.split()
+ n = section
+ logp = float(parts[0])
+ words = tuple(parts[1 : 1 + n])
+ bo = float(parts[1 + n]) if len(parts) > 1 + n else None
+ else:
+ logp = float(parts[0])
+ words = tuple(parts[1].split())
+ bo = float(parts[2]) if len(parts) > 2 else None
+ probs[words] = logp
+ if bo is not None:
+ backoffs[words] = bo
+ return cls(order, probs, backoffs)
+
+ def _logp10(self, context: tuple[str, ...], word: str) -> float:
+ context = context[-(self.order - 1):] if self.order > 1 else ()
+ bo_total = 0.0
+ while True:
+ key = context + (word,)
+ if key in self.probs:
+ return bo_total + self.probs[key]
+ if not context:
+ return bo_total + self.unk_logp10
+ bo_total += self.backoffs.get(context, 0.0)
+ context = context[1:]
+
+ def score(self, history: tuple[str, ...], word: str) -> float:
+ """Natural-log P(word | history)."""
+ return self._logp10((BOS,) + tuple(history), word) * LN10
+
+ def score_end(self, history: tuple[str, ...]) -> float:
+ return self._logp10((BOS,) + tuple(history), EOS) * LN10
+
+ def sentence_logprob(self, words: list[str]) -> float:
+ h: list[str] = []
+ total = 0.0
+ for w in words:
+ total += self.score(tuple(h), w)
+ h.append(w)
+ return total + self.score_end(tuple(h))
+
+
+def build_arpa(sentences: Iterable[str], order: int = 3, discount: float = 0.75, unk_mass: float = 1e-3) -> tuple[dict, dict, dict]:
+ """Interpolated absolute-discounting n-gram model.
+
+ Returns (probs_log10, backoffs_log10, counts_per_order) for ``write_arpa``.
+ """
+ counts: list[Counter] = [Counter() for _ in range(order + 1)]
+ for s in sentences:
+ toks = [BOS] + s.split() + [EOS]
+ for n in range(1, order + 1):
+ for i in range(len(toks) - n + 1):
+ ng = tuple(toks[i : i + n])
+ if n == 1 and ng == (BOS,):
+ continue
+ counts[n][ng] += 1
+
+ # Unigrams: maximum likelihood, with a fixed mass reserved for .
+ total1 = sum(counts[1].values())
+ p: dict[tuple[str, ...], float] = {}
+ for ng, c in counts[1].items():
+ p[ng] = (1 - unk_mass) * c / total1
+ p[(UNK,)] = unk_mass
+ p[(BOS,)] = 0.0
+
+ ctx_counts: list[dict] = [dict() for _ in range(order + 1)]
+ followers: list[dict] = [defaultdict(int) for _ in range(order + 1)]
+ for n in range(2, order + 1):
+ cc: dict[tuple[str, ...], int] = defaultdict(int)
+ for ng, c in counts[n].items():
+ cc[ng[:-1]] += c
+ followers[n][ng[:-1]] += 1
+ ctx_counts[n] = cc
+
+ def lower(ng: tuple[str, ...]) -> float:
+ if len(ng) == 1:
+ return p.get(ng, p[(UNK,)])
+ return p.get(ng) if ng in p else lower_interp(ng)
+
+ def lower_interp(ng: tuple[str, ...]) -> float:
+ # Probability of an unseen n-gram via back-off to its suffix.
+ ctx = ng[:-1]
+ n = len(ng)
+ if ctx not in ctx_counts[n]:
+ return lower(ng[1:])
+ lam = discount * followers[n][ctx] / ctx_counts[n][ctx]
+ return lam * lower(ng[1:])
+
+ for n in range(2, order + 1):
+ for ng, c in counts[n].items():
+ ctx = ng[:-1]
+ cc = ctx_counts[n][ctx]
+ lam = discount * followers[n][ctx] / cc
+ p[ng] = max(c - discount, 0.0) / cc + lam * lower(ng[1:])
+
+ # Back-off weights so that unseen continuations of each context sum correctly.
+ bows: dict[tuple[str, ...], float] = {}
+ for n in range(2, order + 1):
+ seen_by_ctx: dict[tuple[str, ...], list[tuple[str, ...]]] = defaultdict(list)
+ for ng in counts[n]:
+ seen_by_ctx[ng[:-1]].append(ng)
+ for ctx, ngs in seen_by_ctx.items():
+ num = 1.0 - sum(p[ng] for ng in ngs)
+ den = 1.0 - sum(lower(ng[1:]) for ng in ngs)
+ if num <= 0 or den <= 0:
+ bows[ctx] = 0.0
+ else:
+ bows[ctx] = math.log10(num / den)
+
+ probs10 = {ng: (math.log10(v) if v > 0 else -99.0) for ng, v in p.items()}
+ return probs10, bows, {n: len(counts[n]) + (2 if n == 1 else 0) for n in range(1, order + 1)}
+
+
+def write_arpa(path: str | Path, probs10: dict, bows: dict, order: int) -> None:
+ by_n: dict[int, list[tuple[str, ...]]] = defaultdict(list)
+ for ng in probs10:
+ by_n[len(ng)].append(ng)
+ path = Path(path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ opener = gzip.open if path.suffix == ".gz" else open
+ with opener(path, "wt", encoding="utf-8") as f:
+ f.write("\\data\\\n")
+ for n in range(1, order + 1):
+ f.write(f"ngram {n}={len(by_n[n])}\n")
+ for n in range(1, order + 1):
+ f.write(f"\n\\{n}-grams:\n")
+ for ng in sorted(by_n[n]):
+ line = f"{probs10[ng]:.6f}\t{' '.join(ng)}"
+ if n < order and ng in bows:
+ line += f"\t{bows[ng]:.6f}"
+ elif n < order and ng == (BOS,):
+ line += "\t0.0"
+ f.write(line + "\n")
+ f.write("\n\\end\\\n")
diff --git a/canopy/language_model/kenlm_backend.py b/canopy/language_model/kenlm_backend.py
new file mode 100644
index 0000000000000000000000000000000000000000..dded5c7e1c865d2fab659bc5742331a0bed59447
--- /dev/null
+++ b/canopy/language_model/kenlm_backend.py
@@ -0,0 +1,62 @@
+"""Optional KenLM integration: building with ``lmplz``/``build_binary`` and
+scoring through the ``kenlm`` Python module (both LGPL-2.1, not vendored)."""
+
+from __future__ import annotations
+
+import shutil
+import subprocess
+from pathlib import Path
+
+LN10 = 2.302585092994046
+
+
+def build_with_lmplz(text_file: str | Path, out_arpa: str | Path, order: int = 4, prune: list[int] | None = None,
+ memory: str = "40%", lmplz: str | None = None, discount_fallback: bool = True) -> Path:
+ exe = lmplz or shutil.which("lmplz")
+ if not exe:
+ raise FileNotFoundError("KenLM `lmplz` not found on PATH; build KenLM (docs/TRAINING.md) or use --backend python")
+ cmd = [exe, "-o", str(order), "-S", memory, "--text", str(text_file), "--arpa", str(out_arpa)]
+ if prune:
+ cmd += ["--prune", *map(str, prune)]
+ if discount_fallback:
+ cmd.append("--discount_fallback")
+ subprocess.run(cmd, check=True)
+ return Path(out_arpa)
+
+
+def build_binary(arpa: str | Path, out_bin: str | Path, exe: str | None = None) -> Path:
+ exe = exe or shutil.which("build_binary")
+ if not exe:
+ raise FileNotFoundError("KenLM `build_binary` not found on PATH")
+ subprocess.run([exe, str(arpa), str(out_bin)], check=True)
+ return Path(out_bin)
+
+
+class KenLMScorer:
+ """Same interface as ArpaLM (natural-log scores) on top of ``kenlm.Model``."""
+
+ def __init__(self, path: str | Path):
+ import kenlm
+
+ self.model = kenlm.Model(str(path))
+ self.order = self.model.order
+ self._kenlm = kenlm
+
+ def _state_for(self, history: tuple[str, ...]):
+ st = self._kenlm.State()
+ self.model.BeginSentenceWrite(st)
+ for w in history[-(self.order - 1):]:
+ out = self._kenlm.State()
+ self.model.BaseScore(st, w, out)
+ st = out
+ return st
+
+ def score(self, history: tuple[str, ...], word: str) -> float:
+ st = self._state_for(history)
+ out = self._kenlm.State()
+ return self.model.BaseScore(st, word, out) * LN10
+
+ def score_end(self, history: tuple[str, ...]) -> float:
+ st = self._state_for(history)
+ out = self._kenlm.State()
+ return self.model.BaseScore(st, "", out) * LN10
diff --git a/canopy/language_model/text.py b/canopy/language_model/text.py
new file mode 100644
index 0000000000000000000000000000000000000000..aba2554f7212e0cd71427bbc09ee46991df8ad38
--- /dev/null
+++ b/canopy/language_model/text.py
@@ -0,0 +1,38 @@
+"""LM training text preparation with held-out filtering.
+
+LM text is in ``text_train`` space (what the acoustic model emits). Any
+sentence whose ``sentence_key`` occurs in a dev or test manifest is removed,
+because CV-Pakistan sentences are re-recorded by several speakers and would
+otherwise leak test transcripts into the LM.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Iterable
+
+from canopy.data.manifest import sentence_key
+from canopy.text.normalize import get_normalizer
+
+
+def filter_heldout(sentences: Iterable[str], language: str, heldout_keys: set[str]) -> tuple[list[str], int]:
+ norm = get_normalizer(language)
+ kept, dropped = [], 0
+ for s in sentences:
+ if sentence_key(language, norm.eval_norm(s)) in heldout_keys:
+ dropped += 1
+ continue
+ t = norm.train_norm(s)
+ if t:
+ kept.append(t)
+ return kept, dropped
+
+
+def lm_sentences(train_rows: Iterable[dict], language: str, heldout_rows: Iterable[dict], extra_text: Iterable[str] = (),
+ dedupe: bool = True) -> tuple[list[str], dict]:
+ heldout = {r["sentence_key"] for r in heldout_rows if r["language"] == language}
+ base = [r["text_raw"] for r in train_rows if r["language"] == language]
+ kept, dropped = filter_heldout(list(base) + list(extra_text), language, heldout)
+ if dedupe:
+ kept = list(dict.fromkeys(kept))
+ return kept, {"language": language, "sentences": len(kept), "dropped_heldout_matches": dropped,
+ "heldout_sentence_keys": len(heldout)}
diff --git a/canopy/languages.py b/canopy/languages.py
new file mode 100644
index 0000000000000000000000000000000000000000..2eb1c8cf9cabef21036d6a658c1729ae9bb656ab
--- /dev/null
+++ b/canopy/languages.py
@@ -0,0 +1,92 @@
+"""Access to the master language registry (configs/languages.yaml)."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from functools import lru_cache
+from pathlib import Path
+
+from canopy.utils.io import load_yaml
+
+DEFAULT_REGISTRY = Path(__file__).resolve().parents[1] / "configs" / "languages.yaml"
+
+STATUSES = (
+ "production",
+ "supported",
+ "experimental",
+ "data-limited",
+ "evaluation-pending",
+ "not-currently-supported",
+ "auxiliary",
+)
+# Statuses whose data may be used for training. `not-currently-supported`
+# means there is no licence-clean transcribed data at all.
+TRAINABLE_STATUSES = frozenset(STATUSES) - {"not-currently-supported"}
+
+
+@dataclass(frozen=True)
+class Language:
+ code: str
+ name: str
+ status: str
+ primary_script: str
+ alternate_scripts: tuple[str, ...] = ()
+ family: str | None = None
+ omnilingual_seen: bool | None = None
+ raw: dict = field(default_factory=dict, compare=False, repr=False)
+
+ @property
+ def trainable(self) -> bool:
+ return self.status in TRAINABLE_STATUSES
+
+
+class LanguageRegistry:
+ def __init__(self, path: str | Path = DEFAULT_REGISTRY):
+ self.path = Path(path)
+ data = load_yaml(self.path)
+ self.schema_version = data.get("schema_version")
+ self._langs: dict[str, Language] = {}
+ for entry in data["languages"]:
+ scripts = entry.get("scripts") or {}
+ status = entry.get("status")
+ if status not in STATUSES:
+ raise ValueError(f"{self.path}: language {entry.get('code')!r} has unknown status {status!r}")
+ lang = Language(
+ code=entry["code"],
+ name=entry["name"],
+ status=status,
+ primary_script=scripts.get("primary", "Arab"),
+ alternate_scripts=tuple(scripts.get("alternates") or ()),
+ family=entry.get("family"),
+ omnilingual_seen=entry.get("omnilingual_seen"),
+ raw=entry,
+ )
+ if lang.code in self._langs:
+ raise ValueError(f"{self.path}: duplicate language code {lang.code!r}")
+ self._langs[lang.code] = lang
+
+ def __contains__(self, code: str) -> bool:
+ return code in self._langs
+
+ def __getitem__(self, code: str) -> Language:
+ try:
+ return self._langs[code]
+ except KeyError:
+ raise KeyError(f"unknown language code {code!r}; add it to {self.path} first") from None
+
+ def __iter__(self):
+ return iter(self._langs.values())
+
+ def __len__(self) -> int:
+ return len(self._langs)
+
+ def codes(self, trainable_only: bool = False) -> list[str]:
+ return [c for c, lang in self._langs.items() if lang.trainable or not trainable_only]
+
+ def by_status(self, status: str) -> list[Language]:
+ return [lang for lang in self._langs.values() if lang.status == status]
+
+
+@lru_cache(maxsize=4)
+def get_registry(path: str | None = None) -> LanguageRegistry:
+ return LanguageRegistry(path or DEFAULT_REGISTRY)
diff --git a/canopy/models/__init__.py b/canopy/models/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..ccff4eae15fd8e1852409467ddef21f2bedffa6a
--- /dev/null
+++ b/canopy/models/__init__.py
@@ -0,0 +1,4 @@
+from canopy.models.canopy_ctc import CanopyCTC
+from canopy.models.config import PRESETS, ModelConfig
+
+__all__ = ["PRESETS", "CanopyCTC", "ModelConfig"]
diff --git a/canopy/models/canopy_ctc.py b/canopy/models/canopy_ctc.py
new file mode 100644
index 0000000000000000000000000000000000000000..622fe043a0888306736a53ca8c3d7e949d82333e
--- /dev/null
+++ b/canopy/models/canopy_ctc.py
@@ -0,0 +1,107 @@
+from __future__ import annotations
+
+import torch
+import torch.nn.functional as F
+from torch import nn
+from torch.utils.checkpoint import checkpoint
+
+from canopy.frontend.features import LogMelFrontend
+from canopy.models.config import ModelConfig
+from canopy.models.modules import ConvSubsampling4x, MacaronBlock
+
+
+def lengths_to_mask(lengths: torch.Tensor, T: int) -> torch.Tensor:
+ return torch.arange(T, device=lengths.device).unsqueeze(0) < lengths.unsqueeze(1)
+
+
+class CanopyCTC(nn.Module):
+ """Conformer-style CTC encoder with intermediate CTC, optional language
+ conditioning, self-conditioning and a language-ID head."""
+
+ def __init__(self, cfg: ModelConfig):
+ super().__init__()
+ if cfg.vocab_size <= 1:
+ raise ValueError("cfg.vocab_size must be set from the tokenizer")
+ for k in cfg.aux_ctc_layers:
+ if not 1 <= k < cfg.n_layers:
+ raise ValueError(f"aux_ctc_layers entries must be in [1, n_layers), got {k}")
+ self.cfg = cfg
+ d = cfg.d_model
+ self.frontend = LogMelFrontend(n_mels=cfg.n_mels, normalize=cfg.feature_normalize)
+ self.subsampling = ConvSubsampling4x(cfg.n_mels, d, cfg.dropout)
+ self.lang_embed = nn.Embedding(cfg.num_languages + 1, d) if cfg.language_embedding else None
+ if self.lang_embed is not None:
+ nn.init.zeros_(self.lang_embed.weight)
+ n = cfg.n_layers
+ self.blocks = nn.ModuleList([
+ MacaronBlock(d, cfg.n_heads, cfg.ff_multiplier, cfg.conv_kernel, cfg.conv_expansion, cfg.se_ratio,
+ cfg.dropout, cfg.stochastic_depth * i / max(n - 1, 1), cfg.conv_norm)
+ for i in range(n)
+ ])
+ self.ctc_head = nn.Linear(d, cfg.vocab_size)
+ self.aux_heads = nn.ModuleDict({str(k): nn.Linear(d, cfg.vocab_size) for k in cfg.aux_ctc_layers})
+ self.self_cond = (nn.ModuleDict({str(k): nn.Linear(cfg.vocab_size, d, bias=False) for k in cfg.aux_ctc_layers})
+ if cfg.self_conditioning else None)
+ self.lid_head = nn.Linear(d, cfg.num_languages + 1) if cfg.lid_head else None
+ with torch.no_grad():
+ for head in [self.ctc_head, *self.aux_heads.values()]:
+ head.bias[0] = cfg.blank_bias_init
+
+ # ------------------------------------------------------------------
+ def encode(self, feats: torch.Tensor, feat_lengths: torch.Tensor, lang_ids: torch.Tensor | None = None) -> dict:
+ x, lengths = self.subsampling(feats, feat_lengths)
+ mask = lengths_to_mask(lengths, x.shape[1])
+ if self.lang_embed is not None:
+ if lang_ids is None:
+ lang_ids = torch.zeros(x.shape[0], dtype=torch.long, device=x.device)
+ x = x + self.lang_embed(lang_ids).unsqueeze(1)
+ aux_logits: dict[int, torch.Tensor] = {}
+ for i, block in enumerate(self.blocks, start=1):
+ if self.cfg.gradient_checkpointing and self.training:
+ x = checkpoint(block, x, mask, use_reentrant=False)
+ else:
+ x = block(x, mask)
+ key = str(i)
+ if key in self.aux_heads:
+ logits = self.aux_heads[key](x)
+ aux_logits[i] = logits
+ if self.self_cond is not None:
+ x = x + self.self_cond[key](logits.float().softmax(-1).to(x.dtype))
+ out = {"hidden": x, "lengths": lengths, "mask": mask, "logits": self.ctc_head(x), "aux_logits": aux_logits}
+ if self.lid_head is not None:
+ m = mask.unsqueeze(-1).to(x.dtype)
+ pooled = (x * m).sum(1) / m.sum(1).clamp(min=1.0)
+ out["lid_logits"] = self.lid_head(pooled)
+ return out
+
+ def forward(self, wav: torch.Tensor, wav_lengths: torch.Tensor, lang_ids: torch.Tensor | None = None,
+ spec_augment: nn.Module | None = None) -> dict:
+ feats, feat_lengths = self.frontend(wav, wav_lengths)
+ if spec_augment is not None and self.training:
+ feats = spec_augment(feats, feat_lengths)
+ out = self.encode(feats, feat_lengths, lang_ids)
+ out["log_probs"] = F.log_softmax(out["logits"].float(), dim=-1)
+ out["aux_log_probs"] = {k: F.log_softmax(v.float(), dim=-1) for k, v in out["aux_logits"].items()}
+ return out
+
+ # ------------------------------------------------------------------
+ def num_parameters(self, include_aux: bool = True) -> int:
+ n = sum(p.numel() for p in self.parameters())
+ if not include_aux:
+ n -= sum(p.numel() for p in self.aux_heads.parameters())
+ return n
+
+ def parameter_breakdown(self) -> dict[str, int]:
+ def count(m):
+ return sum(p.numel() for p in m.parameters()) if m is not None else 0
+
+ return {
+ "subsampling": count(self.subsampling),
+ "blocks": count(self.blocks),
+ "ctc_head": count(self.ctc_head),
+ "aux_heads": count(self.aux_heads),
+ "self_conditioning": count(self.self_cond),
+ "language_embedding": count(self.lang_embed),
+ "lid_head": count(self.lid_head),
+ "total": count(self),
+ }
diff --git a/canopy/models/config.py b/canopy/models/config.py
new file mode 100644
index 0000000000000000000000000000000000000000..bfffa759c0a18e45dcc43605e085213ad5bc10bf
--- /dev/null
+++ b/canopy/models/config.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+from dataclasses import asdict, dataclass, field, fields
+
+
+@dataclass
+class ModelConfig:
+ # Frontend
+ n_mels: int = 80
+ feature_normalize: str = "utterance" # utterance | per_bin | none
+ # Encoder
+ d_model: int = 384
+ n_layers: int = 12
+ n_heads: int = 6
+ ff_multiplier: float = 3.0
+ conv_kernel: int = 31
+ conv_expansion: int = 2
+ conv_norm: str = "batch" # batch | layer
+ se_ratio: int = 4
+ dropout: float = 0.1
+ stochastic_depth: float = 0.1
+ # Output
+ vocab_size: int = 0
+ blank_bias_init: float = 0.0
+ # Intermediate CTC heads after these blocks (1-indexed: 6 = output of the 6th block).
+ aux_ctc_layers: list[int] = field(default_factory=lambda: [6])
+ self_conditioning: bool = False
+ # Language conditioning. `languages` fixes the id order; id 0 means "no language".
+ languages: list[str] = field(default_factory=list)
+ language_embedding: bool = False
+ lid_head: bool = False
+ # Memory
+ gradient_checkpointing: bool = False
+
+ @property
+ def num_languages(self) -> int:
+ return len(self.languages)
+
+ def to_dict(self) -> dict:
+ return asdict(self)
+
+ @classmethod
+ def from_dict(cls, d: dict) -> ModelConfig:
+ known = {f.name for f in fields(cls)}
+ unknown = set(d) - known
+ if unknown:
+ raise ValueError(f"unknown model config keys: {sorted(unknown)}")
+ return cls(**d)
+
+
+PRESETS: dict[str, dict] = {
+ "canopy-s": {"d_model": 320, "n_layers": 12, "n_heads": 5},
+ "canopy-m": {"d_model": 384, "n_layers": 12, "n_heads": 6},
+ "canopy-m16": {"d_model": 384, "n_layers": 16, "n_heads": 6},
+ "canopy-l": {"d_model": 512, "n_layers": 16, "n_heads": 8, "aux_ctc_layers": [8]},
+ # For tests and smoke runs only.
+ "canopy-tiny": {"d_model": 64, "n_layers": 2, "n_heads": 2, "aux_ctc_layers": [1], "conv_kernel": 7},
+}
diff --git a/canopy/models/modules.py b/canopy/models/modules.py
new file mode 100644
index 0000000000000000000000000000000000000000..16c60de478dc4c5d28ad4c832af840dc0df3a445
--- /dev/null
+++ b/canopy/models/modules.py
@@ -0,0 +1,183 @@
+"""Encoder building blocks: RMSNorm, rotary MHSA, SwiGLU, SE convolution, Macaron block."""
+
+from __future__ import annotations
+
+import torch
+import torch.nn.functional as F
+from torch import nn
+
+
+class RMSNorm(nn.Module):
+ def __init__(self, dim: int, eps: float = 1e-6):
+ super().__init__()
+ self.eps = eps
+ self.weight = nn.Parameter(torch.ones(dim))
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ xf = x.float()
+ xf = xf * torch.rsqrt(xf.pow(2).mean(-1, keepdim=True) + self.eps)
+ return xf.type_as(x) * self.weight
+
+
+class Rotary(nn.Module):
+ def __init__(self, head_dim: int, base: float = 10000.0):
+ super().__init__()
+ inv = 1.0 / (base ** (torch.arange(0, head_dim, 2, dtype=torch.float32) / head_dim))
+ self.register_buffer("inv_freq", inv, persistent=False)
+ self._len = 0
+ self._cos: torch.Tensor | None = None
+ self._sin: torch.Tensor | None = None
+
+ def forward(self, T: int, device, dtype) -> tuple[torch.Tensor, torch.Tensor]:
+ if self._cos is None or T > self._len or self._cos.device != device:
+ n = max(T, 2 * self._len, 1024)
+ t = torch.arange(n, device=device, dtype=torch.float32)
+ freqs = torch.outer(t, self.inv_freq.to(device))
+ emb = torch.cat([freqs, freqs], dim=-1)
+ self._cos, self._sin, self._len = emb.cos(), emb.sin(), n
+ return self._cos[:T].to(dtype), self._sin[:T].to(dtype)
+
+
+def _rotate_half(x: torch.Tensor) -> torch.Tensor:
+ a, b = x.chunk(2, dim=-1)
+ return torch.cat([-b, a], dim=-1)
+
+
+class RotaryMHSA(nn.Module):
+ def __init__(self, d_model: int, n_heads: int, dropout: float):
+ super().__init__()
+ if d_model % n_heads:
+ raise ValueError("d_model must be divisible by n_heads")
+ self.h = n_heads
+ self.dh = d_model // n_heads
+ self.q = nn.Linear(d_model, d_model, bias=False)
+ self.k = nn.Linear(d_model, d_model, bias=False)
+ self.v = nn.Linear(d_model, d_model, bias=False)
+ self.o = nn.Linear(d_model, d_model, bias=False)
+ self.rotary = Rotary(self.dh)
+ self.p = dropout
+
+ def forward(self, x: torch.Tensor, key_mask: torch.Tensor | None) -> torch.Tensor:
+ B, T, D = x.shape
+ q = self.q(x).view(B, T, self.h, self.dh).transpose(1, 2)
+ k = self.k(x).view(B, T, self.h, self.dh).transpose(1, 2)
+ v = self.v(x).view(B, T, self.h, self.dh).transpose(1, 2)
+ cos, sin = self.rotary(T, x.device, q.dtype)
+ q = q * cos + _rotate_half(q) * sin
+ k = k * cos + _rotate_half(k) * sin
+ attn_mask = key_mask[:, None, None, :] if key_mask is not None else None
+ out = F.scaled_dot_product_attention(q, k, v, attn_mask=attn_mask, dropout_p=self.p if self.training else 0.0)
+ return self.o(out.transpose(1, 2).reshape(B, T, D))
+
+
+class SwiGLUFFN(nn.Module):
+ def __init__(self, d_model: int, multiplier: float, dropout: float):
+ super().__init__()
+ inner = int(d_model * multiplier)
+ self.norm = RMSNorm(d_model)
+ self.gate = nn.Linear(d_model, inner, bias=False)
+ self.up = nn.Linear(d_model, inner, bias=False)
+ self.down = nn.Linear(inner, d_model, bias=False)
+ self.drop = nn.Dropout(dropout)
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ h = self.norm(x)
+ h = self.down(self.drop(F.silu(self.gate(h)) * self.up(h)))
+ return self.drop(h)
+
+
+class SqueezeExcite(nn.Module):
+ def __init__(self, channels: int, ratio: int):
+ super().__init__()
+ mid = max(1, channels // ratio)
+ self.fc1 = nn.Linear(channels, mid)
+ self.fc2 = nn.Linear(mid, channels)
+
+ def forward(self, x: torch.Tensor, mask: torch.Tensor | None) -> torch.Tensor:
+ # x: (B, T, C). Mean over valid frames only.
+ if mask is None:
+ s = x.mean(dim=1, keepdim=True)
+ else:
+ m = mask.unsqueeze(-1).to(x.dtype)
+ s = (x * m).sum(dim=1, keepdim=True) / m.sum(dim=1, keepdim=True).clamp(min=1.0)
+ return x * torch.sigmoid(self.fc2(F.silu(self.fc1(s))))
+
+
+class ConvModule(nn.Module):
+ def __init__(self, d_model: int, kernel: int, expansion: int, se_ratio: int, dropout: float, norm: str = "batch"):
+ super().__init__()
+ inner = d_model * expansion
+ self.norm = RMSNorm(d_model)
+ self.pw1 = nn.Linear(d_model, 2 * inner)
+ self.dw = nn.Conv1d(inner, inner, kernel, padding=kernel // 2, groups=inner)
+ if norm == "batch":
+ self.conv_norm: nn.Module = nn.BatchNorm1d(inner)
+ elif norm == "layer":
+ self.conv_norm = nn.GroupNorm(1, inner)
+ else:
+ raise ValueError(f"conv_norm must be batch|layer, got {norm!r}")
+ self.se = SqueezeExcite(inner, se_ratio)
+ self.pw2 = nn.Linear(inner, d_model)
+ self.drop = nn.Dropout(dropout)
+
+ def forward(self, x: torch.Tensor, mask: torch.Tensor | None) -> torch.Tensor:
+ h = F.glu(self.pw1(self.norm(x)), dim=-1)
+ if mask is not None:
+ h = h.masked_fill(~mask.unsqueeze(-1), 0.0)
+ h = self.dw(h.transpose(1, 2))
+ h = F.silu(self.conv_norm(h)).transpose(1, 2)
+ h = self.se(h, mask)
+ return self.drop(self.pw2(h))
+
+
+class MacaronBlock(nn.Module):
+ """½FFN → MHSA → Conv → ½FFN → RMSNorm, each with a residual connection."""
+
+ def __init__(self, d_model: int, n_heads: int, ff_multiplier: float, conv_kernel: int, conv_expansion: int,
+ se_ratio: int, dropout: float, drop_rate: float, conv_norm: str = "batch"):
+ super().__init__()
+ self.ff1 = SwiGLUFFN(d_model, ff_multiplier, dropout)
+ self.attn_norm = RMSNorm(d_model)
+ self.attn = RotaryMHSA(d_model, n_heads, dropout)
+ self.attn_drop = nn.Dropout(dropout)
+ self.conv = ConvModule(d_model, conv_kernel, conv_expansion, se_ratio, dropout, conv_norm)
+ self.ff2 = SwiGLUFFN(d_model, ff_multiplier, dropout)
+ self.final_norm = RMSNorm(d_model)
+ self.drop_rate = drop_rate
+
+ def forward(self, x: torch.Tensor, mask: torch.Tensor | None) -> torch.Tensor:
+ if self.training and self.drop_rate > 0 and torch.rand(()) < self.drop_rate:
+ return x
+ x = x + 0.5 * self.ff1(x)
+ x = x + self.attn_drop(self.attn(self.attn_norm(x), mask))
+ x = x + self.conv(x, mask)
+ x = x + 0.5 * self.ff2(x)
+ return self.final_norm(x)
+
+
+class ConvSubsampling4x(nn.Module):
+ """Two 3×3 stride-2 Conv2d layers (4× in time and frequency) → Linear → RMSNorm."""
+
+ def __init__(self, n_mels: int, d_model: int, dropout: float):
+ super().__init__()
+ self.conv1 = nn.Conv2d(1, d_model, 3, stride=2, padding=1)
+ self.conv2 = nn.Conv2d(d_model, d_model, 3, stride=2, padding=1)
+ f = (n_mels + 1) // 2
+ f = (f + 1) // 2
+ self.linear = nn.Linear(d_model * f, d_model)
+ self.norm = RMSNorm(d_model)
+ self.drop = nn.Dropout(dropout)
+
+ @staticmethod
+ def output_lengths(lengths: torch.Tensor) -> torch.Tensor:
+ lengths = torch.div(lengths - 1, 2, rounding_mode="floor") + 1
+ lengths = torch.div(lengths - 1, 2, rounding_mode="floor") + 1
+ return lengths.clamp(min=1)
+
+ def forward(self, x: torch.Tensor, lengths: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
+ x = F.silu(self.conv1(x.unsqueeze(1)))
+ x = F.silu(self.conv2(x))
+ B, C, T, Fq = x.shape
+ x = x.permute(0, 2, 1, 3).reshape(B, T, C * Fq)
+ x = self.drop(self.norm(self.linear(x)))
+ return x, self.output_lengths(lengths).clamp(max=T)
diff --git a/canopy/text/__init__.py b/canopy/text/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..bbc3d81f0befa228c7ea2e7ce413ebf61e3b3b48
--- /dev/null
+++ b/canopy/text/__init__.py
@@ -0,0 +1,3 @@
+from canopy.text.normalize import Normalizer, get_normalizer
+
+__all__ = ["Normalizer", "get_normalizer"]
diff --git a/canopy/text/normalize.py b/canopy/text/normalize.py
new file mode 100644
index 0000000000000000000000000000000000000000..3694b348d1ba236580c9eb6610e7e227d38c6837
--- /dev/null
+++ b/canopy/text/normalize.py
@@ -0,0 +1,110 @@
+"""Text normalization with three separate views per utterance.
+
+``raw`` the transcript exactly as distributed (never modified)
+``train_norm`` the tokenizer/model target
+``eval_norm`` the scoring view used for WER/CER
+
+Both derived views are deterministic functions of ``raw`` and the language code.
+"""
+
+from __future__ import annotations
+
+import re
+import unicodedata
+from collections import Counter
+from collections.abc import Iterable
+from functools import lru_cache
+
+from canopy.text.rules import HARAKAT, LanguageRules, rules_for
+
+ZWNJ = "\u200c"
+TATWEEL = "\u0640"
+# Invisible format characters that never carry orthographic meaning here.
+_INVISIBLE = frozenset(
+ ["\u200b", "\u200d", "\u200e", "\u200f", "\u2060", "\ufeff", "\u00ad"]
+ + [chr(c) for c in range(0x202A, 0x202F)]
+ + [chr(c) for c in range(0x2066, 0x206A)]
+)
+_WS = re.compile(r"\s+")
+
+_EASTERN_DIGITS = {chr(0x0660 + i): str(i) for i in range(10)}
+_EASTERN_DIGITS.update({chr(0x06F0 + i): str(i) for i in range(10)})
+
+
+def _is_presentation_form(ch: str) -> bool:
+ o = ord(ch)
+ return 0xFB50 <= o <= 0xFDFF or 0xFE70 <= o <= 0xFEFE
+
+
+class Normalizer:
+ def __init__(self, rules: LanguageRules):
+ self.rules = rules
+ self.code = rules.code
+
+ def _base(self, text: str, keep_zwnj: bool) -> str:
+ text = unicodedata.normalize("NFC", text)
+ out = []
+ for ch in text:
+ if _is_presentation_form(ch):
+ out.append(unicodedata.normalize("NFKC", ch))
+ elif ch in _INVISIBLE or ch == TATWEEL:
+ continue
+ elif ch == ZWNJ:
+ if keep_zwnj:
+ out.append(ch)
+ else:
+ out.append(ch)
+ text = "".join(out)
+ if self.rules.char_map:
+ text = "".join(self.rules.char_map.get(ch, ch) for ch in text)
+ buf = []
+ for ch in text:
+ cat = unicodedata.category(ch)
+ if ch in self.rules.keep_chars or ch == ZWNJ:
+ buf.append(ch)
+ elif cat[0] in ("P", "S") or cat in ("Cc", "Cf"):
+ buf.append(" ")
+ else:
+ buf.append(ch)
+ text = "".join(buf)
+ if self.rules.lowercase:
+ text = text.lower()
+ text = unicodedata.normalize("NFC", text)
+ return _WS.sub(" ", text).strip()
+
+ def train_norm(self, raw: str) -> str:
+ return self._base(raw, keep_zwnj=self.rules.keep_zwnj_train)
+
+ def eval_norm(self, raw: str) -> str:
+ text = self._base(raw, keep_zwnj=False)
+ text = "".join(_EASTERN_DIGITS.get(ch, ch) for ch in text)
+ if self.rules.eval_char_map:
+ text = "".join(self.rules.eval_char_map.get(ch, ch) for ch in text)
+ if self.rules.strip_harakat_eval:
+ text = "".join(ch for ch in text if ch not in HARAKAT)
+ return _WS.sub(" ", text).strip()
+
+ def eval_norm_hypothesis(self, hyp: str) -> str:
+ """Hypotheses are already in train_norm space; apply the same scoring view."""
+ return self.eval_norm(hyp)
+
+
+@lru_cache(maxsize=128)
+def get_normalizer(code: str, script: str | None = None) -> Normalizer:
+ return Normalizer(rules_for(code, script))
+
+
+def char_inventory(texts: Iterable[str]) -> Counter:
+ c: Counter = Counter()
+ for t in texts:
+ c.update(t)
+ return c
+
+
+def describe_char(ch: str) -> dict:
+ return {
+ "char": ch,
+ "codepoint": f"U+{ord(ch):04X}",
+ "name": unicodedata.name(ch, ""),
+ "category": unicodedata.category(ch),
+ }
diff --git a/canopy/text/rules.py b/canopy/text/rules.py
new file mode 100644
index 0000000000000000000000000000000000000000..1d9e4b6dd59cdb5d465337c2bda303566b684cd9
--- /dev/null
+++ b/canopy/text/rules.py
@@ -0,0 +1,88 @@
+"""Per-language normalization rules.
+
+Policy (docs/LANGUAGE_MATRIX.md, section 4):
+
+* ``raw_reference`` is never modified.
+* A letter-level mapping applies ONLY to languages listed here with that
+ mapping. Nothing is inherited from Urdu (or any other language) by default.
+* Every language not listed explicitly gets the script-generic rules only,
+ which are limited to changes that carry no orthographic meaning
+ (Unicode NFC, presentation forms, invisible format characters, tatweel,
+ punctuation, whitespace).
+* ``reviewed`` stays False until a native-speaker reviewer signs off the
+ rule set; the evaluation report prints this flag next to each language.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+ARABIC_KAF = "\u0643" # ك
+KEHEH = "\u06a9" # ک
+ARABIC_YEH = "\u064a" # ي
+ALEF_MAKSURA = "\u0649" # ى
+FARSI_YEH = "\u06cc" # ی
+ARABIC_HEH = "\u0647" # ه
+HEH_GOAL = "\u06c1" # ہ
+
+# Optional vowel signs (harakat), superscript alef and small high marks.
+HARAKAT = frozenset(chr(c) for c in list(range(0x064B, 0x0653)) + [0x0670])
+
+
+@dataclass(frozen=True)
+class LanguageRules:
+ code: str
+ script: str = "Arab"
+ # Applied in both train_norm and eval_norm.
+ char_map: dict[str, str] = field(default_factory=dict)
+ # Applied additionally in eval_norm only.
+ eval_char_map: dict[str, str] = field(default_factory=dict)
+ lowercase: bool = False
+ keep_zwnj_train: bool = False
+ strip_harakat_eval: bool = False
+ # Characters that must survive punctuation removal (e.g. apostrophes used as letters).
+ keep_chars: frozenset[str] = frozenset()
+ reviewed: bool = False
+ notes: str = ""
+
+
+_LATIN_APOSTROPHES = frozenset({"'", "\u2019", "\u02bc"})
+
+RULES: dict[str, LanguageRules] = {
+ "urd": LanguageRules(
+ code="urd",
+ char_map={ARABIC_KAF: KEHEH, ARABIC_YEH: FARSI_YEH, ALEF_MAKSURA: FARSI_YEH, ARABIC_HEH: HEH_GOAL},
+ keep_zwnj_train=True,
+ strip_harakat_eval=True,
+ notes="Yeh barree U+06D2 and do-chashmi heh U+06BE are kept distinct. Harakat stripped in eval_norm only.",
+ ),
+ "pus": LanguageRules(
+ code="pus",
+ char_map={ARABIC_KAF: KEHEH},
+ notes="Pashto yeh forms (ی ي ې ۍ ئ) are distinct letters and heh U+0647 is standard; neither is mapped.",
+ ),
+ "snd": LanguageRules(
+ code="snd",
+ notes="Sindhi uses swash kaf U+06AA for /k/ and Arabic yeh/heh; the Urdu mappings must not be applied.",
+ ),
+ "kas": LanguageRules(
+ code="kas",
+ notes="Kashmiri vowel marks are orthographically required; harakat are never stripped.",
+ ),
+ "eng": LanguageRules(code="eng", script="Latn", lowercase=True, keep_chars=_LATIN_APOSTROPHES),
+}
+
+for _code in ("bsk", "kls", "wbl"):
+ RULES[_code] = LanguageRules(
+ code=_code,
+ script="Latn",
+ lowercase=True,
+ keep_chars=_LATIN_APOSTROPHES,
+ notes="Latin-script community orthography. Lowercasing and apostrophe handling await reviewer confirmation.",
+ )
+
+
+def rules_for(code: str, script: str | None = None) -> LanguageRules:
+ if code in RULES:
+ return RULES[code]
+ return LanguageRules(code=code, script=script or "Arab", notes="script-generic rules only")
diff --git a/canopy/tokenizer/__init__.py b/canopy/tokenizer/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..0194708649d9dee5aeb57453b371bc80f7646ca5
--- /dev/null
+++ b/canopy/tokenizer/__init__.py
@@ -0,0 +1,5 @@
+from canopy.tokenizer.base import BLANK, UNK, CTCTokenizer, load_tokenizer
+from canopy.tokenizer.char import CharTokenizer
+from canopy.tokenizer.spm import SentencePieceTokenizer, balanced_sample
+
+__all__ = ["BLANK", "UNK", "CTCTokenizer", "CharTokenizer", "SentencePieceTokenizer", "balanced_sample", "load_tokenizer"]
diff --git a/canopy/tokenizer/base.py b/canopy/tokenizer/base.py
new file mode 100644
index 0000000000000000000000000000000000000000..e84d9f783680bf07be8625615fb21fb6bd1c2d54
--- /dev/null
+++ b/canopy/tokenizer/base.py
@@ -0,0 +1,96 @@
+"""Common CTC tokenizer interface. Id 0 is always the CTC blank, id 1 is ."""
+
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+
+from canopy.utils.io import sha256_file
+
+BLANK = ""
+UNK = ""
+BLANK_ID = 0
+UNK_ID = 1
+
+
+class CTCTokenizer:
+ type: str = "base"
+
+ def __init__(self, tokens: list[str], language_token_ids: dict[str, list[int]] | None = None, meta: dict | None = None):
+ if tokens[BLANK_ID] != BLANK or tokens[UNK_ID] != UNK:
+ raise ValueError("token 0 must be and token 1 must be ")
+ self.tokens = tokens
+ self.token_to_id = {t: i for i, t in enumerate(tokens)}
+ self.language_token_ids = {k: sorted(set(v)) for k, v in (language_token_ids or {}).items()}
+ self.meta = meta or {}
+ self.version: str | None = None
+
+ @property
+ def vocab_size(self) -> int:
+ return len(self.tokens)
+
+ @property
+ def blank_id(self) -> int:
+ return BLANK_ID
+
+ @property
+ def unk_id(self) -> int:
+ return UNK_ID
+
+ def encode(self, text: str, language: str | None = None) -> list[int]:
+ raise NotImplementedError
+
+ def decode(self, ids: list[int]) -> str:
+ raise NotImplementedError
+
+ def always_allowed_ids(self) -> list[int]:
+ return [BLANK_ID]
+
+ def language_mask(self, language: str) -> list[bool] | None:
+ """Allowed output ids for ``language`` (O2 output masking), or None if unknown."""
+ ids = self.language_token_ids.get(language)
+ if ids is None:
+ return None
+ mask = [False] * self.vocab_size
+ for i in ids:
+ mask[i] = True
+ for i in self.always_allowed_ids():
+ mask[i] = True
+ return mask
+
+ def labels_for_pyctcdecode(self) -> list[str]:
+ raise NotImplementedError
+
+ # ------------------------------------------------------------------ io
+ def _payload(self) -> dict:
+ return {"type": self.type, "tokens": self.tokens, "language_token_ids": self.language_token_ids, "meta": self.meta}
+
+ def save(self, directory: str | os.PathLike) -> Path:
+ d = Path(directory)
+ d.mkdir(parents=True, exist_ok=True)
+ path = d / "tokenizer.json"
+ with open(path, "w", encoding="utf-8") as f:
+ json.dump(self._payload(), f, ensure_ascii=False, indent=1)
+ self.version = sha256_file(path)[:16]
+ return path
+
+
+def load_tokenizer(directory: str | os.PathLike) -> CTCTokenizer:
+ d = Path(directory)
+ path = d / "tokenizer.json" if d.is_dir() else d
+ with open(path, encoding="utf-8") as f:
+ payload = json.load(f)
+ t = payload["type"]
+ if t == "char":
+ from canopy.tokenizer.char import CharTokenizer
+
+ tok = CharTokenizer.from_payload(payload)
+ elif t == "sentencepiece":
+ from canopy.tokenizer.spm import SentencePieceTokenizer
+
+ tok = SentencePieceTokenizer.from_payload(payload, path.parent)
+ else:
+ raise ValueError(f"unknown tokenizer type {t!r}")
+ tok.version = sha256_file(path)[:16]
+ return tok
diff --git a/canopy/tokenizer/char.py b/canopy/tokenizer/char.py
new file mode 100644
index 0000000000000000000000000000000000000000..c43ec9ffa5ef0109654be4301e88571179a07525
--- /dev/null
+++ b/canopy/tokenizer/char.py
@@ -0,0 +1,76 @@
+"""Character tokenizers.
+
+``unit="codepoint"``: one token per Unicode code point (NFC).
+``unit="grapheme"``: a base character plus its following combining marks form
+one token, so vowel signs and other diacritics stay attached to their letter.
+"""
+
+from __future__ import annotations
+
+import unicodedata
+from collections import Counter
+from collections.abc import Iterable
+
+from canopy.tokenizer.base import BLANK, UNK, UNK_ID, CTCTokenizer
+
+SPACE = " "
+
+
+def split_units(text: str, unit: str) -> list[str]:
+ if unit == "codepoint":
+ return list(text)
+ if unit != "grapheme":
+ raise ValueError(f"unit must be codepoint|grapheme, got {unit!r}")
+ out: list[str] = []
+ for ch in text:
+ if out and out[-1] != SPACE and unicodedata.category(ch) in ("Mn", "Me", "Mc"):
+ out[-1] += ch
+ else:
+ out.append(ch)
+ return out
+
+
+class CharTokenizer(CTCTokenizer):
+ type = "char"
+
+ def __init__(self, tokens, language_token_ids=None, meta=None):
+ super().__init__(tokens, language_token_ids, meta)
+ self.unit = self.meta.get("unit", "codepoint")
+
+ @classmethod
+ def train(cls, texts_by_language: dict[str, Iterable[str]], unit: str = "codepoint", min_count: int = 1) -> CharTokenizer:
+ counts: Counter = Counter()
+ per_lang: dict[str, Counter] = {}
+ for lang, texts in texts_by_language.items():
+ c: Counter = Counter()
+ for t in texts:
+ c.update(split_units(t, unit))
+ per_lang[lang] = c
+ counts.update(c)
+ units = sorted(u for u, n in counts.items() if n >= min_count and u != SPACE)
+ tokens = [BLANK, UNK, SPACE, *units]
+ idx = {t: i for i, t in enumerate(tokens)}
+ lang_ids = {lang: [idx[u] for u in c if u in idx] for lang, c in per_lang.items()}
+ meta = {"unit": unit, "min_count": min_count, "unit_counts": {u: counts[u] for u in units}}
+ return cls(tokens, lang_ids, meta)
+
+ @classmethod
+ def from_payload(cls, payload: dict) -> CharTokenizer:
+ return cls(payload["tokens"], payload.get("language_token_ids"), payload.get("meta"))
+
+ def always_allowed_ids(self) -> list[int]:
+ return [0, self.token_to_id[SPACE]]
+
+ def encode(self, text: str, language: str | None = None) -> list[int]:
+ return [self.token_to_id.get(u, UNK_ID) for u in split_units(text, self.unit)]
+
+ def decode(self, ids: list[int]) -> str:
+ out = []
+ for i in ids:
+ if i <= 0:
+ continue
+ out.append("\u2047" if i == UNK_ID else self.tokens[i])
+ return " ".join("".join(out).split())
+
+ def labels_for_pyctcdecode(self) -> list[str]:
+ return ["", "\u2047", *self.tokens[2:]]
diff --git a/canopy/tokenizer/diagnostics.py b/canopy/tokenizer/diagnostics.py
new file mode 100644
index 0000000000000000000000000000000000000000..f897725089da263cab153580140a22b1f89dec2f
--- /dev/null
+++ b/canopy/tokenizer/diagnostics.py
@@ -0,0 +1,95 @@
+"""Static tokenizer diagnostics (no acoustic training): RESEARCH_PLAN.md section 7.2."""
+
+from __future__ import annotations
+
+from collections import Counter
+
+import numpy as np
+
+from canopy.data.dataset import ctc_required_frames
+from canopy.data.validation import char_script
+from canopy.tokenizer.base import UNK_ID
+
+
+def _pct(a, q):
+ return float(np.percentile(a, q)) if len(a) else 0.0
+
+
+def tokenization_report(tokenizer, rows: list[dict], *, fps: float = 25.0, d_model: int = 384,
+ tokenizer_id: str = "unnamed") -> dict:
+ """Per-language diagnostics. ``rows`` are training-split manifest rows."""
+ by_lang: dict[str, list[dict]] = {}
+ for r in rows:
+ by_lang.setdefault(r["language"], []).append(r)
+
+ encoded = {lang: [tokenizer.encode(r["text_train"], language=lang) for r in lrows] for lang, lrows in by_lang.items()}
+ global_counts: Counter = Counter()
+ lang_types: dict[str, set] = {}
+ for lang, encs in encoded.items():
+ s = set()
+ for e in encs:
+ global_counts.update(e)
+ s.update(e)
+ lang_types[lang] = s
+
+ reports = {}
+ for lang, lrows in by_lang.items():
+ encs = encoded[lang]
+ n_tok = np.array([len(e) for e in encs], dtype=float)
+ n_chr = np.array([len(r["text_train"].replace(" ", "")) for r in lrows], dtype=float)
+ dur = np.array([r["duration"] for r in lrows], dtype=float)
+ frames = np.floor(dur * fps)
+ req = np.array([ctc_required_frames(e) for e in encs], dtype=float)
+ ratio = req / np.maximum(frames, 1)
+ repeats = req - n_tok
+ types = lang_types[lang]
+ others = set().union(*(v for k, v in lang_types.items() if k != lang)) if len(lang_types) > 1 else set()
+ n_types = max(1, len(types))
+ total_tokens = max(1.0, n_tok.sum())
+ chars = Counter(c for r in lrows for c in r["text_train"] if not c.isspace())
+ total_chars = max(1, sum(chars.values()))
+ script_cov: Counter = Counter()
+ for c, k in chars.items():
+ script_cov[char_script(c)] += k
+ cpt = n_chr / np.maximum(n_tok, 1)
+ tps = n_tok / np.maximum(dur, 1e-6)
+ reports[lang] = {
+ "tokenizer_id": tokenizer_id,
+ "language": lang,
+ "n_utterances": len(lrows),
+ "frames_per_second": fps,
+ "mean_chars_per_token": float(cpt.mean()) if len(cpt) else 0.0,
+ "p50_chars_per_token": _pct(cpt, 50),
+ "p95_chars_per_token": _pct(cpt, 95),
+ "tokens_per_second": {"mean": float(tps.mean()) if len(tps) else 0.0, "p95": _pct(tps, 95), "p99": _pct(tps, 99)},
+ "target_token_frame_ratio": {"mean": float(ratio.mean()) if len(ratio) else 0.0, "p95": _pct(ratio, 95),
+ "p99": _pct(ratio, 99), "max": float(ratio.max()) if len(ratio) else 0.0},
+ "required_blanks_for_repeats_per_utt": float(repeats.mean()) if len(repeats) else 0.0,
+ "frame_budget_violation_rate": float((ratio > 1.0).mean()) if len(ratio) else 0.0,
+ "estimated_blank_rate": float(1.0 - (n_tok.sum() / max(1.0, frames.sum()))),
+ "rare_token_percentage": {
+ f"lt{k}": 100.0 * sum(1 for t in types if global_counts[t] < k) / n_types for k in (10, 100, 1000)
+ },
+ "tokens_used_by_language": len(types),
+ "tokens_exclusive_to_language": len(types - others),
+ "oov_or_unk_rate": float(sum(e.count(UNK_ID) for e in encs) / total_tokens),
+ "byte_fallback_rate": 0.0,
+ "script_coverage": {k: v / total_chars for k, v in sorted(script_cov.items())},
+ "digit_handling": "split" if tokenizer.type == "sentencepiece" else "per-character",
+ "punctuation_handling": "removed_in_train_norm",
+ "ctc_head_params": (d_model + 1) * tokenizer.vocab_size,
+ }
+ return reports
+
+
+def prescreen(reports: dict, max_violation_rate: float = 0.005, max_rare_share: float = 50.0) -> dict:
+ """Apply the section 7.2 pre-screen gate. Returns {passed, failures}."""
+ failures = []
+ for lang, r in reports.items():
+ if r["frame_budget_violation_rate"] > max_violation_rate:
+ failures.append({"language": lang, "reason": "frame_budget_violation_rate",
+ "value": r["frame_budget_violation_rate"]})
+ if r["rare_token_percentage"]["lt100"] > max_rare_share:
+ failures.append({"language": lang, "reason": "rare_token_share_lt100",
+ "value": r["rare_token_percentage"]["lt100"]})
+ return {"passed": not failures, "failures": failures}
diff --git a/canopy/tokenizer/spm.py b/canopy/tokenizer/spm.py
new file mode 100644
index 0000000000000000000000000000000000000000..20b8cce8500fb40c08e01d6bdefe336224d405d4
--- /dev/null
+++ b/canopy/tokenizer/spm.py
@@ -0,0 +1,111 @@
+"""SentencePiece (unigram / BPE) tokenizer with CTC blank at id 0.
+
+SentencePiece's own ids are shifted by +1, so SentencePiece (0) becomes
+Canopy id 1. Normalization is disabled inside SentencePiece
+(``normalization_rule_name=identity``): Canopy's per-language normalizer has
+already produced ``text_train``, and SentencePiece's default NFKC would fold
+characters that are distinct in some Pakistani orthographies.
+"""
+
+from __future__ import annotations
+
+import io
+import math
+import random
+from collections.abc import Iterable
+from pathlib import Path
+
+from canopy.tokenizer.base import BLANK, UNK, CTCTokenizer
+
+SPM_SPACE = "\u2581"
+
+
+def balanced_sample(texts_by_language: dict[str, list[str]], temperature: float, seed: int = 0,
+ total: int | None = None) -> tuple[list[str], dict[str, int]]:
+ """Resample sentences so language ``l`` gets a share ∝ n_l ** (1/temperature)."""
+ rng = random.Random(seed)
+ n = {lang: len(t) for lang, t in texts_by_language.items() if t}
+ total = total or sum(n.values())
+ if math.isinf(temperature):
+ w = {lang: 1.0 for lang in n}
+ else:
+ w = {lang: c ** (1.0 / temperature) for lang, c in n.items()}
+ z = sum(w.values())
+ out, counts = [], {}
+ for lang, texts in texts_by_language.items():
+ if not texts:
+ continue
+ k = max(1, round(total * w[lang] / z))
+ if k <= len(texts):
+ picked = rng.sample(texts, k)
+ else:
+ picked = texts * (k // len(texts)) + rng.sample(texts, k % len(texts))
+ out.extend(picked)
+ counts[lang] = k
+ rng.shuffle(out)
+ return out, counts
+
+
+class SentencePieceTokenizer(CTCTokenizer):
+ type = "sentencepiece"
+
+ def __init__(self, tokens, language_token_ids=None, meta=None, model_proto: bytes | None = None):
+ super().__init__(tokens, language_token_ids, meta)
+ import sentencepiece as spm
+
+ self._proto = model_proto
+ self.sp = spm.SentencePieceProcessor(model_proto=model_proto)
+
+ @classmethod
+ def train(cls, texts_by_language: dict[str, list[str]], vocab_size: int, model_type: str = "unigram",
+ temperature: float = 5.0, seed: int = 0, character_coverage: float = 1.0,
+ max_sentences: int | None = 2_000_000) -> SentencePieceTokenizer:
+ import sentencepiece as spm
+
+ sample, counts = balanced_sample(texts_by_language, temperature, seed,
+ total=min(max_sentences or 10**12, sum(len(v) for v in texts_by_language.values())))
+ buf = io.BytesIO()
+ spm.SentencePieceTrainer.train(
+ sentence_iterator=iter(sample), model_writer=buf, vocab_size=vocab_size, model_type=model_type,
+ character_coverage=character_coverage, byte_fallback=False, split_digits=True,
+ normalization_rule_name="identity", add_dummy_prefix=True, remove_extra_whitespaces=True,
+ unk_id=0, bos_id=-1, eos_id=-1, pad_id=-1, hard_vocab_limit=False,
+ input_sentence_size=0, shuffle_input_sentence=False, num_threads=4, minloglevel=2,
+ )
+ proto = buf.getvalue()
+ sp = spm.SentencePieceProcessor(model_proto=proto)
+ tokens = [BLANK, UNK] + [sp.id_to_piece(i) for i in range(1, sp.get_piece_size())]
+ meta = {"model_type": model_type, "requested_vocab_size": vocab_size, "temperature": temperature,
+ "seed": seed, "character_coverage": character_coverage, "training_sentences_per_language": counts}
+ tok = cls(tokens, None, meta, proto)
+ tok.language_token_ids = {
+ lang: sorted({i for t in texts for i in tok.encode(t)}) for lang, texts in texts_by_language.items()
+ }
+ return tok
+
+ @classmethod
+ def from_payload(cls, payload: dict, directory: Path) -> SentencePieceTokenizer:
+ proto = (directory / "spm.model").read_bytes()
+ return cls(payload["tokens"], payload.get("language_token_ids"), payload.get("meta"), proto)
+
+ def save(self, directory):
+ d = Path(directory)
+ d.mkdir(parents=True, exist_ok=True)
+ (d / "spm.model").write_bytes(self._proto)
+ return super().save(d)
+
+ def encode(self, text: str, language: str | None = None) -> list[int]:
+ return [i + 1 for i in self.sp.encode(text, out_type=int)]
+
+ def decode(self, ids: Iterable[int]) -> str:
+ return self.sp.decode([i - 1 for i in ids if i > 0])
+
+ def always_allowed_ids(self) -> list[int]:
+ ids = [0]
+ sp_space = self.token_to_id.get(SPM_SPACE)
+ if sp_space is not None:
+ ids.append(sp_space)
+ return ids
+
+ def labels_for_pyctcdecode(self) -> list[str]:
+ return ["", "\u2047", *[t.replace(SPM_SPACE, " ") if t.startswith(SPM_SPACE) else t for t in self.tokens[2:]]]
diff --git a/canopy/training/__init__.py b/canopy/training/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..fd3a12ba723dc0ee25bbebf8050ec7e8133b5259
--- /dev/null
+++ b/canopy/training/__init__.py
@@ -0,0 +1,3 @@
+from canopy.training.schedule import WarmupCosine
+
+__all__ = ["WarmupCosine"]
diff --git a/canopy/training/checkpoint.py b/canopy/training/checkpoint.py
new file mode 100644
index 0000000000000000000000000000000000000000..f2180df0d799e68001a01c3681505c2e8fefa508
--- /dev/null
+++ b/canopy/training/checkpoint.py
@@ -0,0 +1,77 @@
+"""Checkpoint format.
+
+Training checkpoints hold model, optimizer, scheduler, scaler and loop state.
+Inference checkpoints (``export_inference_checkpoint``) hold only the model
+config, weights, language list and the tokenizer payload, so one file is
+enough to transcribe.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import torch
+
+from canopy.models.canopy_ctc import CanopyCTC
+from canopy.models.config import ModelConfig
+
+FORMAT_VERSION = 1
+
+
+def save_training_checkpoint(path: Path, *, model, optimizer, scheduler, scaler, state: dict, model_config: dict,
+ tokenizer_dir: str) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = path.with_suffix(".tmp")
+ torch.save({
+ "format_version": FORMAT_VERSION,
+ "kind": "training",
+ "model_config": model_config,
+ "model_state": model.state_dict(),
+ "optimizer_state": optimizer.state_dict(),
+ "scheduler_state": scheduler.state_dict(),
+ "scaler_state": scaler.state_dict() if scaler is not None else None,
+ "loop_state": state,
+ "tokenizer_dir": tokenizer_dir,
+ "rng": {"torch": torch.get_rng_state(),
+ "cuda": torch.cuda.get_rng_state_all() if torch.cuda.is_available() else None},
+ }, tmp)
+ tmp.replace(path)
+
+
+def load_checkpoint(path: str | Path, map_location="cpu") -> dict:
+ return torch.load(str(path), map_location=map_location, weights_only=False)
+
+
+def export_inference_checkpoint(train_ckpt: str | Path, out: str | Path, tokenizer_dir: str | Path | None = None,
+ extra_meta: dict | None = None) -> Path:
+ ck = load_checkpoint(train_ckpt)
+ tok_dir = Path(tokenizer_dir or ck["tokenizer_dir"])
+ payload = json.loads((tok_dir / "tokenizer.json").read_text(encoding="utf-8"))
+ spm_model = (tok_dir / "spm.model").read_bytes() if (tok_dir / "spm.model").exists() else None
+ state = {k: v for k, v in ck["model_state"].items() if not k.startswith("aux_heads.")}
+ cfg = dict(ck["model_config"])
+ if not cfg.get("self_conditioning"):
+ cfg["aux_ctc_layers"] = []
+ out = Path(out)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ torch.save({
+ "format_version": FORMAT_VERSION,
+ "kind": "inference",
+ "model_config": cfg,
+ "model_state": state,
+ "tokenizer_payload": payload,
+ "spm_model": spm_model,
+ "meta": {"source_checkpoint": str(train_ckpt), "loop_state": ck.get("loop_state"), **(extra_meta or {})},
+ }, out)
+ return out
+
+
+def model_from_checkpoint(ck: dict) -> CanopyCTC:
+ cfg = ModelConfig.from_dict(ck["model_config"])
+ model = CanopyCTC(cfg)
+ missing, unexpected = model.load_state_dict(ck["model_state"], strict=False)
+ unexpected = [k for k in unexpected if not k.startswith("aux_heads.")]
+ if missing or unexpected:
+ raise RuntimeError(f"checkpoint mismatch: missing={missing} unexpected={unexpected}")
+ return model
diff --git a/canopy/training/losses.py b/canopy/training/losses.py
new file mode 100644
index 0000000000000000000000000000000000000000..b620864386b8c72ef0db42d47bc11799fa55bccf
--- /dev/null
+++ b/canopy/training/losses.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+import torch
+import torch.nn.functional as F
+
+
+def ctc_loss(log_probs: torch.Tensor, lengths: torch.Tensor, targets: torch.Tensor, target_lengths: torch.Tensor,
+ blank: int = 0) -> torch.Tensor:
+ """Mean over utterances of per-utterance CTC loss divided by target length."""
+ return F.ctc_loss(log_probs.transpose(0, 1).float(), targets, lengths, target_lengths, blank=blank,
+ reduction="mean", zero_infinity=True)
+
+
+def canopy_loss(out: dict, batch: dict, *, aux_weight: float = 0.3, lid_weight: float = 0.0,
+ lid_targets: torch.Tensor | None = None) -> tuple[torch.Tensor, dict]:
+ """InterCTC-style objective: (1 - w) * CTC_final + w * mean(CTC_aux) [+ lid_weight * CE_lid]."""
+ main = ctc_loss(out["log_probs"], out["lengths"], batch["targets"], batch["target_lengths"])
+ parts = {"ctc": main.detach()}
+ loss = main
+ aux = out.get("aux_log_probs") or {}
+ if aux and aux_weight > 0:
+ aux_losses = [ctc_loss(lp, out["lengths"], batch["targets"], batch["target_lengths"]) for lp in aux.values()]
+ aux_mean = torch.stack(aux_losses).mean()
+ loss = (1 - aux_weight) * main + aux_weight * aux_mean
+ parts["aux_ctc"] = aux_mean.detach()
+ if lid_weight > 0 and "lid_logits" in out and lid_targets is not None:
+ lid = F.cross_entropy(out["lid_logits"].float(), lid_targets)
+ loss = loss + lid_weight * lid
+ parts["lid"] = lid.detach()
+ return loss, parts
diff --git a/canopy/training/run.py b/canopy/training/run.py
new file mode 100644
index 0000000000000000000000000000000000000000..16b2cf027fbf89d98e83c5dcf4ddbaecb1b603d2
--- /dev/null
+++ b/canopy/training/run.py
@@ -0,0 +1,48 @@
+"""Run directories and reproducibility artifacts.
+
+runs/canopy_exp___/
+ config.yaml git_commit.txt environment.txt environment.json
+ dataset_manifest.json dataset_hashes.json tokenizer_version tokenizer/
+ model_config.json training_log.jsonl eval_history.jsonl sampler_audit.jsonl
+ training_metrics.json evaluation_metrics.json checkpoints/
+Existing run directories are never overwritten.
+"""
+
+from __future__ import annotations
+
+import json
+import time
+from pathlib import Path
+
+from canopy.utils.env import environment_info, environment_text, git_commit
+from canopy.utils.io import save_json, save_yaml, sha256_file
+
+
+def create_run_dir(output_root: str | Path, exp_id: str, slug: str) -> Path:
+ stamp = time.strftime("%Y%m%d-%H%M%S")
+ d = Path(output_root) / f"canopy_exp_{exp_id}_{slug}_{stamp}"
+ if d.exists():
+ raise FileExistsError(f"{d} already exists; runs are never overwritten")
+ (d / "checkpoints").mkdir(parents=True)
+ return d
+
+
+def write_static_artifacts(run_dir: Path, *, config: dict, manifests: list[str], dataset_summary: dict,
+ tokenizer, model_config: dict, parameter_breakdown: dict) -> None:
+ save_yaml(config, run_dir / "config.yaml")
+ g = git_commit(Path(__file__).resolve().parents[2])
+ (run_dir / "git_commit.txt").write_text(
+ f"{g['commit']}\nbranch: {g['branch']}\ndirty: {g['dirty']}\n", encoding="utf-8")
+ info = environment_info()
+ (run_dir / "environment.txt").write_text(environment_text(info), encoding="utf-8")
+ save_json(info, run_dir / "environment.json")
+ save_json({"manifests": manifests, "summary": dataset_summary}, run_dir / "dataset_manifest.json")
+ save_json({m: sha256_file(m) for m in dict.fromkeys(manifests)}, run_dir / "dataset_hashes.json")
+ tok_path = tokenizer.save(run_dir / "tokenizer")
+ (run_dir / "tokenizer_version").write_text(f"{tokenizer.type} {tokenizer.version} {tok_path.name}\n", encoding="utf-8")
+ save_json({"model": model_config, "parameters": parameter_breakdown}, run_dir / "model_config.json")
+
+
+def append_jsonl(path: Path, obj: dict) -> None:
+ with open(path, "a", encoding="utf-8") as f:
+ f.write(json.dumps(obj, ensure_ascii=False) + "\n")
diff --git a/canopy/training/schedule.py b/canopy/training/schedule.py
new file mode 100644
index 0000000000000000000000000000000000000000..5f99d082ede70ff90206847b62c5ac49838d0734
--- /dev/null
+++ b/canopy/training/schedule.py
@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+import math
+
+
+class WarmupCosine:
+ """Linear warmup from ``init_ratio * lr`` to ``lr``, then cosine decay to ``final_ratio * lr``."""
+
+ def __init__(self, optimizer, *, total_steps: int, warmup_steps: int, init_ratio: float = 0.02,
+ final_ratio: float = 0.02):
+ self.opt = optimizer
+ self.total = max(1, total_steps)
+ self.warmup = max(0, warmup_steps)
+ self.init_ratio = init_ratio
+ self.final_ratio = final_ratio
+ self.base = [g["lr"] for g in optimizer.param_groups]
+ self.step_num = 0
+ self._apply()
+
+ def factor(self, step: int) -> float:
+ if self.warmup and step < self.warmup:
+ return self.init_ratio + (1 - self.init_ratio) * step / self.warmup
+ progress = min(1.0, (step - self.warmup) / max(1, self.total - self.warmup))
+ return self.final_ratio + (1 - self.final_ratio) * 0.5 * (1 + math.cos(math.pi * progress))
+
+ def _apply(self):
+ f = self.factor(self.step_num)
+ for g, b in zip(self.opt.param_groups, self.base):
+ g["lr"] = b * f
+
+ def step(self):
+ self.step_num += 1
+ self._apply()
+
+ def state_dict(self) -> dict:
+ return {"step_num": self.step_num, "base": self.base}
+
+ def load_state_dict(self, d: dict):
+ self.step_num = d["step_num"]
+ self.base = d["base"]
+ self._apply()
diff --git a/canopy/training/trainer.py b/canopy/training/trainer.py
new file mode 100644
index 0000000000000000000000000000000000000000..52e4edafe8ee972ee6658a8a50dba1123858a41b
--- /dev/null
+++ b/canopy/training/trainer.py
@@ -0,0 +1,385 @@
+"""Single-GPU trainer for CanopyCTC.
+
+Model selection uses the dev split only. The test split is never loaded here.
+"""
+
+from __future__ import annotations
+
+import random
+import shutil
+import time
+from pathlib import Path
+
+import torch
+from torch.utils.data import DataLoader
+
+from canopy.data.dataset import ASRDataset, collate
+from canopy.data.manifest import load_manifest, summarize
+from canopy.data.sampler import BalancedBatchSampler
+from canopy.evaluation.runner import evaluate_rows
+from canopy.frontend.augment import SpecAugment
+from canopy.models.canopy_ctc import CanopyCTC
+from canopy.models.config import PRESETS, ModelConfig
+from canopy.tokenizer.base import load_tokenizer
+from canopy.training.checkpoint import load_checkpoint, save_training_checkpoint
+from canopy.training.losses import canopy_loss
+from canopy.training.run import append_jsonl, create_run_dir, write_static_artifacts
+from canopy.training.schedule import WarmupCosine
+from canopy.utils.io import save_json
+from canopy.utils.seed import seed_everything
+
+
+def resolve_device(name: str) -> torch.device:
+ if name == "auto":
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
+ return torch.device(name)
+
+
+def resolve_precision(name: str, device: torch.device) -> str:
+ if name != "auto":
+ if name == "bf16" and device.type == "cuda" and not torch.cuda.is_bf16_supported():
+ raise RuntimeError("bf16 requested but this GPU does not support it; use precision=fp16")
+ return name
+ if device.type != "cuda":
+ return "fp32"
+ return "bf16" if torch.cuda.is_bf16_supported() else "fp16"
+
+
+AMP_DTYPES = {"bf16": torch.bfloat16, "fp16": torch.float16, "fp32": None}
+
+
+def build_model_config(cfg: dict, vocab_size: int, languages: list[str]) -> ModelConfig:
+ mcfg = cfg["model"]
+ base = dict(PRESETS[mcfg.get("preset", "canopy-m")])
+ base.update(mcfg.get("overrides") or {})
+ hw = cfg.get("hardware", {})
+ if "gradient_checkpointing" in hw:
+ base["gradient_checkpointing"] = bool(hw["gradient_checkpointing"])
+ base["vocab_size"] = vocab_size
+ base["languages"] = languages
+ return ModelConfig.from_dict(base)
+
+
+def param_groups(model: torch.nn.Module, weight_decay: float) -> list[dict]:
+ decay, no_decay = [], []
+ for name, p in model.named_parameters():
+ if not p.requires_grad:
+ continue
+ (no_decay if p.ndim < 2 or "lang_embed" in name else decay).append(p)
+ return [{"params": decay, "weight_decay": weight_decay}, {"params": no_decay, "weight_decay": 0.0}]
+
+
+def _subsample_dev(rows: list[dict], per_lang: int | None, seed: int) -> list[dict]:
+ if not per_lang:
+ return rows
+ by: dict[str, list[dict]] = {}
+ for r in rows:
+ by.setdefault(r["language"], []).append(r)
+ rng = random.Random(seed)
+ out = []
+ for _lang, rs in sorted(by.items()):
+ rs = sorted(rs, key=lambda r: r["id"])
+ out.extend(rs if len(rs) <= per_lang else rng.sample(rs, per_lang))
+ return out
+
+
+class Trainer:
+ @staticmethod
+ def _log_resume(run_dir: Path, cfg: dict, state: dict) -> None:
+ """Append to resume_log.jsonl; warn when the effective batch differs from the original run."""
+ import yaml
+
+ from canopy.utils.env import environment_info
+
+ original = {}
+ if (run_dir / "config.yaml").exists():
+ original = yaml.safe_load((run_dir / "config.yaml").read_text(encoding="utf-8")) or {}
+
+ def batch_key(c: dict) -> tuple:
+ h, t = c.get("hardware", {}), c.get("train", {})
+ return h.get("max_batch_seconds"), h.get("max_batch_size"), t.get("grad_accum")
+
+ changed = bool(original) and batch_key(original) != batch_key(cfg)
+ if changed:
+ print(f"[canopy] WARNING: batch settings changed on resume {batch_key(original)} -> {batch_key(cfg)} "
+ "(max_batch_seconds, max_batch_size, grad_accum); the remainder of the run is not comparable "
+ "to an uninterrupted run and the sampler position within the epoch is approximate")
+ append_jsonl(run_dir / "resume_log.jsonl", {
+ "time": time.strftime("%Y-%m-%dT%H:%M:%S"), "step": state["step"], "epoch": state["epoch"],
+ "sources": cfg.get("_sources"), "hardware": cfg.get("hardware"), "grad_accum": cfg.get("train", {}).get("grad_accum"),
+ "batch_settings_changed": changed, "environment": environment_info()})
+
+ def __init__(self, cfg: dict, resume: str | None = None):
+ self.cfg = cfg
+ self.resume = resume
+
+ # ------------------------------------------------------------------ setup
+ def _load_data(self):
+ d = self.cfg["data"]
+ prov = ["human"] + (["pseudo"] if d.get("include_pseudo") else []) + (["synthetic"] if d.get("include_synthetic") else [])
+ train_manifests = list(d["train_manifests"])
+ dev_manifests = list(d.get("dev_manifests") or train_manifests)
+ train = load_manifest(train_manifests, languages=d.get("languages"), splits=[d.get("train_split", "train")],
+ provenances=prov, min_duration=d.get("min_duration", 0.3),
+ max_duration=d.get("max_duration", 20.0))
+ dev_prov = ["human"] + (["synthetic"] if d.get("include_synthetic") else [])
+ dev = load_manifest(dev_manifests, languages=d.get("languages"), splits=[d.get("dev_split", "dev")],
+ provenances=dev_prov, max_duration=d.get("max_duration_eval", 60.0))
+ if not train:
+ raise RuntimeError("no training rows after filtering; check data.train_manifests / languages / splits")
+ if not dev:
+ raise RuntimeError("no dev rows; model selection needs a dev split (never the test split)")
+ dev = _subsample_dev(dev, d.get("max_dev_utts_per_language"), self.cfg.get("seed", 0))
+ return train, dev, train_manifests + [m for m in dev_manifests if m not in train_manifests]
+
+ def run(self) -> Path:
+ cfg = self.cfg
+ seed = int(cfg.get("seed", 1234))
+ seed_everything(seed, deterministic=bool(cfg.get("deterministic", False)))
+ hw = cfg.get("hardware", {})
+ device = resolve_device(hw.get("device", "auto"))
+ precision = resolve_precision(hw.get("precision", "auto"), device)
+ amp_dtype = AMP_DTYPES[precision]
+ if device.type == "cuda":
+ torch.backends.cuda.matmul.allow_tf32 = True
+ torch.backends.cudnn.allow_tf32 = True
+
+ train_rows, dev_rows, manifests = self._load_data()
+ tokenizer = load_tokenizer(cfg["tokenizer"]["path"])
+ languages = sorted({r["language"] for r in train_rows})
+ lang2id = {lang: i + 1 for i, lang in enumerate(languages)}
+ mcfg = build_model_config(cfg, tokenizer.vocab_size, languages)
+ model = CanopyCTC(mcfg).to(device)
+
+ aug = cfg.get("augment", {})
+ train_ds = ASRDataset(train_rows, tokenizer, lang2id, encoder_fps=25.0,
+ speed_factors=tuple(aug.get("speed_factors", [1.0])), train=True)
+ samp = cfg.get("sampling", {})
+ sampler = BalancedBatchSampler(
+ [r["language"] for r in train_ds.rows], [r["duration"] for r in train_ds.rows],
+ max_batch_seconds=hw.get("max_batch_seconds", 200), max_batch_size=hw.get("max_batch_size", 64),
+ temperature=float(samp.get("temperature", 5.0)), caps_hours=samp.get("caps_hours"),
+ default_cap_hours=samp.get("default_cap_hours"), epoch_hours=samp.get("epoch_hours"), seed=seed)
+ nw = int(hw.get("num_workers", 4))
+ loader = DataLoader(train_ds, batch_sampler=sampler, collate_fn=collate, num_workers=nw,
+ pin_memory=device.type == "cuda", persistent_workers=nw > 0,
+ prefetch_factor=hw.get("prefetch_factor", 4) if nw > 0 else None)
+ sa = aug.get("specaugment", {}) or {}
+ specaug = SpecAugment(**sa) if aug.get("specaugment_enabled", True) else None
+
+ oc = cfg["optim"]
+ tc = cfg["train"]
+ optimizer = torch.optim.AdamW(param_groups(model, oc.get("weight_decay", 0.01)), lr=float(oc["lr"]),
+ betas=tuple(oc.get("betas", [0.9, 0.98])), eps=float(oc.get("eps", 1e-8)),
+ fused=device.type == "cuda")
+ max_steps = int(tc["max_steps"])
+ scheduler = WarmupCosine(optimizer, total_steps=max_steps, warmup_steps=int(oc.get("warmup_steps", 5000)),
+ init_ratio=float(oc.get("warmup_init_ratio", 0.02)),
+ final_ratio=float(oc.get("final_lr_ratio", 0.02)))
+ scaler = torch.amp.GradScaler("cuda", enabled=precision == "fp16")
+
+ state = {"step": 0, "micro": 0, "epoch": 0, "batch_in_epoch": 0, "audio_seconds": 0.0, "best_macro_cer": None,
+ "best_step": None, "evals_since_best": 0, "oom_skips": 0, "nan_skips": 0, "elapsed_s": 0.0}
+ if self.resume:
+ ck = load_checkpoint(self.resume, map_location=device)
+ run_dir = Path(self.resume).resolve().parent.parent
+ model.load_state_dict(ck["model_state"])
+ optimizer.load_state_dict(ck["optimizer_state"])
+ scheduler.load_state_dict(ck["scheduler_state"])
+ if ck.get("scaler_state"):
+ scaler.load_state_dict(ck["scaler_state"])
+ state.update(ck["loop_state"])
+ print(f"[canopy] resumed from {self.resume} at step {state['step']}")
+ self._log_resume(run_dir, cfg, state)
+ else:
+ ex = cfg.get("experiment", {})
+ run_dir = create_run_dir(ex.get("output_root", "runs"), str(ex.get("id", "000")), ex.get("slug", "run"))
+ write_static_artifacts(
+ run_dir, config=cfg, manifests=manifests,
+ dataset_summary={"train": summarize(train_ds.rows), "dev": summarize(dev_rows),
+ "train_filter": train_ds.stats.as_dict(), "languages": languages},
+ tokenizer=tokenizer, model_config=mcfg.to_dict(), parameter_breakdown=model.parameter_breakdown())
+ tok_dir = str(run_dir / "tokenizer")
+ if hw.get("compile"):
+ model = torch.compile(model) # type: ignore[assignment]
+ core = getattr(model, "_orig_mod", model)
+
+ print(f"[canopy] run dir: {run_dir}")
+ print(f"[canopy] device={device} precision={precision} params={core.num_parameters():,} "
+ f"languages={len(languages)} train_utts={len(train_ds)} dev_utts={len(dev_rows)}")
+ print(f"[canopy] filtered: {train_ds.stats.as_dict()}")
+
+ accum = int(tc.get("grad_accum", 1))
+ lc = cfg.get("loss", {})
+ aux_w = float(lc.get("aux_ctc_weight", 0.3))
+ lid_w = float(lc.get("lid_weight", 0.0))
+ cond_drop = float(lc.get("lang_condition_dropout", 0.5))
+ clip = float(oc.get("grad_clip", 1.0))
+ log_every, eval_every = int(tc.get("log_every", 50)), int(tc.get("eval_every", 2000))
+ save_every = int(tc.get("save_every", eval_every))
+ max_hours = tc.get("max_audio_hours")
+ patience = tc.get("early_stop_patience")
+ max_nan = int(tc.get("max_consecutive_nan", 50))
+ max_oom = int(tc.get("max_oom_skips", 200))
+ ev = cfg.get("eval", {})
+
+ def save(name: str):
+ state["elapsed_s"] = elapsed_before + (time.time() - t_start)
+ save_training_checkpoint(run_dir / "checkpoints" / name, model=core, optimizer=optimizer, scheduler=scheduler,
+ scaler=scaler, state=dict(state), model_config=mcfg.to_dict(), tokenizer_dir=tok_dir)
+
+ def evaluate(n_boot: int = 0) -> dict:
+ metrics, _ = evaluate_rows(core, tokenizer, dev_rows, lang2id=lang2id, device=device,
+ max_batch_seconds=hw.get("eval_max_batch_seconds", 2 * hw.get("max_batch_seconds", 200)),
+ use_language=ev.get("use_language", True), use_masks=ev.get("use_masks", True),
+ amp_dtype=amp_dtype, n_boot=n_boot)
+ core.train()
+ return metrics
+
+ def do_eval() -> bool:
+ m = evaluate()
+ rec = {"step": state["step"], "audio_hours": state["audio_seconds"] / 3600, "macro": m["macro"],
+ "weighted": m["weighted"], "languages": {k: {"cer": v["cer"], "wer": v["wer"]} for k, v in m["languages"].items()},
+ "mean_blank_rate": m["decode"]["mean_blank_rate"]}
+ append_jsonl(run_dir / "eval_history.jsonl", rec)
+ cer = m["macro"]["cer"]
+ print(f"[canopy] eval step {state['step']}: dev macro CER {cer:.4f} WER {m['macro']['wer']:.4f} "
+ f"(weighted CER {m['weighted']['cer']:.4f})")
+ if state["best_macro_cer"] is None or cer < state["best_macro_cer"]:
+ state.update(best_macro_cer=cer, best_step=state["step"], evals_since_best=0)
+ save("best.pt")
+ else:
+ state["evals_since_best"] += 1
+ return bool(patience) and state["evals_since_best"] >= int(patience)
+
+ def rotate():
+ keep = int(tc.get("keep_last", 3))
+ ckpts = sorted((run_dir / "checkpoints").glob("step_*.pt"), key=lambda p: int(p.stem.split("_")[1]))
+ for p in ckpts[:-keep] if keep > 0 else []:
+ p.unlink(missing_ok=True)
+
+ model.train()
+ optimizer.zero_grad(set_to_none=True)
+ t_start = time.time()
+ elapsed_before = float(state.get("elapsed_s", 0.0))
+ window = {"loss": 0.0, "ctc": 0.0, "aux": 0.0, "n": 0, "audio": 0.0, "t": time.time()}
+ consecutive_nan = 0
+ stop = False
+ if device.type == "cuda":
+ torch.cuda.reset_peak_memory_stats(device)
+
+ if len(sampler) == 0:
+ raise RuntimeError("sampler produced no batches")
+ while not stop and state["step"] < max_steps:
+ sampler.set_epoch(state["epoch"])
+ if state["batch_in_epoch"] == 0:
+ append_jsonl(run_dir / "sampler_audit.jsonl", sampler.audit())
+ sampler.start_batch = state["batch_in_epoch"]
+ for batch in loader:
+ state["batch_in_epoch"] += 1
+ wav = batch["wav"].to(device, non_blocking=True)
+ wl = batch["wav_lengths"].to(device, non_blocking=True)
+ b = {"targets": batch["targets"].to(device, non_blocking=True),
+ "target_lengths": batch["target_lengths"].to(device, non_blocking=True)}
+ true_lids = batch["lang_ids"].to(device, non_blocking=True)
+ lids = true_lids
+ if core.lang_embed is not None and cond_drop > 0:
+ lids = torch.where(torch.rand(lids.shape, device=device) < cond_drop, torch.zeros_like(lids), lids)
+ try:
+ with torch.autocast(device.type, dtype=amp_dtype, enabled=amp_dtype is not None):
+ out = model(wav, wl, lids, spec_augment=specaug)
+ loss, parts = canopy_loss(out, b, aux_weight=aux_w, lid_weight=lid_w, lid_targets=true_lids)
+ if not torch.isfinite(loss):
+ consecutive_nan += 1
+ state["nan_skips"] += 1
+ optimizer.zero_grad(set_to_none=True)
+ if consecutive_nan >= max_nan:
+ raise RuntimeError(f"{consecutive_nan} consecutive non-finite losses; aborting")
+ continue
+ consecutive_nan = 0
+ scaler.scale(loss / accum).backward()
+ except torch.cuda.OutOfMemoryError:
+ state["oom_skips"] += 1
+ del wav, wl, b
+ optimizer.zero_grad(set_to_none=True)
+ torch.cuda.empty_cache()
+ print(f"[canopy] OOM on a batch (skipped {state['oom_skips']}); lower hardware.max_batch_seconds if frequent")
+ if state["oom_skips"] > max_oom:
+ raise
+ continue
+
+ batch_audio = float(batch["wav_lengths"].sum()) / 16000
+ state["audio_seconds"] += batch_audio
+ window["audio"] += batch_audio
+ window["loss"] += float(loss.detach())
+ window["ctc"] += float(parts["ctc"])
+ window["aux"] += float(parts.get("aux_ctc", 0.0))
+ window["n"] += 1
+ state["micro"] += 1
+ if state["micro"] % accum:
+ continue
+
+ scaler.unscale_(optimizer)
+ gnorm = torch.nn.utils.clip_grad_norm_(core.parameters(), clip)
+ scaler.step(optimizer)
+ scaler.update()
+ optimizer.zero_grad(set_to_none=True)
+ scheduler.step()
+ state["step"] += 1
+ step = state["step"]
+
+ if step % log_every == 0:
+ dt = time.time() - window["t"]
+ n = max(1, window["n"])
+ rec = {"step": step, "epoch": state["epoch"], "loss": window["loss"] / n, "ctc": window["ctc"] / n,
+ "aux_ctc": window["aux"] / n, "lr": optimizer.param_groups[0]["lr"], "grad_norm": float(gnorm),
+ "audio_hours_seen": state["audio_seconds"] / 3600, "x_realtime": window["audio"] / max(dt, 1e-9),
+ "oom_skips": state["oom_skips"], "nan_skips": state["nan_skips"]}
+ if device.type == "cuda":
+ rec["gpu_peak_mem_gb"] = torch.cuda.max_memory_allocated(device) / 2**30
+ if device.type == "cuda":
+ rec["max_mem_gb"] = torch.cuda.max_memory_allocated(device) / 2**30
+ append_jsonl(run_dir / "training_log.jsonl", rec)
+ print(f"[canopy] step {step} loss {rec['loss']:.3f} ctc {rec['ctc']:.3f} lr {rec['lr']:.2e} "
+ f"gnorm {rec['grad_norm']:.2f} {rec['x_realtime']:.0f}xRT seen {rec['audio_hours_seen']:.1f}h"
+ + (f" mem {rec['max_mem_gb']:.1f}GB" if "max_mem_gb" in rec else ""))
+ window = {"loss": 0.0, "ctc": 0.0, "aux": 0.0, "n": 0, "audio": 0.0, "t": time.time()}
+
+ if step % eval_every == 0:
+ stop = do_eval()
+ if step % save_every == 0:
+ save(f"step_{step}.pt")
+ save("last.pt")
+ rotate()
+ if stop or step >= max_steps or (max_hours and state["audio_seconds"] / 3600 >= float(max_hours)):
+ stop = True
+ break
+ else:
+ state["epoch"] += 1
+ state["batch_in_epoch"] = 0
+
+ if state["best_step"] != state["step"]:
+ do_eval()
+ save("last.pt")
+ elapsed = elapsed_before + (time.time() - t_start)
+
+ best = load_checkpoint(run_dir / "checkpoints" / "best.pt", map_location=device)
+ core.load_state_dict(best["model_state"])
+ final = evaluate(n_boot=int(ev.get("final_bootstrap", 1000)))
+ final["checkpoint"] = "best.pt"
+ final["step"] = best["loop_state"]["step"]
+ final["split"] = "dev"
+ save_json(final, run_dir / "evaluation_metrics.json")
+ save_json({
+ "steps": state["step"], "micro_batches": state["micro"], "epochs_completed": state["epoch"],
+ "audio_hours_seen": state["audio_seconds"] / 3600, "elapsed_hours": elapsed / 3600,
+ "x_realtime_overall": state["audio_seconds"] / max(elapsed, 1e-9), "precision": precision,
+ "device": str(device), "best_step": state["best_step"], "best_dev_macro_cer": state["best_macro_cer"],
+ "oom_skips": state["oom_skips"], "nan_skips": state["nan_skips"], "train_filter": train_ds.stats.as_dict(),
+ "sampler_language_probabilities": sampler.probs, "train_hours_by_language": sampler.hours,
+ "max_memory_gb": torch.cuda.max_memory_allocated(device) / 2**30 if device.type == "cuda" else None,
+ }, run_dir / "training_metrics.json")
+ if tc.get("copy_best_to"):
+ shutil.copy2(run_dir / "checkpoints" / "best.pt", tc["copy_best_to"])
+ print(f"[canopy] done. best dev macro CER {state['best_macro_cer']:.4f} at step {state['best_step']}; run dir {run_dir}")
+ return run_dir
diff --git a/canopy/utils/__init__.py b/canopy/utils/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..2663874eccef0871a175a4c92674e34332d5f3b4
--- /dev/null
+++ b/canopy/utils/__init__.py
@@ -0,0 +1,13 @@
+from canopy.utils.io import load_yaml, read_jsonl, save_json, save_yaml, sha256_file, sha256_text, write_jsonl
+from canopy.utils.seed import seed_everything
+
+__all__ = [
+ "load_yaml",
+ "read_jsonl",
+ "save_json",
+ "save_yaml",
+ "seed_everything",
+ "sha256_file",
+ "sha256_text",
+ "write_jsonl",
+]
diff --git a/canopy/utils/env.py b/canopy/utils/env.py
new file mode 100644
index 0000000000000000000000000000000000000000..cc308222729e3c9cda52ffda5cd522aecdab90fe
--- /dev/null
+++ b/canopy/utils/env.py
@@ -0,0 +1,64 @@
+"""Capture the software/hardware environment for reproducibility records."""
+
+from __future__ import annotations
+
+import importlib
+import platform
+import subprocess
+import sys
+from pathlib import Path
+
+import torch
+
+_PACKAGES = ["numpy", "torch", "torchaudio", "sentencepiece", "soundfile", "yaml", "kenlm", "pyctcdecode",
+ "transformers", "onnx", "onnxruntime"]
+
+
+def _version(name: str) -> str | None:
+ try:
+ mod = importlib.import_module(name)
+ except Exception:
+ return None
+ return getattr(mod, "__version__", "installed (no __version__)")
+
+
+def git_commit(repo: str | Path = ".") -> dict:
+ def run(*args: str) -> str | None:
+ try:
+ return subprocess.check_output(["git", *args], cwd=repo, stderr=subprocess.DEVNULL, text=True).strip()
+ except Exception:
+ return None
+
+ commit = run("rev-parse", "HEAD")
+ status = run("status", "--porcelain")
+ return {"commit": commit, "dirty": bool(status) if status is not None else None, "branch": run("rev-parse", "--abbrev-ref", "HEAD")}
+
+
+def environment_info() -> dict:
+ info = {
+ "python": sys.version.split()[0],
+ "platform": platform.platform(),
+ "processor": platform.processor(),
+ "packages": {p: _version(p) for p in _PACKAGES},
+ "torch_cuda": torch.version.cuda,
+ "cudnn": torch.backends.cudnn.version() if torch.backends.cudnn.is_available() else None,
+ "cuda_available": torch.cuda.is_available(),
+ "gpus": [],
+ }
+ if torch.cuda.is_available():
+ for i in range(torch.cuda.device_count()):
+ p = torch.cuda.get_device_properties(i)
+ info["gpus"].append({"name": p.name, "total_memory_gb": round(p.total_memory / 2**30, 2),
+ "capability": f"{p.major}.{p.minor}"})
+ return info
+
+
+def environment_text(info: dict | None = None) -> str:
+ info = info or environment_info()
+ lines = [f"python: {info['python']}", f"platform: {info['platform']}", f"torch_cuda: {info['torch_cuda']}",
+ f"cudnn: {info['cudnn']}", f"cuda_available: {info['cuda_available']}"]
+ for g in info["gpus"]:
+ lines.append(f"gpu: {g['name']} ({g['total_memory_gb']} GB, sm_{g['capability']})")
+ for k, v in info["packages"].items():
+ lines.append(f"{k}: {v}")
+ return "\n".join(lines) + "\n"
diff --git a/canopy/utils/io.py b/canopy/utils/io.py
new file mode 100644
index 0000000000000000000000000000000000000000..892752c385cbbdb83d291cfcbdd755d1b70ed3d5
--- /dev/null
+++ b/canopy/utils/io.py
@@ -0,0 +1,129 @@
+from __future__ import annotations
+
+import copy
+import hashlib
+import json
+import os
+from collections.abc import Iterable, Iterator
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+
+def load_yaml(path: str | os.PathLike) -> Any:
+ with open(path, encoding="utf-8") as f:
+ return yaml.safe_load(f)
+
+
+def save_yaml(obj: Any, path: str | os.PathLike) -> None:
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
+ with open(path, "w", encoding="utf-8") as f:
+ yaml.safe_dump(obj, f, allow_unicode=True, sort_keys=False)
+
+
+def save_json(obj: Any, path: str | os.PathLike, indent: int = 2) -> None:
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
+ tmp = f"{path}.tmp"
+ with open(tmp, "w", encoding="utf-8") as f:
+ json.dump(obj, f, ensure_ascii=False, indent=indent)
+ f.write("\n")
+ os.replace(tmp, path)
+
+
+def read_jsonl(path: str | os.PathLike) -> Iterator[dict]:
+ with open(path, encoding="utf-8") as f:
+ for line_no, line in enumerate(f, 1):
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ yield json.loads(line)
+ except json.JSONDecodeError as e:
+ raise ValueError(f"{path}:{line_no}: invalid JSON: {e}") from e
+
+
+def write_jsonl(rows: Iterable[dict], path: str | os.PathLike) -> int:
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
+ n = 0
+ tmp = f"{path}.tmp"
+ with open(tmp, "w", encoding="utf-8") as f:
+ for row in rows:
+ f.write(json.dumps(row, ensure_ascii=False))
+ f.write("\n")
+ n += 1
+ os.replace(tmp, path)
+ return n
+
+
+def sha256_file(path: str | os.PathLike, chunk: int = 1 << 20) -> str:
+ h = hashlib.sha256()
+ with open(path, "rb") as f:
+ while True:
+ b = f.read(chunk)
+ if not b:
+ break
+ h.update(b)
+ return h.hexdigest()
+
+
+def sha256_text(text: str) -> str:
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
+
+
+def deep_merge(base: dict, override: dict) -> dict:
+ """Recursively merge ``override`` into a copy of ``base``. An empty dict in
+ ``override`` replaces the base value instead of merging into it."""
+ out = copy.deepcopy(base)
+ for k, v in override.items():
+ if isinstance(v, dict) and v and isinstance(out.get(k), dict):
+ out[k] = deep_merge(out[k], v)
+ else:
+ out[k] = copy.deepcopy(v)
+ return out
+
+
+def load_config(path: str | os.PathLike) -> dict:
+ """Load a YAML config, resolving an optional ``inherit:`` list of parent files.
+
+ Parents are resolved relative to the child file and merged in order, then
+ the child's own keys are applied on top.
+ """
+ path = Path(path)
+ cfg = load_yaml(path) or {}
+ parents = cfg.pop("inherit", []) or []
+ if isinstance(parents, str):
+ parents = [parents]
+ merged: dict = {}
+ for p in parents:
+ merged = deep_merge(merged, load_config((path.parent / p).resolve()))
+ return deep_merge(merged, cfg)
+
+
+def set_by_dotted_key(cfg: dict, dotted: str, value: Any) -> None:
+ keys = dotted.split(".")
+ node = cfg
+ for k in keys[:-1]:
+ node = node.setdefault(k, {})
+ node[keys[-1]] = value
+
+
+def apply_overrides(cfg: dict, overrides: Iterable[str]) -> dict:
+ """Apply ``a.b.c=value`` overrides; values are parsed as YAML scalars."""
+ for item in overrides:
+ if "=" not in item:
+ raise ValueError(f"override must look like key=value, got {item!r}")
+ k, v = item.split("=", 1)
+ set_by_dotted_key(cfg, k.strip(), _parse_scalar(v))
+ return cfg
+
+
+def _parse_scalar(v: str):
+ value = yaml.safe_load(v)
+ if isinstance(value, str):
+ # YAML 1.1 reads "3e-4" as a string; learning rates are commonly written that way.
+ try:
+ return float(value)
+ except ValueError:
+ return value
+ return value
diff --git a/canopy/utils/seed.py b/canopy/utils/seed.py
new file mode 100644
index 0000000000000000000000000000000000000000..b6609cf00374f8e5ddcba4ad89e13efa586ddb2f
--- /dev/null
+++ b/canopy/utils/seed.py
@@ -0,0 +1,19 @@
+from __future__ import annotations
+
+import os
+import random
+
+import numpy as np
+import torch
+
+
+def seed_everything(seed: int, deterministic: bool = False) -> None:
+ random.seed(seed)
+ np.random.seed(seed)
+ torch.manual_seed(seed)
+ torch.cuda.manual_seed_all(seed)
+ os.environ["PYTHONHASHSEED"] = str(seed)
+ if deterministic:
+ torch.backends.cudnn.deterministic = True
+ torch.backends.cudnn.benchmark = False
+ torch.use_deterministic_algorithms(True, warn_only=True)
diff --git a/canopy_m.py b/canopy_m.py
new file mode 100644
index 0000000000000000000000000000000000000000..33885b3b1f1fcacd8afd4d3e1b8133f766e4f853
--- /dev/null
+++ b/canopy_m.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python
+"""Transcribe Urdu or Sindhi with ProximaAI/Canopy-M.
+
+Install once:
+
+ pip install torch torchaudio soundfile numpy pyyaml huggingface_hub
+ hf auth login
+
+Download this repository (it contains this script and the Canopy code), then:
+
+ hf download ProximaAI/Canopy-M --local-dir Canopy-M
+ python Canopy-M/canopy_m.py clip.wav --language urd
+ python Canopy-M/canopy_m.py clip.wav --language snd --device cpu
+
+From Python, with this directory on the path:
+
+ from canopy_m import CanopyM
+ asr = CanopyM.from_pretrained("ProximaAI/Canopy-M")
+ print(asr.transcribe("clip.wav", language="urd"))
+ print(asr.transcribe("clip.wav", language="sindhi"))
+
+``language`` is ``urd`` or ``snd``. ``ur`` / ``urdu`` and ``sd`` / ``sindhi`` are accepted.
+The published scores use greedy CTC with the language id on.
+"""
+
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+
+_HERE = Path(__file__).resolve().parent
+for _root in (_HERE, _HERE.parent):
+ if (_root / "canopy" / "__init__.py").is_file():
+ sys.path.insert(0, str(_root))
+ break
+
+import torch
+
+from canopy.inference.transcriber import Transcriber
+
+REPO_ID = "ProximaAI/Canopy-M"
+ALIASES = {"ur": "urd", "urdu": "urd", "urd": "urd", "sd": "snd", "sindhi": "snd", "snd": "snd"}
+
+
+def resolve_language(language: str) -> str:
+ key = language.strip().lower()
+ if key not in ALIASES:
+ known = "urd, snd (also ur, urdu, sd, sindhi)"
+ raise ValueError(f"unknown language {language!r}. Use one of: {known}")
+ return ALIASES[key]
+
+
+class CanopyM:
+ """Urdu and Sindhi CTC recogniser backed by the Canopy-M checkpoint."""
+
+ def __init__(self, transcriber: Transcriber):
+ self.transcriber = transcriber
+
+ @classmethod
+ def from_pretrained(cls, repo_id: str = REPO_ID, *, device: str | None = None, token: str | None = None) -> CanopyM:
+ if device is None:
+ device = "cuda" if torch.cuda.is_available() else "cpu"
+ local = _HERE / "canopy-m.pt"
+ if local.is_file():
+ asr = Transcriber.from_checkpoint(local, device=device)
+ else:
+ asr = Transcriber.from_pretrained(repo_id, device=device, token=token)
+ return cls(asr)
+
+ def transcribe(self, audio, language: str = "urd", decoder: str = "greedy") -> str:
+ return self.transcriber.transcribe(audio, language=resolve_language(language), decoder=decoder)
+
+
+def main() -> None:
+ ap = argparse.ArgumentParser(description="Transcribe with ProximaAI/Canopy-M")
+ ap.add_argument("audio", nargs="+", help="wav, flac, or mp3 paths")
+ ap.add_argument("--language", required=True, help="urd or snd")
+ ap.add_argument("--device", default=None, help="cpu, cuda, or omit for auto")
+ ap.add_argument("--repo", default=REPO_ID)
+ args = ap.parse_args()
+ asr = CanopyM.from_pretrained(args.repo, device=args.device)
+ for path in args.audio:
+ print(f"{path}\t{asr.transcribe(path, language=args.language)}")
+
+
+if __name__ == "__main__":
+ main()