HY-2012 commited on
Commit
ea47387
·
verified ·
1 Parent(s): 999a656

First commit

Browse files
.gitattributes CHANGED
@@ -33,3 +33,14 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ assets/moss_prompts/en_4_4p5s.wav filter=lfs diff=lfs merge=lfs -text
37
+ assets/moss_prompts/zh_1_4p5s.wav filter=lfs diff=lfs merge=lfs -text
38
+ models/axmodels/decoder_part0.axmodel filter=lfs diff=lfs merge=lfs -text
39
+ models/axmodels/decoder_part1.axmodel filter=lfs diff=lfs merge=lfs -text
40
+ models/axmodels/decoder_part2.axmodel filter=lfs diff=lfs merge=lfs -text
41
+ models/axmodels/decoder_part3.axmodel filter=lfs diff=lfs merge=lfs -text
42
+ models/axmodels/encoder.axmodel filter=lfs diff=lfs merge=lfs -text
43
+ outputs/en_long_paragraph.wav filter=lfs diff=lfs merge=lfs -text
44
+ outputs/en_sentence.wav filter=lfs diff=lfs merge=lfs -text
45
+ outputs/zh_long_paragraph.wav filter=lfs diff=lfs merge=lfs -text
46
+ outputs/zh_sentence.wav filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,3 +1,168 @@
1
- ---
2
- license: mit
3
- ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ZipVoice.AXERA
2
+
3
+ ZipVoice 板端推理 demo。
4
+
5
+ ## 功能
6
+
7
+ - 支持中文和英文语音生成。
8
+ - 支持语音克隆。
9
+
10
+ ## 模型转换
11
+
12
+ For those interested in model conversion, refer to:
13
+
14
+ - [Pulsar2 Docs — How to Convert ONNX to axmodel](https://pulsar2-docs.readthedocs.io/en/latest/pulsar2/introduction.html)
15
+ - [export / quantization Project](https://github.com/AXERA-TECH/ZipVoice.AXERA)
16
+
17
+ ## 支持平台
18
+
19
+ - AX650
20
+ - [M4N-Dock(爱芯派Pro)](https://wiki.sipeed.com/hardware/zh/maixIV/m4ndock/m4ndock.html)
21
+ - [M.2 Accelerator Card](https://docs.m5stack.com/zh_CN/ai_hardware/LLM-8850_Card)
22
+
23
+ ## 目录结构
24
+
25
+ ```text
26
+ ZipVoice.AXERA/
27
+ ├── assets/
28
+ │ ├── moss_prompts/
29
+ │ └── paragraphs/
30
+ ├── configs/
31
+ ├── models/
32
+ │ └── axmodels/
33
+ ├── resources/
34
+ │ ├── vocos-mel-24khz/
35
+ │ └── zipvoice_hf/
36
+ ├── scripts/
37
+ ├── infer_zipvoice_axera.py
38
+ ├── requirements.txt
39
+ └── README.md
40
+ ```
41
+
42
+ ## 环境
43
+
44
+ ### 安装 pyaxengine
45
+
46
+ [pyaxengine Releases](https://github.com/AXERA-TECH/pyaxengine/releases/latest) 下载对应版本的 `.whl` 文件,然后安装:
47
+
48
+ ```bash
49
+ pip3 install axengine-x.x.x-py3-none-any.whl
50
+ ```
51
+
52
+ ### Python 依赖
53
+
54
+ ```bash
55
+ pip3 install -r requirements.txt
56
+ ```
57
+
58
+ ## 推理命令
59
+
60
+ 进入目录:
61
+
62
+ ```bash
63
+ cd /root/huyuan/workspace/TTS/ZipVoice.AXERA
64
+ ```
65
+
66
+ ### 句子
67
+
68
+ 中文:
69
+ ```bash
70
+ python3 infer_zipvoice_axera.py \
71
+ --text "今天午后天气很好,我打开窗户,听见远处有人聊天,水杯也轻轻晃了一下。" \
72
+ --prompt-text "不管怎么样我和汤姆还是要感谢贝尔卡金的援手" \
73
+ --prompt-wav assets/moss_prompts/zh_1_4p5s.wav \
74
+ --output-wav outputs/zh_sentence.wav \
75
+ --speed 1.0 \
76
+ --num-step 10 \
77
+ --seed 42
78
+ ```
79
+
80
+ 推理结果:
81
+ ```text
82
+ 推理耗时: 5.781s
83
+ 生成语音耗时: 6.411s
84
+ RTF: 0.9018
85
+ ```
86
+
87
+ 音频:[outputs/zh_sentence.wav](outputs/zh_sentence.wav)
88
+
89
+ 英文:
90
+
91
+ ```bash
92
+ python3 infer_zipvoice_axera.py \
93
+ --text "This morning, a small train left the station, carrying sleepy passengers toward a bright coastal town." \
94
+ --prompt-text "This is almost twice the current industry production level per train." \
95
+ --prompt-wav assets/moss_prompts/en_4_4p5s.wav \
96
+ --output-wav outputs/en_sentence.wav \
97
+ --speed 1.0 \
98
+ --num-step 10 \
99
+ --seed 42
100
+ ```
101
+
102
+ 推理结果:
103
+ ```text
104
+ 推理耗时: 5.711s
105
+ 生成语音耗时: 6.411s
106
+ RTF: 0.8909
107
+ ```
108
+
109
+ 音频:[outputs/en_sentence.wav](outputs/en_sentence.wav)
110
+
111
+
112
+ ### 段落
113
+
114
+ 中文:
115
+ ```bash
116
+ python3 infer_zipvoice_axera.py \
117
+ --text-file assets/paragraphs/zh_ginkgo.txt \
118
+ --prompt-text "不管怎么样我和汤姆还是要感谢贝尔卡金的援手" \
119
+ --prompt-wav assets/moss_prompts/zh_1_4p5s.wav \
120
+ --output-wav outputs/zh_long_paragraph.wav \
121
+ --speed 1.0 \
122
+ --num-step 10 \
123
+ --seed 42
124
+ ```
125
+
126
+ 推理结果:
127
+ ```text
128
+ 推理耗时: 40.292s
129
+ 生成语音耗时: 44.744s
130
+ RTF: 0.9005
131
+ ```
132
+
133
+ 音频:[outputs/zh_long_paragraph.wav](outputs/zh_long_paragraph.wav)
134
+
135
+ 英文:
136
+
137
+ ```bash
138
+ python3 infer_zipvoice_axera.py \
139
+ --text-file assets/paragraphs/en_scavenger.txt \
140
+ --prompt-text "This is almost twice the current industry production level per train." \
141
+ --prompt-wav assets/moss_prompts/en_4_4p5s.wav \
142
+ --output-wav outputs/en_long_paragraph.wav \
143
+ --speed 1.0 \
144
+ --num-step 10 \
145
+ --seed 42
146
+ ```
147
+
148
+ 推理结果:
149
+ ```
150
+ 推理耗时: 62.161s
151
+ 生成语音耗时: 64.749s
152
+ RTF: 0.9600
153
+ ```
154
+
155
+ 音频:[outputs/en_long_paragraph.wav](outputs/en_long_paragraph.wav)
156
+
157
+ ## 参数说明
158
+
159
+ - `--prompt-wav`:参考音频,用于控制音色。
160
+ - `--prompt-text`:参考音频对应文本,必须尽量和 `prompt-wav` 内容一致,否则 tokenizer 长度和音频帧数不匹配,会影响语速和效果。
161
+ - `--speed`:控制生成帧数,值越大音频越短,语速越快;值越小音频越长,语速越慢。
162
+ - `--num-step`:采样步数。步数越大通常效果更稳,推理耗时线性增加(当 num-step=8 时,以上推理的 rtf=0.75 左右)。
163
+ - `--max-feat-len`:decoder 固定 feature 长度,当前为 1024。
164
+
165
+
166
+ ## 参考
167
+
168
+ - [ZipVoice](https://github.com/k2-fsa/ZipVoice)
assets/moss_prompts/en_4_4p5s.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fa360ae39ff73bfee70f10ba997ff5ae5c15c1dc25bb382510f6e9654f0f11b8
3
+ size 216078
assets/moss_prompts/zh_1_4p5s.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6f377c00e0cce4c1ca699bd0e53b553151faeddcb1755a5e9604cdbbeb67e2eb
3
+ size 216078
assets/paragraphs/en_scavenger.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ At dusk, I saw an old scavenger digging through a bin on a quiet street corner. His clothes were torn, his back bent from years of labor. I was about to walk past him, as most people did, when something made me stop. From the pile of waste, he pulled out a thin, battered book. Curious, I stepped closer. It was a collection of poems, its pages yellowed and its cover stained. Instead of tossing it aside, the old man wiped the dirt off with his sleeve. Then, in a soft, trembling voice, he began to read: "Beside the eastern fence, I pick chrysanthemums; in quiet leisure, I see the southern hills." The words floated in the evening air. At that moment, the setting sun touched his silver hair, and his eyes, bright and clear, seemed to belong to someone else entirely. He was no longer a scavenger standing by garbage; he was a traveler in a world of beauty and peace. After a long while, he closed the book, pressed it gently to his chest, and slipped it inside his worn jacket. Then he went back to searching for plastic bottles, his movements slow and steady. I stood there, speechless.
assets/paragraphs/zh_ginkgo.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ 窗外的银杏叶又黄了。我坐在老家的院子里,看那一片片扇形的叶子在风中轻轻摇摆,像是无数小扇子,扇走了夏天最后的暑气,也扇来了秋天的凉意。阳光透过枝叶的缝隙洒下来,在地上投下斑驳的光影,那光影随着风不停地晃动,像一群调皮的孩子在捉迷藏。说起来,这棵银杏树是我爷爷年轻时种下的,算算已有十多年了。爷爷去年秋天走了,走的时候,银杏叶正黄得灿烂。我记得他临终前说的最后一句话是:“叶子落了,来年还会再长。”当时我不太明白这句话的意思,现在看着满树金黄,忽然好像懂了些什么。
configs/decoder4_split_manifest.json ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": 2,
3
+ "source_summary": "/data/huyuan/workspace/LLM/TTS/ZIPVOICE_RICH_CHECK0_REQUANT_SUMMARY.md",
4
+ "source_static_onnx_manifest": "/data/huyuan/workspace/LLM/TTS/ZipVoice.AXERA/models/static_mask_1024_cfgfix_nolog_encoder_attn_loop_sigmoid_bump_decoder4/decoder4_split_manifest.json",
5
+ "source_encoder_axmodel": "/data/huyuan/workspace/LLM/TTS/ZipVoice.AXERA/model_convert/axmodels_nolog_encoder_core_rich_check0/encoder_core_nolog_rich_check0.axmodel",
6
+ "source_decoder_axmodel_dir": "/data/huyuan/workspace/LLM/TTS/ZipVoice.AXERA/model_convert/axmodels_attn_loop_sigmoid_bump_decoder4_rich_check0",
7
+ "encoder": {
8
+ "name": "encoder_core",
9
+ "file": "encoder.axmodel",
10
+ "inputs": [
11
+ "cat_tokens"
12
+ ],
13
+ "outputs": [
14
+ "encoded"
15
+ ]
16
+ },
17
+ "decoder_parts": [
18
+ {
19
+ "name": "fm_decoder_part0",
20
+ "file": "decoder_part0.axmodel",
21
+ "inputs": [
22
+ "t",
23
+ "x",
24
+ "text_condition",
25
+ "speech_condition",
26
+ "guidance_scale",
27
+ "padding_mask"
28
+ ],
29
+ "outputs": [
30
+ "decoder_hidden_p0",
31
+ "time_hidden",
32
+ "padding_mask2",
33
+ "cfg_scale"
34
+ ]
35
+ },
36
+ {
37
+ "name": "fm_decoder_part1",
38
+ "file": "decoder_part1.axmodel",
39
+ "inputs": [
40
+ "decoder_hidden_p0",
41
+ "time_hidden",
42
+ "padding_mask2"
43
+ ],
44
+ "outputs": [
45
+ "decoder_hidden_p1"
46
+ ]
47
+ },
48
+ {
49
+ "name": "fm_decoder_part2",
50
+ "file": "decoder_part2.axmodel",
51
+ "inputs": [
52
+ "decoder_hidden_p1",
53
+ "time_hidden",
54
+ "padding_mask2"
55
+ ],
56
+ "outputs": [
57
+ "decoder_hidden_p2"
58
+ ]
59
+ },
60
+ {
61
+ "name": "fm_decoder_part3",
62
+ "file": "decoder_part3.axmodel",
63
+ "inputs": [
64
+ "decoder_hidden_p2",
65
+ "time_hidden",
66
+ "padding_mask2",
67
+ "cfg_scale"
68
+ ],
69
+ "outputs": [
70
+ "v"
71
+ ]
72
+ }
73
+ ]
74
+ }
configs/runtime_config.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_type": "zipvoice_decoder4",
3
+ "max_tokens": 384,
4
+ "max_feat_len": 1024,
5
+ "feat_dim": 100,
6
+ "sampling_rate": 24000,
7
+ "hop_length": 256,
8
+ "num_step": 16,
9
+ "t_shift": 0.5,
10
+ "guidance_scale": 1.0,
11
+ "speed": 1.0
12
+ }
infer_zipvoice_axera.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import time
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+ import soundfile as sf
12
+
13
+ from scripts.common_infer import extract_prompt_features, load_tokenizer
14
+ from scripts.common_infer import load_vocoder, vocoder_decode_loaded
15
+ from scripts.text_processing import build_cat_tokens, build_segments
16
+ from scripts.text_processing import load_text, normalize_punctuation, setup_jieba_cache
17
+ from scripts.zipvoice_decoder4_runtime import Decoder4ZipVoiceBoardRuntime
18
+
19
+
20
+ def parse_args() -> argparse.Namespace:
21
+ repo_dir = Path(__file__).resolve().parent
22
+ p = argparse.ArgumentParser(
23
+ description="ZipVoice encoder + decoder4 inference on AX650",
24
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
25
+ )
26
+ p.add_argument("--config-dir", default=str(repo_dir / "configs"))
27
+ p.add_argument("--model-dir", default=str(repo_dir / "models" / "axmodels"))
28
+ p.add_argument("--text", default=None, help="Text to synthesize")
29
+ p.add_argument("--text-file", default=None, help="UTF-8 text file containing text")
30
+ p.add_argument("--prompt-text", required=True)
31
+ p.add_argument("--prompt-wav", required=True)
32
+ p.add_argument("--output-wav", default="outputs/zipvoice_axera.wav")
33
+ p.add_argument("--num-step", type=int, default=12)
34
+ p.add_argument("--guidance-scale", type=float, default=1.0)
35
+ p.add_argument("--speed", type=float, default=0.85)
36
+ p.add_argument("--t-shift", type=float, default=0.5)
37
+ p.add_argument("--seed", type=int, default=42)
38
+ p.add_argument("--max-tokens", type=int, default=384)
39
+ p.add_argument("--max-feat-len", type=int, default=1024)
40
+ p.add_argument("--feat-scale", type=float, default=0.1)
41
+ p.add_argument("--target-rms", type=float, default=0.1)
42
+ p.add_argument("--min-generated-frames", type=int, default=360)
43
+ p.add_argument("--max-generated-frames", type=int, default=620)
44
+ p.add_argument(
45
+ "--max-raw-feat-ratio",
46
+ type=float,
47
+ default=1.2,
48
+ help="Allow raw features_len up to this ratio of max_feat_len before splitting",
49
+ )
50
+ p.add_argument("--silence-ms", type=int, default=140)
51
+ return p.parse_args()
52
+
53
+
54
+ def main() -> None:
55
+ args = parse_args()
56
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
57
+
58
+ repo_dir = Path(__file__).resolve().parent
59
+ setup_jieba_cache(repo_dir)
60
+ output_wav = Path(args.output_wav)
61
+ output_wav.parent.mkdir(parents=True, exist_ok=True)
62
+
63
+ t_target_text_start = time.perf_counter()
64
+ original_text = load_text(args)
65
+ text = normalize_punctuation(original_text)
66
+ target_text_preprocess_sec = time.perf_counter() - t_target_text_start
67
+ prompt_text = normalize_punctuation(args.prompt_text)
68
+ if text != original_text:
69
+ logging.debug("Normalized text punctuation: %s", text)
70
+ if prompt_text != args.prompt_text:
71
+ logging.debug("Normalized prompt punctuation: %s", prompt_text)
72
+
73
+ logging.debug("Loading tokenizer...")
74
+ tokenizer = load_tokenizer(repo_dir)
75
+ prompt_tokens = tokenizer.texts_to_token_ids([prompt_text])[0]
76
+ max_text_tokens = args.max_tokens - len(prompt_tokens) - 1
77
+ if max_text_tokens <= 0:
78
+ raise ValueError("prompt_tokens leaves no room for text")
79
+
80
+ logging.debug("Extracting prompt features from %s...", args.prompt_wav)
81
+ prompt_features, prompt_rms = extract_prompt_features(
82
+ args.prompt_wav,
83
+ repo_dir=repo_dir,
84
+ sampling_rate=24000,
85
+ feat_scale=args.feat_scale,
86
+ target_rms=args.target_rms,
87
+ )
88
+ prompt_frames = int(prompt_features.shape[1])
89
+ logging.debug(
90
+ "prompt_tokens=%d, prompt_frames=%d",
91
+ len(prompt_tokens),
92
+ prompt_frames,
93
+ )
94
+
95
+ t_segment_planning_start = time.perf_counter()
96
+ segments = build_segments(
97
+ tokenizer=tokenizer,
98
+ text=text,
99
+ prompt_frames=prompt_frames,
100
+ prompt_tokens_len=len(prompt_tokens),
101
+ speed=args.speed,
102
+ max_feat_len=args.max_feat_len,
103
+ max_text_tokens=max_text_tokens,
104
+ min_generated_frames=args.min_generated_frames,
105
+ max_generated_frames=args.max_generated_frames,
106
+ max_raw_feat_ratio=args.max_raw_feat_ratio,
107
+ )
108
+ segment_planning_sec = time.perf_counter() - t_segment_planning_start
109
+ logging.debug("Built %d segments", len(segments))
110
+ for index, segment in enumerate(segments, start=1):
111
+ logging.debug(
112
+ "segment %02d: text_tokens=%d generated_frames=%d features_len=%d clamped=%s text=%s",
113
+ index,
114
+ segment["text_tokens"],
115
+ segment["generated_frames"],
116
+ segment["features_len"],
117
+ segment["clamped"],
118
+ segment["text"],
119
+ )
120
+
121
+ logging.info("正在加载模型...")
122
+ runtime = Decoder4ZipVoiceBoardRuntime(
123
+ config_dir=args.config_dir,
124
+ models_dir=args.model_dir,
125
+ max_feat_len=args.max_feat_len,
126
+ max_tokens=args.max_tokens,
127
+ num_step=args.num_step,
128
+ t_shift=args.t_shift,
129
+ )
130
+ logging.debug("Loading vocoder...")
131
+ vocoder = load_vocoder(repo_dir)
132
+ logging.info("模型加载完成")
133
+
134
+ audios: list[np.ndarray] = []
135
+ silence = np.zeros(int(24000 * args.silence_ms / 1000), dtype=np.float32)
136
+ total_segment_sec = 0.0
137
+
138
+ for index, segment in enumerate(segments, start=1):
139
+ logging.info("开始推理第 %d/%d 句", index, len(segments))
140
+ t_segment_start = time.perf_counter()
141
+ seg_text = str(segment["text"])
142
+ text_tokens = tokenizer.texts_to_token_ids([seg_text])[0]
143
+ cat_tokens = build_cat_tokens(tokenizer, prompt_tokens, text_tokens, args.max_tokens)
144
+
145
+ pred_features, timing = runtime.sample(
146
+ cat_tokens=cat_tokens,
147
+ prompt_tokens_len=len(prompt_tokens),
148
+ text_tokens_len=len(text_tokens),
149
+ prompt_features=prompt_features,
150
+ prompt_features_len=prompt_frames,
151
+ speed=args.speed,
152
+ guidance_scale=args.guidance_scale,
153
+ seed=args.seed + index - 1,
154
+ )
155
+
156
+ audio = vocoder_decode_loaded(
157
+ vocoder,
158
+ pred_features,
159
+ feat_scale=args.feat_scale,
160
+ target_rms=args.target_rms,
161
+ prompt_rms=prompt_rms,
162
+ )
163
+ segment_sec = time.perf_counter() - t_segment_start
164
+ audio_sec = len(audio) / 24000
165
+
166
+ if audios:
167
+ audios.append(silence)
168
+ audios.append(audio.astype(np.float32, copy=False))
169
+
170
+ total_segment_sec += segment_sec
171
+ logging.debug(
172
+ "segment %02d done: audio=%.2fs runtime=%.2fs",
173
+ index,
174
+ audio_sec,
175
+ segment_sec,
176
+ )
177
+
178
+ final_audio = np.concatenate(audios) if audios else np.zeros(0, dtype=np.float32)
179
+ sf.write(str(output_wav), final_audio, samplerate=24000)
180
+ final_audio_sec = len(final_audio) / 24000
181
+ runtime_sec_for_rtf = target_text_preprocess_sec + segment_planning_sec + total_segment_sec
182
+ rtf = (
183
+ runtime_sec_for_rtf / final_audio_sec if final_audio_sec > 0 else float("inf")
184
+ )
185
+
186
+ logging.info("推理耗时: %.3fs", runtime_sec_for_rtf)
187
+ logging.info("生成语音耗时: %.3fs", final_audio_sec)
188
+ logging.info("RTF: %.4f", rtf)
189
+
190
+
191
+ if __name__ == "__main__":
192
+ main()
models/axmodels/decoder_part0.axmodel ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c174cd5458a1490936055a5060b00f9198431b5b97a1cd64176fcb67378e548b
3
+ size 46276224
models/axmodels/decoder_part1.axmodel ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:94d2c75c7b2199f829fe0a9a44762ac8bac194e3e5ae589826a69671f3031442
3
+ size 35358649
models/axmodels/decoder_part2.axmodel ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c9eb7ceec3baaef0b4e56513a791ffedaa3fbea63aeac41fcdd96f6850bf0fb8
3
+ size 38312969
models/axmodels/decoder_part3.axmodel ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2cb91ec05e2d4cb69de73b4e258ce8eb85ce7d507fa795029bb071a336c72821
3
+ size 47283806
models/axmodels/encoder.axmodel ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7d6d7564e79edb513c0d456663c27a7606d272fa8f472b58364a59d9682a3ee3
3
+ size 14093939
outputs/en_long_paragraph.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6d59398ed12e49d51011777d80a0dbbf8b3c6322be7ec4f12338e8ca2740ddc2
3
+ size 3108012
outputs/en_sentence.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:cc94c226559874053edde5331913364b6e2cd01ef7dde58e3c8df7735296e377
3
+ size 307756
outputs/zh_long_paragraph.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6805938a16e5b4a257444c2b33897ffba0d3a5cdbdb7a2de9dd93dd629273947
3
+ size 2147756
outputs/zh_sentence.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2997789e759fd6852ea30c73226d04a09369dabb777244c75d641798b09f1dab
3
+ size 307756
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ numpy>=1.24.0
2
+ soundfile
3
+ torch
4
+ torchaudio
5
+ vocos
6
+ jieba
7
+ pypinyin
8
+ piper_phonemize
9
+
resources/vocos-mel-24khz/config.yaml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ feature_extractor:
2
+ class_path: vocos.feature_extractors.MelSpectrogramFeatures
3
+ init_args:
4
+ sample_rate: 24000
5
+ n_fft: 1024
6
+ hop_length: 256
7
+ n_mels: 100
8
+ padding: center
9
+
10
+ backbone:
11
+ class_path: vocos.models.VocosBackbone
12
+ init_args:
13
+ input_channels: 100
14
+ dim: 512
15
+ intermediate_dim: 1536
16
+ num_layers: 8
17
+
18
+ head:
19
+ class_path: vocos.heads.ISTFTHead
20
+ init_args:
21
+ dim: 512
22
+ n_fft: 1024
23
+ hop_length: 256
24
+ padding: center
resources/vocos-mel-24khz/pytorch_model.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:97ec976ad1fd67a33ab2682d29c0ac7df85234fae875aefcc5fb215681a91b2a
3
+ size 54365991
resources/zipvoice_hf/zipvoice/model.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model" : {
3
+ "fm_decoder_downsampling_factor" : [1,2,4,2,1],
4
+ "fm_decoder_num_layers" : [2,2,4,4,4],
5
+ "fm_decoder_cnn_module_kernel" : [31,15,7,15,31],
6
+ "fm_decoder_feedforward_dim" : 1536,
7
+ "fm_decoder_num_heads" : 4,
8
+ "fm_decoder_dim" : 512,
9
+ "text_encoder_num_layers" : 4,
10
+ "text_encoder_feedforward_dim" : 512,
11
+ "text_encoder_cnn_module_kernel" : 9,
12
+ "text_encoder_num_heads" : 4,
13
+ "text_encoder_dim" : 192,
14
+ "query_head_dim" : 32,
15
+ "value_head_dim" : 12,
16
+ "pos_head_dim" : 4,
17
+ "pos_dim" : 48,
18
+ "time_embed_dim" : 192,
19
+ "text_embed_dim" : 192,
20
+ "feat_dim": 100
21
+ },
22
+ "feature" : {
23
+ "sampling_rate": 24000,
24
+ "type": "vocos"
25
+ }
26
+ }
resources/zipvoice_hf/zipvoice/tokens.txt ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ _ 0
2
+ ^ 1
3
+ $ 2
4
+ 3
5
+ ! 4
6
+ ' 5
7
+ ( 6
8
+ ) 7
9
+ , 8
10
+ - 9
11
+ . 10
12
+ : 11
13
+ ; 12
14
+ ? 13
15
+ a 14
16
+ b 15
17
+ c 16
18
+ d 17
19
+ e 18
20
+ f 19
21
+ h 20
22
+ i 21
23
+ j 22
24
+ k 23
25
+ l 24
26
+ m 25
27
+ n 26
28
+ o 27
29
+ p 28
30
+ q 29
31
+ r 30
32
+ s 31
33
+ t 32
34
+ u 33
35
+ v 34
36
+ w 35
37
+ x 36
38
+ y 37
39
+ z 38
40
+ æ 39
41
+ ç 40
42
+ ð 41
43
+ ø 42
44
+ ħ 43
45
+ ŋ 44
46
+ œ 45
47
+ ǀ 46
48
+ ǁ 47
49
+ ǂ 48
50
+ ǃ 49
51
+ ɐ 50
52
+ ɑ 51
53
+ ɒ 52
54
+ ɓ 53
55
+ ɔ 54
56
+ ɕ 55
57
+ ɖ 56
58
+ ɗ 57
59
+ ɘ 58
60
+ ə 59
61
+ ɚ 60
62
+ ɛ 61
63
+ ɜ 62
64
+ ɞ 63
65
+ ɟ 64
66
+ ɠ 65
67
+ ɡ 66
68
+ ɢ 67
69
+ ɣ 68
70
+ ɤ 69
71
+ ɥ 70
72
+ ɦ 71
73
+ ɧ 72
74
+ ɨ 73
75
+ ɪ 74
76
+ ɫ 75
77
+ ɬ 76
78
+ ɭ 77
79
+ ɮ 78
80
+ ɯ 79
81
+ ɰ 80
82
+ ɱ 81
83
+ ɲ 82
84
+ ɳ 83
85
+ ɴ 84
86
+ ɵ 85
87
+ ɶ 86
88
+ ɸ 87
89
+ ɹ 88
90
+ ɺ 89
91
+ ɻ 90
92
+ ɽ 91
93
+ ɾ 92
94
+ ʀ 93
95
+ ʁ 94
96
+ ʂ 95
97
+ ʃ 96
98
+ ʄ 97
99
+ ʈ 98
100
+ ʉ 99
101
+ ʊ 100
102
+ ʋ 101
103
+ ʌ 102
104
+ ʍ 103
105
+ ʎ 104
106
+ ʏ 105
107
+ ʐ 106
108
+ ʑ 107
109
+ ʒ 108
110
+ ʔ 109
111
+ ʕ 110
112
+ ʘ 111
113
+ ʙ 112
114
+ ʛ 113
115
+ ʜ 114
116
+ ʝ 115
117
+ ʟ 116
118
+ ʡ 117
119
+ ʢ 118
120
+ ʲ 119
121
+ ˈ 120
122
+ ˌ 121
123
+ ː 122
124
+ ˑ 123
125
+ ˞ 124
126
+ β 125
127
+ θ 126
128
+ χ 127
129
+ ᵻ 128
130
+ ⱱ 129
131
+ 0 130
132
+ 1 131
133
+ 2 132
134
+ 3 133
135
+ 4 134
136
+ 5 135
137
+ 6 136
138
+ 7 137
139
+ 8 138
140
+ 9 139
141
+ ̧ 140
142
+ ̃ 141
143
+ ̪ 142
144
+ ̯ 143
145
+ ̩ 144
146
+ ʰ 145
147
+ ˤ 146
148
+ ε 147
149
+ ↓ 148
150
+ # 149
151
+ " 150
152
+ ↑ 151
153
+ ̺ 152
154
+ ̻ 153
155
+ g 154
156
+ ʦ 155
157
+ X 156
158
+ ̝ 157
159
+ ̊ 158
160
+ a1 159
161
+ a2 160
162
+ a3 161
163
+ a4 162
164
+ a5 163
165
+ ai1 164
166
+ ai2 165
167
+ ai3 166
168
+ ai4 167
169
+ ai5 168
170
+ an1 169
171
+ an2 170
172
+ an3 171
173
+ an4 172
174
+ an5 173
175
+ ang1 174
176
+ ang2 175
177
+ ang3 176
178
+ ang4 177
179
+ ang5 178
180
+ ao1 179
181
+ ao2 180
182
+ ao3 181
183
+ ao4 182
184
+ ao5 183
185
+ b0 184
186
+ c0 185
187
+ ch0 186
188
+ d0 187
189
+ e1 188
190
+ e2 189
191
+ e3 190
192
+ e4 191
193
+ e5 192
194
+ ei1 193
195
+ ei2 194
196
+ ei3 195
197
+ ei4 196
198
+ ei5 197
199
+ en1 198
200
+ en2 199
201
+ en3 200
202
+ en4 201
203
+ en5 202
204
+ eng1 203
205
+ eng2 204
206
+ eng3 205
207
+ eng4 206
208
+ eng5 207
209
+ er2 208
210
+ er3 209
211
+ er4 210
212
+ er5 211
213
+ f0 212
214
+ g0 213
215
+ g2 214
216
+ g3 215
217
+ g4 216
218
+ g5 217
219
+ h0 218
220
+ i1 219
221
+ i2 220
222
+ i3 221
223
+ i4 222
224
+ i5 223
225
+ ia1 224
226
+ ia2 225
227
+ ia3 226
228
+ ia4 227
229
+ ia5 228
230
+ ian1 229
231
+ ian2 230
232
+ ian3 231
233
+ ian4 232
234
+ ian5 233
235
+ iang1 234
236
+ iang2 235
237
+ iang3 236
238
+ iang4 237
239
+ iang5 238
240
+ iao1 239
241
+ iao2 240
242
+ iao3 241
243
+ iao4 242
244
+ iao5 243
245
+ ie1 244
246
+ ie2 245
247
+ ie3 246
248
+ ie4 247
249
+ ie5 248
250
+ in1 249
251
+ in2 250
252
+ in3 251
253
+ in4 252
254
+ in5 253
255
+ ing1 254
256
+ ing2 255
257
+ ing3 256
258
+ ing4 257
259
+ ing5 258
260
+ iong1 259
261
+ iong2 260
262
+ iong3 261
263
+ iong4 262
264
+ iu1 263
265
+ iu2 264
266
+ iu3 265
267
+ iu4 266
268
+ iu5 267
269
+ j0 268
270
+ k0 269
271
+ l0 270
272
+ m0 271
273
+ m1 272
274
+ m2 273
275
+ m4 274
276
+ m5 275
277
+ n0 276
278
+ n2 277
279
+ n3 278
280
+ n4 279
281
+ n5 280
282
+ ng5 281
283
+ o1 282
284
+ o2 283
285
+ o3 284
286
+ o4 285
287
+ o5 286
288
+ ong1 287
289
+ ong2 288
290
+ ong3 289
291
+ ong4 290
292
+ ong5 291
293
+ ou1 292
294
+ ou2 293
295
+ ou3 294
296
+ ou4 295
297
+ ou5 296
298
+ p0 297
299
+ q0 298
300
+ r0 299
301
+ s0 300
302
+ sh0 301
303
+ t0 302
304
+ u1 303
305
+ u2 304
306
+ u3 305
307
+ u4 306
308
+ u5 307
309
+ ua1 308
310
+ ua2 309
311
+ ua3 310
312
+ ua4 311
313
+ uai1 312
314
+ uai2 313
315
+ uai3 314
316
+ uai4 315
317
+ uai5 316
318
+ uan1 317
319
+ uan2 318
320
+ uan3 319
321
+ uan4 320
322
+ uan5 321
323
+ uang1 322
324
+ uang2 323
325
+ uang3 324
326
+ uang4 325
327
+ uang5 326
328
+ ue1 327
329
+ ue2 328
330
+ ue3 329
331
+ ue4 330
332
+ ui1 331
333
+ ui2 332
334
+ ui3 333
335
+ ui4 334
336
+ ui5 335
337
+ un1 336
338
+ un2 337
339
+ un3 338
340
+ un4 339
341
+ un5 340
342
+ uo1 341
343
+ uo2 342
344
+ uo3 343
345
+ uo4 344
346
+ uo5 345
347
+ v2 346
348
+ v3 347
349
+ v4 348
350
+ ve3 349
351
+ ve4 350
352
+ w0 351
353
+ x0 352
354
+ y0 353
355
+ z0 354
356
+ zh0 355
357
+ ê1 356
358
+ ê2 357
359
+ ê3 358
360
+ ê4 359
resources/zipvoice_hf/zipvoice/zipvoice_base.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model" : {
3
+ "fm_decoder_downsampling_factor" : [1,2,4,2,1],
4
+ "fm_decoder_num_layers" : [2,2,4,4,4],
5
+ "fm_decoder_cnn_module_kernel" : [31,15,7,15,31],
6
+ "fm_decoder_feedforward_dim" : 1536,
7
+ "fm_decoder_num_heads" : 4,
8
+ "fm_decoder_dim" : 512,
9
+ "text_encoder_num_layers" : 4,
10
+ "text_encoder_feedforward_dim" : 512,
11
+ "text_encoder_cnn_module_kernel" : 9,
12
+ "text_encoder_num_heads" : 4,
13
+ "text_encoder_dim" : 192,
14
+ "query_head_dim" : 32,
15
+ "value_head_dim" : 12,
16
+ "pos_head_dim" : 4,
17
+ "pos_dim" : 48,
18
+ "time_embed_dim" : 192,
19
+ "text_embed_dim" : 192,
20
+ "feat_dim": 100
21
+ },
22
+ "feature" : {
23
+ "sampling_rate": 24000,
24
+ "type": "vocos"
25
+ }
26
+ }
scripts/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # ZipVoice board inference scripts
scripts/common_infer.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import numpy as np
6
+
7
+
8
+ def load_tokenizer(repo_dir: str | Path):
9
+ from scripts.local_tokenizer import LocalEmiliaTokenizer
10
+
11
+ token_file = Path(repo_dir) / "resources" / "zipvoice_hf" / "zipvoice" / "tokens.txt"
12
+ if not token_file.is_file():
13
+ raise FileNotFoundError(f"tokens.txt not found: {token_file}")
14
+ return LocalEmiliaTokenizer(token_file=str(token_file))
15
+
16
+
17
+ def extract_prompt_features(
18
+ prompt_wav: str | Path,
19
+ repo_dir: str | Path,
20
+ sampling_rate: int,
21
+ feat_scale: float,
22
+ target_rms: float,
23
+ ):
24
+ from scripts.local_audio import LocalVocosFbank, load_prompt_wav, rms_norm
25
+ import torch
26
+
27
+ wav = load_prompt_wav(prompt_wav, sampling_rate=sampling_rate)
28
+ wav, prompt_rms = rms_norm(wav, target_rms)
29
+ extractor = LocalVocosFbank()
30
+ features = extractor.extract(wav, sampling_rate=sampling_rate)
31
+ if not isinstance(features, torch.Tensor):
32
+ features = torch.from_numpy(features)
33
+ features = features.unsqueeze(0) * feat_scale
34
+ return features.cpu().numpy().astype(np.float32), float(prompt_rms)
35
+
36
+
37
+ def load_vocoder(repo_dir: str | Path):
38
+ from scripts.local_audio import load_local_vocos
39
+ import torch
40
+
41
+ vocoder_dir = Path(repo_dir) / "resources" / "vocos-mel-24khz"
42
+ if not (vocoder_dir / "config.yaml").is_file() or not (
43
+ vocoder_dir / "pytorch_model.bin"
44
+ ).is_file():
45
+ raise FileNotFoundError(f"Local Vocos files not found in {vocoder_dir}")
46
+ vocoder = load_local_vocos(vocoder_dir)
47
+ vocoder = vocoder.to(torch.device("cpu"))
48
+ vocoder.eval()
49
+ return vocoder
50
+
51
+
52
+ def vocoder_decode_loaded(
53
+ vocoder,
54
+ features: np.ndarray,
55
+ feat_scale: float,
56
+ target_rms: float,
57
+ prompt_rms: float,
58
+ ) -> np.ndarray:
59
+ from scripts.local_audio import rms_norm
60
+ import torch
61
+
62
+ feat_tensor = torch.from_numpy(features).float().permute(0, 2, 1) / feat_scale
63
+ with torch.no_grad():
64
+ wav = vocoder.decode(feat_tensor).squeeze(1).clamp(-1, 1)
65
+ wav = rms_norm(wav, target_rms)[0]
66
+ if prompt_rms < target_rms:
67
+ wav = wav * prompt_rms / target_rms
68
+ return wav.squeeze().cpu().numpy()
scripts/local_audio.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ import torch
7
+ import torchaudio
8
+ from vocos import Vocos
9
+
10
+
11
+ @dataclass
12
+ class VocosFbankConfig:
13
+ sampling_rate: int = 24000
14
+ n_mels: int = 100
15
+ n_fft: int = 1024
16
+ hop_length: int = 256
17
+
18
+
19
+ def compute_num_frames(num_samples: int, hop_length: int) -> int:
20
+ return int((num_samples + hop_length // 2) // hop_length)
21
+
22
+
23
+ class LocalVocosFbank:
24
+ def __init__(self) -> None:
25
+ self.config = VocosFbankConfig()
26
+ self.fbank = torchaudio.transforms.MelSpectrogram(
27
+ sample_rate=self.config.sampling_rate,
28
+ n_fft=self.config.n_fft,
29
+ hop_length=self.config.hop_length,
30
+ n_mels=self.config.n_mels,
31
+ center=True,
32
+ power=1,
33
+ )
34
+
35
+ def extract(self, samples: torch.Tensor, sampling_rate: int) -> torch.Tensor:
36
+ if sampling_rate != self.config.sampling_rate:
37
+ raise ValueError(
38
+ f"Mismatched sampling rate: expected {self.config.sampling_rate}, got {sampling_rate}"
39
+ )
40
+ if samples.ndim == 1:
41
+ samples = samples.unsqueeze(0)
42
+ if samples.ndim != 2:
43
+ raise ValueError(f"Expected waveform shape [C, T], got {tuple(samples.shape)}")
44
+ if samples.shape[0] == 2:
45
+ samples = samples.mean(dim=0, keepdim=True)
46
+
47
+ mel = self.fbank(samples).clamp(min=1e-7).log()
48
+ mel = mel.reshape(-1, mel.shape[-1]).t()
49
+ num_frames = compute_num_frames(samples.shape[1], self.config.hop_length)
50
+
51
+ if mel.shape[0] > num_frames:
52
+ mel = mel[:num_frames]
53
+ elif mel.shape[0] < num_frames:
54
+ mel = torch.nn.functional.pad(
55
+ mel.unsqueeze(0),
56
+ (0, 0, 0, num_frames - mel.shape[0]),
57
+ mode="replicate",
58
+ ).squeeze(0)
59
+ return mel
60
+
61
+
62
+ def load_prompt_wav(prompt_wav: str | Path, sampling_rate: int) -> torch.Tensor:
63
+ wav, sr = torchaudio.load(str(prompt_wav))
64
+ if sr != sampling_rate:
65
+ wav = torchaudio.transforms.Resample(orig_freq=sr, new_freq=sampling_rate)(wav)
66
+ return wav
67
+
68
+
69
+ def rms_norm(wav: torch.Tensor, target_rms: float):
70
+ wav_rms = torch.sqrt(torch.mean(torch.square(wav)))
71
+ if wav_rms < target_rms:
72
+ wav = wav * target_rms / wav_rms
73
+ return wav, wav_rms
74
+
75
+
76
+ def load_local_vocos(vocoder_dir: str | Path):
77
+ vocoder_dir = Path(vocoder_dir)
78
+ vocoder = Vocos.from_hparams(str(vocoder_dir / "config.yaml"))
79
+ state_dict = torch.load(
80
+ str(vocoder_dir / "pytorch_model.bin"),
81
+ weights_only=True,
82
+ map_location="cpu",
83
+ )
84
+ vocoder.load_state_dict(state_dict)
85
+ return vocoder
scripts/local_tokenizer.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import re
5
+ from functools import reduce
6
+ from pathlib import Path
7
+ from typing import Dict, List
8
+
9
+ import jieba
10
+ from pypinyin import Style, lazy_pinyin
11
+ from pypinyin.contrib.tone_convert import to_finals_tone3, to_initials
12
+
13
+ try:
14
+ from piper_phonemize import phonemize_espeak
15
+ except Exception as ex: # pragma: no cover - board dependency check
16
+ raise RuntimeError(
17
+ f"{ex}\nPlease install piper_phonemize for English tokenization."
18
+ )
19
+
20
+ jieba.default_logger.setLevel(logging.INFO)
21
+
22
+
23
+ class EnglishTextNormalizer:
24
+ def normalize(self, text: str) -> str:
25
+ return text
26
+
27
+
28
+ class ChineseTextNormalizer:
29
+ def normalize(self, text: str) -> str:
30
+ try:
31
+ import cn2an
32
+
33
+ return cn2an.transform(text, "an2cn")
34
+ except Exception:
35
+ return text
36
+
37
+
38
+ class LocalEmiliaTokenizer:
39
+ """Small board-side Emilia tokenizer without lhotse/CutSet dependencies."""
40
+
41
+ def __init__(self, token_file: str | Path):
42
+ self.english_normalizer = EnglishTextNormalizer()
43
+ self.chinese_normalizer = ChineseTextNormalizer()
44
+ self.token2id: Dict[str, int] = {}
45
+ with open(token_file, "r", encoding="utf-8") as f:
46
+ for line in f:
47
+ token, token_id = line.rstrip().split("\t")
48
+ self.token2id[token] = int(token_id)
49
+ self.pad_id = self.token2id["_"]
50
+ self.vocab_size = len(self.token2id)
51
+
52
+ def texts_to_token_ids(self, texts: List[str]) -> List[List[int]]:
53
+ return self.tokens_to_token_ids(self.texts_to_tokens(texts))
54
+
55
+ def texts_to_tokens(self, texts: List[str]) -> List[List[str]]:
56
+ phoneme_list = []
57
+ for text in texts:
58
+ text = self.map_punctuations(text)
59
+ segments = self.get_segment(text)
60
+ all_phoneme = []
61
+ for seg_text, seg_type in segments:
62
+ if seg_type == "zh":
63
+ all_phoneme += self.tokenize_zh(seg_text)
64
+ elif seg_type == "en":
65
+ all_phoneme += self.tokenize_en(seg_text)
66
+ elif seg_type == "pinyin":
67
+ all_phoneme += self.tokenize_pinyin(seg_text)
68
+ elif seg_type == "tag":
69
+ all_phoneme.append(seg_text)
70
+ else:
71
+ logging.debug("Skipping unknown language segment: %r", (seg_text, seg_type))
72
+ phoneme_list.append(all_phoneme)
73
+ return phoneme_list
74
+
75
+ def tokens_to_token_ids(self, tokens_list: List[List[str]]) -> List[List[int]]:
76
+ token_ids_list = []
77
+ for tokens in tokens_list:
78
+ token_ids = []
79
+ for token in tokens:
80
+ if token not in self.token2id:
81
+ logging.debug("Skip OOV token %s", token)
82
+ continue
83
+ token_ids.append(self.token2id[token])
84
+ token_ids_list.append(token_ids)
85
+ return token_ids_list
86
+
87
+ def tokenize_zh(self, text: str) -> List[str]:
88
+ try:
89
+ text = self.chinese_normalizer.normalize(text)
90
+ segs = list(jieba.cut(text))
91
+ full = lazy_pinyin(
92
+ segs,
93
+ style=Style.TONE3,
94
+ tone_sandhi=True,
95
+ neutral_tone_with_five=True,
96
+ )
97
+ phones = []
98
+ for item in full:
99
+ if not (item[0:-1].isalpha() and item[-1] in ("1", "2", "3", "4", "5")):
100
+ phones.append(item)
101
+ else:
102
+ phones.extend(self.separate_pinyin(item))
103
+ return phones
104
+ except Exception as ex:
105
+ logging.debug("Tokenization of Chinese text failed: %s", ex)
106
+ return []
107
+
108
+ def tokenize_en(self, text: str) -> List[str]:
109
+ try:
110
+ text = self.english_normalizer.normalize(text)
111
+ tokens = phonemize_espeak(text, "en-us")
112
+ return reduce(lambda x, y: x + y, tokens)
113
+ except Exception as ex:
114
+ logging.debug("Tokenization of English text failed: %s", ex)
115
+ return []
116
+
117
+ def tokenize_pinyin(self, text: str) -> List[str]:
118
+ try:
119
+ text = text.lstrip("<").rstrip(">")
120
+ if not (text[0:-1].isalpha() and text[-1] in ("1", "2", "3", "4", "5")):
121
+ logging.debug("Invalid pinyin token: %s", text)
122
+ return []
123
+ return self.separate_pinyin(text)
124
+ except Exception as ex:
125
+ logging.debug("Tokenize pinyin failed: %s", ex)
126
+ return []
127
+
128
+ @staticmethod
129
+ def separate_pinyin(text: str) -> List[str]:
130
+ pinyins = []
131
+ initial = to_initials(text, strict=False)
132
+ final = to_finals_tone3(text, strict=False, neutral_tone_with_five=True)
133
+ if initial:
134
+ pinyins.append(initial + "0")
135
+ if final:
136
+ pinyins.append(final)
137
+ return pinyins
138
+
139
+ @staticmethod
140
+ def map_punctuations(text: str) -> str:
141
+ replacements = {
142
+ ",": ",",
143
+ "。": ".",
144
+ "!": "!",
145
+ "?": "?",
146
+ ";": ";",
147
+ ":": ":",
148
+ "、": ",",
149
+ "‘": "'",
150
+ "“": '"',
151
+ "”": '"',
152
+ "’": "'",
153
+ "⋯": "…",
154
+ "···": "…",
155
+ "・・・": "…",
156
+ "...": "…",
157
+ }
158
+ for src, dst in replacements.items():
159
+ text = text.replace(src, dst)
160
+ return text
161
+
162
+ def get_segment(self, text: str):
163
+ segments = []
164
+ types = []
165
+ temp_seg = ""
166
+ temp_lang = ""
167
+ parts = re.compile(r"[<[].*?[>\]]|.").findall(text)
168
+ for part in parts:
169
+ if self.is_chinese(part) or self.is_pinyin(part):
170
+ types.append("zh")
171
+ elif self.is_alphabet(part):
172
+ types.append("en")
173
+ else:
174
+ types.append("other")
175
+
176
+ for index, part_type in enumerate(types):
177
+ if index == 0:
178
+ temp_seg += parts[index]
179
+ temp_lang = part_type
180
+ elif temp_lang == "other":
181
+ temp_seg += parts[index]
182
+ temp_lang = part_type
183
+ elif part_type in [temp_lang, "other"]:
184
+ temp_seg += parts[index]
185
+ else:
186
+ segments.append((temp_seg, temp_lang))
187
+ temp_seg = parts[index]
188
+ temp_lang = part_type
189
+ segments.append((temp_seg, temp_lang))
190
+ return self.split_segments(segments)
191
+
192
+ @staticmethod
193
+ def split_segments(segments):
194
+ result = []
195
+ for temp_seg, temp_lang in segments:
196
+ parts = re.split(r"([<[].*?[>\]])", temp_seg)
197
+ for part in parts:
198
+ if not part:
199
+ continue
200
+ if part.startswith("<") and part.endswith(">"):
201
+ result.append((part, "pinyin"))
202
+ elif part.startswith("[") and part.endswith("]"):
203
+ result.append((part, "tag"))
204
+ else:
205
+ result.append((part, temp_lang))
206
+ return result
207
+
208
+ @staticmethod
209
+ def is_chinese(char: str) -> bool:
210
+ return "\u4e00" <= char <= "\u9fa5"
211
+
212
+ @staticmethod
213
+ def is_alphabet(char: str) -> bool:
214
+ return ("A" <= char <= "Z") or ("a" <= char <= "z")
215
+
216
+ @staticmethod
217
+ def is_pinyin(part: str) -> bool:
218
+ return part.startswith("<") and part.endswith(">")
scripts/text_processing.py ADDED
@@ -0,0 +1,390 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import re
5
+ import unicodedata
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+
10
+
11
+ SENTENCE_PUNCT = "。!?.!?。."
12
+ ELLIPSIS_PUNCT = {"…"}
13
+ QUOTE_PUNCT = set("\"'“”‘’「」『』")
14
+
15
+
16
+ def is_text_punctuation(ch: str) -> bool:
17
+ return ch in ELLIPSIS_PUNCT or unicodedata.category(ch).startswith("P")
18
+
19
+
20
+ def comma_for_context(left: str, right: str) -> str:
21
+ context = left + right
22
+ return "," if re.search(r"[A-Za-z0-9]", context) else ","
23
+
24
+
25
+ def setup_jieba_cache(repo_dir: Path) -> None:
26
+ import jieba
27
+
28
+ cache_dir = repo_dir / ".work_tmp" / "jieba"
29
+ cache_dir.mkdir(parents=True, exist_ok=True)
30
+ jieba.dt.tmp_dir = str(cache_dir)
31
+ jieba.dt.cache_file = "jieba.cache"
32
+
33
+
34
+ def load_text(args: argparse.Namespace) -> str:
35
+ if args.text_file:
36
+ text = Path(args.text_file).read_text(encoding="utf-8")
37
+ elif args.text:
38
+ text = args.text
39
+ else:
40
+ raise ValueError("Either --text or --text-file is required")
41
+ text = re.sub(r"[ \t\r\f\v]+", " ", text)
42
+ text = re.sub(r"\n+", " ", text)
43
+ return text.strip()
44
+
45
+
46
+ def normalize_punctuation(text: str) -> str:
47
+ text = re.sub(r"\s+", " ", text.strip())
48
+ text = text.replace("……", "。").replace("...", ".")
49
+ chars: list[str] = []
50
+ i = 0
51
+ while i < len(text):
52
+ ch = text[i]
53
+ if is_text_punctuation(ch):
54
+ j = i
55
+ punct_run = []
56
+ while j < len(text) and is_text_punctuation(text[j]):
57
+ punct_run.append(text[j])
58
+ j += 1
59
+ if all(p in QUOTE_PUNCT for p in punct_run):
60
+ i = j
61
+ continue
62
+ sentence_marks = [p for p in punct_run if p in SENTENCE_PUNCT]
63
+ left = chars[-1] if chars else ""
64
+ right = text[j] if j < len(text) else ""
65
+ mark = sentence_marks[-1] if sentence_marks else comma_for_context(left, right)
66
+ if chars and is_text_punctuation(chars[-1]):
67
+ chars[-1] = mark
68
+ else:
69
+ chars.append(mark)
70
+ i = j
71
+ continue
72
+ chars.append(ch)
73
+ i += 1
74
+
75
+ normalized = "".join(chars)
76
+ normalized = re.sub(r"[ \t]+([,。!?,.!?.。])", r"\1", normalized)
77
+ normalized = re.sub(r"([,。!?])\s+", r"\1", normalized)
78
+ normalized = re.sub(r"([,.!?.。])(?=[A-Za-z0-9])", r"\1 ", normalized)
79
+ normalized = re.sub(r"\s{2,}", " ", normalized)
80
+ normalized = re.sub(r"([,,])\s*[,,]+", r"\1", normalized)
81
+ normalized = re.sub(r"[,,]+([。!?.!?.。])", r"\1", normalized)
82
+ normalized = re.sub(r"([。!?.!?.。])([,,]+)", r"\1", normalized)
83
+ return normalized.strip()
84
+
85
+
86
+ def join_units(left: str, right: str) -> str:
87
+ if not left:
88
+ return right.strip()
89
+ right = right.strip()
90
+ if not right:
91
+ return left.strip()
92
+ if re.search(r"[\u4e00-\u9fff]$", left) or re.match(r"^[\u4e00-\u9fff]", right):
93
+ return left.rstrip() + right
94
+ return left.rstrip() + " " + right
95
+
96
+
97
+ def split_units(text: str) -> list[str]:
98
+ text = text.strip()
99
+ if not text:
100
+ return []
101
+ pattern = r"[^。!?!?;;,,、::\n]+[。!?!?;;,,、::]?"
102
+ units = [m.group(0).strip() for m in re.finditer(pattern, text) if m.group(0).strip()]
103
+ return units or [text]
104
+
105
+
106
+ def estimate_lengths(
107
+ prompt_frames: int,
108
+ prompt_tokens: int,
109
+ text_tokens: int,
110
+ speed: float,
111
+ max_feat_len: int,
112
+ ) -> dict[str, int | bool]:
113
+ raw_features_len = int(
114
+ np.ceil(prompt_frames / prompt_tokens * (prompt_tokens + text_tokens) / speed)
115
+ )
116
+ features_len = min(raw_features_len, max_feat_len)
117
+ generated_frames = features_len - prompt_frames
118
+ if generated_frames <= 0:
119
+ generated_frames = features_len
120
+ return {
121
+ "raw_features_len": raw_features_len,
122
+ "features_len": features_len,
123
+ "generated_frames": generated_frames,
124
+ "clamped": raw_features_len > max_feat_len,
125
+ }
126
+
127
+
128
+ def token_count(tokenizer, text: str) -> int:
129
+ return len(tokenizer.texts_to_token_ids([text])[0])
130
+
131
+
132
+ def split_long_unit(tokenizer, unit: str, max_text_tokens: int) -> list[str]:
133
+ if token_count(tokenizer, unit) <= max_text_tokens:
134
+ return [unit]
135
+
136
+ if " " in unit:
137
+ pieces = unit.split()
138
+ chunks: list[str] = []
139
+ current = ""
140
+ for piece in pieces:
141
+ candidate = join_units(current, piece)
142
+ if current and token_count(tokenizer, candidate) > max_text_tokens:
143
+ chunks.append(current)
144
+ current = piece
145
+ else:
146
+ current = candidate
147
+ if current:
148
+ chunks.append(current)
149
+ return chunks
150
+
151
+ chunks = []
152
+ current = ""
153
+ for char in unit:
154
+ candidate = current + char
155
+ if current and token_count(tokenizer, candidate) > max_text_tokens:
156
+ chunks.append(current)
157
+ current = char
158
+ else:
159
+ current = candidate
160
+ if current:
161
+ chunks.append(current)
162
+ return chunks
163
+
164
+
165
+ def build_segments(
166
+ tokenizer,
167
+ text: str,
168
+ prompt_frames: int,
169
+ prompt_tokens_len: int,
170
+ speed: float,
171
+ max_feat_len: int,
172
+ max_text_tokens: int,
173
+ min_generated_frames: int,
174
+ max_generated_frames: int,
175
+ max_raw_feat_ratio: float,
176
+ ) -> list[dict[str, object]]:
177
+ raw_units = split_units(text)
178
+ units: list[str] = []
179
+ for unit in raw_units:
180
+ units.extend(split_long_unit(tokenizer, unit, max_text_tokens))
181
+
182
+ segments: list[dict[str, object]] = []
183
+ current = ""
184
+ for unit in units:
185
+ candidate = join_units(current, unit)
186
+ cand_tokens = token_count(tokenizer, candidate)
187
+ cand_est = estimate_lengths(
188
+ prompt_frames,
189
+ prompt_tokens_len,
190
+ cand_tokens,
191
+ speed,
192
+ max_feat_len,
193
+ )
194
+ raw_too_long = int(cand_est["raw_features_len"]) > int(max_feat_len * max_raw_feat_ratio)
195
+ too_long = (
196
+ cand_tokens > max_text_tokens
197
+ or int(cand_est["generated_frames"]) > max_generated_frames
198
+ or raw_too_long
199
+ )
200
+ if current and too_long:
201
+ cur_tokens = token_count(tokenizer, current)
202
+ cur_est = estimate_lengths(
203
+ prompt_frames,
204
+ prompt_tokens_len,
205
+ cur_tokens,
206
+ speed,
207
+ max_feat_len,
208
+ )
209
+ segments.append({"text": current, "text_tokens": cur_tokens, **cur_est})
210
+ current = unit
211
+ else:
212
+ current = candidate
213
+
214
+ if current:
215
+ cur_tokens = token_count(tokenizer, current)
216
+ cur_est = estimate_lengths(
217
+ prompt_frames,
218
+ prompt_tokens_len,
219
+ cur_tokens,
220
+ speed,
221
+ max_feat_len,
222
+ )
223
+ segments.append({"text": current, "text_tokens": cur_tokens, **cur_est})
224
+
225
+ if len(segments) >= 2 and int(segments[-1]["generated_frames"]) < min_generated_frames:
226
+ merged_text = join_units(str(segments[-2]["text"]), str(segments[-1]["text"]))
227
+ merged_tokens = token_count(tokenizer, merged_text)
228
+ merged_est = estimate_lengths(
229
+ prompt_frames,
230
+ prompt_tokens_len,
231
+ merged_tokens,
232
+ speed,
233
+ max_feat_len,
234
+ )
235
+ raw_ok = int(merged_est["raw_features_len"]) <= int(max_feat_len * max_raw_feat_ratio)
236
+ if (
237
+ merged_tokens <= max_text_tokens
238
+ and int(merged_est["generated_frames"]) <= max_generated_frames
239
+ and raw_ok
240
+ ):
241
+ segments[-2] = {"text": merged_text, "text_tokens": merged_tokens, **merged_est}
242
+ segments.pop()
243
+ else:
244
+ rebalanced = rebalance_short_tail(
245
+ tokenizer,
246
+ str(segments[-2]["text"]),
247
+ str(segments[-1]["text"]),
248
+ prompt_frames,
249
+ prompt_tokens_len,
250
+ speed,
251
+ max_feat_len,
252
+ max_text_tokens,
253
+ min_generated_frames,
254
+ max_generated_frames,
255
+ max_raw_feat_ratio,
256
+ )
257
+ if rebalanced is not None:
258
+ segments[-2], segments[-1] = rebalanced
259
+
260
+ return segments
261
+
262
+
263
+ def split_rebalance_pieces(text: str) -> list[str]:
264
+ units = split_units(text)
265
+ if len(units) > 1:
266
+ return units
267
+ if " " in text:
268
+ return text.split()
269
+ return list(text)
270
+
271
+
272
+ def segment_record(
273
+ tokenizer,
274
+ text: str,
275
+ prompt_frames: int,
276
+ prompt_tokens_len: int,
277
+ speed: float,
278
+ max_feat_len: int,
279
+ ) -> dict[str, object]:
280
+ text_tokens = token_count(tokenizer, text)
281
+ est = estimate_lengths(
282
+ prompt_frames,
283
+ prompt_tokens_len,
284
+ text_tokens,
285
+ speed,
286
+ max_feat_len,
287
+ )
288
+ return {"text": text, "text_tokens": text_tokens, **est}
289
+
290
+
291
+ def segment_within_limits(
292
+ record: dict[str, object],
293
+ max_text_tokens: int,
294
+ max_generated_frames: int,
295
+ max_raw_feat: int,
296
+ ) -> bool:
297
+ return (
298
+ int(record["text_tokens"]) <= max_text_tokens
299
+ and int(record["generated_frames"]) <= max_generated_frames
300
+ and int(record["raw_features_len"]) <= max_raw_feat
301
+ )
302
+
303
+
304
+ def rebalance_short_tail(
305
+ tokenizer,
306
+ prev_text: str,
307
+ tail_text: str,
308
+ prompt_frames: int,
309
+ prompt_tokens_len: int,
310
+ speed: float,
311
+ max_feat_len: int,
312
+ max_text_tokens: int,
313
+ min_generated_frames: int,
314
+ max_generated_frames: int,
315
+ max_raw_feat_ratio: float,
316
+ ) -> tuple[dict[str, object], dict[str, object]] | None:
317
+ max_raw_feat = int(max_feat_len * max_raw_feat_ratio)
318
+ best: tuple[dict[str, object], dict[str, object]] | None = None
319
+ best_tail_frames = -1
320
+
321
+ piece_sets = [split_rebalance_pieces(prev_text)]
322
+ if " " in prev_text:
323
+ word_pieces = prev_text.split()
324
+ if len(word_pieces) > 1 and word_pieces != piece_sets[0]:
325
+ piece_sets.append(word_pieces)
326
+
327
+ for pieces in piece_sets:
328
+ if len(pieces) < 2:
329
+ continue
330
+ for split_at in range(len(pieces) - 1, 0, -1):
331
+ new_prev = (
332
+ join_units("", " ".join(pieces[:split_at]))
333
+ if " " in prev_text
334
+ else "".join(pieces[:split_at])
335
+ )
336
+ moved_tail = (
337
+ join_units("", " ".join(pieces[split_at:]))
338
+ if " " in prev_text
339
+ else "".join(pieces[split_at:])
340
+ )
341
+ new_tail = join_units(moved_tail, tail_text)
342
+ prev_record = segment_record(
343
+ tokenizer,
344
+ new_prev,
345
+ prompt_frames,
346
+ prompt_tokens_len,
347
+ speed,
348
+ max_feat_len,
349
+ )
350
+ tail_record = segment_record(
351
+ tokenizer,
352
+ new_tail,
353
+ prompt_frames,
354
+ prompt_tokens_len,
355
+ speed,
356
+ max_feat_len,
357
+ )
358
+ if not segment_within_limits(
359
+ tail_record,
360
+ max_text_tokens,
361
+ max_generated_frames,
362
+ max_raw_feat,
363
+ ):
364
+ continue
365
+ if not segment_within_limits(
366
+ prev_record,
367
+ max_text_tokens,
368
+ max_generated_frames,
369
+ max_raw_feat,
370
+ ):
371
+ continue
372
+ prev_frames = int(prev_record["generated_frames"])
373
+ tail_frames = int(tail_record["generated_frames"])
374
+ if prev_frames < min_generated_frames:
375
+ continue
376
+ if tail_frames > best_tail_frames:
377
+ best = (prev_record, tail_record)
378
+ best_tail_frames = tail_frames
379
+ if tail_frames >= min_generated_frames:
380
+ return best
381
+
382
+ return best
383
+
384
+
385
+ def build_cat_tokens(tokenizer, prompt_tokens: list[int], text_tokens: list[int], max_tokens: int) -> np.ndarray:
386
+ pad_id = tokenizer.pad_id
387
+ cat = prompt_tokens + text_tokens + [pad_id]
388
+ cat_tokens = np.full((1, max_tokens), pad_id, dtype=np.int64)
389
+ cat_tokens[0, : len(cat)] = np.array(cat, dtype=np.int64)
390
+ return cat_tokens
scripts/zipvoice_decoder4_runtime.py ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any, Dict, Iterable, List
10
+
11
+ import numpy as np
12
+
13
+ from scripts.zipvoice_runtime import AxeSession
14
+
15
+
16
+ class Decoder4ZipVoiceBoardRuntime:
17
+ """Runs encoder_core_nolog.axmodel and fm_decoder_part0..part3.axmodel."""
18
+
19
+ def __init__(
20
+ self,
21
+ config_dir: str | Path,
22
+ models_dir: str | Path,
23
+ max_feat_len: int = 1024,
24
+ max_tokens: int = 384,
25
+ num_step: int = 16,
26
+ t_shift: float = 0.5,
27
+ ) -> None:
28
+ self.config_dir = Path(config_dir)
29
+ self.models_dir = Path(models_dir)
30
+ self.max_feat_len = int(max_feat_len)
31
+ self.max_tokens = int(max_tokens)
32
+ self.num_step = int(num_step)
33
+ self.t_shift = float(t_shift)
34
+
35
+ self._load_config()
36
+ self._load_manifest()
37
+ self.sessions: Dict[str, AxeSession] = {}
38
+ self._load_models()
39
+ self._load_decoder_input_metadata()
40
+
41
+ def _load_config(self) -> None:
42
+ config_path = self.config_dir / "runtime_config.json"
43
+ config = json.loads(config_path.read_text()) if config_path.exists() else {}
44
+ self.feat_dim = int(config.get("feat_dim", 100))
45
+ self.sampling_rate = int(config.get("sampling_rate", 24000))
46
+ self.hop_length = int(config.get("hop_length", 256))
47
+ logging.debug(
48
+ "Decoder4 runtime: max_tokens=%d, max_feat_len=%d, feat_dim=%d, num_step=%d",
49
+ self.max_tokens,
50
+ self.max_feat_len,
51
+ self.feat_dim,
52
+ self.num_step,
53
+ )
54
+
55
+ def _load_manifest(self) -> None:
56
+ manifest_path = self.config_dir / "decoder4_split_manifest.json"
57
+ if not manifest_path.exists():
58
+ raise FileNotFoundError(f"decoder4_split_manifest.json not found: {manifest_path}")
59
+ self.manifest = json.loads(manifest_path.read_text())
60
+ self.encoder_info = self.manifest["encoder"]
61
+ self.decoder_parts = self.manifest["decoder_parts"]
62
+
63
+ def _load_models(self) -> None:
64
+ model_infos = [self.encoder_info, *self.decoder_parts]
65
+ for info in model_infos:
66
+ name = info["name"]
67
+ path = self.models_dir / info["file"]
68
+ logging.debug("Loading %s from %s", name, path)
69
+ self.sessions[name] = AxeSession(path)
70
+ logging.debug("Loaded encoder + %d decoder4 parts", len(self.decoder_parts))
71
+
72
+ def _load_decoder_input_metadata(self) -> None:
73
+ part0 = self.decoder_parts[0]
74
+ sess = self.sessions[part0["name"]]
75
+ input_names = sess.input_names
76
+ self.decoder_has_padding_mask = "padding_mask" in input_names
77
+ self.decoder_seq_len = self.max_feat_len
78
+ if "x" in input_names:
79
+ index = input_names.index("x")
80
+ input_info = sess._inputs[index] if index < len(sess._inputs) else None
81
+ shape = getattr(input_info, "shape", None) if input_info is not None else None
82
+ if shape is not None and len(shape) >= 2 and isinstance(shape[1], (int, np.integer)):
83
+ self.decoder_seq_len = int(shape[1])
84
+
85
+ if self.decoder_seq_len != self.max_feat_len:
86
+ logging.debug(
87
+ "decoder x seq_len=%d differs from configured max_feat_len=%d; "
88
+ "using model seq_len for decoder feeds",
89
+ self.decoder_seq_len,
90
+ self.max_feat_len,
91
+ )
92
+ logging.debug(
93
+ "Decoder4 model metadata: seq_len=%d, has_padding_mask=%s",
94
+ self.decoder_seq_len,
95
+ self.decoder_has_padding_mask,
96
+ )
97
+
98
+ @staticmethod
99
+ def _coerce_input_dtype(value: np.ndarray, input_info: Any | None) -> np.ndarray:
100
+ if input_info is None:
101
+ return value
102
+
103
+ expected_dtype = getattr(input_info, "dtype", None)
104
+ if expected_dtype is None:
105
+ expected_dtype = getattr(input_info, "type", None)
106
+ if expected_dtype is None:
107
+ return value
108
+
109
+ dtype_text = str(expected_dtype).lower()
110
+ if "float32" in dtype_text:
111
+ return np.ascontiguousarray(value, dtype=np.float32)
112
+ if "int32" in dtype_text:
113
+ return np.ascontiguousarray(value, dtype=np.int32)
114
+ if "int64" in dtype_text:
115
+ return np.ascontiguousarray(value, dtype=np.int64)
116
+ if "uint8" in dtype_text:
117
+ return np.ascontiguousarray(value, dtype=np.uint8)
118
+ if "bool" in dtype_text:
119
+ return np.ascontiguousarray(value, dtype=np.bool_)
120
+ return np.ascontiguousarray(value)
121
+
122
+ def _run_model(
123
+ self,
124
+ name: str,
125
+ expected_inputs: Iterable[str],
126
+ expected_outputs: Iterable[str],
127
+ values: Dict[str, np.ndarray],
128
+ ) -> Dict[str, np.ndarray]:
129
+ sess = self.sessions[name]
130
+ expected_inputs = list(expected_inputs)
131
+ expected_outputs = list(expected_outputs)
132
+
133
+ feed: Dict[str, np.ndarray] = {}
134
+ for index, actual_name in enumerate(sess.input_names):
135
+ input_info = sess._inputs[index] if index < len(sess._inputs) else None
136
+ if actual_name in values:
137
+ feed[actual_name] = self._coerce_input_dtype(values[actual_name], input_info)
138
+ continue
139
+ if index < len(expected_inputs) and expected_inputs[index] in values:
140
+ feed[actual_name] = self._coerce_input_dtype(
141
+ values[expected_inputs[index]], input_info
142
+ )
143
+ continue
144
+ expected = expected_inputs[index] if index < len(expected_inputs) else None
145
+ raise KeyError(
146
+ f"Missing input for {name}: actual={actual_name!r}, expected={expected!r}"
147
+ )
148
+
149
+ raw_outputs = sess.run(feed)
150
+ mapped: Dict[str, np.ndarray] = {}
151
+ for index, expected_name in enumerate(expected_outputs):
152
+ if expected_name in raw_outputs:
153
+ mapped[expected_name] = raw_outputs[expected_name]
154
+ continue
155
+ if index < len(sess.output_names) and sess.output_names[index] in raw_outputs:
156
+ mapped[expected_name] = raw_outputs[sess.output_names[index]]
157
+ continue
158
+ raise KeyError(f"Missing output for {name}: {expected_name!r}")
159
+ return mapped
160
+
161
+ def run_encoder(self, cat_tokens: np.ndarray) -> np.ndarray:
162
+ # Pulsar2/AXEngine exposes the quantized encoder token input as int32
163
+ # even though the reference ONNX path uses int64 token IDs.
164
+ cat_tokens = np.asarray(cat_tokens, dtype=np.int32)
165
+ outputs = self._run_model(
166
+ self.encoder_info["name"],
167
+ self.encoder_info["inputs"],
168
+ self.encoder_info["outputs"],
169
+ {"cat_tokens": cat_tokens},
170
+ )
171
+ return outputs[self.encoder_info["outputs"][0]].astype(np.float32)
172
+
173
+ def run_decoder(
174
+ self,
175
+ t: np.ndarray,
176
+ x: np.ndarray,
177
+ text_condition: np.ndarray,
178
+ speech_condition: np.ndarray,
179
+ guidance_scale: np.ndarray,
180
+ padding_mask: np.ndarray | None = None,
181
+ ) -> np.ndarray:
182
+ seq_len = x.shape[1]
183
+ values: Dict[str, np.ndarray] = {
184
+ "t": np.asarray(t, dtype=np.float32).reshape(1),
185
+ "x": x.astype(np.float32),
186
+ "text_condition": text_condition.astype(np.float32),
187
+ "speech_condition": speech_condition.astype(np.float32),
188
+ "guidance_scale": np.asarray(guidance_scale, dtype=np.float32).reshape(1),
189
+ "padding_mask": padding_mask.astype(np.bool_)
190
+ if padding_mask is not None
191
+ else np.zeros((1, seq_len), dtype=np.bool_),
192
+ }
193
+
194
+ for part in self.decoder_parts:
195
+ outputs = self._run_model(
196
+ part["name"],
197
+ part["inputs"],
198
+ part["outputs"],
199
+ values,
200
+ )
201
+ values.update(outputs)
202
+
203
+ final_output = self.decoder_parts[-1]["outputs"][0]
204
+ return values[final_output].astype(np.float32)
205
+
206
+ def duration_expand(
207
+ self,
208
+ encoded: np.ndarray,
209
+ prompt_tokens_len: int,
210
+ text_tokens_len: int,
211
+ prompt_features_len: int,
212
+ speed: float,
213
+ ) -> tuple[np.ndarray, int]:
214
+ total_tokens_len = prompt_tokens_len + text_tokens_len
215
+ features_len = int(
216
+ np.ceil(prompt_features_len / prompt_tokens_len * total_tokens_len / speed)
217
+ )
218
+ if features_len > self.max_feat_len:
219
+ logging.debug(
220
+ "features_len=%d > max_feat_len=%d, clamping",
221
+ features_len,
222
+ self.max_feat_len,
223
+ )
224
+ features_len = self.max_feat_len
225
+
226
+ token_dur = features_len // total_tokens_len
227
+ embed_no_pad = encoded[0, :total_tokens_len, :]
228
+ text_condition = np.repeat(embed_no_pad, token_dur, axis=0)
229
+
230
+ residual = features_len - text_condition.shape[0]
231
+ if residual > 0:
232
+ last_embed = encoded[0, total_tokens_len : total_tokens_len + 1, :]
233
+ text_condition = np.concatenate(
234
+ [text_condition, np.repeat(last_embed, residual, axis=0)],
235
+ axis=0,
236
+ )
237
+
238
+ text_condition = text_condition[:features_len, :]
239
+ return text_condition[np.newaxis, :, :].astype(np.float32), features_len
240
+
241
+ def _get_time_steps(self) -> np.ndarray:
242
+ t = np.linspace(0.0, 1.0, self.num_step + 1, dtype=np.float32)
243
+ ts = self.t_shift
244
+ return ts * t / (1.0 + (ts - 1.0) * t)
245
+
246
+ def sample(
247
+ self,
248
+ cat_tokens: np.ndarray,
249
+ prompt_tokens_len: int,
250
+ text_tokens_len: int,
251
+ prompt_features: np.ndarray,
252
+ prompt_features_len: int,
253
+ speed: float = 1.0,
254
+ guidance_scale: float = 1.0,
255
+ seed: int = 666,
256
+ ) -> tuple[np.ndarray, Dict[str, Any]]:
257
+ logging.debug(
258
+ "sample: prompt_tokens=%d, text_tokens=%d, prompt_frames=%d, "
259
+ "speed=%.2f, guidance_scale=%.2f, seed=%d",
260
+ prompt_tokens_len,
261
+ text_tokens_len,
262
+ prompt_features_len,
263
+ speed,
264
+ guidance_scale,
265
+ seed,
266
+ )
267
+ t_total_start = time.perf_counter()
268
+
269
+ t_start = time.perf_counter()
270
+ encoded = self.run_encoder(cat_tokens)
271
+ t_enc = time.perf_counter() - t_start
272
+ logging.debug(" encoder: %.3f s (output shape=%s)", t_enc, encoded.shape)
273
+
274
+ t_start = time.perf_counter()
275
+ text_condition, features_len = self.duration_expand(
276
+ encoded,
277
+ prompt_tokens_len,
278
+ text_tokens_len,
279
+ prompt_features_len,
280
+ speed,
281
+ )
282
+ t_dur = time.perf_counter() - t_start
283
+ logging.debug(" duration_expand: %.3f s (features_len=%d)", t_dur, features_len)
284
+
285
+ seq_len = self.decoder_seq_len or self.max_feat_len
286
+ if features_len > seq_len:
287
+ raise ValueError(
288
+ f"features_len={features_len} exceeds decoder sequence length {seq_len}"
289
+ )
290
+ if (
291
+ self.decoder_seq_len is not None
292
+ and not self.decoder_has_padding_mask
293
+ and features_len != seq_len
294
+ ):
295
+ raise ValueError(
296
+ "Fixed no-mask decoder requires exact feature length: "
297
+ f"features_len={features_len}, decoder_seq_len={seq_len}"
298
+ )
299
+ if prompt_features.shape[1] > seq_len:
300
+ raise ValueError(
301
+ f"prompt feature length {prompt_features.shape[1]} exceeds "
302
+ f"decoder sequence length {seq_len}"
303
+ )
304
+
305
+ text_cond_padded = np.zeros((1, seq_len, self.feat_dim), dtype=np.float32)
306
+ text_cond_padded[0, :features_len] = text_condition[0, :features_len]
307
+
308
+ speech_cond_padded = np.zeros((1, seq_len, self.feat_dim), dtype=np.float32)
309
+ prompt_actual_len = prompt_features.shape[1]
310
+ speech_cond_padded[0, :prompt_actual_len] = prompt_features[0].astype(np.float32)
311
+
312
+ padding_mask = np.zeros((1, seq_len), dtype=np.bool_)
313
+ padding_mask[:, features_len:] = True
314
+
315
+ rng = np.random.RandomState(seed)
316
+ x = rng.randn(1, seq_len, self.feat_dim).astype(np.float32)
317
+ x[:, features_len:, :] = 0.0
318
+
319
+ timesteps = self._get_time_steps()
320
+ gs = np.array([guidance_scale], dtype=np.float32)
321
+
322
+ t_dec_total = 0.0
323
+ for step in range(self.num_step):
324
+ t_val = np.array([float(timesteps[step])], dtype=np.float32)
325
+ t_start = time.perf_counter()
326
+ v = self.run_decoder(
327
+ t_val,
328
+ x,
329
+ text_cond_padded,
330
+ speech_cond_padded,
331
+ gs,
332
+ padding_mask,
333
+ )
334
+ t_dec_total += time.perf_counter() - t_start
335
+ dt = float(timesteps[step + 1] - timesteps[step])
336
+ x = (x + v * dt).astype(np.float32)
337
+ x[:, features_len:, :] = 0.0
338
+
339
+ logging.debug(
340
+ " %s (NPU x%d): %.3f s total (avg %.3f ms/step)",
341
+ getattr(self, "decoder_label", "decoder4"),
342
+ self.num_step,
343
+ t_dec_total,
344
+ t_dec_total / self.num_step * 1000,
345
+ )
346
+
347
+ generated_frames = features_len - prompt_features_len
348
+ if generated_frames <= 0:
349
+ generated_frames = features_len
350
+ pred_features = x[0, :features_len, :]
351
+ else:
352
+ pred_features = x[0, prompt_features_len:features_len, :]
353
+
354
+ t_total = time.perf_counter() - t_total_start
355
+ timing = {
356
+ "encoder_time_sec": round(t_enc, 3),
357
+ "duration_expand_time_sec": round(t_dur, 3),
358
+ "decoder_time_sec": round(t_dec_total, 3),
359
+ "total_time_sec": round(t_total, 3),
360
+ "generated_frames": int(generated_frames),
361
+ "features_len": int(features_len),
362
+ }
363
+ logging.debug(" total: %.3f s", t_total)
364
+ return pred_features[np.newaxis, :, :].astype(np.float32), timing
scripts/zipvoice_runtime.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Dict, List
6
+
7
+ import numpy as np
8
+
9
+ from contextlib import contextmanager
10
+
11
+
12
+ @contextmanager
13
+ def _suppress_native_stdio():
14
+ if os.environ.get("ZIPVOICE_AXERA_VERBOSE"):
15
+ yield
16
+ return
17
+
18
+ stdout_fd = os.dup(1)
19
+ stderr_fd = os.dup(2)
20
+ try:
21
+ with open(os.devnull, "w") as devnull:
22
+ os.dup2(devnull.fileno(), 1)
23
+ os.dup2(devnull.fileno(), 2)
24
+ yield
25
+ finally:
26
+ os.dup2(stdout_fd, 1)
27
+ os.dup2(stderr_fd, 2)
28
+ os.close(stdout_fd)
29
+ os.close(stderr_fd)
30
+
31
+ try:
32
+ with _suppress_native_stdio():
33
+ import axengine
34
+ HAS_AXENGINE = True
35
+ except ImportError:
36
+ HAS_AXENGINE = False
37
+
38
+
39
+ class AxeSession:
40
+ """Thin wrapper around axengine.InferenceSession for one axmodel."""
41
+
42
+ def __init__(self, model_path: str | Path):
43
+ if not HAS_AXENGINE:
44
+ raise RuntimeError("axengine not available; run this script on the AX650 board")
45
+ self.path = Path(model_path)
46
+ if not self.path.exists():
47
+ raise FileNotFoundError(f"Model not found: {self.path}")
48
+ with _suppress_native_stdio():
49
+ self._session = axengine.InferenceSession(str(self.path))
50
+ self._inputs = self._session.get_inputs()
51
+ self._outputs = self._session.get_outputs()
52
+
53
+ @property
54
+ def input_names(self) -> List[str]:
55
+ return [item.name for item in self._inputs]
56
+
57
+ @property
58
+ def output_names(self) -> List[str]:
59
+ return [item.name for item in self._outputs]
60
+
61
+ def run(self, feed_dict: Dict[str, np.ndarray]) -> Dict[str, np.ndarray]:
62
+ outputs = self._session.run(None, feed_dict)
63
+ if isinstance(outputs, dict):
64
+ return outputs
65
+ if isinstance(outputs, (list, tuple)):
66
+ return {name: value for name, value in zip(self.output_names, outputs)}
67
+ if len(self.output_names) == 1:
68
+ return {self.output_names[0]: outputs}
69
+ raise RuntimeError(f"Unexpected AXEngine output type from {self.path.name}: {type(outputs)}")