| """ |
| Forced-alignment: 把 control2instruct.json 里 segments 的 txt 在合成音频里定位起止时间(秒), |
| 回写到每个 segment item 的 'start' / 'end' 字段。 |
| |
| 依赖 torchaudio 自带的 MMS_FA bundle(多语言强制对齐器,1100+ 语言), |
| 配合 uroman 进行非拉丁字符到拉丁字符的转写。 |
| |
| 用法: |
| python align_segments.py --tsv /path/to/test.tsv \ |
| --out-json /path/to/test_aligned.json \ |
| [--device cuda] |
| |
| 产物 (test_aligned.json) 的 schema: |
| { |
| "items": [ |
| { |
| "row_id": 0, |
| "json_path": "...", |
| "wav_path": "...", |
| "duration": 5.64, |
| "segments": [ |
| {"instruct": "...", "txt": "...", "start": 0.08, "end": 2.51, |
| "norm_text": "okay mom i should ..."}, |
| ... |
| ] |
| }, |
| ... |
| ] |
| } |
| """ |
| import argparse |
| import csv |
| import json |
| import os |
| import sys |
| import time |
| from typing import Dict, List, Optional, Tuple |
|
|
| import numpy as np |
| import soundfile as sf |
| import torch |
| import torchaudio |
| import torchaudio.functional as Faudio |
|
|
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) |
| from utils_common import load_wav_mono_16k |
|
|
|
|
| _DEFAULT_TORCH_HOME = "/workspace/echoloc/hf_cache/torch_home" |
| os.environ.setdefault("TORCH_HOME", _DEFAULT_TORCH_HOME) |
|
|
|
|
| |
| class TextNormalizer: |
| def __init__(self): |
| try: |
| import uroman as _uroman |
| except ImportError: |
| raise RuntimeError("uroman not installed. pip install uroman") |
| self._ur = _uroman.Uroman() |
|
|
| def __call__(self, text: str, allowed: set) -> str: |
| s = self._ur.romanize_string(text) |
| s = s.lower() |
| out_chars = [] |
| for ch in s: |
| if ch in allowed: |
| out_chars.append(ch) |
| elif ch.isspace(): |
| out_chars.append(" ") |
| else: |
| out_chars.append(" ") |
| s = "".join(out_chars) |
| while " " in s: |
| s = s.replace(" ", " ") |
| return s.strip() |
|
|
|
|
| |
| class MMSAligner: |
| BLANK = 0 |
| STAR_LABEL = "*" |
|
|
| def __init__(self, device: str = "cuda"): |
| bundle = torchaudio.pipelines.MMS_FA |
| self.bundle = bundle |
| self.sr = bundle.sample_rate |
| self.device = device |
| self.model = bundle.get_model().to(device).eval() |
| labels = list(bundle.get_labels()) |
| |
| self.labels = labels |
| self.dict = {c: i for i, c in enumerate(labels)} |
| self.allowed = set(c for c in labels if c not in ("-", self.STAR_LABEL)) |
| self.norm = TextNormalizer() |
|
|
| @torch.inference_mode() |
| def emission(self, wav_np: np.ndarray) -> Tuple[torch.Tensor, int]: |
| """Return log-prob emission (1, T, C) and audio length in samples.""" |
| wav = torch.from_numpy(wav_np).float().unsqueeze(0).to(self.device) |
| em, _ = self.model(wav) |
| return em, wav.shape[-1] |
|
|
| def align(self, wav_np: np.ndarray, segment_texts: List[str]) -> List[Dict]: |
| """ |
| Returns one dict per segment: {"start": float, "end": float, "norm_text": str} |
| On failure returns start/end = None. |
| """ |
| em, n_samples = self.emission(wav_np) |
|
|
| |
| words: List[str] = [] |
| word_seg: List[int] = [] |
| normed_per_seg: List[str] = [] |
| for sid, txt in enumerate(segment_texts): |
| n = self.norm(txt, self.allowed) |
| normed_per_seg.append(n) |
| for w in n.split(): |
| words.append(w) |
| word_seg.append(sid) |
|
|
| if not words: |
| return [ |
| {"start": None, "end": None, "norm_text": normed_per_seg[i]} |
| for i in range(len(segment_texts)) |
| ] |
|
|
| |
| tok_ids: List[int] = [] |
| word_spans: List[Tuple[int, int]] = [] |
| for w in words: |
| s = len(tok_ids) |
| for ch in w: |
| if ch in self.dict and ch != "-" and ch != self.STAR_LABEL: |
| tok_ids.append(self.dict[ch]) |
| e = len(tok_ids) |
| word_spans.append((s, e)) |
|
|
| if not tok_ids: |
| return [ |
| {"start": None, "end": None, "norm_text": normed_per_seg[i]} |
| for i in range(len(segment_texts)) |
| ] |
|
|
| targets = torch.tensor([tok_ids], dtype=torch.int32, device=self.device) |
| try: |
| aligned, scores = Faudio.forced_align(em, targets, blank=self.BLANK) |
| except RuntimeError as ex: |
| |
| print(f"[WARN] forced_align failed: {ex}", flush=True) |
| return [ |
| {"start": None, "end": None, "norm_text": normed_per_seg[i]} |
| for i in range(len(segment_texts)) |
| ] |
|
|
| spans = Faudio.merge_tokens(aligned[0].cpu(), scores[0].cpu()) |
| nonblank = [s for s in spans if int(s.token) != self.BLANK] |
| if len(nonblank) != len(tok_ids): |
| |
| print( |
| f"[WARN] nonblank({len(nonblank)}) != tok_ids({len(tok_ids)}); " |
| f"falling back to proportional time split", |
| flush=True, |
| ) |
| return self._proportional_split(segment_texts, normed_per_seg, n_samples) |
|
|
| ratio = n_samples / em.shape[1] / self.sr |
| seg_times: Dict[int, List[float]] = {} |
| for wi, (s, e) in enumerate(word_spans): |
| if e <= s: |
| continue |
| sid = word_seg[wi] |
| sp = nonblank[s:e] |
| t0 = float(sp[0].start) * ratio |
| t1 = float(sp[-1].end) * ratio |
| if sid not in seg_times: |
| seg_times[sid] = [t0, t1] |
| else: |
| seg_times[sid][0] = min(seg_times[sid][0], t0) |
| seg_times[sid][1] = max(seg_times[sid][1], t1) |
|
|
| results = [] |
| dur_total = n_samples / self.sr |
| for i in range(len(segment_texts)): |
| if i in seg_times: |
| t0, t1 = seg_times[i] |
| t0 = max(0.0, t0 - 0.02) |
| t1 = min(dur_total, t1 + 0.02) |
| results.append( |
| {"start": round(t0, 3), "end": round(t1, 3), "norm_text": normed_per_seg[i]} |
| ) |
| else: |
| results.append({"start": None, "end": None, "norm_text": normed_per_seg[i]}) |
| return results |
|
|
| @staticmethod |
| def _proportional_split( |
| segment_texts: List[str], normed_per_seg: List[str], n_samples: int, sr: int = 16000 |
| ) -> List[Dict]: |
| |
| lens = [max(1, len(t.replace(" ", ""))) for t in normed_per_seg] |
| total = sum(lens) |
| dur = n_samples / sr |
| out = [] |
| acc = 0.0 |
| for i, L in enumerate(lens): |
| d = dur * L / total |
| t0 = acc |
| t1 = acc + d |
| acc = t1 |
| out.append({"start": round(t0, 3), "end": round(t1, 3), "norm_text": normed_per_seg[i]}) |
| return out |
|
|
|
|
| def read_tsv(path: str) -> List[Tuple[int, str, str]]: |
| rows = [] |
| with open(path, "r", encoding="utf-8") as f: |
| rdr = csv.reader(f, delimiter="\t") |
| for line in rdr: |
| if len(line) < 3: |
| continue |
| rid, jp, wp = line[0], line[1], line[2] |
| try: |
| rid_i = int(rid) |
| except ValueError: |
| continue |
| rows.append((rid_i, jp, wp)) |
| return rows |
|
|
|
|
| def main(): |
| ap = argparse.ArgumentParser() |
| ap.add_argument("--tsv", required=True, help="input tsv with columns row_id, json, wav") |
| ap.add_argument("--out-json", required=True, help="output aligned json") |
| ap.add_argument("--device", default="cuda") |
| ap.add_argument("--seg-key", default="segments", help="json key holding segments") |
| ap.add_argument("--limit", type=int, default=-1, help="debug: only process first N rows") |
| args = ap.parse_args() |
|
|
| rows = read_tsv(args.tsv) |
| if args.limit > 0: |
| rows = rows[: args.limit] |
| print(f"[align] {len(rows)} rows from {args.tsv}", flush=True) |
|
|
| aligner = MMSAligner(device=args.device) |
|
|
| items = [] |
| t0 = time.time() |
| for k, (rid, jp, wp) in enumerate(rows): |
| if not os.path.exists(jp) or not os.path.exists(wp): |
| print(f"[skip row={rid}] missing file: {jp} | {wp}", flush=True) |
| continue |
| try: |
| with open(jp, "r", encoding="utf-8") as f: |
| meta = json.load(f) |
| except Exception as ex: |
| print(f"[skip row={rid}] bad json: {ex}", flush=True) |
| continue |
|
|
| segs = meta.get(args.seg_key, []) |
| if not segs: |
| continue |
| seg_texts = [s.get("txt", "") for s in segs] |
|
|
| try: |
| wav = load_wav_mono_16k(wp, target_sr=aligner.sr) |
| except Exception as ex: |
| print(f"[skip row={rid}] load_wav: {ex}", flush=True) |
| continue |
|
|
| try: |
| timed = aligner.align(wav, seg_texts) |
| except Exception as ex: |
| print(f"[fallback row={rid}] align fail: {ex}", flush=True) |
| timed = MMSAligner._proportional_split(seg_texts, seg_texts, len(wav), aligner.sr) |
|
|
| new_segs = [] |
| for orig, t in zip(segs, timed): |
| d = dict(orig) |
| d["start"] = t["start"] |
| d["end"] = t["end"] |
| d["norm_text"] = t["norm_text"] |
| new_segs.append(d) |
|
|
| items.append( |
| { |
| "row_id": rid, |
| "json_path": jp, |
| "wav_path": wp, |
| "duration": round(len(wav) / aligner.sr, 3), |
| "segments": new_segs, |
| "combined": meta.get("combined"), |
| "combined_no_speaker": meta.get("combined_no_speaker"), |
| } |
| ) |
|
|
| if (k + 1) % 50 == 0: |
| elapsed = time.time() - t0 |
| print( |
| f"[align] {k + 1}/{len(rows)} ~{elapsed:.1f}s " |
| f"({(k + 1) / max(elapsed, 1e-6):.2f} it/s)", |
| flush=True, |
| ) |
|
|
| os.makedirs(os.path.dirname(args.out_json), exist_ok=True) |
| with open(args.out_json, "w", encoding="utf-8") as f: |
| json.dump({"items": items}, f, ensure_ascii=False, indent=2) |
| print(f"[align] done. saved {len(items)} items -> {args.out_json}", flush=True) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|