voice-correction-reference-raps / inference_reference.py
Jianshu001's picture
Upload inference_reference.py with huggingface_hub
7532d25 verified
Raw
History Blame Contribute Delete
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()
@torch.no_grad()
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))