Download inference_reference.py from Jianshu001/voice-correction-reference-raps: direct link, hf CLI and curl.
- Browser
- Download file 8.62 kB
-
https://huggingface.co/Jianshu001/voice-correction-reference-raps/resolve/main/inference_reference.py
- Command line
-
hf download hf://Jianshu001/voice-correction-reference-raps/inference_reference.py
-
curl -L -o inference_reference.py https://huggingface.co/Jianshu001/voice-correction-reference-raps/resolve/main/inference_reference.py
8.62 kB
| """Reference-based pronunciation scorer (2-stage RAPS). | |
| Inputs: user audio + reference (standard) audio + canonical text. | |
| Stage 1 (RAPS, end-to-end fine-tuned WavLM + reference cross-attention): | |
| user/ref audio → V21 forced-align → per-phone user-vs-ref comparison → per-phone engine-style score | |
| Stage 2 (Transformer aggregator): | |
| per-phone scores → utterance accuracy + integrity (engine-calibrated) | |
| Holdout results (260520, no leakage): | |
| word : acc P<10 80.2% / P<15 90.2% ; integ P<10 98.6% | |
| sentence: acc P<10 77.3% / P<15 89.0% ; integ P<10 82.4% / P<15 93.3% | |
| all : acc P<10 78.6% / P<15 89.3% ; integ P<10 87.8% / P<15 94.2% | |
| Models: | |
| exp_raps_xattn/best.pth (RAPS stage-1, ~324M) | |
| exp_stage2_agg/best.pth (Transformer aggregator, 3.3MB) | |
| sentence_scorer_v32v3_compact/v21_xlsr_phoneme.pt (V21 forced align) | |
| sentence_scorer_v32v3_compact/phone_vocab.json | |
| gpu_service/custom_phone_dict.json (G2P) | |
| Usage: | |
| python3 inference_reference.py --user user.wav --ref ref.wav --text "I am going to school" | |
| """ | |
| import os, sys, json, re, argparse | |
| from pathlib import Path | |
| import numpy as np | |
| import torch, torch.nn as nn | |
| import torch.nn.functional as F | |
| import soundfile as sf | |
| from transformers import WavLMModel, Wav2Vec2ForCTC | |
| REPO = Path(__file__).resolve().parent | |
| sys.path.insert(0, str(REPO)) | |
| sys.path.insert(0, str(REPO/'sentence_scorer_v32v3_compact')) | |
| SR = 16000; MAX_PHONES = 50 | |
| # ---------- G2P ---------- | |
| _G2P=None; _CD=None | |
| def g2p(): | |
| global _G2P | |
| if _G2P is None: | |
| import g2p_en; _G2P=g2p_en.G2p() | |
| return _G2P | |
| def custom_dict(): | |
| global _CD | |
| if _CD is None: | |
| p=REPO/'gpu_service/custom_phone_dict.json'; _CD=json.load(open(p)) if p.exists() else {} | |
| return _CD | |
| def text_to_phones(text, phone_to_id): | |
| """Return phone ids + word_id-per-phone (for fluency word grouping).""" | |
| text=re.sub(r"[^a-z'\s]"," ",text.lower()); text=re.sub(r"\s+"," ",text).strip() | |
| ids=[]; wids=[] | |
| for wi,w in enumerate(text.split()): | |
| cd=custom_dict() | |
| phs=cd[w] if w in cd else [re.sub(r"\d+$","",t).lower() for t in g2p()(w) if t!=" "] | |
| for p in phs: | |
| ids.append(phone_to_id.get(p, phone_to_id.get('<unk>',1))); wids.append(wi) | |
| return ids, wids | |
| def load_audio(path, max_sec=10.0): | |
| w,sr=sf.read(path,dtype='float32') | |
| if w.ndim>1: w=w.mean(1) | |
| if sr!=SR: | |
| idx=np.linspace(0,len(w)-1,int(len(w)*SR/sr)).astype(np.int64); w=w[idx] | |
| w=w[:int(max_sec*SR)]; w=np.pad(w,int(0.2*SR)).astype(np.float32) | |
| return (w-w.mean())/(w.std()+1e-7) | |
| # ---------- V21 aligner (reuse) ---------- | |
| from inference_sentence import V21Aligner | |
| # ---------- Stage 1: RAPS ---------- | |
| class RAPS(nn.Module): | |
| def __init__(s, n_phon=60, embed=256): | |
| super().__init__() | |
| s.w=WavLMModel.from_pretrained('microsoft/wavlm-large'); H=s.w.config.hidden_size | |
| s.proj=nn.Linear(H*4,embed); s.pe=nn.Embedding(n_phon+1,embed,padding_idx=0) | |
| s.pos=nn.Parameter(torch.zeros(1,MAX_PHONES,embed)) | |
| s.blocks=nn.ModuleList([nn.TransformerEncoderLayer(embed,8,embed*4,0.1,batch_first=True) for _ in range(4)]) | |
| s.xattn=nn.MultiheadAttention(H,8,batch_first=True) | |
| s.phn=nn.Linear(embed,1); s.pherr=nn.Linear(embed,1) | |
| s.utt=nn.Sequential(nn.Linear(embed+7,embed),nn.GELU(),nn.Linear(embed,5)) | |
| def enc(s,w,dev): | |
| wt=torch.from_numpy(w).unsqueeze(0).to(dev) | |
| with torch.amp.autocast('cuda',dtype=torch.bfloat16): | |
| o=s.w(wt,attention_mask=torch.ones(1,len(w),device=dev)).last_hidden_state[0] | |
| return o.float(), s.w._get_feat_extract_output_lengths(torch.tensor([len(w)])).item() | |
| class AGG(nn.Module): | |
| """Unified 4-metric aggregator: phone scores + fluency feats → acc/integ/flu/overall.""" | |
| def __init__(s,emb=128,n_phon=60,nflu=8): | |
| super().__init__(); s.inp=nn.Linear(2,emb); s.pe=nn.Embedding(n_phon+1,emb,padding_idx=0) | |
| s.pos=nn.Parameter(torch.zeros(1,MAX_PHONES,emb)) | |
| s.bl=nn.ModuleList([nn.TransformerEncoderLayer(emb,8,emb*4,0.1,batch_first=True) for _ in range(4)]) | |
| s.h=nn.Sequential(nn.Linear(emb+nflu,emb),nn.GELU(),nn.Linear(emb,4)) | |
| def forward(s,x,pc,f): | |
| vm=(pc>0); h=s.inp(x)+s.pe(pc)+s.pos[:,:x.size(1)] | |
| for b in s.bl: h=b(h,src_key_padding_mask=~vm) | |
| vmf=vm.float().unsqueeze(-1); pooled=(h*vmf).sum(1)/vmf.sum(1).clamp(min=1) | |
| return s.h(torch.cat([pooled,f],-1)) | |
| def fluency_feats(bu, wids, n): | |
| """8-dim fluency feats from V21 user spans + word ids — matches unified_aggregator training.""" | |
| s=np.array([bu[i][0] for i in range(n)]); e=np.array([bu[i][1] for i in range(n)]); w=np.array(wids[:n]) | |
| total=e.max()-s.min()+1 if n>0 else 1 | |
| wd=[]; gaps=[]; prev=None | |
| for wi in sorted(set(w.tolist())): | |
| ph=np.where(w==wi)[0]; ws=s[ph].min(); we=e[ph].max(); wd.append(we-ws+1) | |
| if prev is not None: gaps.append(max(0,ws-prev)) | |
| prev=we | |
| wd=np.array(wd) if wd else np.array([1.]); gaps=np.array(gaps) if gaps else np.array([0.]) | |
| return np.array([n/max(total,1)*50, total/50.0, len(set(w.tolist())), | |
| wd.mean()/50.0, wd.std()/50.0, gaps.mean()/50.0, gaps.max()/50.0, | |
| float((gaps>10).sum())], np.float32) | |
| def mapb(b,Tv,Tw): | |
| a=int(b[0]*Tw/max(Tv,1)); bb=max(a,int((b[1]+1)*Tw/max(Tv,1))-1) | |
| a=max(0,min(a,Tw-1)); bb=max(a,min(bb,Tw-1)); return a,bb | |
| class ReferenceScorer: | |
| def __init__(s, device='cuda:0', | |
| raps='exp_raps_clean/best.pth', agg='exp_unified_agg/best.pth', | |
| v21='sentence_scorer_v32v3_compact/v21_xlsr_phoneme.pt', | |
| vocab='sentence_scorer_v32v3_compact/phone_vocab.json'): | |
| s.dev=device; torch.cuda.set_device(int(device.split(':')[1])) | |
| pv=json.load(open(REPO/vocab)); s.vocab=pv['phone_vocab']; s.p2id=pv['phone_to_id'] | |
| s.aligner=V21Aligner(str(REPO/v21), device) | |
| s.raps=RAPS().to(device); s.raps.load_state_dict(torch.load(REPO/raps,map_location='cpu',weights_only=False)['model_state'],strict=False); s.raps.eval() | |
| s.agg=AGG().to(device); s.agg.load_state_dict(torch.load(REPO/agg,map_location='cpu',weights_only=False)['model_state']); s.agg.eval() | |
| def score(s, user_path, ref_path, text): | |
| ids,wids=text_to_phones(text, s.p2id) | |
| ids=ids[:MAX_PHONES]; wids=wids[:MAX_PHONES] | |
| n=len(ids) | |
| if n==0: return {'error':'empty text'} | |
| wu=load_audio(user_path); wr=load_audio(ref_path) | |
| bu,Tu,_=s.aligner.align_and_posteriors(wu,ids) | |
| br,Tr,_=s.aligner.align_and_posteriors(wr,ids) | |
| Hu,Lu=s.raps.enc(wu,s.dev); Hr,Lr=s.raps.enc(wr,s.dev) | |
| toks=[] | |
| for p in range(n): | |
| a,b=mapb(bu[p],Tu,Lu); c,d=mapb(br[p],Tr,Lr) | |
| U=Hu[a:b+1]; R=Hr[c:d+1] | |
| A,_=s.raps.xattn(U.unsqueeze(0),R.unsqueeze(0),R.unsqueeze(0),need_weights=False); A=A[0] | |
| u=U.mean(0); am=A.mean(0); mx=(U-A).abs().max(0).values | |
| toks.append(s.raps.proj(torch.cat([u,am,u-am,mx]))) | |
| x=torch.stack(toks).unsqueeze(0) | |
| pidt=torch.tensor([[i+1 for i in ids]],device=s.dev) | |
| x=x+s.raps.pe(pidt)+s.raps.pos[:,:n] | |
| for blk in s.raps.blocks: x=blk(x) | |
| phn=(s.raps.phn(x).squeeze(-1)[0]).cpu().numpy() # 0-10 scale | |
| phone_scores=(phn*10).clip(0,100) # 0-100 | |
| # unified aggregator: phone scores + fluency feats → acc/integ/flu/overall | |
| sc=np.zeros((1,MAX_PHONES),np.float32); pc=np.zeros((1,MAX_PHONES),np.int64); per=np.zeros((1,MAX_PHONES),np.float32) | |
| sc[0,:n]=phn; pc[0,:n]=[i+1 for i in ids] | |
| flu=fluency_feats(bu, wids, n)[None] | |
| xin=np.stack([sc,per],-1) | |
| out=s.agg(torch.from_numpy(xin).to(s.dev), torch.from_numpy(pc).to(s.dev), | |
| torch.from_numpy(flu).to(s.dev))[0].cpu().numpy() | |
| clip=lambda v: round(float(np.clip(v,0,100)),1) | |
| return { | |
| 'accuracy': clip(out[0]), | |
| 'integrity': clip(out[1]), | |
| 'fluency': clip(out[2]), | |
| 'overall': clip(out[3]), | |
| 'phones': [s.vocab[i] for i in ids], | |
| 'phone_scores': [round(float(x),1) for x in phone_scores], | |
| } | |
| if __name__=='__main__': | |
| ap=argparse.ArgumentParser() | |
| ap.add_argument('--user',required=True); ap.add_argument('--ref',required=True) | |
| ap.add_argument('--text',required=True); ap.add_argument('--gpu',type=int,default=0) | |
| a=ap.parse_args() | |
| sc=ReferenceScorer(device=f'cuda:{a.gpu}') | |
| print(json.dumps(sc.score(a.user,a.ref,a.text),indent=2,ensure_ascii=False)) | |