Luigi commited on
Commit
0998dc1
·
verified ·
1 Parent(s): 1261e7b

add long-text chunking synth

Browse files
Files changed (1) hide show
  1. scripts/synth_long.py +103 -0
scripts/synth_long.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Long-text synthesis with automatic chunking. The acoustic model degrades past ~max_frames
3
+ (1400 ~ 15s) because the absolute positional encoding saturates -> garbled syllables in the back
4
+ half of long utterances. Fix: split text at punctuation into clauses, greedily pack clauses so each
5
+ chunk's PREDICTED frame count stays under a safe budget, synth each chunk, concatenate with a short
6
+ gap. No retrain. Same pipeline as synth_from_text.py."""
7
+ import argparse, json, re, sys
8
+ from pathlib import Path
9
+ sys.path.insert(0, "/home/luigi/jetson-tts/mossnano/zhtw8k")
10
+ import numpy as np, soundfile as sf, onnxruntime as ort
11
+ import frontend_bopomofo as F
12
+ from synth_from_text import host_regulate
13
+
14
+ BN = ["frames", "frame_meta", "local_ctx_raw", "abs_pos", "pitch_frame", "frame_mask"]
15
+ # split AFTER these (keep the delimiter with the preceding clause for prosody)
16
+ SPLIT_RE = re.compile(r'(?<=[。!?;;!?\n,,、])')
17
+
18
+
19
+ def clauses(text):
20
+ parts = [p for p in SPLIT_RE.split(text) if p.strip()]
21
+ return parts or [text]
22
+
23
+
24
+ class LongSynth:
25
+ def __init__(self, onnx_dir, frame_budget=1100, gap_ms=70):
26
+ self.meta = json.load(open(f"{onnx_dir}/meta.json"))
27
+ so = ort.SessionOptions(); so.intra_op_num_threads = 4
28
+ self.sA = ort.InferenceSession(f"{onnx_dir}/acoustic_encoder.onnx", so, providers=["CPUExecutionProvider"])
29
+ self.sB = ort.InferenceSession(f"{onnx_dir}/acoustic_decoder.onnx", so, providers=["CPUExecutionProvider"])
30
+ self.sV = ort.InferenceSession(f"{onnx_dir}/vocoder.onnx", so, providers=["CPUExecutionProvider"])
31
+ self.sr = self.meta["sample_rate"]
32
+ self.budget = frame_budget
33
+ self.gap = np.zeros(int(self.sr * gap_ms / 1000), np.float32)
34
+
35
+ def _encode(self, text):
36
+ o = F.text_to_ids(text)
37
+ if not o["phone_ids"]:
38
+ return None
39
+ phone = np.array([o["phone_ids"]], np.int64); tone = np.array([o["tone_ids"]], np.int64)
40
+ lang = np.array([o["lang_ids"]], np.int64); spk = np.zeros(1, np.int64)
41
+ cond, dur, pitch = self.sA.run(None, {"phone": phone, "tone": tone, "lang": lang, "speaker": spk})
42
+ return cond, dur, pitch
43
+
44
+ def _decode(self, enc):
45
+ cond, dur, pitch = enc
46
+ reg = host_regulate(cond, dur, pitch, self.meta["abs_frame_bins"], self.meta["max_frames"])
47
+ feeds = {n: (reg[n].astype(np.float32) if reg[n].dtype != bool else reg[n]) for n in BN}
48
+ feeds["abs_pos"] = reg["abs_pos"].astype(np.int64)
49
+ mel = self.sB.run(None, feeds)[0]
50
+ return self.sV.run(None, {"mel": mel.astype(np.float32)})[0].reshape(-1)
51
+
52
+ def pack(self, text):
53
+ """Greedily pack clauses into chunks whose predicted frame total <= budget."""
54
+ chunks, cur, cur_frames = [], "", 0
55
+ for cl in clauses(text):
56
+ enc = self._encode(cl)
57
+ f = int(enc[1].sum()) if enc is not None else 0
58
+ if cur and cur_frames + f > self.budget:
59
+ chunks.append(cur); cur, cur_frames = "", 0
60
+ cur += cl; cur_frames += f
61
+ # a single clause already over budget: still emit it alone (rare)
62
+ if cur_frames > self.budget and cur == cl:
63
+ chunks.append(cur); cur, cur_frames = "", 0
64
+ if cur:
65
+ chunks.append(cur)
66
+ return chunks
67
+
68
+ def synth(self, text):
69
+ chs = self.pack(text)
70
+ wavs = []
71
+ for i, c in enumerate(chs):
72
+ enc = self._encode(c)
73
+ if enc is None:
74
+ continue
75
+ wavs.append(self._decode(enc))
76
+ if i < len(chs) - 1:
77
+ wavs.append(self.gap)
78
+ return (np.concatenate(wavs) if wavs else np.zeros(1, np.float32)), chs
79
+
80
+
81
+ def main():
82
+ ap = argparse.ArgumentParser()
83
+ ap.add_argument("--onnx-dir", required=True)
84
+ ap.add_argument("--out-dir", required=True)
85
+ ap.add_argument("--texts", required=True, help="jsonl with {id,text}")
86
+ ap.add_argument("--frame-budget", type=int, default=1100)
87
+ ap.add_argument("--gap-ms", type=int, default=70)
88
+ a = ap.parse_args()
89
+ ls = LongSynth(a.onnx_dir, a.frame_budget, a.gap_ms)
90
+ Path(a.out_dir).mkdir(parents=True, exist_ok=True)
91
+ man = open(f"{a.out_dir}/synth.jsonl", "w")
92
+ for r in (json.loads(l) for l in open(a.texts) if l.strip()):
93
+ wav, chs = ls.synth(r["text"])
94
+ wp = f"{a.out_dir}/{r['id']}.wav"; sf.write(wp, wav, ls.sr)
95
+ man.write(json.dumps({"id": r["id"], "text": r["text"], "wav": wp, "chunks": len(chs),
96
+ "dur": round(len(wav)/ls.sr, 2)}, ensure_ascii=False) + "\n")
97
+ print(f" {r['id']}: {len(chs)} chunks, {len(wav)/ls.sr:.1f}s -> {wp}")
98
+ man.close()
99
+ print(f"DONE synth_long -> {a.out_dir}/synth.jsonl")
100
+
101
+
102
+ if __name__ == "__main__":
103
+ main()