三芯片合并:AX620E/AX637 升级 encoder+decoder 全 NPU,新增新一代 SDK;AX650 保持老 SDK
Browse files- README.md +60 -21
- cpp/CMakeLists.txt +36 -0
- cpp/README.md +85 -0
- cpp/cmake/aarch64-linux-gnu.toolchain.cmake +15 -0
- cpp/src/ax_runner.cpp +188 -0
- cpp/src/ax_runner.h +38 -0
- cpp/src/host_chain.cpp +110 -0
- cpp/src/host_chain.h +63 -0
- cpp/src/inflect_tts.cpp +120 -0
- cpp/src/inflect_tts.h +36 -0
- cpp/src/main.cpp +219 -0
- cpp/src/wav_writer.cpp +52 -0
- cpp/src/wav_writer.h +8 -0
- models/ax620e/decoder.axmodel +3 -0
- models/ax620e/encoder.axmodel +3 -0
- models/ax620e/model_meta.json +269 -0
- models/ax637/decoder.axmodel +3 -0
- models/ax637/encoder.axmodel +3 -0
- models/ax637/model_meta.json +269 -0
- python/README.md +103 -0
- python/example.py +80 -0
- python/inflect_ax_tts/__init__.py +13 -0
- python/inflect_ax_tts/backend.py +90 -0
- python/inflect_ax_tts/frontend.py +75 -0
- python/inflect_ax_tts/host_chain.py +217 -0
- python/inflect_ax_tts/symbols.py +16 -0
- python/inflect_ax_tts/text_normalize.py +395 -0
- python/inflect_ax_tts/tts.py +170 -0
- python/inflect_ax_tts/wav_io.py +22 -0
- python/requirements.txt +11 -0
- python/tests/test_host_chain.py +244 -0
- run.sh +17 -5
- setup.sh +11 -6
README.md
CHANGED
|
@@ -5,17 +5,59 @@ tags: [text-to-speech, vits, ax650, ax620e, ax637, npu, onnx]
|
|
| 5 |
pipeline_tag: text-to-speech
|
| 6 |
---
|
| 7 |
|
| 8 |
-
# Inflect-Micro-v2 for AXera NPU
|
| 9 |
|
| 10 |
High-quality English TTS (VITS) optimized for **AX650 / AX620E / AX637** NPUs.
|
| 11 |
|
| 12 |
-
|
| 13 |
-
|------|---------|---------|-----------|
|
| 14 |
-
| **AX650** | NPU (axmodel) | NPU (axmodel) | U16 |
|
| 15 |
-
| **AX620E** | NPU (axmodel) | CPU (onnxruntime) | U16 |
|
| 16 |
-
| **AX637** | NPU (axmodel) | CPU (onnxruntime) | U16 |
|
| 17 |
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
**"Hello and welcome to Inflect Micro V2 text to speech."**
|
| 21 |
<audio controls src="https://huggingface.co/AXERA-TECH/inflect_micro_v2/resolve/main/samples/en_hello.wav"></audio>
|
|
@@ -32,21 +74,18 @@ High-quality English TTS (VITS) optimized for **AX650 / AX620E / AX637** NPUs.
|
|
| 32 |
**"This audio was generated entirely on the AX650 NPU chip."**
|
| 33 |
<audio controls src="https://huggingface.co/AXERA-TECH/inflect_micro_v2/resolve/main/samples/en_intro.wav"></audio>
|
| 34 |
|
| 35 |
-
##
|
| 36 |
-
|
| 37 |
-
```bash
|
| 38 |
-
bash setup.sh
|
| 39 |
-
bash run.sh "Hello world." output.wav cli ax650
|
| 40 |
-
```
|
| 41 |
-
|
| 42 |
-
## 📁 Structure
|
| 43 |
|
| 44 |
```
|
| 45 |
models/
|
| 46 |
-
ax650/
|
| 47 |
-
ax620e/
|
| 48 |
-
ax637/
|
| 49 |
-
python/
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
```
|
|
|
|
| 5 |
pipeline_tag: text-to-speech
|
| 6 |
---
|
| 7 |
|
| 8 |
+
# Inflect-Micro-v2 for AXera NPU(三芯片)
|
| 9 |
|
| 10 |
High-quality English TTS (VITS) optimized for **AX650 / AX620E / AX637** NPUs.
|
| 11 |
|
| 12 |
+
## 芯片支持
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
| Chip | Encoder | Decoder | SDK |
|
| 15 |
+
|------|---------|---------|-----|
|
| 16 |
+
| **AX650** | NPU (axmodel) | NPU (axmodel) | 老一代 `inflect_tts_sdk`(emb + dp 分离) |
|
| 17 |
+
| **AX620E** | NPU (axmodel) | **NPU (axmodel)** | 新一代 `inflect_ax_tts` |
|
| 18 |
+
| **AX637** | NPU (axmodel) | **NPU (axmodel)** | 新一代 `inflect_ax_tts` |
|
| 19 |
+
|
| 20 |
+
> 注:AX620E/AX637 已从“encoder NPU + decoder CPU(onnxruntime)”升级为
|
| 21 |
+
> **encoder/decoder 双 NPU**。AX650 保持原有全 NPU 交付与老 SDK 不变。
|
| 22 |
+
|
| 23 |
+
## 模型
|
| 24 |
+
|
| 25 |
+
```
|
| 26 |
+
models/
|
| 27 |
+
ax650/ — 老一代全 NPU(encoder/decoder axmodel + dp.onnx + emb.npy)
|
| 28 |
+
ax620e/ — 新一代全 NPU(encoder.axmodel + decoder.axmodel, T=256/Tp=512)
|
| 29 |
+
ax637/ — 新一代全 NPU(encoder.axmodel + decoder.axmodel, 权重 S16 升级版)
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
新一代 encoder 输入 `tokens [1,256] S32` + `x_lengths [1] S32`,输出
|
| 33 |
+
`m_p/logs_p [1,192,256]`、`logw [1,1,256]`;decoder 输入 `z_p [1,192,512] fp32`,
|
| 34 |
+
输出 `wav [1,1,131072]`(≈5.46 s/块)。Host 侧完成音素前端、时长取整、
|
| 35 |
+
generate_path 对齐、prior 扩展与噪声注入(seed 可控)。
|
| 36 |
+
|
| 37 |
+
## 快速开始
|
| 38 |
+
|
| 39 |
+
```bash
|
| 40 |
+
bash setup.sh
|
| 41 |
+
|
| 42 |
+
# AX650(老 SDK)
|
| 43 |
+
bash run.sh "Hello world." output.wav cli ax650
|
| 44 |
+
|
| 45 |
+
# AX620E / AX637(新 SDK,encoder+decoder 全 NPU)
|
| 46 |
+
bash run.sh "Hello world." output.wav cli ax620e
|
| 47 |
+
bash run.sh "Hello world." output.wav cli ax637
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
新 SDK 也可以直接用:
|
| 51 |
+
|
| 52 |
+
```bash
|
| 53 |
+
cd python
|
| 54 |
+
python3 example.py \
|
| 55 |
+
--encoder ../models/ax620e/encoder.axmodel \
|
| 56 |
+
--decoder ../models/ax620e/decoder.axmodel \
|
| 57 |
+
--text "The quick brown fox jumps over the lazy dog." --output out.wav
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
## 音频示例(AX650 NPU)
|
| 61 |
|
| 62 |
**"Hello and welcome to Inflect Micro V2 text to speech."**
|
| 63 |
<audio controls src="https://huggingface.co/AXERA-TECH/inflect_micro_v2/resolve/main/samples/en_hello.wav"></audio>
|
|
|
|
| 74 |
**"This audio was generated entirely on the AX650 NPU chip."**
|
| 75 |
<audio controls src="https://huggingface.co/AXERA-TECH/inflect_micro_v2/resolve/main/samples/en_intro.wav"></audio>
|
| 76 |
|
| 77 |
+
## 目录结构
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
```
|
| 80 |
models/
|
| 81 |
+
ax650/ — 全 NPU(老一代模型,emb + dp 分离)
|
| 82 |
+
ax620e/ — 全 NPU(新一代模型)
|
| 83 |
+
ax637/ — 全 NPU(新一代模型,decoder 权重 S16)
|
| 84 |
+
python/
|
| 85 |
+
inflect_tts_sdk/ — 老 SDK(AX650)
|
| 86 |
+
inflect_ax_tts/ — 新 SDK(AX620E/AX637)+ example.py + tests
|
| 87 |
+
cpp/
|
| 88 |
+
inflect_tts_sdk/ — 老 C++ SDK(AX650)
|
| 89 |
+
CMakeLists.txt + src/ — 新 C++ SDK(AX620E/AX637)
|
| 90 |
+
samples/ — 音频示例(AX650 NPU 生成)
|
| 91 |
```
|
cpp/CMakeLists.txt
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
cmake_minimum_required(VERSION 3.16)
|
| 2 |
+
project(inflect_tts_sdk CXX)
|
| 3 |
+
|
| 4 |
+
set(CMAKE_CXX_STANDARD 17)
|
| 5 |
+
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
| 6 |
+
set(CMAKE_CXX_EXTENSIONS OFF)
|
| 7 |
+
|
| 8 |
+
if(NOT CMAKE_BUILD_TYPE)
|
| 9 |
+
set(CMAKE_BUILD_TYPE Release)
|
| 10 |
+
endif()
|
| 11 |
+
|
| 12 |
+
# Root of the AXera BSP runtime (contains include/ with ax_engine_api.h,
|
| 13 |
+
# ax_sys_api.h and lib/ with libax_engine.so, libax_sys.so). Leave empty for a
|
| 14 |
+
# host configure/build check — the engine wrapper compiles to a throwing stub.
|
| 15 |
+
set(AX_RUNTIME_ROOT "" CACHE PATH "AX BSP runtime root (include/ + lib/)")
|
| 16 |
+
|
| 17 |
+
add_library(inflect_tts STATIC
|
| 18 |
+
src/inflect_tts.cpp
|
| 19 |
+
src/host_chain.cpp
|
| 20 |
+
src/ax_runner.cpp
|
| 21 |
+
src/wav_writer.cpp
|
| 22 |
+
)
|
| 23 |
+
target_include_directories(inflect_tts PUBLIC src)
|
| 24 |
+
|
| 25 |
+
if(AX_RUNTIME_ROOT)
|
| 26 |
+
target_compile_definitions(inflect_tts PRIVATE INFLECT_WITH_AX_ENGINE=1)
|
| 27 |
+
target_include_directories(inflect_tts PRIVATE ${AX_RUNTIME_ROOT}/include)
|
| 28 |
+
target_link_directories(inflect_tts PUBLIC ${AX_RUNTIME_ROOT}/lib)
|
| 29 |
+
target_link_libraries(inflect_tts PUBLIC ax_engine ax_sys)
|
| 30 |
+
message(STATUS "inflect_tts: AX runtime from ${AX_RUNTIME_ROOT}")
|
| 31 |
+
else()
|
| 32 |
+
message(STATUS "inflect_tts: AX_RUNTIME_ROOT not set — building host stub (no NPU runtime)")
|
| 33 |
+
endif()
|
| 34 |
+
|
| 35 |
+
add_executable(inflect_tts_cli src/main.cpp)
|
| 36 |
+
target_link_libraries(inflect_tts_cli PRIVATE inflect_tts)
|
cpp/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Inflect AX TTS C++ SDK
|
| 2 |
+
|
| 3 |
+
`owensong/Inflect-Micro-v2` Encoder/Decoder 两段式 AXMODEL 的 C++ 推理 SDK,面向 **AX620E / AX637** 板端。直接链接 AX Engine 运行时(`ax_engine` + `ax_sys`,AX620E BSP 惯例)。两套 axmodel 接口一致,通过模型路径切换目标。
|
| 4 |
+
|
| 5 |
+
## 范围说明
|
| 6 |
+
|
| 7 |
+
- **包含**:encoder/decoder 推理封装(`AxRunner`)、完整 Host 链(时长取整、`generate_path` 对齐、prior 扩展、噪声注入、Tp=512 分块 crossfade、edge fade、clip、WAV 写出)——纯 C++17,无 Eigen/第三方依赖;`generate_path` 为纯整数逻辑,扩展 matmul 由 0/1 对齐性质等价实现为逐帧 gather(与 matmul 逐位一致,有自测)。
|
| 8 |
+
- **不包含**:eSpeak 文本前端(文本 → 音素 id)。C++ 侧 API 接收音素 id 序列;文本前端请用 Python SDK(`../python`)离线生成 id,或在板端自行集成 eSpeak-ng 后按模型仓库 `runtime/text/symbols.py` 映射。
|
| 9 |
+
- **seed**:C++ 用 `std::mt19937_64 + normal_distribution`,SDK 内可复现;与 PyTorch 参考及 Python SDK(numpy PCG64)均不逐位一致。
|
| 10 |
+
|
| 11 |
+
## 目录
|
| 12 |
+
|
| 13 |
+
```
|
| 14 |
+
src/ax_runner.{h,cpp} AX Engine 运行时封装(AX_RUNTIME_ROOT 未设时为 stub)
|
| 15 |
+
src/host_chain.{h,cpp} Host 链:expand_priors / inject_noise / 分块 crossfade / edge fade
|
| 16 |
+
src/inflect_tts.{h,cpp} InflectTTS 类:synthesize_tokens(token_ids, speed, variation, seed)
|
| 17 |
+
src/wav_writer.{h,cpp} 16-bit PCM WAV 写出
|
| 18 |
+
src/main.cpp inflect_tts_cli 示例 + --selftest
|
| 19 |
+
cmake/aarch64-linux-gnu.toolchain.cmake
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
## 构建
|
| 23 |
+
|
| 24 |
+
### 本机 configure / 构建检查(无 BSP,stub 引擎)
|
| 25 |
+
|
| 26 |
+
```bash
|
| 27 |
+
cd package/cpp
|
| 28 |
+
cmake -B build .
|
| 29 |
+
cmake --build build -j
|
| 30 |
+
./build/inflect_tts_cli --selftest # Host 链自测(无需 AX 运行时)
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
### 板端 aarch64 交叉编译(AX620E)
|
| 34 |
+
|
| 35 |
+
```bash
|
| 36 |
+
cd package/cpp
|
| 37 |
+
cmake -B build-aarch64 \
|
| 38 |
+
-DCMAKE_TOOLCHAIN_FILE=cmake/aarch64-linux-gnu.toolchain.cmake \
|
| 39 |
+
-DAX_RUNTIME_ROOT=/opt/ax620e_bsp/runtime \
|
| 40 |
+
.
|
| 41 |
+
cmake --build build-aarch64 -j
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
`AX_RUNTIME_ROOT` 指向 AX620E BSP SDK 的运行时目录(示例写 `/opt/ax620e_bsp/runtime`,按实际 BSP 安装位置替换),需含 `include/ax_engine_api.h`、`include/ax_sys_api.h` 与 `lib/libax_engine.so`、`lib/libax_sys.so`。板内原生编译去掉 toolchain 行、保留 `-DAX_RUNTIME_ROOT` 即可。AX637 同一份代码,仅 axmodel 与 BSP 库不同。
|
| 45 |
+
|
| 46 |
+
## 板端部署与运行
|
| 47 |
+
|
| 48 |
+
```bash
|
| 49 |
+
# 目标机(示例 IP 192.168.1.10,按实际板地址替换)
|
| 50 |
+
scp build-aarch64/inflect_tts_cli root@192.168.1.10:/opt/inflect/
|
| 51 |
+
scp ../models/ax620e/encoder.axmodel ../models/ax620e/decoder.axmodel root@192.168.1.10:/opt/inflect/
|
| 52 |
+
|
| 53 |
+
# 板上(dummy 音素 id,对应文本 "ðə kwɪk")
|
| 54 |
+
./inflect_tts_cli \
|
| 55 |
+
--encoder encoder.axmodel --decoder decoder.axmodel \
|
| 56 |
+
--tokens 81,83,16,53,65,102,53 \
|
| 57 |
+
--output out.wav --speed 1.0 --variation 0.667 --seed 0
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
## API
|
| 61 |
+
|
| 62 |
+
```cpp
|
| 63 |
+
#include "inflect_tts.h"
|
| 64 |
+
#include "wav_writer.h"
|
| 65 |
+
|
| 66 |
+
InflectTTS tts("encoder.axmodel", "decoder.axmodel");
|
| 67 |
+
std::vector<int64_t> ids = {81, 83, 16, 53, 65, 102, 53}; // 音素 id(未 intersperse)
|
| 68 |
+
std::vector<float> wav = tts.synthesize_tokens(ids, /*speed*/1.0f,
|
| 69 |
+
/*variation*/0.667f, /*seed*/0);
|
| 70 |
+
write_wav("out.wav", wav, 24000);
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
- `token_ids`:音素 id(**未** intersperse;SDK 内部插 blank,长度上限 127 音素 → 255 tokens,对应 encoder 静态 T=256)。
|
| 74 |
+
- 返回 24 kHz mono float32,[-1,1],已做 5 ms edge fade。
|
| 75 |
+
|
| 76 |
+
## I/O 契约(与 Python SDK 一致)
|
| 77 |
+
|
| 78 |
+
- encoder in:`tokens` int32 [1,256](0 右补齐;**注意 axmodel 运行时输入为 S32**,S64 input_processors 已在编译期折叠)、`x_lengths` int32 [1];out:`m_p`/`logs_p` fp32 [1,192,256]、`logw` fp32 [1,1,256](按输出名寻址,取前 x_lengths 帧)。
|
| 79 |
+
- decoder in:`z_p` fp32 [1,192,512](右补零);out:`wav` fp32 [1,1,131072],按有效帧裁剪、块间 64 帧 crossfade。
|
| 80 |
+
- Host 链限制:decoder 右缘 padding 会经卷积感受野倒灌污染末段帧(实测 T'=51 时约最后 12 帧;SDK 以 5 ms edge fade 缓解);多块(T'>512)crossfade 路径未做数值门禁验证。
|
| 81 |
+
|
| 82 |
+
## 已知限制
|
| 83 |
+
|
| 84 |
+
- 本仓库构建机上 **未做交叉编译与板端运行验证**(无 aarch64 工具链/无板):仅验证本机 configure + 构建 + `--selftest` 通过。`ax_runner.cpp` 的 AX Engine 调用按 AX620E BSP 惯例编写,首次板端构建如遇到 API 差异以 BSP 头文件为准。
|
| 85 |
+
- 文本前端不在 C++ 范围内(见"范围说明")。
|
cpp/cmake/aarch64-linux-gnu.toolchain.cmake
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# aarch64 cross toolchain for AX620E / AX637 boards.
|
| 2 |
+
# Usage:
|
| 3 |
+
# cmake -B build-aarch64 \
|
| 4 |
+
# -DCMAKE_TOOLCHAIN_FILE=cmake/aarch64-linux-gnu.toolchain.cmake \
|
| 5 |
+
# -DAX_RUNTIME_ROOT=<ax bsp runtime root> .
|
| 6 |
+
set(CMAKE_SYSTEM_NAME Linux)
|
| 7 |
+
set(CMAKE_SYSTEM_PROCESSOR aarch64)
|
| 8 |
+
|
| 9 |
+
set(CMAKE_C_COMPILER aarch64-linux-gnu-gcc)
|
| 10 |
+
set(CMAKE_CXX_COMPILER aarch64-linux-gnu-g++)
|
| 11 |
+
|
| 12 |
+
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
|
| 13 |
+
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
|
| 14 |
+
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
|
| 15 |
+
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
|
cpp/src/ax_runner.cpp
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#include "ax_runner.h"
|
| 2 |
+
|
| 3 |
+
#include <stdexcept>
|
| 4 |
+
|
| 5 |
+
#ifdef INFLECT_WITH_AX_ENGINE
|
| 6 |
+
|
| 7 |
+
#include <ax_engine_api.h>
|
| 8 |
+
#include <ax_sys_api.h>
|
| 9 |
+
|
| 10 |
+
#include <cstring>
|
| 11 |
+
#include <fstream>
|
| 12 |
+
#include <iterator>
|
| 13 |
+
|
| 14 |
+
namespace {
|
| 15 |
+
|
| 16 |
+
std::vector<char> read_binary(const std::string& path) {
|
| 17 |
+
std::ifstream file(path, std::ios::binary);
|
| 18 |
+
if (!file) {
|
| 19 |
+
throw std::runtime_error("failed to open " + path);
|
| 20 |
+
}
|
| 21 |
+
return std::vector<char>(std::istreambuf_iterator<char>(file),
|
| 22 |
+
std::istreambuf_iterator<char>());
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
void check_ax(int ret, const char* message) {
|
| 26 |
+
if (ret != 0) {
|
| 27 |
+
throw std::runtime_error(message);
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
} // namespace
|
| 32 |
+
|
| 33 |
+
struct AxRunner::Impl {
|
| 34 |
+
AX_ENGINE_HANDLE handle = nullptr;
|
| 35 |
+
AX_ENGINE_CONTEXT_T context = nullptr;
|
| 36 |
+
AX_ENGINE_IO_INFO_T* info = nullptr;
|
| 37 |
+
AX_ENGINE_IO_T io{};
|
| 38 |
+
std::vector<AX_ENGINE_IO_BUFFER_T> inputs;
|
| 39 |
+
std::vector<AX_ENGINE_IO_BUFFER_T> outputs;
|
| 40 |
+
std::vector<char> model;
|
| 41 |
+
|
| 42 |
+
explicit Impl(const std::string& model_path) : model(read_binary(model_path)) {
|
| 43 |
+
check_ax(AX_SYS_Init(), "AX_SYS_Init failed");
|
| 44 |
+
|
| 45 |
+
AX_ENGINE_NPU_ATTR_T npu_attr;
|
| 46 |
+
std::memset(&npu_attr, 0, sizeof(npu_attr));
|
| 47 |
+
npu_attr.eHardMode = static_cast<AX_ENGINE_NPU_MODE_T>(0);
|
| 48 |
+
check_ax(AX_ENGINE_Init(&npu_attr), "AX_ENGINE_Init failed");
|
| 49 |
+
|
| 50 |
+
AX_ENGINE_HANDLE_EXTRA_T extra;
|
| 51 |
+
std::memset(&extra, 0, sizeof(extra));
|
| 52 |
+
char model_name[] = "inflect_tts";
|
| 53 |
+
extra.pName = reinterpret_cast<AX_S8*>(model_name);
|
| 54 |
+
check_ax(AX_ENGINE_CreateHandleV2(&handle, model.data(),
|
| 55 |
+
static_cast<AX_U32>(model.size()), &extra),
|
| 56 |
+
"AX_ENGINE_CreateHandleV2 failed");
|
| 57 |
+
check_ax(AX_ENGINE_CreateContextV2(handle, &context),
|
| 58 |
+
"AX_ENGINE_CreateContextV2 failed");
|
| 59 |
+
check_ax(AX_ENGINE_GetIOInfo(handle, &info), "AX_ENGINE_GetIOInfo failed");
|
| 60 |
+
if (!info || info->nInputSize < 1 || info->nOutputSize < 1) {
|
| 61 |
+
throw std::runtime_error("model has no input or output tensors");
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
inputs.resize(info->nInputSize);
|
| 65 |
+
outputs.resize(info->nOutputSize);
|
| 66 |
+
io.pInputs = inputs.data();
|
| 67 |
+
io.nInputSize = info->nInputSize;
|
| 68 |
+
io.pOutputs = outputs.data();
|
| 69 |
+
io.nOutputSize = info->nOutputSize;
|
| 70 |
+
|
| 71 |
+
for (AX_U32 i = 0; i < info->nInputSize; ++i) {
|
| 72 |
+
allocate(inputs[i], info->pInputs[i].nSize, "inflect_input");
|
| 73 |
+
}
|
| 74 |
+
for (AX_U32 i = 0; i < info->nOutputSize; ++i) {
|
| 75 |
+
allocate(outputs[i], info->pOutputs[i].nSize, "inflect_output");
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
~Impl() {
|
| 80 |
+
for (auto& item : inputs) {
|
| 81 |
+
if (item.phyAddr) AX_SYS_MemFree(item.phyAddr, item.pVirAddr);
|
| 82 |
+
}
|
| 83 |
+
for (auto& item : outputs) {
|
| 84 |
+
if (item.phyAddr) AX_SYS_MemFree(item.phyAddr, item.pVirAddr);
|
| 85 |
+
}
|
| 86 |
+
if (handle) AX_ENGINE_DestroyHandle(handle);
|
| 87 |
+
AX_ENGINE_Deinit();
|
| 88 |
+
AX_SYS_Deinit();
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
static void allocate(AX_ENGINE_IO_BUFFER_T& buffer, AX_U32 size, const char* token) {
|
| 92 |
+
std::memset(&buffer, 0, sizeof(buffer));
|
| 93 |
+
buffer.nSize = size;
|
| 94 |
+
check_ax(AX_SYS_MemAllocCached(&buffer.phyAddr, &buffer.pVirAddr, buffer.nSize,
|
| 95 |
+
128, reinterpret_cast<const AX_S8*>(token)),
|
| 96 |
+
"AX_SYS_MemAllocCached failed");
|
| 97 |
+
}
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
AxRunner::AxRunner(const std::string& model_path) : impl_(new Impl(model_path)) {}
|
| 101 |
+
AxRunner::~AxRunner() { delete impl_; }
|
| 102 |
+
|
| 103 |
+
std::vector<size_t> AxRunner::input_sizes() const {
|
| 104 |
+
std::vector<size_t> sizes;
|
| 105 |
+
for (AX_U32 i = 0; i < impl_->info->nInputSize; ++i) {
|
| 106 |
+
sizes.push_back(impl_->info->pInputs[i].nSize);
|
| 107 |
+
}
|
| 108 |
+
return sizes;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
std::vector<size_t> AxRunner::output_sizes() const {
|
| 112 |
+
std::vector<size_t> sizes;
|
| 113 |
+
for (AX_U32 i = 0; i < impl_->info->nOutputSize; ++i) {
|
| 114 |
+
sizes.push_back(impl_->info->pOutputs[i].nSize);
|
| 115 |
+
}
|
| 116 |
+
return sizes;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
std::vector<std::string> AxRunner::input_names() const {
|
| 120 |
+
std::vector<std::string> names;
|
| 121 |
+
for (AX_U32 i = 0; i < impl_->info->nInputSize; ++i) {
|
| 122 |
+
names.emplace_back(impl_->info->pInputs[i].pName
|
| 123 |
+
? reinterpret_cast<const char*>(impl_->info->pInputs[i].pName)
|
| 124 |
+
: "");
|
| 125 |
+
}
|
| 126 |
+
return names;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
std::vector<std::string> AxRunner::output_names() const {
|
| 130 |
+
std::vector<std::string> names;
|
| 131 |
+
for (AX_U32 i = 0; i < impl_->info->nOutputSize; ++i) {
|
| 132 |
+
names.emplace_back(impl_->info->pOutputs[i].pName
|
| 133 |
+
? reinterpret_cast<const char*>(impl_->info->pOutputs[i].pName)
|
| 134 |
+
: "");
|
| 135 |
+
}
|
| 136 |
+
return names;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
std::vector<std::vector<uint8_t>> AxRunner::run(
|
| 140 |
+
const std::vector<std::pair<const void*, size_t>>& feeds) {
|
| 141 |
+
if (feeds.size() != impl_->inputs.size()) {
|
| 142 |
+
throw std::runtime_error("feed count != model input count");
|
| 143 |
+
}
|
| 144 |
+
for (size_t i = 0; i < feeds.size(); ++i) {
|
| 145 |
+
if (feeds[i].second != impl_->inputs[i].nSize) {
|
| 146 |
+
throw std::runtime_error("feed byte size != model input buffer size");
|
| 147 |
+
}
|
| 148 |
+
std::memcpy(impl_->inputs[i].pVirAddr, feeds[i].first, feeds[i].second);
|
| 149 |
+
AX_SYS_MflushCache(impl_->inputs[i].phyAddr, impl_->inputs[i].pVirAddr,
|
| 150 |
+
impl_->inputs[i].nSize);
|
| 151 |
+
}
|
| 152 |
+
check_ax(AX_ENGINE_RunSyncV2(impl_->handle, impl_->context, &impl_->io),
|
| 153 |
+
"AX_ENGINE_RunSyncV2 failed");
|
| 154 |
+
|
| 155 |
+
std::vector<std::vector<uint8_t>> result(impl_->outputs.size());
|
| 156 |
+
for (size_t i = 0; i < impl_->outputs.size(); ++i) {
|
| 157 |
+
AX_SYS_MinvalidateCache(impl_->outputs[i].phyAddr, impl_->outputs[i].pVirAddr,
|
| 158 |
+
impl_->outputs[i].nSize);
|
| 159 |
+
result[i].resize(impl_->outputs[i].nSize);
|
| 160 |
+
std::memcpy(result[i].data(), impl_->outputs[i].pVirAddr,
|
| 161 |
+
impl_->outputs[i].nSize);
|
| 162 |
+
}
|
| 163 |
+
return result;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
#else // !INFLECT_WITH_AX_ENGINE — host stub (configure/build only)
|
| 167 |
+
|
| 168 |
+
struct AxRunner::Impl {};
|
| 169 |
+
|
| 170 |
+
AxRunner::AxRunner(const std::string&) : impl_(nullptr) {
|
| 171 |
+
throw std::runtime_error(
|
| 172 |
+
"inflect_tts was built without the AX runtime; reconfigure with "
|
| 173 |
+
"-DAX_RUNTIME_ROOT=<ax bsp root> (see sdk/cpp/README.md)");
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
AxRunner::~AxRunner() = default;
|
| 177 |
+
|
| 178 |
+
std::vector<size_t> AxRunner::input_sizes() const { return {}; }
|
| 179 |
+
std::vector<size_t> AxRunner::output_sizes() const { return {}; }
|
| 180 |
+
std::vector<std::string> AxRunner::input_names() const { return {}; }
|
| 181 |
+
std::vector<std::string> AxRunner::output_names() const { return {}; }
|
| 182 |
+
|
| 183 |
+
std::vector<std::vector<uint8_t>> AxRunner::run(
|
| 184 |
+
const std::vector<std::pair<const void*, size_t>>&) {
|
| 185 |
+
throw std::runtime_error("AX runtime not available in this build");
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
#endif // INFLECT_WITH_AX_ENGINE
|
cpp/src/ax_runner.h
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#pragma once
|
| 2 |
+
|
| 3 |
+
// Thin wrapper over the AX Engine runtime (ax_engine/ax_sys from the AXera
|
| 4 |
+
// BSP, AX620E convention). Compiled against the real runtime when CMake is
|
| 5 |
+
// configured with -DAX_RUNTIME_ROOT=<bsp root>; otherwise a stub that throws
|
| 6 |
+
// on construction (host-side configure/build still passes — see README).
|
| 7 |
+
|
| 8 |
+
#include <cstddef>
|
| 9 |
+
#include <cstdint>
|
| 10 |
+
#include <string>
|
| 11 |
+
#include <utility>
|
| 12 |
+
#include <vector>
|
| 13 |
+
|
| 14 |
+
class AxRunner {
|
| 15 |
+
public:
|
| 16 |
+
explicit AxRunner(const std::string& model_path);
|
| 17 |
+
~AxRunner();
|
| 18 |
+
|
| 19 |
+
AxRunner(const AxRunner&) = delete;
|
| 20 |
+
AxRunner& operator=(const AxRunner&) = delete;
|
| 21 |
+
|
| 22 |
+
// Input/output buffer sizes in bytes, in model-declared order.
|
| 23 |
+
std::vector<size_t> input_sizes() const;
|
| 24 |
+
std::vector<size_t> output_sizes() const;
|
| 25 |
+
// Tensor names in model-declared order (empty when unavailable).
|
| 26 |
+
std::vector<std::string> input_names() const;
|
| 27 |
+
std::vector<std::string> output_names() const;
|
| 28 |
+
|
| 29 |
+
// feeds[i] = (data, bytes) for input i; returns output byte buffers in
|
| 30 |
+
// model output order (m_p, logs_p, logw for the encoder; wav for the
|
| 31 |
+
// decoder — all float32).
|
| 32 |
+
std::vector<std::vector<uint8_t>> run(
|
| 33 |
+
const std::vector<std::pair<const void*, size_t>>& feeds);
|
| 34 |
+
|
| 35 |
+
private:
|
| 36 |
+
struct Impl;
|
| 37 |
+
Impl* impl_;
|
| 38 |
+
};
|
cpp/src/host_chain.cpp
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#include "host_chain.h"
|
| 2 |
+
|
| 3 |
+
#include <algorithm>
|
| 4 |
+
#include <cmath>
|
| 5 |
+
#include <random>
|
| 6 |
+
#include <stdexcept>
|
| 7 |
+
|
| 8 |
+
namespace inflect {
|
| 9 |
+
|
| 10 |
+
ExpandedPriors expand_priors(const float* logw, const float* m_p,
|
| 11 |
+
const float* logs_p, int t_total, int x_len,
|
| 12 |
+
float length_scale) {
|
| 13 |
+
if (x_len < 1 || x_len > t_total) {
|
| 14 |
+
throw std::invalid_argument("x_len out of range [1, t_total]");
|
| 15 |
+
}
|
| 16 |
+
// Duration rounding: w = exp(logw) * x_mask * length_scale; w_ceil = ceil(w).
|
| 17 |
+
// Pure integer cumsum from here on (generate_path).
|
| 18 |
+
std::vector<int64_t> cum(t_total);
|
| 19 |
+
int64_t acc = 0;
|
| 20 |
+
for (int i = 0; i < t_total; ++i) {
|
| 21 |
+
const float w = (i < x_len) ? std::exp(logw[i]) * length_scale : 0.0f;
|
| 22 |
+
acc += static_cast<int64_t>(std::ceil(w));
|
| 23 |
+
cum[i] = acc;
|
| 24 |
+
}
|
| 25 |
+
ExpandedPriors priors;
|
| 26 |
+
priors.t_prime = static_cast<int>(std::max<int64_t>(acc, 1));
|
| 27 |
+
priors.m_p.resize(static_cast<size_t>(kHiddenChannels) * priors.t_prime);
|
| 28 |
+
priors.logs_p.resize(static_cast<size_t>(kHiddenChannels) * priors.t_prime);
|
| 29 |
+
|
| 30 |
+
// Alignment: output frame t copies input frame src(t), where src(t) is the
|
| 31 |
+
// unique i with cum[i-1] <= t < cum[i]. This is exactly
|
| 32 |
+
// attn @ m_p^T with the 0/1 generate_path alignment.
|
| 33 |
+
int src = 0;
|
| 34 |
+
for (int t = 0; t < priors.t_prime; ++t) {
|
| 35 |
+
while (src < t_total - 1 && cum[src] <= t) {
|
| 36 |
+
++src;
|
| 37 |
+
}
|
| 38 |
+
if (cum[src] <= t) {
|
| 39 |
+
break; // t beyond total duration: leave zeros (matches masked path)
|
| 40 |
+
}
|
| 41 |
+
for (int c = 0; c < kHiddenChannels; ++c) {
|
| 42 |
+
const size_t dst = static_cast<size_t>(c) * priors.t_prime + t;
|
| 43 |
+
const size_t s = static_cast<size_t>(c) * t_total + src;
|
| 44 |
+
priors.m_p[dst] = m_p[s];
|
| 45 |
+
priors.logs_p[dst] = logs_p[s];
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
return priors;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
std::vector<float> inject_noise(const ExpandedPriors& priors, float variation,
|
| 52 |
+
uint64_t seed) {
|
| 53 |
+
std::mt19937_64 rng(seed);
|
| 54 |
+
std::normal_distribution<float> normal(0.0f, 1.0f);
|
| 55 |
+
std::vector<float> z_p(priors.m_p.size());
|
| 56 |
+
for (size_t i = 0; i < z_p.size(); ++i) {
|
| 57 |
+
z_p[i] = priors.m_p[i] + normal(rng) * std::exp(priors.logs_p[i]) * variation;
|
| 58 |
+
}
|
| 59 |
+
return z_p;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
std::vector<int> decoder_chunk_starts(int t_prime) {
|
| 63 |
+
if (t_prime <= kDecoderTp) {
|
| 64 |
+
return {0};
|
| 65 |
+
}
|
| 66 |
+
const int stride = kDecoderTp - kDecoderOverlap;
|
| 67 |
+
std::vector<int> starts;
|
| 68 |
+
for (int s = 0; s + kDecoderTp <= t_prime; s += stride) {
|
| 69 |
+
starts.push_back(s);
|
| 70 |
+
}
|
| 71 |
+
const int last = t_prime - kDecoderTp;
|
| 72 |
+
if (starts.back() < last) {
|
| 73 |
+
starts.push_back(last);
|
| 74 |
+
}
|
| 75 |
+
return starts;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
void crossfade_append(std::vector<float>& out, const std::vector<float>& wav,
|
| 79 |
+
int ov_frames) {
|
| 80 |
+
const size_t ov = static_cast<size_t>(ov_frames) * kHopLength;
|
| 81 |
+
if (ov == 0 || out.size() < ov || wav.size() < ov) {
|
| 82 |
+
out.insert(out.end(), wav.begin(), wav.end());
|
| 83 |
+
return;
|
| 84 |
+
}
|
| 85 |
+
const size_t tail = out.size() - ov;
|
| 86 |
+
for (size_t i = 0; i < ov; ++i) {
|
| 87 |
+
const float t = static_cast<float>(i + 1) / static_cast<float>(ov);
|
| 88 |
+
out[tail + i] = out[tail + i] * (1.0f - t) + wav[i] * t;
|
| 89 |
+
}
|
| 90 |
+
out.insert(out.end(), wav.begin() + ov, wav.end());
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
void edge_fade(std::vector<float>& waveform, float milliseconds) {
|
| 94 |
+
const size_t frames = std::min(
|
| 95 |
+
static_cast<size_t>(kSampleRate * milliseconds / 1000.0f + 0.5f),
|
| 96 |
+
waveform.size() / 2);
|
| 97 |
+
for (size_t i = 0; i < frames; ++i) {
|
| 98 |
+
const float t = static_cast<float>(i + 1) / static_cast<float>(frames);
|
| 99 |
+
waveform[i] *= t;
|
| 100 |
+
waveform[waveform.size() - 1 - i] *= t;
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
void clip_inplace(std::vector<float>& waveform, float lo, float hi) {
|
| 105 |
+
for (float& v : waveform) {
|
| 106 |
+
v = std::min(hi, std::max(lo, v));
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
} // namespace inflect
|
cpp/src/host_chain.h
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#pragma once
|
| 2 |
+
|
| 3 |
+
// Host-side chain between encoder.axmodel and decoder.axmodel, pure C++17
|
| 4 |
+
// (no Eigen, no torch). Mirrors export/export_onnx.py:host_expand, which
|
| 5 |
+
// replicates origin/runtime/models.py:546-558:
|
| 6 |
+
//
|
| 7 |
+
// w = exp(logw) * x_mask * length_scale
|
| 8 |
+
// w_ceil = ceil(w); T' = max(sum(w_ceil), 1)
|
| 9 |
+
// attn = generate_path(w_ceil, mask) -- pure integer interval logic
|
| 10 |
+
// m_p' = attn @ m_p^T ; logs_p' = attn @ logs_p^T
|
| 11 |
+
//
|
| 12 |
+
// generate_path produces a 0/1 alignment with exactly one 1 per output frame,
|
| 13 |
+
// so the "matmul" is implemented as an exact per-frame gather (same integer
|
| 14 |
+
// cumsum intervals, provably identical result).
|
| 15 |
+
|
| 16 |
+
#include <cstdint>
|
| 17 |
+
#include <vector>
|
| 18 |
+
|
| 19 |
+
namespace inflect {
|
| 20 |
+
|
| 21 |
+
constexpr int kSampleRate = 24000;
|
| 22 |
+
constexpr int kHopLength = 256;
|
| 23 |
+
constexpr int kHiddenChannels = 192;
|
| 24 |
+
constexpr int kEncoderT = 256; // encoder static token length (0-padded)
|
| 25 |
+
constexpr int kDecoderTp = 512; // decoder static frame length per chunk
|
| 26 |
+
constexpr int kDecoderOverlap = 64; // frames of overlap between decoder chunks
|
| 27 |
+
|
| 28 |
+
struct ExpandedPriors {
|
| 29 |
+
int t_prime = 0; // T'
|
| 30 |
+
// channel-major [kHiddenChannels * t_prime]: element (c, t) at c*t_prime+t
|
| 31 |
+
std::vector<float> m_p;
|
| 32 |
+
std::vector<float> logs_p;
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
// logw: [t_total] (encoder output logw[0,0,:])
|
| 36 |
+
// m_p: [192 * t_total] channel-major (encoder output m_p[0])
|
| 37 |
+
// logs_p: [192 * t_total] channel-major
|
| 38 |
+
// x_len: number of valid (unpadded) token frames
|
| 39 |
+
ExpandedPriors expand_priors(const float* logw, const float* m_p,
|
| 40 |
+
const float* logs_p, int t_total, int x_len,
|
| 41 |
+
float length_scale);
|
| 42 |
+
|
| 43 |
+
// z_p = m_p' + randn * exp(logs_p') * variation, channel-major [192 * T'].
|
| 44 |
+
// NOTE: std::mt19937_64 + normal_distribution — deterministic within this SDK
|
| 45 |
+
// for a given seed, NOT bit-identical to the PyTorch reference.
|
| 46 |
+
std::vector<float> inject_noise(const ExpandedPriors& priors, float variation,
|
| 47 |
+
uint64_t seed);
|
| 48 |
+
|
| 49 |
+
// Frame offsets of Tp-sized decoder chunks covering [0, t_prime) with
|
| 50 |
+
// >= kDecoderOverlap overlap (EXPORT_NOTES §5.3).
|
| 51 |
+
std::vector<int> decoder_chunk_starts(int t_prime);
|
| 52 |
+
|
| 53 |
+
// Crossfade-append `wav` (samples of chunk starting at `start_frame`) onto
|
| 54 |
+
// `out`, overlapping the previous chunk tail by ov_frames frames.
|
| 55 |
+
void crossfade_append(std::vector<float>& out, const std::vector<float>& wav,
|
| 56 |
+
int ov_frames);
|
| 57 |
+
|
| 58 |
+
// 5 ms raised-linear edge fade, as origin/inference.py.
|
| 59 |
+
void edge_fade(std::vector<float>& waveform, float milliseconds = 5.0f);
|
| 60 |
+
|
| 61 |
+
void clip_inplace(std::vector<float>& waveform, float lo = -1.0f, float hi = 1.0f);
|
| 62 |
+
|
| 63 |
+
} // namespace inflect
|
cpp/src/inflect_tts.cpp
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#include "inflect_tts.h"
|
| 2 |
+
|
| 3 |
+
#include <algorithm>
|
| 4 |
+
#include <cstring>
|
| 5 |
+
#include <stdexcept>
|
| 6 |
+
|
| 7 |
+
#include "host_chain.h"
|
| 8 |
+
|
| 9 |
+
namespace {
|
| 10 |
+
|
| 11 |
+
// The compiled AXMODELs take S32 token inputs at runtime: the S64
|
| 12 |
+
// input_processors of the ONNX graphs are folded into the model at compile
|
| 13 |
+
// time (COMPILE_NOTES §3, confirmed by SIMULATE §1).
|
| 14 |
+
using token_t = int32_t;
|
| 15 |
+
|
| 16 |
+
// Index of tensor `name` in `names`; falls back to `fallback` when names are
|
| 17 |
+
// unavailable (older runtimes may not report tensor names).
|
| 18 |
+
size_t name_index(const std::vector<std::string>& names, const char* name,
|
| 19 |
+
size_t fallback) {
|
| 20 |
+
const auto it = std::find(names.begin(), names.end(), name);
|
| 21 |
+
if (it != names.end()) {
|
| 22 |
+
return static_cast<size_t>(it - names.begin());
|
| 23 |
+
}
|
| 24 |
+
return fallback;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
} // namespace
|
| 28 |
+
|
| 29 |
+
InflectTTS::InflectTTS(const std::string& encoder_path,
|
| 30 |
+
const std::string& decoder_path)
|
| 31 |
+
: encoder_(encoder_path), decoder_(decoder_path) {}
|
| 32 |
+
|
| 33 |
+
std::vector<float> InflectTTS::synthesize_tokens(
|
| 34 |
+
const std::vector<int64_t>& token_ids, float speed, float variation,
|
| 35 |
+
uint64_t seed) {
|
| 36 |
+
if (speed < 0.5f || speed > 2.0f) {
|
| 37 |
+
throw std::invalid_argument("speed must be between 0.5 and 2.0");
|
| 38 |
+
}
|
| 39 |
+
if (variation < 0.0f || variation > 1.0f) {
|
| 40 |
+
throw std::invalid_argument("variation must be between 0.0 and 1.0");
|
| 41 |
+
}
|
| 42 |
+
// intersperse(add_blank): N phonemes -> 2N+1 tokens.
|
| 43 |
+
const int x_len = static_cast<int>(token_ids.size()) * 2 + 1;
|
| 44 |
+
if (x_len > inflect::kEncoderT) {
|
| 45 |
+
throw std::invalid_argument(
|
| 46 |
+
"token sequence exceeds encoder static T=256 (split the text)");
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
// ---- encoder: tokens [1,256] zero-padded + x_lengths [1] -------------
|
| 50 |
+
std::vector<token_t> tokens(inflect::kEncoderT, 0);
|
| 51 |
+
for (size_t i = 0; i < token_ids.size(); ++i) {
|
| 52 |
+
tokens[2 * i + 1] = static_cast<token_t>(token_ids[i]);
|
| 53 |
+
}
|
| 54 |
+
token_t x_lengths[1] = {static_cast<token_t>(x_len)};
|
| 55 |
+
const auto enc_in_names = encoder_.input_names();
|
| 56 |
+
std::vector<std::pair<const void*, size_t>> enc_feeds(2);
|
| 57 |
+
enc_feeds[name_index(enc_in_names, "tokens", 0)] =
|
| 58 |
+
{tokens.data(), tokens.size() * sizeof(token_t)};
|
| 59 |
+
enc_feeds[name_index(enc_in_names, "x_lengths", 1)] =
|
| 60 |
+
{x_lengths, sizeof(x_lengths)};
|
| 61 |
+
auto enc_out = encoder_.run(enc_feeds);
|
| 62 |
+
if (enc_out.size() != 3) {
|
| 63 |
+
throw std::runtime_error("encoder must produce 3 outputs (m_p, logs_p, logw)");
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
// ---- host chain: durations + generate_path + expansion + noise -------
|
| 67 |
+
const auto enc_names = encoder_.output_names();
|
| 68 |
+
const float* m_p = reinterpret_cast<const float*>(
|
| 69 |
+
enc_out[name_index(enc_names, "m_p", 0)].data());
|
| 70 |
+
const float* logs_p = reinterpret_cast<const float*>(
|
| 71 |
+
enc_out[name_index(enc_names, "logs_p", 1)].data());
|
| 72 |
+
const float* logw = reinterpret_cast<const float*>(
|
| 73 |
+
enc_out[name_index(enc_names, "logw", 2)].data());
|
| 74 |
+
auto priors = inflect::expand_priors(logw, m_p, logs_p, inflect::kEncoderT,
|
| 75 |
+
x_len, 1.0f / speed);
|
| 76 |
+
std::vector<float> z_p = inflect::inject_noise(priors, variation, seed);
|
| 77 |
+
|
| 78 |
+
// ---- decoder: Tp=512 chunks, overlap crossfade, tail trim ------------
|
| 79 |
+
const int t_prime = priors.t_prime;
|
| 80 |
+
const auto starts = inflect::decoder_chunk_starts(t_prime);
|
| 81 |
+
std::vector<float> out;
|
| 82 |
+
int prev_end = 0;
|
| 83 |
+
for (size_t k = 0; k < starts.size(); ++k) {
|
| 84 |
+
const int start = starts[k];
|
| 85 |
+
const int take = std::min(inflect::kDecoderTp, t_prime - start);
|
| 86 |
+
std::vector<float> z_chunk(
|
| 87 |
+
static_cast<size_t>(inflect::kHiddenChannels) * inflect::kDecoderTp, 0.0f);
|
| 88 |
+
for (int c = 0; c < inflect::kHiddenChannels; ++c) {
|
| 89 |
+
const float* src =
|
| 90 |
+
z_p.data() + static_cast<size_t>(c) * t_prime + start;
|
| 91 |
+
float* dst =
|
| 92 |
+
z_chunk.data() + static_cast<size_t>(c) * inflect::kDecoderTp;
|
| 93 |
+
std::memcpy(dst, src, static_cast<size_t>(take) * sizeof(float));
|
| 94 |
+
}
|
| 95 |
+
std::vector<float> wav = run_decoder_chunk(z_chunk);
|
| 96 |
+
if (k == 0) {
|
| 97 |
+
out = std::move(wav);
|
| 98 |
+
} else {
|
| 99 |
+
inflect::crossfade_append(out, wav, prev_end - start);
|
| 100 |
+
}
|
| 101 |
+
prev_end = start + inflect::kDecoderTp;
|
| 102 |
+
}
|
| 103 |
+
out.resize(static_cast<size_t>(t_prime) * inflect::kHopLength);
|
| 104 |
+
inflect::edge_fade(out);
|
| 105 |
+
inflect::clip_inplace(out);
|
| 106 |
+
return out;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
std::vector<float> InflectTTS::run_decoder_chunk(
|
| 110 |
+
const std::vector<float>& z_chunk) {
|
| 111 |
+
auto dec_out = decoder_.run(
|
| 112 |
+
{{z_chunk.data(), z_chunk.size() * sizeof(float)}});
|
| 113 |
+
if (dec_out.size() != 1) {
|
| 114 |
+
throw std::runtime_error("decoder must produce 1 output (wav)");
|
| 115 |
+
}
|
| 116 |
+
const size_t n = dec_out[0].size() / sizeof(float);
|
| 117 |
+
std::vector<float> wav(n);
|
| 118 |
+
std::memcpy(wav.data(), dec_out[0].data(), dec_out[0].size());
|
| 119 |
+
return wav;
|
| 120 |
+
}
|
cpp/src/inflect_tts.h
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#pragma once
|
| 2 |
+
|
| 3 |
+
// InflectTTS: two-stage (encoder/decoder AXMODEL) VITS TTS pipeline.
|
| 4 |
+
//
|
| 5 |
+
// Scope note: the eSpeak text frontend is NOT part of the C++ SDK — the
|
| 6 |
+
// caller supplies phoneme token ids (produced by the Python SDK frontend or
|
| 7 |
+
// a board-side eSpeak-ng integration; see README). The full duration /
|
| 8 |
+
// alignment / expansion / noise / chunking host chain IS implemented here.
|
| 9 |
+
|
| 10 |
+
#include <cstdint>
|
| 11 |
+
#include <string>
|
| 12 |
+
#include <vector>
|
| 13 |
+
|
| 14 |
+
#include "ax_runner.h"
|
| 15 |
+
|
| 16 |
+
class InflectTTS {
|
| 17 |
+
public:
|
| 18 |
+
InflectTTS(const std::string& encoder_path, const std::string& decoder_path);
|
| 19 |
+
|
| 20 |
+
// token_ids: raw phoneme ids (NOT interspersed; blank 0 is inserted here,
|
| 21 |
+
// mirroring commons.intersperse with add_blank=true).
|
| 22 |
+
// speed in [0.5, 2.0], variation in [0.0, 1.0].
|
| 23 |
+
// Returns 24 kHz mono float32 samples in [-1, 1].
|
| 24 |
+
std::vector<float> synthesize_tokens(const std::vector<int64_t>& token_ids,
|
| 25 |
+
float speed = 1.0f,
|
| 26 |
+
float variation = 0.667f,
|
| 27 |
+
uint64_t seed = 0);
|
| 28 |
+
|
| 29 |
+
private:
|
| 30 |
+
AxRunner encoder_;
|
| 31 |
+
AxRunner decoder_;
|
| 32 |
+
|
| 33 |
+
// One Tp=512 decoder chunk: z_chunk channel-major [192 * kDecoderTp]
|
| 34 |
+
// (zero right-padded) -> wav [kDecoderTp * 256].
|
| 35 |
+
std::vector<float> run_decoder_chunk(const std::vector<float>& z_chunk);
|
| 36 |
+
};
|
cpp/src/main.cpp
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// inflect_tts_cli — Inflect AX TTS C++ SDK example / smoke tool.
|
| 2 |
+
//
|
| 3 |
+
// Synthesize from phoneme ids (eSpeak text frontend is intentionally out of
|
| 4 |
+
// scope for the C++ SDK — see README):
|
| 5 |
+
//
|
| 6 |
+
// ./inflect_tts_cli \
|
| 7 |
+
// --encoder models/ax620e/encoder.axmodel \
|
| 8 |
+
// --decoder models/ax620e/decoder.axmodel \
|
| 9 |
+
// --tokens 81,83,16,53,65,102,53 --output out.wav
|
| 10 |
+
//
|
| 11 |
+
// Host-side self test of the pure-C++ host chain (no AX runtime needed):
|
| 12 |
+
//
|
| 13 |
+
// ./inflect_tts_cli --selftest
|
| 14 |
+
|
| 15 |
+
#include <algorithm>
|
| 16 |
+
#include <cmath>
|
| 17 |
+
#include <cstdint>
|
| 18 |
+
#include <cstdio>
|
| 19 |
+
#include <fstream>
|
| 20 |
+
#include <iostream>
|
| 21 |
+
#include <random>
|
| 22 |
+
#include <stdexcept>
|
| 23 |
+
#include <string>
|
| 24 |
+
#include <vector>
|
| 25 |
+
|
| 26 |
+
#include "host_chain.h"
|
| 27 |
+
#include "inflect_tts.h"
|
| 28 |
+
#include "wav_writer.h"
|
| 29 |
+
|
| 30 |
+
namespace {
|
| 31 |
+
|
| 32 |
+
int selftest() {
|
| 33 |
+
int failures = 0;
|
| 34 |
+
auto check = [&](bool ok, const char* name) {
|
| 35 |
+
std::cout << (ok ? "[PASS] " : "[FAIL] ") << name << "\n";
|
| 36 |
+
if (!ok) ++failures;
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
// 1) expand_priors == explicit generate_path + matmul reference.
|
| 40 |
+
{
|
| 41 |
+
std::mt19937 rng(42);
|
| 42 |
+
std::normal_distribution<float> norm(0.0f, 1.0f);
|
| 43 |
+
const int t_total = 16, x_len = 12;
|
| 44 |
+
std::vector<float> logw(t_total), m_p(inflect::kHiddenChannels * t_total),
|
| 45 |
+
logs_p(inflect::kHiddenChannels * t_total);
|
| 46 |
+
for (auto& v : logw) v = norm(rng);
|
| 47 |
+
for (auto& v : m_p) v = norm(rng);
|
| 48 |
+
for (auto& v : logs_p) v = norm(rng);
|
| 49 |
+
const float ls = 1.25f;
|
| 50 |
+
auto priors = inflect::expand_priors(logw.data(), m_p.data(), logs_p.data(),
|
| 51 |
+
t_total, x_len, ls);
|
| 52 |
+
|
| 53 |
+
// Reference: integer cumsum + explicit 0/1 attn matmul.
|
| 54 |
+
std::vector<int64_t> cum(t_total);
|
| 55 |
+
int64_t acc = 0;
|
| 56 |
+
for (int i = 0; i < t_total; ++i) {
|
| 57 |
+
const float w = (i < x_len) ? std::exp(logw[i]) * ls : 0.0f;
|
| 58 |
+
acc += static_cast<int64_t>(std::ceil(w));
|
| 59 |
+
cum[i] = acc;
|
| 60 |
+
}
|
| 61 |
+
const int t_prime = static_cast<int>(std::max<int64_t>(acc, 1));
|
| 62 |
+
check(priors.t_prime == t_prime, "expand_priors: T' == cumsum sum");
|
| 63 |
+
double max_diff = 0.0;
|
| 64 |
+
for (int t = 0; t < t_prime; ++t) {
|
| 65 |
+
for (int c = 0; c < inflect::kHiddenChannels; ++c) {
|
| 66 |
+
float ref = 0.0f;
|
| 67 |
+
for (int i = 0; i < t_total; ++i) {
|
| 68 |
+
const int64_t lo = (i == 0) ? 0 : cum[i - 1];
|
| 69 |
+
if (lo <= t && t < cum[i]) {
|
| 70 |
+
ref = m_p[static_cast<size_t>(c) * t_total + i];
|
| 71 |
+
break;
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
max_diff = std::max(max_diff,
|
| 75 |
+
static_cast<double>(std::fabs(
|
| 76 |
+
priors.m_p[static_cast<size_t>(c) * t_prime + t] - ref)));
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
check(max_diff == 0.0, "expand_priors: gather == attn matmul (exact)");
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
// 2) Unity durations: identity expansion.
|
| 83 |
+
{
|
| 84 |
+
const int t_total = 8, x_len = 5;
|
| 85 |
+
std::vector<float> logw(t_total, 0.0f); // exp(0)=1 -> 1 frame each
|
| 86 |
+
std::vector<float> m_p(inflect::kHiddenChannels * t_total),
|
| 87 |
+
logs_p(inflect::kHiddenChannels * t_total);
|
| 88 |
+
for (size_t i = 0; i < m_p.size(); ++i) {
|
| 89 |
+
m_p[i] = static_cast<float>(i % 7) * 0.1f;
|
| 90 |
+
logs_p[i] = -1.0f;
|
| 91 |
+
}
|
| 92 |
+
auto priors = inflect::expand_priors(logw.data(), m_p.data(), logs_p.data(),
|
| 93 |
+
t_total, x_len, 1.0f);
|
| 94 |
+
bool ok = priors.t_prime == x_len;
|
| 95 |
+
for (int t = 0; ok && t < x_len; ++t) {
|
| 96 |
+
for (int c = 0; c < inflect::kHiddenChannels; ++c) {
|
| 97 |
+
ok = priors.m_p[static_cast<size_t>(c) * priors.t_prime + t] ==
|
| 98 |
+
m_p[static_cast<size_t>(c) * t_total + t];
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
check(ok, "expand_priors: unity durations == identity");
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
// 3) Noise injection determinism.
|
| 105 |
+
{
|
| 106 |
+
inflect::ExpandedPriors priors;
|
| 107 |
+
priors.t_prime = 4;
|
| 108 |
+
priors.m_p.assign(inflect::kHiddenChannels * 4, 0.5f);
|
| 109 |
+
priors.logs_p.assign(inflect::kHiddenChannels * 4, -1.0f);
|
| 110 |
+
const auto a = inflect::inject_noise(priors, 0.667f, 7);
|
| 111 |
+
const auto b = inflect::inject_noise(priors, 0.667f, 7);
|
| 112 |
+
const auto c = inflect::inject_noise(priors, 0.667f, 8);
|
| 113 |
+
check(a == b, "inject_noise: same seed reproducible");
|
| 114 |
+
check(a != c, "inject_noise: different seed differs");
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
// 4) Decoder chunk starts: coverage + >=64-frame overlap.
|
| 118 |
+
{
|
| 119 |
+
const auto single = inflect::decoder_chunk_starts(51);
|
| 120 |
+
check(single.size() == 1 && single[0] == 0, "chunk_starts: T'<=512 single");
|
| 121 |
+
const auto multi = inflect::decoder_chunk_starts(812);
|
| 122 |
+
bool ok = multi.size() >= 2 && multi.front() == 0 &&
|
| 123 |
+
multi.back() + inflect::kDecoderTp >= 812;
|
| 124 |
+
for (size_t i = 1; ok && i < multi.size(); ++i) {
|
| 125 |
+
ok = multi[i] < multi[i - 1] + inflect::kDecoderTp - inflect::kDecoderOverlap + 1;
|
| 126 |
+
}
|
| 127 |
+
check(ok, "chunk_starts: T'=812 coverage + overlap");
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
// 5) WAV writer round trip header.
|
| 131 |
+
{
|
| 132 |
+
const std::string path = "inflect_selftest_tmp.wav";
|
| 133 |
+
std::vector<float> wav(2400, 0.25f);
|
| 134 |
+
write_wav(path, wav, 24000);
|
| 135 |
+
std::ifstream f(path, std::ios::binary);
|
| 136 |
+
char riff[4];
|
| 137 |
+
f.read(riff, 4);
|
| 138 |
+
const bool ok = f && riff[0] == 'R' && riff[1] == 'I' && riff[2] == 'F' &&
|
| 139 |
+
riff[3] == 'F';
|
| 140 |
+
f.close();
|
| 141 |
+
std::remove(path.c_str());
|
| 142 |
+
check(ok, "write_wav: RIFF header");
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
std::cout << (failures ? "SELFTEST FAILED" : "SELFTEST PASS") << "\n";
|
| 146 |
+
return failures ? 1 : 0;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
std::vector<int64_t> parse_tokens(const std::string& csv) {
|
| 150 |
+
std::vector<int64_t> ids;
|
| 151 |
+
size_t pos = 0;
|
| 152 |
+
while (pos <= csv.size()) {
|
| 153 |
+
const size_t comma = csv.find(',', pos);
|
| 154 |
+
const std::string part = csv.substr(pos, comma - pos);
|
| 155 |
+
if (!part.empty()) {
|
| 156 |
+
ids.push_back(std::stoll(part));
|
| 157 |
+
}
|
| 158 |
+
if (comma == std::string::npos) break;
|
| 159 |
+
pos = comma + 1;
|
| 160 |
+
}
|
| 161 |
+
return ids;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
void usage(const char* argv0) {
|
| 165 |
+
std::cerr
|
| 166 |
+
<< "usage:\n"
|
| 167 |
+
<< " " << argv0 << " --selftest\n"
|
| 168 |
+
<< " " << argv0 << " --encoder ENC.axmodel --decoder DEC.axmodel\n"
|
| 169 |
+
<< " --tokens 81,83,16,53,65,102,53 --output out.wav\n"
|
| 170 |
+
<< " [--speed 1.0] [--variation 0.667] [--seed 0]\n";
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
} // namespace
|
| 174 |
+
|
| 175 |
+
int main(int argc, char** argv) {
|
| 176 |
+
std::string encoder, decoder, tokens_csv, output;
|
| 177 |
+
float speed = 1.0f, variation = 0.667f;
|
| 178 |
+
uint64_t seed = 0;
|
| 179 |
+
for (int i = 1; i < argc; ++i) {
|
| 180 |
+
const std::string arg = argv[i];
|
| 181 |
+
auto next = [&](const char* name) -> std::string {
|
| 182 |
+
if (i + 1 >= argc) {
|
| 183 |
+
throw std::runtime_error(std::string("missing value for ") + name);
|
| 184 |
+
}
|
| 185 |
+
return argv[++i];
|
| 186 |
+
};
|
| 187 |
+
if (arg == "--selftest") return selftest();
|
| 188 |
+
if (arg == "--encoder") encoder = next("--encoder");
|
| 189 |
+
else if (arg == "--decoder") decoder = next("--decoder");
|
| 190 |
+
else if (arg == "--tokens") tokens_csv = next("--tokens");
|
| 191 |
+
else if (arg == "--output") output = next("--output");
|
| 192 |
+
else if (arg == "--speed") speed = std::stof(next("--speed"));
|
| 193 |
+
else if (arg == "--variation") variation = std::stof(next("--variation"));
|
| 194 |
+
else if (arg == "--seed") seed = std::stoull(next("--seed"));
|
| 195 |
+
else if (arg == "--help" || arg == "-h") { usage(argv[0]); return 0; }
|
| 196 |
+
else {
|
| 197 |
+
std::cerr << "unknown argument: " << arg << "\n";
|
| 198 |
+
usage(argv[0]);
|
| 199 |
+
return 2;
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
if (encoder.empty() || decoder.empty() || tokens_csv.empty() || output.empty()) {
|
| 203 |
+
usage(argv[0]);
|
| 204 |
+
return 2;
|
| 205 |
+
}
|
| 206 |
+
try {
|
| 207 |
+
InflectTTS tts(encoder, decoder);
|
| 208 |
+
const auto ids = parse_tokens(tokens_csv);
|
| 209 |
+
auto wav = tts.synthesize_tokens(ids, speed, variation, seed);
|
| 210 |
+
write_wav(output, wav, inflect::kSampleRate);
|
| 211 |
+
std::cout << "wrote " << output << ": " << wav.size() << " samples ("
|
| 212 |
+
<< static_cast<double>(wav.size()) / inflect::kSampleRate
|
| 213 |
+
<< " s @ " << inflect::kSampleRate << " Hz)\n";
|
| 214 |
+
} catch (const std::exception& exc) {
|
| 215 |
+
std::cerr << "error: " << exc.what() << "\n";
|
| 216 |
+
return 1;
|
| 217 |
+
}
|
| 218 |
+
return 0;
|
| 219 |
+
}
|
cpp/src/wav_writer.cpp
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#include "wav_writer.h"
|
| 2 |
+
|
| 3 |
+
#include <algorithm>
|
| 4 |
+
#include <cmath>
|
| 5 |
+
#include <cstdint>
|
| 6 |
+
#include <fstream>
|
| 7 |
+
#include <stdexcept>
|
| 8 |
+
|
| 9 |
+
namespace {
|
| 10 |
+
|
| 11 |
+
void write_u32(std::ofstream& f, uint32_t v) {
|
| 12 |
+
char b[4] = {static_cast<char>(v & 0xff), static_cast<char>((v >> 8) & 0xff),
|
| 13 |
+
static_cast<char>((v >> 16) & 0xff), static_cast<char>((v >> 24) & 0xff)};
|
| 14 |
+
f.write(b, 4);
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
void write_u16(std::ofstream& f, uint16_t v) {
|
| 18 |
+
char b[2] = {static_cast<char>(v & 0xff), static_cast<char>((v >> 8) & 0xff)};
|
| 19 |
+
f.write(b, 2);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
} // namespace
|
| 23 |
+
|
| 24 |
+
void write_wav(const std::string& path, const std::vector<float>& waveform,
|
| 25 |
+
int sample_rate) {
|
| 26 |
+
std::ofstream f(path, std::ios::binary);
|
| 27 |
+
if (!f) {
|
| 28 |
+
throw std::runtime_error("failed to open " + path);
|
| 29 |
+
}
|
| 30 |
+
const uint32_t data_bytes = static_cast<uint32_t>(waveform.size() * 2);
|
| 31 |
+
f.write("RIFF", 4);
|
| 32 |
+
write_u32(f, 36 + data_bytes);
|
| 33 |
+
f.write("WAVE", 4);
|
| 34 |
+
f.write("fmt ", 4);
|
| 35 |
+
write_u32(f, 16); // PCM chunk size
|
| 36 |
+
write_u16(f, 1); // PCM format
|
| 37 |
+
write_u16(f, 1); // mono
|
| 38 |
+
write_u32(f, static_cast<uint32_t>(sample_rate));
|
| 39 |
+
write_u32(f, static_cast<uint32_t>(sample_rate * 2)); // byte rate
|
| 40 |
+
write_u16(f, 2); // block align
|
| 41 |
+
write_u16(f, 16); // bits per sample
|
| 42 |
+
f.write("data", 4);
|
| 43 |
+
write_u32(f, data_bytes);
|
| 44 |
+
for (float v : waveform) {
|
| 45 |
+
v = std::min(1.0f, std::max(-1.0f, v));
|
| 46 |
+
write_u16(f, static_cast<uint16_t>(
|
| 47 |
+
static_cast<int16_t>(std::lround(v * 32767.0f))));
|
| 48 |
+
}
|
| 49 |
+
if (!f) {
|
| 50 |
+
throw std::runtime_error("failed while writing " + path);
|
| 51 |
+
}
|
| 52 |
+
}
|
cpp/src/wav_writer.h
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#pragma once
|
| 2 |
+
|
| 3 |
+
#include <string>
|
| 4 |
+
#include <vector>
|
| 5 |
+
|
| 6 |
+
// Write float32 mono samples in [-1, 1] as 16-bit PCM WAV.
|
| 7 |
+
void write_wav(const std::string& path, const std::vector<float>& waveform,
|
| 8 |
+
int sample_rate = 24000);
|
models/ax620e/decoder.axmodel
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b30551a723f8d87182c5211de471da80b3ce2b998bcdc6ead4d8d3b895409d16
|
| 3 |
+
size 22973407
|
models/ax620e/encoder.axmodel
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6651c46a2e747bda45e1781d7993696d605ec2a1b0cec44bab49d91a3cc96bad
|
| 3 |
+
size 2322718
|
models/ax620e/model_meta.json
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"model": "owensong/Inflect-Micro-v2 (VITS-family TTS, 9.36M params)",
|
| 3 |
+
"opset": 17,
|
| 4 |
+
"exported_with": "torch 2.13.0+cpu dynamo exporter, fp32, weight_norm collapsed (9,344,753 params)",
|
| 5 |
+
"sample_rate": 24000,
|
| 6 |
+
"hop_length": 256,
|
| 7 |
+
"graphs": {
|
| 8 |
+
"encoder": {
|
| 9 |
+
"file": "encoder.onnx",
|
| 10 |
+
"contains": [
|
| 11 |
+
"enc_p (TextEncoder: embedding*sqrt(96) + 3-layer Transformer w/ relative-position attention)",
|
| 12 |
+
"dp (deterministic DurationPredictor, use_sdp=false)"
|
| 13 |
+
],
|
| 14 |
+
"inputs": {
|
| 15 |
+
"tokens": {
|
| 16 |
+
"dtype": "int64",
|
| 17 |
+
"shape": [
|
| 18 |
+
1,
|
| 19 |
+
"T"
|
| 20 |
+
],
|
| 21 |
+
"note": "phoneme ids, add_blank intersperse -> 2N+1"
|
| 22 |
+
},
|
| 23 |
+
"x_lengths": {
|
| 24 |
+
"dtype": "int64",
|
| 25 |
+
"shape": [
|
| 26 |
+
1
|
| 27 |
+
]
|
| 28 |
+
}
|
| 29 |
+
},
|
| 30 |
+
"outputs": {
|
| 31 |
+
"m_p": {
|
| 32 |
+
"dtype": "float32",
|
| 33 |
+
"shape": [
|
| 34 |
+
1,
|
| 35 |
+
192,
|
| 36 |
+
"T"
|
| 37 |
+
]
|
| 38 |
+
},
|
| 39 |
+
"logs_p": {
|
| 40 |
+
"dtype": "float32",
|
| 41 |
+
"shape": [
|
| 42 |
+
1,
|
| 43 |
+
192,
|
| 44 |
+
"T"
|
| 45 |
+
]
|
| 46 |
+
},
|
| 47 |
+
"logw": {
|
| 48 |
+
"dtype": "float32",
|
| 49 |
+
"shape": [
|
| 50 |
+
1,
|
| 51 |
+
1,
|
| 52 |
+
"T"
|
| 53 |
+
],
|
| 54 |
+
"note": "log-duration; host: w=exp(logw)*length_scale, ceil"
|
| 55 |
+
}
|
| 56 |
+
},
|
| 57 |
+
"dynamic_axes": {
|
| 58 |
+
"tokens": {
|
| 59 |
+
"1": "T"
|
| 60 |
+
},
|
| 61 |
+
"m_p": {
|
| 62 |
+
"2": "T"
|
| 63 |
+
},
|
| 64 |
+
"logs_p": {
|
| 65 |
+
"2": "T"
|
| 66 |
+
},
|
| 67 |
+
"logw": {
|
| 68 |
+
"2": "T"
|
| 69 |
+
}
|
| 70 |
+
},
|
| 71 |
+
"suggested_static_shape": {
|
| 72 |
+
"T": 256
|
| 73 |
+
}
|
| 74 |
+
},
|
| 75 |
+
"decoder": {
|
| 76 |
+
"file": "decoder.onnx",
|
| 77 |
+
"contains": [
|
| 78 |
+
"flow (ResidualCouplingBlock x4 + Flip, reverse)",
|
| 79 |
+
"dec (HiFi-GAN style Generator, upsample 8x8x2x2=256)"
|
| 80 |
+
],
|
| 81 |
+
"inputs": {
|
| 82 |
+
"z_p": {
|
| 83 |
+
"dtype": "float32",
|
| 84 |
+
"shape": [
|
| 85 |
+
1,
|
| 86 |
+
192,
|
| 87 |
+
"Tp"
|
| 88 |
+
],
|
| 89 |
+
"note": "m_p_expanded + randn*exp(logs_p_expanded)*noise_scale; noise injected on host (seed-controlled)"
|
| 90 |
+
}
|
| 91 |
+
},
|
| 92 |
+
"outputs": {
|
| 93 |
+
"wav": {
|
| 94 |
+
"dtype": "float32",
|
| 95 |
+
"shape": [
|
| 96 |
+
1,
|
| 97 |
+
1,
|
| 98 |
+
"256*Tp"
|
| 99 |
+
],
|
| 100 |
+
"note": "24 kHz mono, tanh-bounded [-1,1]"
|
| 101 |
+
}
|
| 102 |
+
},
|
| 103 |
+
"dynamic_axes": {
|
| 104 |
+
"z_p": {
|
| 105 |
+
"2": "Tp"
|
| 106 |
+
},
|
| 107 |
+
"wav": {
|
| 108 |
+
"2": "256*Tp"
|
| 109 |
+
}
|
| 110 |
+
},
|
| 111 |
+
"suggested_static_shape": {
|
| 112 |
+
"Tp": 512
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
},
|
| 116 |
+
"host_side": {
|
| 117 |
+
"frontend": "eSpeak-ng phonemizer + symbols mapping + intersperse(0)",
|
| 118 |
+
"duration": "w=exp(logw)*x_mask/speed; w_ceil=ceil(w); T'=sum(w_ceil) (data-dependent)",
|
| 119 |
+
"align": "generate_path(w_ceil, mask): cumsum + sequence_mask (integer logic)",
|
| 120 |
+
"expand": "m_p'=attn@m_p, logs_p'=attn@logs_p (matmul [1,T',T]x[1,T,192])",
|
| 121 |
+
"noise": "z_p = m_p' + randn*exp(logs_p')*variation; torch.manual_seed(seed)",
|
| 122 |
+
"postprocess": "edge fade 5 ms + clip [-1,1] (see origin/inference.py)"
|
| 123 |
+
},
|
| 124 |
+
"numeric_baseline": {
|
| 125 |
+
"seed": 0,
|
| 126 |
+
"variation": 0.667,
|
| 127 |
+
"speed": 1.0,
|
| 128 |
+
"dummy_tokens": [
|
| 129 |
+
0,
|
| 130 |
+
81,
|
| 131 |
+
0,
|
| 132 |
+
83,
|
| 133 |
+
0,
|
| 134 |
+
16,
|
| 135 |
+
0,
|
| 136 |
+
53,
|
| 137 |
+
0,
|
| 138 |
+
65,
|
| 139 |
+
0,
|
| 140 |
+
102,
|
| 141 |
+
0,
|
| 142 |
+
53,
|
| 143 |
+
0
|
| 144 |
+
],
|
| 145 |
+
"dummy_phonemes": "ðə kwɪk",
|
| 146 |
+
"dummy_T": 15,
|
| 147 |
+
"dummy_frames_Tp": 51,
|
| 148 |
+
"dummy_samples": 13056,
|
| 149 |
+
"note": "ACQUIRE smoke baseline was 7 phonemes -> 13568 samples; the exact ACQUIRE dummy sequence is not recorded, this deterministic 7-phoneme sequence is the EXPORT parity baseline instead."
|
| 150 |
+
},
|
| 151 |
+
"verification": {
|
| 152 |
+
"method": "onnxruntime CPU vs PyTorch fp32 reference",
|
| 153 |
+
"metrics": {
|
| 154 |
+
"encoder": [
|
| 155 |
+
{
|
| 156 |
+
"tensor": "m_p@T=15,xlen=15",
|
| 157 |
+
"cosine_similarity": 0.9999999999999486,
|
| 158 |
+
"max_abs_diff": 9.5367431640625e-07
|
| 159 |
+
},
|
| 160 |
+
{
|
| 161 |
+
"tensor": "logs_p@T=15,xlen=15",
|
| 162 |
+
"cosine_similarity": 0.9999999999999916,
|
| 163 |
+
"max_abs_diff": 2.384185791015625e-07
|
| 164 |
+
},
|
| 165 |
+
{
|
| 166 |
+
"tensor": "logw@T=15,xlen=15",
|
| 167 |
+
"cosine_similarity": 0.9999999999998201,
|
| 168 |
+
"max_abs_diff": 1.3113021850585938e-06
|
| 169 |
+
},
|
| 170 |
+
{
|
| 171 |
+
"tensor": "m_p@T=33,xlen=33",
|
| 172 |
+
"cosine_similarity": 0.999999999999952,
|
| 173 |
+
"max_abs_diff": 1.430511474609375e-06
|
| 174 |
+
},
|
| 175 |
+
{
|
| 176 |
+
"tensor": "logs_p@T=33,xlen=33",
|
| 177 |
+
"cosine_similarity": 0.9999999999999927,
|
| 178 |
+
"max_abs_diff": 2.384185791015625e-07
|
| 179 |
+
},
|
| 180 |
+
{
|
| 181 |
+
"tensor": "logw@T=33,xlen=33",
|
| 182 |
+
"cosine_similarity": 0.9999999999999284,
|
| 183 |
+
"max_abs_diff": 8.344650268554688e-07
|
| 184 |
+
},
|
| 185 |
+
{
|
| 186 |
+
"tensor": "m_p@T=64,xlen=40",
|
| 187 |
+
"cosine_similarity": 0.9999999999999566,
|
| 188 |
+
"max_abs_diff": 1.1920928955078125e-06
|
| 189 |
+
},
|
| 190 |
+
{
|
| 191 |
+
"tensor": "logs_p@T=64,xlen=40",
|
| 192 |
+
"cosine_similarity": 0.9999999999999887,
|
| 193 |
+
"max_abs_diff": 4.172325134277344e-07
|
| 194 |
+
},
|
| 195 |
+
{
|
| 196 |
+
"tensor": "logw@T=64,xlen=40",
|
| 197 |
+
"cosine_similarity": 0.9999999999999055,
|
| 198 |
+
"max_abs_diff": 1.6689300537109375e-06
|
| 199 |
+
},
|
| 200 |
+
{
|
| 201 |
+
"tensor": "m_p@T=128,xlen=128",
|
| 202 |
+
"cosine_similarity": 0.99999999999994,
|
| 203 |
+
"max_abs_diff": 2.1457672119140625e-06
|
| 204 |
+
},
|
| 205 |
+
{
|
| 206 |
+
"tensor": "logs_p@T=128,xlen=128",
|
| 207 |
+
"cosine_similarity": 0.9999999999999903,
|
| 208 |
+
"max_abs_diff": 2.980232238769531e-07
|
| 209 |
+
},
|
| 210 |
+
{
|
| 211 |
+
"tensor": "logw@T=128,xlen=128",
|
| 212 |
+
"cosine_similarity": 0.9999999999998469,
|
| 213 |
+
"max_abs_diff": 1.6689300537109375e-06
|
| 214 |
+
},
|
| 215 |
+
{
|
| 216 |
+
"tensor": "m_p@T=256,xlen=256",
|
| 217 |
+
"cosine_similarity": 0.9999999999999413,
|
| 218 |
+
"max_abs_diff": 1.430511474609375e-06
|
| 219 |
+
},
|
| 220 |
+
{
|
| 221 |
+
"tensor": "logs_p@T=256,xlen=256",
|
| 222 |
+
"cosine_similarity": 0.9999999999999761,
|
| 223 |
+
"max_abs_diff": 5.960464477539062e-07
|
| 224 |
+
},
|
| 225 |
+
{
|
| 226 |
+
"tensor": "logw@T=256,xlen=256",
|
| 227 |
+
"cosine_similarity": 0.9999999999998836,
|
| 228 |
+
"max_abs_diff": 1.9073486328125e-06
|
| 229 |
+
}
|
| 230 |
+
],
|
| 231 |
+
"decoder": [
|
| 232 |
+
{
|
| 233 |
+
"tensor": "wav@Tp=15",
|
| 234 |
+
"cosine_similarity": 0.9999999999978111,
|
| 235 |
+
"max_abs_diff": 6.407499313354492e-07
|
| 236 |
+
},
|
| 237 |
+
{
|
| 238 |
+
"tensor": "wav@Tp=53",
|
| 239 |
+
"cosine_similarity": 0.9999999999974042,
|
| 240 |
+
"max_abs_diff": 3.888271749019623e-07
|
| 241 |
+
},
|
| 242 |
+
{
|
| 243 |
+
"tensor": "wav@Tp=100",
|
| 244 |
+
"cosine_similarity": 0.9999999999919882,
|
| 245 |
+
"max_abs_diff": 1.7862766981124878e-06
|
| 246 |
+
},
|
| 247 |
+
{
|
| 248 |
+
"tensor": "wav@Tp=256",
|
| 249 |
+
"cosine_similarity": 0.9999999999763655,
|
| 250 |
+
"max_abs_diff": 4.171393811702728e-06
|
| 251 |
+
},
|
| 252 |
+
{
|
| 253 |
+
"tensor": "wav@Tp=512",
|
| 254 |
+
"cosine_similarity": 0.9999999999960342,
|
| 255 |
+
"max_abs_diff": 1.3224780559539795e-06
|
| 256 |
+
}
|
| 257 |
+
],
|
| 258 |
+
"end_to_end": [
|
| 259 |
+
{
|
| 260 |
+
"tensor": "wav(dummy)",
|
| 261 |
+
"cosine_similarity": 0.9999999999137207,
|
| 262 |
+
"max_abs_diff": 9.82731580734253e-06
|
| 263 |
+
}
|
| 264 |
+
]
|
| 265 |
+
},
|
| 266 |
+
"all_cosine_similarity_min": 0.9999999999137207
|
| 267 |
+
},
|
| 268 |
+
"export_time_seconds": 18.9
|
| 269 |
+
}
|
models/ax637/decoder.axmodel
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:fd0a39f93c113d78045c583ca79c74bb5e3e65b8b7cf6e028b0601d30024819c
|
| 3 |
+
size 21256191
|
models/ax637/encoder.axmodel
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:7c365cdcaa2696a81ffb11e5c333be634908f06b851c65d8f85dcc056a5a5346
|
| 3 |
+
size 2220488
|
models/ax637/model_meta.json
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"model": "owensong/Inflect-Micro-v2 (VITS-family TTS, 9.36M params)",
|
| 3 |
+
"opset": 17,
|
| 4 |
+
"exported_with": "torch 2.13.0+cpu dynamo exporter, fp32, weight_norm collapsed (9,344,753 params)",
|
| 5 |
+
"sample_rate": 24000,
|
| 6 |
+
"hop_length": 256,
|
| 7 |
+
"graphs": {
|
| 8 |
+
"encoder": {
|
| 9 |
+
"file": "encoder.onnx",
|
| 10 |
+
"contains": [
|
| 11 |
+
"enc_p (TextEncoder: embedding*sqrt(96) + 3-layer Transformer w/ relative-position attention)",
|
| 12 |
+
"dp (deterministic DurationPredictor, use_sdp=false)"
|
| 13 |
+
],
|
| 14 |
+
"inputs": {
|
| 15 |
+
"tokens": {
|
| 16 |
+
"dtype": "int64",
|
| 17 |
+
"shape": [
|
| 18 |
+
1,
|
| 19 |
+
"T"
|
| 20 |
+
],
|
| 21 |
+
"note": "phoneme ids, add_blank intersperse -> 2N+1"
|
| 22 |
+
},
|
| 23 |
+
"x_lengths": {
|
| 24 |
+
"dtype": "int64",
|
| 25 |
+
"shape": [
|
| 26 |
+
1
|
| 27 |
+
]
|
| 28 |
+
}
|
| 29 |
+
},
|
| 30 |
+
"outputs": {
|
| 31 |
+
"m_p": {
|
| 32 |
+
"dtype": "float32",
|
| 33 |
+
"shape": [
|
| 34 |
+
1,
|
| 35 |
+
192,
|
| 36 |
+
"T"
|
| 37 |
+
]
|
| 38 |
+
},
|
| 39 |
+
"logs_p": {
|
| 40 |
+
"dtype": "float32",
|
| 41 |
+
"shape": [
|
| 42 |
+
1,
|
| 43 |
+
192,
|
| 44 |
+
"T"
|
| 45 |
+
]
|
| 46 |
+
},
|
| 47 |
+
"logw": {
|
| 48 |
+
"dtype": "float32",
|
| 49 |
+
"shape": [
|
| 50 |
+
1,
|
| 51 |
+
1,
|
| 52 |
+
"T"
|
| 53 |
+
],
|
| 54 |
+
"note": "log-duration; host: w=exp(logw)*length_scale, ceil"
|
| 55 |
+
}
|
| 56 |
+
},
|
| 57 |
+
"dynamic_axes": {
|
| 58 |
+
"tokens": {
|
| 59 |
+
"1": "T"
|
| 60 |
+
},
|
| 61 |
+
"m_p": {
|
| 62 |
+
"2": "T"
|
| 63 |
+
},
|
| 64 |
+
"logs_p": {
|
| 65 |
+
"2": "T"
|
| 66 |
+
},
|
| 67 |
+
"logw": {
|
| 68 |
+
"2": "T"
|
| 69 |
+
}
|
| 70 |
+
},
|
| 71 |
+
"suggested_static_shape": {
|
| 72 |
+
"T": 256
|
| 73 |
+
}
|
| 74 |
+
},
|
| 75 |
+
"decoder": {
|
| 76 |
+
"file": "decoder.onnx",
|
| 77 |
+
"contains": [
|
| 78 |
+
"flow (ResidualCouplingBlock x4 + Flip, reverse)",
|
| 79 |
+
"dec (HiFi-GAN style Generator, upsample 8x8x2x2=256)"
|
| 80 |
+
],
|
| 81 |
+
"inputs": {
|
| 82 |
+
"z_p": {
|
| 83 |
+
"dtype": "float32",
|
| 84 |
+
"shape": [
|
| 85 |
+
1,
|
| 86 |
+
192,
|
| 87 |
+
"Tp"
|
| 88 |
+
],
|
| 89 |
+
"note": "m_p_expanded + randn*exp(logs_p_expanded)*noise_scale; noise injected on host (seed-controlled)"
|
| 90 |
+
}
|
| 91 |
+
},
|
| 92 |
+
"outputs": {
|
| 93 |
+
"wav": {
|
| 94 |
+
"dtype": "float32",
|
| 95 |
+
"shape": [
|
| 96 |
+
1,
|
| 97 |
+
1,
|
| 98 |
+
"256*Tp"
|
| 99 |
+
],
|
| 100 |
+
"note": "24 kHz mono, tanh-bounded [-1,1]"
|
| 101 |
+
}
|
| 102 |
+
},
|
| 103 |
+
"dynamic_axes": {
|
| 104 |
+
"z_p": {
|
| 105 |
+
"2": "Tp"
|
| 106 |
+
},
|
| 107 |
+
"wav": {
|
| 108 |
+
"2": "256*Tp"
|
| 109 |
+
}
|
| 110 |
+
},
|
| 111 |
+
"suggested_static_shape": {
|
| 112 |
+
"Tp": 512
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
},
|
| 116 |
+
"host_side": {
|
| 117 |
+
"frontend": "eSpeak-ng phonemizer + symbols mapping + intersperse(0)",
|
| 118 |
+
"duration": "w=exp(logw)*x_mask/speed; w_ceil=ceil(w); T'=sum(w_ceil) (data-dependent)",
|
| 119 |
+
"align": "generate_path(w_ceil, mask): cumsum + sequence_mask (integer logic)",
|
| 120 |
+
"expand": "m_p'=attn@m_p, logs_p'=attn@logs_p (matmul [1,T',T]x[1,T,192])",
|
| 121 |
+
"noise": "z_p = m_p' + randn*exp(logs_p')*variation; torch.manual_seed(seed)",
|
| 122 |
+
"postprocess": "edge fade 5 ms + clip [-1,1] (see origin/inference.py)"
|
| 123 |
+
},
|
| 124 |
+
"numeric_baseline": {
|
| 125 |
+
"seed": 0,
|
| 126 |
+
"variation": 0.667,
|
| 127 |
+
"speed": 1.0,
|
| 128 |
+
"dummy_tokens": [
|
| 129 |
+
0,
|
| 130 |
+
81,
|
| 131 |
+
0,
|
| 132 |
+
83,
|
| 133 |
+
0,
|
| 134 |
+
16,
|
| 135 |
+
0,
|
| 136 |
+
53,
|
| 137 |
+
0,
|
| 138 |
+
65,
|
| 139 |
+
0,
|
| 140 |
+
102,
|
| 141 |
+
0,
|
| 142 |
+
53,
|
| 143 |
+
0
|
| 144 |
+
],
|
| 145 |
+
"dummy_phonemes": "ðə kwɪk",
|
| 146 |
+
"dummy_T": 15,
|
| 147 |
+
"dummy_frames_Tp": 51,
|
| 148 |
+
"dummy_samples": 13056,
|
| 149 |
+
"note": "ACQUIRE smoke baseline was 7 phonemes -> 13568 samples; the exact ACQUIRE dummy sequence is not recorded, this deterministic 7-phoneme sequence is the EXPORT parity baseline instead."
|
| 150 |
+
},
|
| 151 |
+
"verification": {
|
| 152 |
+
"method": "onnxruntime CPU vs PyTorch fp32 reference",
|
| 153 |
+
"metrics": {
|
| 154 |
+
"encoder": [
|
| 155 |
+
{
|
| 156 |
+
"tensor": "m_p@T=15,xlen=15",
|
| 157 |
+
"cosine_similarity": 0.9999999999999486,
|
| 158 |
+
"max_abs_diff": 9.5367431640625e-07
|
| 159 |
+
},
|
| 160 |
+
{
|
| 161 |
+
"tensor": "logs_p@T=15,xlen=15",
|
| 162 |
+
"cosine_similarity": 0.9999999999999916,
|
| 163 |
+
"max_abs_diff": 2.384185791015625e-07
|
| 164 |
+
},
|
| 165 |
+
{
|
| 166 |
+
"tensor": "logw@T=15,xlen=15",
|
| 167 |
+
"cosine_similarity": 0.9999999999998201,
|
| 168 |
+
"max_abs_diff": 1.3113021850585938e-06
|
| 169 |
+
},
|
| 170 |
+
{
|
| 171 |
+
"tensor": "m_p@T=33,xlen=33",
|
| 172 |
+
"cosine_similarity": 0.999999999999952,
|
| 173 |
+
"max_abs_diff": 1.430511474609375e-06
|
| 174 |
+
},
|
| 175 |
+
{
|
| 176 |
+
"tensor": "logs_p@T=33,xlen=33",
|
| 177 |
+
"cosine_similarity": 0.9999999999999927,
|
| 178 |
+
"max_abs_diff": 2.384185791015625e-07
|
| 179 |
+
},
|
| 180 |
+
{
|
| 181 |
+
"tensor": "logw@T=33,xlen=33",
|
| 182 |
+
"cosine_similarity": 0.9999999999999284,
|
| 183 |
+
"max_abs_diff": 8.344650268554688e-07
|
| 184 |
+
},
|
| 185 |
+
{
|
| 186 |
+
"tensor": "m_p@T=64,xlen=40",
|
| 187 |
+
"cosine_similarity": 0.9999999999999566,
|
| 188 |
+
"max_abs_diff": 1.1920928955078125e-06
|
| 189 |
+
},
|
| 190 |
+
{
|
| 191 |
+
"tensor": "logs_p@T=64,xlen=40",
|
| 192 |
+
"cosine_similarity": 0.9999999999999887,
|
| 193 |
+
"max_abs_diff": 4.172325134277344e-07
|
| 194 |
+
},
|
| 195 |
+
{
|
| 196 |
+
"tensor": "logw@T=64,xlen=40",
|
| 197 |
+
"cosine_similarity": 0.9999999999999055,
|
| 198 |
+
"max_abs_diff": 1.6689300537109375e-06
|
| 199 |
+
},
|
| 200 |
+
{
|
| 201 |
+
"tensor": "m_p@T=128,xlen=128",
|
| 202 |
+
"cosine_similarity": 0.99999999999994,
|
| 203 |
+
"max_abs_diff": 2.1457672119140625e-06
|
| 204 |
+
},
|
| 205 |
+
{
|
| 206 |
+
"tensor": "logs_p@T=128,xlen=128",
|
| 207 |
+
"cosine_similarity": 0.9999999999999903,
|
| 208 |
+
"max_abs_diff": 2.980232238769531e-07
|
| 209 |
+
},
|
| 210 |
+
{
|
| 211 |
+
"tensor": "logw@T=128,xlen=128",
|
| 212 |
+
"cosine_similarity": 0.9999999999998469,
|
| 213 |
+
"max_abs_diff": 1.6689300537109375e-06
|
| 214 |
+
},
|
| 215 |
+
{
|
| 216 |
+
"tensor": "m_p@T=256,xlen=256",
|
| 217 |
+
"cosine_similarity": 0.9999999999999413,
|
| 218 |
+
"max_abs_diff": 1.430511474609375e-06
|
| 219 |
+
},
|
| 220 |
+
{
|
| 221 |
+
"tensor": "logs_p@T=256,xlen=256",
|
| 222 |
+
"cosine_similarity": 0.9999999999999761,
|
| 223 |
+
"max_abs_diff": 5.960464477539062e-07
|
| 224 |
+
},
|
| 225 |
+
{
|
| 226 |
+
"tensor": "logw@T=256,xlen=256",
|
| 227 |
+
"cosine_similarity": 0.9999999999998836,
|
| 228 |
+
"max_abs_diff": 1.9073486328125e-06
|
| 229 |
+
}
|
| 230 |
+
],
|
| 231 |
+
"decoder": [
|
| 232 |
+
{
|
| 233 |
+
"tensor": "wav@Tp=15",
|
| 234 |
+
"cosine_similarity": 0.9999999999978111,
|
| 235 |
+
"max_abs_diff": 6.407499313354492e-07
|
| 236 |
+
},
|
| 237 |
+
{
|
| 238 |
+
"tensor": "wav@Tp=53",
|
| 239 |
+
"cosine_similarity": 0.9999999999974042,
|
| 240 |
+
"max_abs_diff": 3.888271749019623e-07
|
| 241 |
+
},
|
| 242 |
+
{
|
| 243 |
+
"tensor": "wav@Tp=100",
|
| 244 |
+
"cosine_similarity": 0.9999999999919882,
|
| 245 |
+
"max_abs_diff": 1.7862766981124878e-06
|
| 246 |
+
},
|
| 247 |
+
{
|
| 248 |
+
"tensor": "wav@Tp=256",
|
| 249 |
+
"cosine_similarity": 0.9999999999763655,
|
| 250 |
+
"max_abs_diff": 4.171393811702728e-06
|
| 251 |
+
},
|
| 252 |
+
{
|
| 253 |
+
"tensor": "wav@Tp=512",
|
| 254 |
+
"cosine_similarity": 0.9999999999960342,
|
| 255 |
+
"max_abs_diff": 1.3224780559539795e-06
|
| 256 |
+
}
|
| 257 |
+
],
|
| 258 |
+
"end_to_end": [
|
| 259 |
+
{
|
| 260 |
+
"tensor": "wav(dummy)",
|
| 261 |
+
"cosine_similarity": 0.9999999999137207,
|
| 262 |
+
"max_abs_diff": 9.82731580734253e-06
|
| 263 |
+
}
|
| 264 |
+
]
|
| 265 |
+
},
|
| 266 |
+
"all_cosine_similarity_min": 0.9999999999137207
|
| 267 |
+
},
|
| 268 |
+
"export_time_seconds": 18.9
|
| 269 |
+
}
|
python/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Inflect AX TTS Python SDK
|
| 2 |
+
|
| 3 |
+
`owensong/Inflect-Micro-v2`(VITS 系英文 TTS,文本 → 24 kHz 单声道波形)的 Encoder/Decoder 两段式 AXMODEL 推理 SDK,双目标 **AX620E(NPU2)/ AX637(NPU1)**。两套 AXMODEL 接口完全一致(同 shape 契约),SDK 通过模型路径切换目标,代码零改动。
|
| 4 |
+
|
| 5 |
+
## 推理流水线
|
| 6 |
+
|
| 7 |
+
```
|
| 8 |
+
text → [Host: eSpeak 音素化 + symbols 映射 + intersperse(add_blank)]
|
| 9 |
+
→ tokens[1,256](int,0 补齐)+ x_lengths[1]
|
| 10 |
+
→ encoder.axmodel → m_p/logs_p[1,192,256]、logw[1,1,256]
|
| 11 |
+
→ [Host: 取前 x_lengths 帧;w=exp(logw)*length_scale;ceil;generate_path;
|
| 12 |
+
matmul 扩展为 T' 帧;z_p=m_p'+randn(seed)*exp(logs_p')*variation]
|
| 13 |
+
→ 按 Tp=512 分块(64 帧重叠 crossfade)→ decoder.axmodel
|
| 14 |
+
→ 波形块拼接(尾部裁剪)→ edge fade 5ms + clip → 24kHz mono wav
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
- encoder 静态 shape `T=256`(tokens 以 0 右补齐,`x_lengths` 填真实长度;单 chunk 上限 255 个 intersperse 后 token,超长文本自动二次拆分)。
|
| 18 |
+
- decoder 静态 shape `Tp=512`(~5.46 s/块),Host 侧分块 + 64 帧重叠 crossfade,最后一块尾部裁剪(EXPORT_NOTES §5.3)。
|
| 19 |
+
- 噪声注入在 Host 侧:`np.random.default_rng(seed)`。**seed 在 SDK 内可复现,但与 PyTorch 参考(torch MT19937)不逐位一致。**
|
| 20 |
+
|
| 21 |
+
## 模型文件位置
|
| 22 |
+
|
| 23 |
+
| 目标 | encoder | decoder |
|
| 24 |
+
| --- | --- | --- |
|
| 25 |
+
| AX620E | `models/ax620e/encoder.axmodel` | `models/ax620e/decoder.axmodel` |
|
| 26 |
+
| AX637 | `models/ax637/encoder.axmodel` | `models/ax637/decoder.axmodel`(权重 S16 升级版) |
|
| 27 |
+
|
| 28 |
+
(路径相对于交付包根目录 `package/`;AX637 decoder 为权重 S16 升级版,wav cos 0.9995,详见 `../reports/accuracy_summary.md`。)
|
| 29 |
+
|
| 30 |
+
## 环境与安装
|
| 31 |
+
|
| 32 |
+
板端 / 主机(有 NPU 运行时):
|
| 33 |
+
|
| 34 |
+
```bash
|
| 35 |
+
pip install -r requirements.txt # numpy, phonemizer, espeakng-loader, num2words, pyaxengine
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
- eSpeak-ng:优先使用系统库(`/usr/lib/x86_64-linux-gnu/libespeak-ng.so.1` 或 aarch64 对应路径,Debian/Ubuntu:`apt install libespeak-ng1`);无系统库时 `espeakng-loader` pip 包自动提供。**仅真实文本合成需要;`--demo-tokens` 与 `synthesize_tokens()` 不依赖 eSpeak。**
|
| 39 |
+
- 本机(Host)无数值 NPU 时的验证替身:`pip install onnxruntime`,把 `../model_convert/export/encoder.onnx` / `../model_convert/export/decoder.onnx` 作为模型路径传入即可(backend 自动切换)。
|
| 40 |
+
|
| 41 |
+
## API
|
| 42 |
+
|
| 43 |
+
```python
|
| 44 |
+
from inflect_ax_tts import InflectTTS
|
| 45 |
+
|
| 46 |
+
tts = InflectTTS(
|
| 47 |
+
"../models/ax620e/encoder.axmodel",
|
| 48 |
+
"../models/ax620e/decoder.axmodel",
|
| 49 |
+
) # backend="auto": .axmodel -> pyaxengine, .onnx -> onnxruntime
|
| 50 |
+
|
| 51 |
+
# 文本合成(需要 eSpeak)
|
| 52 |
+
sr, wav = tts.synthesize("The quick brown fox.", speed=1.0, variation=0.667, seed=0)
|
| 53 |
+
tts.save("The quick brown fox.", "out.wav")
|
| 54 |
+
|
| 55 |
+
# 免前端:直接给音素 id(未 intersperse,SDK 内部加 blank)
|
| 56 |
+
sr, wav = tts.synthesize_tokens([81, 83, 16, 53, 65, 102, 53], seed=0)
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
- `speed` ∈ [0.5, 2.0](length_scale = 1/speed);`variation` ∈ [0.0, 1.0](噪声幅度,基线 0.667);`seed` 整数,同参可复现。
|
| 60 |
+
- 返回 `(24000, float32 ndarray)`,幅值 [-1, 1];`write_wav()` 写 16-bit PCM。
|
| 61 |
+
|
| 62 |
+
## 示例
|
| 63 |
+
|
| 64 |
+
```bash
|
| 65 |
+
cd package/python
|
| 66 |
+
|
| 67 |
+
# 板端真实文本(AX620E;AX637 换路径即可)
|
| 68 |
+
python example.py \
|
| 69 |
+
--encoder ../models/ax620e/encoder.axmodel \
|
| 70 |
+
--decoder ../models/ax620e/decoder.axmodel \
|
| 71 |
+
--text "The quick brown fox jumps over the lazy dog." \
|
| 72 |
+
--output out.wav
|
| 73 |
+
|
| 74 |
+
# Host 干跑:无 NPU、无 eSpeak,onnxruntime 替身 + 确定性 dummy tokens
|
| 75 |
+
python example.py --demo-tokens \
|
| 76 |
+
--encoder ../model_convert/export/encoder.onnx \
|
| 77 |
+
--decoder ../model_convert/export/decoder.onnx \
|
| 78 |
+
--output demo.wav
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
## Host 前后处理说明
|
| 82 |
+
|
| 83 |
+
- **前端**(仅 `synthesize()` 文本路径):`normalize_text`(数字/缩写/日期/货币展开,`num2words`)→ eSpeak `en-us` 音素化(`preserve_punctuation + with_stress`)→ 音素覆盖表(2 条)→ 逐字符 symbols 映射 → `intersperse(0)`。与 `origin/inference.py` 完全一致。
|
| 84 |
+
- **时长/对齐**(纯 numpy 整数逻辑):`w=exp(logw)*x_mask/speed` → `ceil` → `T'=max(sum,1)` → `generate_path`(cumsum 区间)→ `attn @ m_p/logs_p` 扩展。
|
| 85 |
+
- **噪声**:`z_p = m_p' + randn(seed) * exp(logs_p') * variation`。
|
| 86 |
+
- **decoder 分块**:`Tp=512`、64 帧重叠线性 crossfade;`T'<=512` 单块路径即 SIMULATE 验证路径。
|
| 87 |
+
- **后处理**:每块 edge fade 5 ms、句间停顿(按上一句末标点 0.08~0.28 s)、最终 clip [-1,1]。
|
| 88 |
+
|
| 89 |
+
## 测试(Host,onnxruntime 替身)
|
| 90 |
+
|
| 91 |
+
```bash
|
| 92 |
+
cd package/python
|
| 93 |
+
python tests/test_host_chain.py # 或 pytest tests/
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
覆盖:Host 链(`expand_priors`)与 PyTorch 参考逐位级一致性、encoder padding 语义、decoder 分块合成与 fp32 参考链路数值一致(ORT 层面)、端到端确定性、前端冒烟(espeak 可用时)。
|
| 97 |
+
|
| 98 |
+
## 已知差异 / 限制
|
| 99 |
+
|
| 100 |
+
- seed 语义与 PyTorch 参考不逐位一致(RNG 不同),仅保证 SDK 内可复现。
|
| 101 |
+
- encoder 输入 dtype:AXMODEL 运行时为 **int32**(input_processors S64 已折叠进模型),ONNX 为 int64;SDK 按 session 元数据自适应。
|
| 102 |
+
- decoder 多块(T'>512)crossfade 路径未做数值门禁验证(仿真仅覆盖单块路径)。
|
| 103 |
+
- 精度结论(仿真,详见 `../reports/accuracy_summary.md`):encoder 双目标 cos ≥ 0.9999;AX637 decoder(权重 S16 升级版)wav cos 0.9995 [PASS];**AX620E decoder(基线)wav cos ≈ 0.95,未达 0.99 门禁**(权重 S8 量化导致的高频细节损失,本工具链版本不可修复),属量化方案限制,非 SDK 问题。
|
python/example.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Inflect AX TTS SDK example.
|
| 3 |
+
|
| 4 |
+
Real text synthesis (requires phonemizer + espeak-ng, see README):
|
| 5 |
+
|
| 6 |
+
python example.py \
|
| 7 |
+
--encoder ../models/ax620e/encoder.axmodel \
|
| 8 |
+
--decoder ../models/ax620e/decoder.axmodel \
|
| 9 |
+
--text "The quick brown fox jumps over the lazy dog." \
|
| 10 |
+
--output out.wav
|
| 11 |
+
|
| 12 |
+
Host dry-run without NPU and without eSpeak (onnxruntime stand-in on the
|
| 13 |
+
fp32 ONNX graphs, deterministic dummy tokens from
|
| 14 |
+
../models/model_meta.json numeric_baseline):
|
| 15 |
+
|
| 16 |
+
python example.py --demo-tokens \
|
| 17 |
+
--encoder ../model_convert/export/encoder.onnx \
|
| 18 |
+
--decoder ../model_convert/export/decoder.onnx \
|
| 19 |
+
--output demo.wav
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import argparse
|
| 25 |
+
import sys
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
|
| 28 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
| 29 |
+
|
| 30 |
+
from inflect_ax_tts import DUMMY_PHONEME_IDS, InflectTTS, write_wav
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def main() -> None:
|
| 34 |
+
parser = argparse.ArgumentParser(description="Inflect AX TTS SDK example")
|
| 35 |
+
parser.add_argument("--encoder", required=True, help="encoder .axmodel (or .onnx)")
|
| 36 |
+
parser.add_argument("--decoder", required=True, help="decoder .axmodel (or .onnx)")
|
| 37 |
+
parser.add_argument("--text", help="text to synthesize (needs phonemizer + eSpeak-ng)")
|
| 38 |
+
parser.add_argument(
|
| 39 |
+
"--demo-tokens",
|
| 40 |
+
action="store_true",
|
| 41 |
+
help="skip the text frontend and use the deterministic EXPORT dummy "
|
| 42 |
+
"phoneme ids (no eSpeak dependency)",
|
| 43 |
+
)
|
| 44 |
+
parser.add_argument("--output", required=True, help="output wav path")
|
| 45 |
+
parser.add_argument("--speed", type=float, default=1.0)
|
| 46 |
+
parser.add_argument("--variation", type=float, default=0.667)
|
| 47 |
+
parser.add_argument("--seed", type=int, default=0)
|
| 48 |
+
parser.add_argument(
|
| 49 |
+
"--backend",
|
| 50 |
+
default="auto",
|
| 51 |
+
choices=["auto", "axengine", "onnxruntime"],
|
| 52 |
+
help="inference backend (auto: .axmodel -> pyaxengine, .onnx -> onnxruntime)",
|
| 53 |
+
)
|
| 54 |
+
args = parser.parse_args()
|
| 55 |
+
|
| 56 |
+
if bool(args.text) == bool(args.demo_tokens):
|
| 57 |
+
parser.error("provide exactly one of --text or --demo-tokens")
|
| 58 |
+
|
| 59 |
+
tts = InflectTTS(args.encoder, args.decoder, backend=args.backend)
|
| 60 |
+
if args.demo_tokens:
|
| 61 |
+
sample_rate, wav = tts.synthesize_tokens(
|
| 62 |
+
DUMMY_PHONEME_IDS,
|
| 63 |
+
speed=args.speed,
|
| 64 |
+
variation=args.variation,
|
| 65 |
+
seed=args.seed,
|
| 66 |
+
)
|
| 67 |
+
else:
|
| 68 |
+
sample_rate, wav = tts.synthesize(
|
| 69 |
+
args.text, speed=args.speed, variation=args.variation, seed=args.seed
|
| 70 |
+
)
|
| 71 |
+
write_wav(args.output, wav, sample_rate)
|
| 72 |
+
print(
|
| 73 |
+
f"wrote {args.output}: {wav.size} samples "
|
| 74 |
+
f"({wav.size / sample_rate:.2f} s @ {sample_rate} Hz), "
|
| 75 |
+
f"amp [{wav.min():.3f}, {wav.max():.3f}]"
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
if __name__ == "__main__":
|
| 80 |
+
main()
|
python/inflect_ax_tts/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Inflect AX TTS SDK — two-stage (encoder/decoder) AXMODEL pipeline for
|
| 2 |
+
owensong/Inflect-Micro-v2 on AXera AX620E / AX637.
|
| 3 |
+
|
| 4 |
+
from inflect_ax_tts import InflectTTS
|
| 5 |
+
tts = InflectTTS("models/ax620e/encoder.axmodel", "models/ax620e/decoder.axmodel")
|
| 6 |
+
tts.save("The quick brown fox.", "out.wav", speed=1.0, variation=0.667, seed=0)
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from .tts import DUMMY_PHONEME_IDS, InflectTTS
|
| 10 |
+
from .wav_io import write_wav
|
| 11 |
+
|
| 12 |
+
__all__ = ["InflectTTS", "DUMMY_PHONEME_IDS", "write_wav"]
|
| 13 |
+
__version__ = "1.0.0"
|
python/inflect_ax_tts/backend.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Inference backend wrapper: pyaxengine (default) or onnxruntime (host stand-in).
|
| 2 |
+
|
| 3 |
+
Both expose an onnxruntime-like API (run / get_inputs / get_outputs). The
|
| 4 |
+
backend is selected from the model file suffix unless overridden:
|
| 5 |
+
|
| 6 |
+
*.axmodel -> axengine.InferenceSession (pyaxengine, on-device or host sim)
|
| 7 |
+
*.onnx -> onnxruntime.InferenceSession (host numeric stand-in only)
|
| 8 |
+
|
| 9 |
+
Encoder input dtype note: the compiled AXMODELs declare their token inputs as
|
| 10 |
+
S32 at runtime (COMPILE_NOTES §3 folds the S64 input_processors into the
|
| 11 |
+
model; SIMULATE §1 confirmed S32 binaries), while the ONNX graphs take int64.
|
| 12 |
+
The wrapper therefore adapts the feed dtype from the session's input metadata.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class ModelSession:
|
| 23 |
+
"""Minimal name-addressable inference session over axengine/onnxruntime."""
|
| 24 |
+
|
| 25 |
+
def __init__(self, model_path: str | Path, backend: str = "auto") -> None:
|
| 26 |
+
self.path = Path(model_path)
|
| 27 |
+
if not self.path.is_file():
|
| 28 |
+
raise FileNotFoundError(self.path)
|
| 29 |
+
if backend == "auto":
|
| 30 |
+
backend = "axengine" if self.path.suffix == ".axmodel" else "onnxruntime"
|
| 31 |
+
self.backend = backend
|
| 32 |
+
if backend == "axengine":
|
| 33 |
+
try:
|
| 34 |
+
from axengine import InferenceSession
|
| 35 |
+
except ImportError as exc:
|
| 36 |
+
raise ImportError(
|
| 37 |
+
"pyaxengine is required for .axmodel files "
|
| 38 |
+
"(pip install axengine on an AXera host/BSP environment)"
|
| 39 |
+
) from exc
|
| 40 |
+
self._sess = InferenceSession(str(self.path))
|
| 41 |
+
elif backend == "onnxruntime":
|
| 42 |
+
try:
|
| 43 |
+
import onnxruntime as ort
|
| 44 |
+
except ImportError as exc:
|
| 45 |
+
raise ImportError(
|
| 46 |
+
"onnxruntime is required for .onnx files "
|
| 47 |
+
"(host numeric stand-in only)"
|
| 48 |
+
) from exc
|
| 49 |
+
self._sess = ort.InferenceSession(
|
| 50 |
+
str(self.path), providers=["CPUExecutionProvider"]
|
| 51 |
+
)
|
| 52 |
+
else:
|
| 53 |
+
raise ValueError(f"unknown backend {backend!r}")
|
| 54 |
+
|
| 55 |
+
# -- metadata -----------------------------------------------------------
|
| 56 |
+
def _inputs(self):
|
| 57 |
+
return self._sess.get_inputs()
|
| 58 |
+
|
| 59 |
+
def output_names(self) -> list[str]:
|
| 60 |
+
return [o.name for o in self._sess.get_outputs()]
|
| 61 |
+
|
| 62 |
+
def input_dtype(self, name: str) -> np.dtype:
|
| 63 |
+
"""Best-effort numpy dtype of a named input.
|
| 64 |
+
|
| 65 |
+
onnxruntime exposes `.type` strings ('tensor(int64)'); pyaxengine
|
| 66 |
+
exposes numpy-like `.dtype`. Fallback: int32 for .axmodel (see module
|
| 67 |
+
docstring), int64 for .onnx.
|
| 68 |
+
"""
|
| 69 |
+
for meta in self._inputs():
|
| 70 |
+
if meta.name != name:
|
| 71 |
+
continue
|
| 72 |
+
dt = getattr(meta, "dtype", None)
|
| 73 |
+
if dt is not None:
|
| 74 |
+
try:
|
| 75 |
+
return np.dtype(dt)
|
| 76 |
+
except TypeError:
|
| 77 |
+
pass
|
| 78 |
+
type_str = str(getattr(meta, "type", "") or "")
|
| 79 |
+
if "int32" in type_str:
|
| 80 |
+
return np.dtype(np.int32)
|
| 81 |
+
if "int64" in type_str:
|
| 82 |
+
return np.dtype(np.int64)
|
| 83 |
+
if "float" in type_str:
|
| 84 |
+
return np.dtype(np.float32)
|
| 85 |
+
return np.dtype(np.int32 if self.path.suffix == ".axmodel" else np.int64)
|
| 86 |
+
|
| 87 |
+
# -- inference ----------------------------------------------------------
|
| 88 |
+
def run(self, feeds: dict[str, np.ndarray]) -> dict[str, np.ndarray]:
|
| 89 |
+
results = self._sess.run(None, feeds)
|
| 90 |
+
return dict(zip(self.output_names(), results))
|
python/inflect_ax_tts/frontend.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""VITS text frontend: text -> phoneme token ids.
|
| 2 |
+
|
| 3 |
+
Faithful port of origin/inflect_vits_frontend.py + the token mapping from
|
| 4 |
+
origin/runtime/text/__init__.py + commons.intersperse. Text normalization and
|
| 5 |
+
eSpeak configuration live in text_normalize.py (verbatim copy of
|
| 6 |
+
origin/inflect_nano_v2_frontend.py).
|
| 7 |
+
|
| 8 |
+
Pipeline (mirrors origin/inference.py:InflectTTS._tokens):
|
| 9 |
+
text -> normalize_text -> eSpeak phonemize (en-us, with_stress,
|
| 10 |
+
preserve_punctuation) -> phoneme overrides -> per-character symbol ids
|
| 11 |
+
-> intersperse(0) (add_blank)
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from .symbols import symbols
|
| 17 |
+
from .text_normalize import _configure_espeak, normalize_text
|
| 18 |
+
|
| 19 |
+
__all__ = ["normalize_text", "phonemize_text", "text_to_token_ids", "intersperse"]
|
| 20 |
+
|
| 21 |
+
# eSpeak is the general fallback. This table contains verified exceptions only
|
| 22 |
+
# (verbatim from origin/inflect_vits_frontend.py).
|
| 23 |
+
PHONEME_OVERRIDES = {
|
| 24 |
+
"sˈæskɐtʃˌuːən": "sɐskˈætʃəwən",
|
| 25 |
+
"flʊɹɹˈɛsənt": "flʊˈɹɛsənt",
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
_SYMBOL_TO_ID = {s: i for i, s in enumerate(symbols)}
|
| 29 |
+
_BLANK_ID = 0 # symbols[0] == '_'
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def intersperse(seq: list[int], item: int = _BLANK_ID) -> list[int]:
|
| 33 |
+
"""commons.intersperse: insert `item` around every element -> 2N+1."""
|
| 34 |
+
result = [item] * (len(seq) * 2 + 1)
|
| 35 |
+
result[1::2] = seq
|
| 36 |
+
return result
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def phonemize_text(text: str) -> str:
|
| 40 |
+
"""normalize + eSpeak phonemize + overrides (run_vits_frontend equivalent)."""
|
| 41 |
+
import re
|
| 42 |
+
|
| 43 |
+
normalized = normalize_text(text)
|
| 44 |
+
_configure_espeak()
|
| 45 |
+
from phonemizer import phonemize
|
| 46 |
+
|
| 47 |
+
phoneme_text = phonemize(
|
| 48 |
+
[normalized],
|
| 49 |
+
language="en-us",
|
| 50 |
+
backend="espeak",
|
| 51 |
+
strip=True,
|
| 52 |
+
preserve_punctuation=True,
|
| 53 |
+
with_stress=True,
|
| 54 |
+
njobs=1,
|
| 55 |
+
)[0]
|
| 56 |
+
for source, replacement in PHONEME_OVERRIDES.items():
|
| 57 |
+
phoneme_text = phoneme_text.replace(source, replacement)
|
| 58 |
+
return re.sub(r"\s+", " ", phoneme_text).strip()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def text_to_token_ids(text: str, add_blank: bool = True) -> list[int]:
|
| 62 |
+
"""Full frontend: text -> interspersed phoneme ids (2N+1 with add_blank)."""
|
| 63 |
+
phoneme_text = phonemize_text(text)
|
| 64 |
+
try:
|
| 65 |
+
sequence = [_SYMBOL_TO_ID[ch] for ch in phoneme_text]
|
| 66 |
+
except KeyError as exc:
|
| 67 |
+
raise ValueError(
|
| 68 |
+
f"phoneme {exc.args[0]!r} from {phoneme_text!r} is not in the "
|
| 69 |
+
"model symbol table"
|
| 70 |
+
) from exc
|
| 71 |
+
if add_blank:
|
| 72 |
+
sequence = intersperse(sequence, _BLANK_ID)
|
| 73 |
+
if not sequence:
|
| 74 |
+
raise ValueError("The text frontend produced no speakable tokens.")
|
| 75 |
+
return sequence
|
python/inflect_ax_tts/host_chain.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Host-side chain between encoder.axmodel and decoder.axmodel (pure numpy).
|
| 2 |
+
|
| 3 |
+
Replicates, in numpy, the torch host chain of export/export_onnx.py:host_expand
|
| 4 |
+
(which itself mirrors origin/runtime/models.py:546-558):
|
| 5 |
+
|
| 6 |
+
w = exp(logw) * x_mask * length_scale
|
| 7 |
+
w_ceil = ceil(w); T' = max(sum(w_ceil), 1)
|
| 8 |
+
attn = generate_path(w_ceil, mask) # pure integer logic
|
| 9 |
+
m_p' = attn @ m_p^T ; logs_p' = attn @ logs_p^T
|
| 10 |
+
z_p = m_p' + randn * exp(logs_p') * variation (seed-controlled, host)
|
| 11 |
+
|
| 12 |
+
Also contains the fixed-shape plumbing: encoder T=256 zero padding and decoder
|
| 13 |
+
Tp=512 chunking with overlap crossfade (EXPORT_NOTES §5.3 recommends
|
| 14 |
+
>=64-frame overlap between chunks; single-chunk T'<=512 is the path validated
|
| 15 |
+
by SIMULATE).
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import numpy as np
|
| 21 |
+
|
| 22 |
+
SAMPLE_RATE = 24000
|
| 23 |
+
HOP_LENGTH = 256
|
| 24 |
+
HIDDEN_CHANNELS = 192
|
| 25 |
+
ENCODER_T = 256
|
| 26 |
+
DECODER_TP = 512
|
| 27 |
+
DECODER_OVERLAP = 64 # frames of overlap between decoder chunks (EXPORT_NOTES §5.3)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
# Duration rounding + alignment (generate_path)
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
|
| 34 |
+
def expand_priors(
|
| 35 |
+
logw: np.ndarray,
|
| 36 |
+
m_p: np.ndarray,
|
| 37 |
+
logs_p: np.ndarray,
|
| 38 |
+
x_len: int,
|
| 39 |
+
length_scale: float,
|
| 40 |
+
) -> tuple[np.ndarray, np.ndarray, int]:
|
| 41 |
+
"""Duration rounding + generate_path + prior expansion.
|
| 42 |
+
|
| 43 |
+
Args:
|
| 44 |
+
logw: [1, 1, T] log-durations (encoder output, static T)
|
| 45 |
+
m_p: [1, C, T] prior means
|
| 46 |
+
logs_p: [1, C, T] prior log-stds
|
| 47 |
+
x_len: number of valid (unpadded) token frames
|
| 48 |
+
length_scale: 1.0 / speed
|
| 49 |
+
|
| 50 |
+
Returns:
|
| 51 |
+
(m_p_e [T', C], logs_p_e [T', C], T')
|
| 52 |
+
"""
|
| 53 |
+
t_total = logw.shape[-1]
|
| 54 |
+
if not 1 <= x_len <= t_total:
|
| 55 |
+
raise ValueError(f"x_len {x_len} out of range [1, {t_total}]")
|
| 56 |
+
x_mask = (np.arange(t_total) < x_len).astype(np.float32)
|
| 57 |
+
w = np.exp(logw[0, 0].astype(np.float32)) * x_mask * np.float32(length_scale)
|
| 58 |
+
w_ceil = np.ceil(w).astype(np.int64)
|
| 59 |
+
y_len = max(int(w_ceil.sum()), 1)
|
| 60 |
+
|
| 61 |
+
# generate_path (origin/runtime/commons.py): with the masked durations the
|
| 62 |
+
# alignment reduces to pure integer interval logic — frame t of the output
|
| 63 |
+
# copies input frame i for cum[i-1] <= t < cum[i]. O(T) loop, no matmul
|
| 64 |
+
# of the [T', T] path needed; we still build attn explicitly for the prior
|
| 65 |
+
# expansion to stay a literal port (T=256, T'<=~2000: cheap).
|
| 66 |
+
cum = np.cumsum(w_ceil)
|
| 67 |
+
attn = np.zeros((y_len, t_total), dtype=np.float32)
|
| 68 |
+
lo = 0
|
| 69 |
+
for i in range(t_total):
|
| 70 |
+
hi = min(int(cum[i]), y_len)
|
| 71 |
+
if hi > lo:
|
| 72 |
+
attn[lo:hi, i] = 1.0
|
| 73 |
+
lo = hi
|
| 74 |
+
m_p_e = attn @ m_p[0].T.astype(np.float32)
|
| 75 |
+
logs_p_e = attn @ logs_p[0].T.astype(np.float32)
|
| 76 |
+
return m_p_e, logs_p_e, y_len
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def inject_noise(
|
| 80 |
+
m_p_e: np.ndarray,
|
| 81 |
+
logs_p_e: np.ndarray,
|
| 82 |
+
variation: float,
|
| 83 |
+
seed: int,
|
| 84 |
+
) -> np.ndarray:
|
| 85 |
+
"""z_p = m_p' + randn * exp(logs_p') * variation, seed-controlled.
|
| 86 |
+
|
| 87 |
+
NOTE: numpy PCG64 is used, not torch's MT19937 — a given `seed` is
|
| 88 |
+
deterministic and reproducible within this SDK but is NOT bit-identical to
|
| 89 |
+
the PyTorch reference (origin/inference.py).
|
| 90 |
+
"""
|
| 91 |
+
rng = np.random.default_rng(seed)
|
| 92 |
+
noise = rng.standard_normal(m_p_e.shape, dtype=np.float32)
|
| 93 |
+
z_p = m_p_e + noise * np.exp(logs_p_e.astype(np.float32)) * np.float32(variation)
|
| 94 |
+
return z_p.T.copy() # [C, T']
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# ---------------------------------------------------------------------------
|
| 98 |
+
# Decoder chunk composition (Tp=512 static shape, overlap crossfade)
|
| 99 |
+
# ---------------------------------------------------------------------------
|
| 100 |
+
|
| 101 |
+
def decoder_chunk_starts(t_prime: int, chunk: int = DECODER_TP,
|
| 102 |
+
overlap: int = DECODER_OVERLAP) -> list[int]:
|
| 103 |
+
"""Frame offsets of decoder chunks covering [0, t_prime) with >=overlap."""
|
| 104 |
+
if t_prime <= chunk:
|
| 105 |
+
return [0]
|
| 106 |
+
stride = chunk - overlap
|
| 107 |
+
starts = list(range(0, t_prime - chunk + 1, stride))
|
| 108 |
+
last = t_prime - chunk
|
| 109 |
+
if starts[-1] < last:
|
| 110 |
+
starts.append(last)
|
| 111 |
+
return starts
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def decode_waveform(
|
| 115 |
+
z_p: np.ndarray,
|
| 116 |
+
run_decoder,
|
| 117 |
+
chunk: int = DECODER_TP,
|
| 118 |
+
overlap: int = DECODER_OVERLAP,
|
| 119 |
+
) -> np.ndarray:
|
| 120 |
+
"""Run the fixed-shape decoder over z_p and stitch chunks.
|
| 121 |
+
|
| 122 |
+
Args:
|
| 123 |
+
z_p: [C, T'] float32 prior with noise injected.
|
| 124 |
+
run_decoder: callable taking z_p_chunk [C, chunk] float32 (already
|
| 125 |
+
right-padded with zeros) and returning wav [chunk*HOP] float32.
|
| 126 |
+
chunk/overlap: see DECODER_TP / DECODER_OVERLAP.
|
| 127 |
+
|
| 128 |
+
Returns:
|
| 129 |
+
wav [T'*HOP] float32 (tail trimmed; chunk overlaps crossfaded).
|
| 130 |
+
"""
|
| 131 |
+
t_prime = z_p.shape[1]
|
| 132 |
+
hop = HOP_LENGTH
|
| 133 |
+
starts = decoder_chunk_starts(t_prime, chunk, overlap)
|
| 134 |
+
|
| 135 |
+
out: np.ndarray | None = None
|
| 136 |
+
prev_end_frame = 0
|
| 137 |
+
for start in starts:
|
| 138 |
+
take = min(chunk, t_prime - start)
|
| 139 |
+
z_chunk = np.zeros((z_p.shape[0], chunk), dtype=np.float32)
|
| 140 |
+
z_chunk[:, :take] = z_p[:, start:start + take]
|
| 141 |
+
wav = np.asarray(run_decoder(z_chunk), dtype=np.float32)
|
| 142 |
+
if out is None:
|
| 143 |
+
out = wav
|
| 144 |
+
else:
|
| 145 |
+
ov_frames = prev_end_frame - start
|
| 146 |
+
ov = ov_frames * hop
|
| 147 |
+
fade_in = np.linspace(0.0, 1.0, ov, endpoint=True, dtype=np.float32)
|
| 148 |
+
out = np.concatenate(
|
| 149 |
+
[out[:-ov], out[-ov:] * (1.0 - fade_in) + wav[:ov] * fade_in, wav[ov:]]
|
| 150 |
+
)
|
| 151 |
+
prev_end_frame = start + chunk
|
| 152 |
+
assert out is not None
|
| 153 |
+
return out[: t_prime * hop]
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
# ---------------------------------------------------------------------------
|
| 157 |
+
# Waveform post-processing (origin/inference.py)
|
| 158 |
+
# ---------------------------------------------------------------------------
|
| 159 |
+
|
| 160 |
+
def edge_fade(waveform: np.ndarray, sample_rate: int = SAMPLE_RATE,
|
| 161 |
+
milliseconds: float = 5.0) -> np.ndarray:
|
| 162 |
+
frames = min(round(sample_rate * milliseconds / 1000.0), waveform.size // 2)
|
| 163 |
+
if frames <= 0:
|
| 164 |
+
return waveform
|
| 165 |
+
output = waveform.copy()
|
| 166 |
+
ramp = np.linspace(0.0, 1.0, frames, endpoint=True, dtype=np.float32)
|
| 167 |
+
output[:frames] *= ramp
|
| 168 |
+
output[-frames:] *= ramp[::-1]
|
| 169 |
+
return output
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
# ---------------------------------------------------------------------------
|
| 173 |
+
# Text chunking (origin/inference.py: split_text / boundary_pause_seconds)
|
| 174 |
+
# ---------------------------------------------------------------------------
|
| 175 |
+
|
| 176 |
+
def split_text(text: str, limit: int = 280) -> list[str]:
|
| 177 |
+
import re
|
| 178 |
+
|
| 179 |
+
normalized = " ".join(text.split())
|
| 180 |
+
sentences = [
|
| 181 |
+
part.strip()
|
| 182 |
+
for part in re.split(r"(?<=[.!?;:])\s+", normalized)
|
| 183 |
+
if part.strip()
|
| 184 |
+
]
|
| 185 |
+
chunks: list[str] = []
|
| 186 |
+
for sentence in sentences or [normalized]:
|
| 187 |
+
while len(sentence) > limit:
|
| 188 |
+
search = sentence[: limit + 1]
|
| 189 |
+
punctuation = max(search.rfind(mark) for mark in (",", ";", ":"))
|
| 190 |
+
split_at = (
|
| 191 |
+
punctuation + 1
|
| 192 |
+
if punctuation >= limit // 2
|
| 193 |
+
else sentence.rfind(" ", 0, limit + 1)
|
| 194 |
+
)
|
| 195 |
+
if split_at < limit // 2:
|
| 196 |
+
split_at = limit
|
| 197 |
+
chunks.append(sentence[:split_at].strip())
|
| 198 |
+
sentence = sentence[split_at:].strip()
|
| 199 |
+
if sentence:
|
| 200 |
+
chunks.append(sentence)
|
| 201 |
+
return chunks
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def boundary_pause_seconds(chunk: str) -> float:
|
| 205 |
+
ending = chunk.rstrip()[-1:] if chunk.strip() else ""
|
| 206 |
+
return {
|
| 207 |
+
"?": 0.28,
|
| 208 |
+
"!": 0.24,
|
| 209 |
+
".": 0.22,
|
| 210 |
+
";": 0.16,
|
| 211 |
+
":": 0.13,
|
| 212 |
+
",": 0.09,
|
| 213 |
+
}.get(ending, 0.08)
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def seconds_to_samples(seconds: float) -> int:
|
| 217 |
+
return round(seconds * SAMPLE_RATE)
|
python/inflect_ax_tts/symbols.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
""" from https://github.com/keithito/tacotron """
|
| 2 |
+
|
| 3 |
+
'''
|
| 4 |
+
Defines the set of symbols used in text input to the model.
|
| 5 |
+
'''
|
| 6 |
+
_pad = '_'
|
| 7 |
+
_punctuation = ';:,.!?¡¿—…"«»“” '
|
| 8 |
+
_letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
| 9 |
+
_letters_ipa = "ɑɐɒæɓʙβɔɕçɗɖðʤəɘɚɛɜɝɞɟʄɡɠɢʛɦɧħɥʜɨɪʝɭɬɫɮʟɱɯɰŋɳɲɴøɵɸθœɶʘɹɺɾɻʀʁɽʂʃʈʧʉʊʋⱱʌɣɤʍχʎʏʑʐʒʔʡʕʢǀǁǂǃˈˌːˑʼʴʰʱʲʷˠˤ˞↓↑→↗↘'̩'ᵻ"
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# Export all symbols:
|
| 13 |
+
symbols = [_pad] + list(_punctuation) + list(_letters) + list(_letters_ipa)
|
| 14 |
+
|
| 15 |
+
# Special symbol ids
|
| 16 |
+
SPACE_ID = symbols.index(" ")
|
python/inflect_ax_tts/text_normalize.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import re
|
| 7 |
+
import sys
|
| 8 |
+
from dataclasses import asdict, dataclass
|
| 9 |
+
from datetime import date
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
from num2words import num2words
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
MONTHS = [
|
| 16 |
+
"January",
|
| 17 |
+
"February",
|
| 18 |
+
"March",
|
| 19 |
+
"April",
|
| 20 |
+
"May",
|
| 21 |
+
"June",
|
| 22 |
+
"July",
|
| 23 |
+
"August",
|
| 24 |
+
"September",
|
| 25 |
+
"October",
|
| 26 |
+
"November",
|
| 27 |
+
"December",
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
WORD_OVERRIDES = {
|
| 31 |
+
"Qwen3": "Qwen three",
|
| 32 |
+
"Qwen": "Qwen",
|
| 33 |
+
"PyTorch": "pie torch",
|
| 34 |
+
"SQLite": "ess cue lite",
|
| 35 |
+
"USB-C": "you ess bee see",
|
| 36 |
+
"RTX 3060": "ar tee ex thirty sixty",
|
| 37 |
+
"RTX 3090": "ar tee ex thirty ninety",
|
| 38 |
+
"RTX 4090": "ar tee ex forty ninety",
|
| 39 |
+
"RTX 5080": "ar tee ex fifty eighty",
|
| 40 |
+
"RTX 5090": "ar tee ex fifty ninety",
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
LETTER_NAMES = {
|
| 44 |
+
"A": "ay",
|
| 45 |
+
"B": "bee",
|
| 46 |
+
"C": "see",
|
| 47 |
+
"D": "dee",
|
| 48 |
+
"E": "ee",
|
| 49 |
+
"F": "eff",
|
| 50 |
+
"G": "gee",
|
| 51 |
+
"H": "aitch",
|
| 52 |
+
"I": "eye",
|
| 53 |
+
"J": "jay",
|
| 54 |
+
"K": "kay",
|
| 55 |
+
"L": "ell",
|
| 56 |
+
"M": "em",
|
| 57 |
+
"N": "en",
|
| 58 |
+
"O": "oh",
|
| 59 |
+
"P": "pee",
|
| 60 |
+
"Q": "cue",
|
| 61 |
+
"R": "ar",
|
| 62 |
+
"S": "ess",
|
| 63 |
+
"T": "tee",
|
| 64 |
+
"U": "you",
|
| 65 |
+
"V": "vee",
|
| 66 |
+
"W": "double you",
|
| 67 |
+
"X": "ex",
|
| 68 |
+
"Y": "why",
|
| 69 |
+
"Z": "zee",
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
ABBREVIATIONS = {
|
| 73 |
+
"Dr.": "doctor",
|
| 74 |
+
"Mr.": "mister",
|
| 75 |
+
"Mrs.": "missus",
|
| 76 |
+
"Ms.": "miss",
|
| 77 |
+
"Prof.": "professor",
|
| 78 |
+
"St.": "saint",
|
| 79 |
+
"vs.": "versus",
|
| 80 |
+
"etc.": "et cetera",
|
| 81 |
+
"e.g.": "for example",
|
| 82 |
+
"i.e.": "that is",
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
PUNCT_TRANSLATION = str.maketrans(
|
| 86 |
+
{
|
| 87 |
+
"\u2018": "'",
|
| 88 |
+
"\u2019": "'",
|
| 89 |
+
"\u201c": '"',
|
| 90 |
+
"\u201d": '"',
|
| 91 |
+
"\u2013": "-",
|
| 92 |
+
"\u2014": ", ",
|
| 93 |
+
"\u2026": "...",
|
| 94 |
+
"(": ", ",
|
| 95 |
+
")": ", ",
|
| 96 |
+
"[": ", ",
|
| 97 |
+
"]": ", ",
|
| 98 |
+
"{": ", ",
|
| 99 |
+
"}": ", ",
|
| 100 |
+
}
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
_ESPEAK_CONFIGURED = False
|
| 104 |
+
_ESPEAK_BACKEND = None
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@dataclass
|
| 108 |
+
class FrontendOutput:
|
| 109 |
+
raw_text: str
|
| 110 |
+
normalized_text: str
|
| 111 |
+
phoneme_text: str
|
| 112 |
+
tokens: list[str]
|
| 113 |
+
token_count: int
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _words(value: int | float, *, ordinal: bool = False) -> str:
|
| 117 |
+
if ordinal:
|
| 118 |
+
text = num2words(value, to="ordinal")
|
| 119 |
+
else:
|
| 120 |
+
text = num2words(value)
|
| 121 |
+
return text.replace("-", " ").replace(",", "")
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _digit_words(text: str) -> str:
|
| 125 |
+
return " ".join(_words(int(ch)) for ch in text if ch.isdigit())
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _identifier_digits(text: str) -> str:
|
| 129 |
+
words = []
|
| 130 |
+
for index, character in enumerate(text):
|
| 131 |
+
if not character.isdigit():
|
| 132 |
+
continue
|
| 133 |
+
words.append("oh" if character == "0" and index > 0 else _words(int(character)))
|
| 134 |
+
return " ".join(words)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _expand_identifier_token(token: str) -> str:
|
| 138 |
+
match = re.fullmatch(r"([A-Za-z]?)(\d+)([A-Za-z]?)", token)
|
| 139 |
+
if match is None:
|
| 140 |
+
return token
|
| 141 |
+
prefix, digits, suffix = match.groups()
|
| 142 |
+
pieces = []
|
| 143 |
+
if prefix:
|
| 144 |
+
pieces.append(LETTER_NAMES[prefix.upper()])
|
| 145 |
+
if len(digits) == 3 or digits.startswith("0"):
|
| 146 |
+
pieces.append(_identifier_digits(digits))
|
| 147 |
+
else:
|
| 148 |
+
pieces.append(_words(int(digits)))
|
| 149 |
+
if suffix:
|
| 150 |
+
pieces.append(LETTER_NAMES[suffix.upper()])
|
| 151 |
+
return " ".join(pieces)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def _expand_labeled_identifier(match: re.Match[str]) -> str:
|
| 155 |
+
return f"{match.group(1)} {_expand_identifier_token(match.group(2))}"
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _expand_street_number(match: re.Match[str]) -> str:
|
| 159 |
+
return _identifier_digits(match.group(1))
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _expand_money(match: re.Match[str]) -> str:
|
| 163 |
+
raw = match.group(1).replace(",", "")
|
| 164 |
+
dollars, _, cents = raw.partition(".")
|
| 165 |
+
dollar_count = int(dollars)
|
| 166 |
+
parts = [_words(dollar_count), "dollar" if dollar_count == 1 else "dollars"]
|
| 167 |
+
if cents:
|
| 168 |
+
cents = cents[:2].ljust(2, "0")
|
| 169 |
+
cent_count = int(cents)
|
| 170 |
+
if cent_count:
|
| 171 |
+
parts.extend(["and", _words(cent_count), "cent" if cent_count == 1 else "cents"])
|
| 172 |
+
return " ".join(parts)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def _expand_date_slash(match: re.Match[str]) -> str:
|
| 176 |
+
month = int(match.group(1))
|
| 177 |
+
day = int(match.group(2))
|
| 178 |
+
year = int(match.group(3))
|
| 179 |
+
try:
|
| 180 |
+
date(year, month, day)
|
| 181 |
+
except ValueError:
|
| 182 |
+
return match.group(0)
|
| 183 |
+
return f"{MONTHS[month - 1]} {_words(day, ordinal=True)} {_words(year)}"
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _expand_time(match: re.Match[str]) -> str:
|
| 187 |
+
hour = int(match.group(1))
|
| 188 |
+
minute = int(match.group(2))
|
| 189 |
+
suffix = match.group(3) or ""
|
| 190 |
+
pieces = [_words(hour)]
|
| 191 |
+
if minute == 0:
|
| 192 |
+
pieces.append("o clock")
|
| 193 |
+
elif minute < 10:
|
| 194 |
+
pieces.extend(["oh", _words(minute)])
|
| 195 |
+
else:
|
| 196 |
+
pieces.append(_words(minute))
|
| 197 |
+
if suffix:
|
| 198 |
+
suffix = suffix.lower().replace(".", "")
|
| 199 |
+
pieces.extend(list(suffix))
|
| 200 |
+
return " ".join(pieces)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def _expand_bare_hour_time(match: re.Match[str]) -> str:
|
| 204 |
+
hour = int(match.group(1))
|
| 205 |
+
suffix = re.sub(r"[^A-Za-z]", "", match.group(2)).lower()
|
| 206 |
+
return f"{_words(hour)} {' '.join(suffix)}"
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def _expand_version(match: re.Match[str]) -> str:
|
| 210 |
+
return " point ".join(_words(int(part)) for part in match.group(0).split("."))
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def _expand_decimal(match: re.Match[str]) -> str:
|
| 214 |
+
whole, frac = match.group(1), match.group(2)
|
| 215 |
+
return f"{_words(int(whole))} point {_digit_words(frac)}"
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _expand_ordinal(match: re.Match[str]) -> str:
|
| 219 |
+
return _words(int(match.group(1)), ordinal=True)
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def _expand_number(match: re.Match[str]) -> str:
|
| 223 |
+
value = match.group(0).replace(",", "")
|
| 224 |
+
if len(value) >= 5 and not value.startswith("20"):
|
| 225 |
+
return _digit_words(value)
|
| 226 |
+
return _words(int(value))
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def _expand_phone(match: re.Match[str]) -> str:
|
| 230 |
+
left, right = match.group(1), match.group(2)
|
| 231 |
+
return f"{_digit_words(left)}, {_digit_words(right)}"
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def _expand_acronym(match: re.Match[str]) -> str:
|
| 235 |
+
acronym = match.group(0)
|
| 236 |
+
if len(acronym) <= 1:
|
| 237 |
+
return acronym
|
| 238 |
+
return " ".join(LETTER_NAMES.get(ch, ch) for ch in acronym)
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def normalize_text(text: str) -> str:
|
| 242 |
+
text = text.translate(PUNCT_TRANSLATION)
|
| 243 |
+
text = re.sub(r"\s+", " ", text).strip()
|
| 244 |
+
|
| 245 |
+
for src, dst in WORD_OVERRIDES.items():
|
| 246 |
+
text = re.sub(rf"\b{re.escape(src)}\b", dst, text)
|
| 247 |
+
for src, dst in ABBREVIATIONS.items():
|
| 248 |
+
text = re.sub(rf"\b{re.escape(src)}", dst, text, flags=re.IGNORECASE)
|
| 249 |
+
|
| 250 |
+
text = re.sub(r"\b([A-Z])(?:\.([A-Z]))+\.", lambda m: " ".join(re.findall(r"[A-Z]", m.group(0))), text)
|
| 251 |
+
text = re.sub(
|
| 252 |
+
r"\b(apartment|apt\.?|suite|unit|room|flight|extension|order|invoice|locker|aisle|gate)\s+([A-Za-z]?\d{1,4}[A-Za-z]?)\b",
|
| 253 |
+
_expand_labeled_identifier,
|
| 254 |
+
text,
|
| 255 |
+
flags=re.IGNORECASE,
|
| 256 |
+
)
|
| 257 |
+
text = re.sub(
|
| 258 |
+
r"\b(\d{3})(?=\s+(?:North|South|East|West)\b)",
|
| 259 |
+
_expand_street_number,
|
| 260 |
+
text,
|
| 261 |
+
flags=re.IGNORECASE,
|
| 262 |
+
)
|
| 263 |
+
text = re.sub(r"\$(\d[\d,]*(?:\.\d{1,2})?)", _expand_money, text)
|
| 264 |
+
text = re.sub(r"\b(0?[1-9]|1[0-2])/(0?[1-9]|[12]\d|3[01])/(20\d{2}|19\d{2})\b", _expand_date_slash, text)
|
| 265 |
+
text = re.sub(r"\b(\d{1,2}):(\d{2})\s*([AaPp]\.?\s*[Mm]\.?)?\b", _expand_time, text)
|
| 266 |
+
text = re.sub(r"\b(\d{1,2})\s*([AaPp]\.?\s*[Mm]\.?)\b", _expand_bare_hour_time, text)
|
| 267 |
+
text = re.sub(r"\b(\d{3})-(\d{4})\b", _expand_phone, text)
|
| 268 |
+
text = re.sub(r"\b\d+(?:\.\d+){2,}\b", _expand_version, text)
|
| 269 |
+
text = re.sub(r"\b(\d+)\.(\d+)\b", _expand_decimal, text)
|
| 270 |
+
text = re.sub(r"\b(\d+)(st|nd|rd|th)\b", _expand_ordinal, text, flags=re.IGNORECASE)
|
| 271 |
+
text = re.sub(r"\b\d[\d,]*\b", _expand_number, text)
|
| 272 |
+
text = re.sub(r"\b[A-Z]{2,}\b", _expand_acronym, text)
|
| 273 |
+
text = re.sub(r",(?:\s*,)+", ",", text)
|
| 274 |
+
text = re.sub(r",\s*([.!?])", r"\1", text)
|
| 275 |
+
text = re.sub(r"\s+([,;:.!?])", r"\1", text)
|
| 276 |
+
text = re.sub(r"([,;:.!?])(?=\S)", r"\1 ", text)
|
| 277 |
+
return re.sub(r"\s+", " ", text).strip()
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def _configure_espeak() -> None:
|
| 281 |
+
global _ESPEAK_CONFIGURED
|
| 282 |
+
if _ESPEAK_CONFIGURED:
|
| 283 |
+
return
|
| 284 |
+
|
| 285 |
+
# Prefer the persistent distro library for long Linux preprocessing jobs.
|
| 286 |
+
# espeakng-loader extracts a temporary shared object, which can exhaust mmap
|
| 287 |
+
# resources when phonemizer repeatedly creates backends over a large corpus.
|
| 288 |
+
system_libraries = (
|
| 289 |
+
Path("/usr/lib/x86_64-linux-gnu/libespeak-ng.so.1"),
|
| 290 |
+
Path("/usr/lib/aarch64-linux-gnu/libespeak-ng.so.1"),
|
| 291 |
+
Path("/usr/lib64/libespeak-ng.so.1"),
|
| 292 |
+
)
|
| 293 |
+
system_library = next((path for path in system_libraries if path.is_file()), None)
|
| 294 |
+
if system_library is not None:
|
| 295 |
+
os.environ.setdefault("PHONEMIZER_ESPEAK_LIBRARY", str(system_library))
|
| 296 |
+
else:
|
| 297 |
+
import espeakng_loader
|
| 298 |
+
|
| 299 |
+
os.environ.setdefault("PHONEMIZER_ESPEAK_LIBRARY", espeakng_loader.get_library_path())
|
| 300 |
+
os.environ.setdefault("ESPEAK_DATA_PATH", espeakng_loader.get_data_path())
|
| 301 |
+
espeakng_loader.make_library_available()
|
| 302 |
+
espeakng_loader.load_library()
|
| 303 |
+
_ESPEAK_CONFIGURED = True
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def phonemize_normalized_text(normalized_text: str) -> str:
|
| 307 |
+
global _ESPEAK_BACKEND
|
| 308 |
+
_configure_espeak()
|
| 309 |
+
from phonemizer.backend import EspeakBackend
|
| 310 |
+
from phonemizer.separator import Separator
|
| 311 |
+
|
| 312 |
+
if _ESPEAK_BACKEND is None:
|
| 313 |
+
_ESPEAK_BACKEND = EspeakBackend(
|
| 314 |
+
language="en-us",
|
| 315 |
+
preserve_punctuation=True,
|
| 316 |
+
with_stress=True,
|
| 317 |
+
language_switch="remove-flags",
|
| 318 |
+
)
|
| 319 |
+
return _ESPEAK_BACKEND.phonemize(
|
| 320 |
+
[normalized_text],
|
| 321 |
+
separator=Separator(phone=" ", word=" | ", syllable=""),
|
| 322 |
+
strip=True,
|
| 323 |
+
njobs=1,
|
| 324 |
+
)[0]
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
def tokenize_phoneme_text(phoneme_text: str) -> list[str]:
|
| 328 |
+
text = phoneme_text.replace("|", " <word> ")
|
| 329 |
+
text = re.sub(r"([,;:.!?])", r" \1 ", text)
|
| 330 |
+
tokens = [tok for tok in re.split(r"\s+", text.strip()) if tok]
|
| 331 |
+
return tokens
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def run_frontend(text: str) -> FrontendOutput:
|
| 335 |
+
normalized = normalize_text(text)
|
| 336 |
+
phoneme_text = phonemize_normalized_text(normalized)
|
| 337 |
+
tokens = tokenize_phoneme_text(phoneme_text)
|
| 338 |
+
return FrontendOutput(
|
| 339 |
+
raw_text=text,
|
| 340 |
+
normalized_text=normalized,
|
| 341 |
+
phoneme_text=phoneme_text,
|
| 342 |
+
tokens=tokens,
|
| 343 |
+
token_count=len(tokens),
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def _iter_input_rows(path: Path):
|
| 348 |
+
with path.open("r", encoding="utf-8") as f:
|
| 349 |
+
for line_number, line in enumerate(f, 1):
|
| 350 |
+
line = line.strip()
|
| 351 |
+
if not line:
|
| 352 |
+
continue
|
| 353 |
+
if line.startswith("{"):
|
| 354 |
+
row = json.loads(line)
|
| 355 |
+
text = row.get("target_text") or row.get("text") or row.get("source_text")
|
| 356 |
+
if not text:
|
| 357 |
+
raise ValueError(f"No text field found at {path}:{line_number}")
|
| 358 |
+
yield row, str(text)
|
| 359 |
+
else:
|
| 360 |
+
yield {"line_number": line_number}, line
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def main() -> None:
|
| 364 |
+
if hasattr(sys.stdout, "reconfigure"):
|
| 365 |
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
| 366 |
+
if hasattr(sys.stderr, "reconfigure"):
|
| 367 |
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
| 368 |
+
|
| 369 |
+
ap = argparse.ArgumentParser(description="Inflect-Nano-v2 English text frontend.")
|
| 370 |
+
ap.add_argument("--text", help="Single text string to normalize and phonemize.")
|
| 371 |
+
ap.add_argument("--input", type=Path, help="Text file or JSONL to process.")
|
| 372 |
+
ap.add_argument("--out", type=Path, help="Output JSONL path for --input.")
|
| 373 |
+
args = ap.parse_args()
|
| 374 |
+
|
| 375 |
+
if bool(args.text) == bool(args.input):
|
| 376 |
+
raise SystemExit("Provide exactly one of --text or --input.")
|
| 377 |
+
|
| 378 |
+
if args.text:
|
| 379 |
+
print(json.dumps(asdict(run_frontend(args.text)), ensure_ascii=False, indent=2))
|
| 380 |
+
return
|
| 381 |
+
|
| 382 |
+
if not args.out:
|
| 383 |
+
raise SystemExit("--out is required with --input.")
|
| 384 |
+
|
| 385 |
+
args.out.parent.mkdir(parents=True, exist_ok=True)
|
| 386 |
+
with args.out.open("w", encoding="utf-8") as f:
|
| 387 |
+
for metadata, text in _iter_input_rows(args.input):
|
| 388 |
+
result = asdict(run_frontend(text))
|
| 389 |
+
result["metadata"] = metadata
|
| 390 |
+
f.write(json.dumps(result, ensure_ascii=False) + "\n")
|
| 391 |
+
print(f"Wrote {args.out}")
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
if __name__ == "__main__":
|
| 395 |
+
main()
|
python/inflect_ax_tts/tts.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""InflectTTS: two-stage (encoder/decoder AXMODEL) VITS TTS pipeline.
|
| 2 |
+
|
| 3 |
+
Pipeline (per text chunk):
|
| 4 |
+
|
| 5 |
+
text -> [Host: eSpeak phonemize + symbol ids + intersperse(add_blank)]
|
| 6 |
+
-> tokens[1,256] int (0-padded) + x_lengths[1]
|
| 7 |
+
-> encoder.axmodel -> m_p/logs_p[1,192,256], logw[1,1,256]
|
| 8 |
+
-> [Host: first x_lengths frames; w=exp(logw)*length_scale; ceil;
|
| 9 |
+
generate_path; attn matmul -> T' frames;
|
| 10 |
+
z_p = m_p' + randn(seed)*exp(logs_p')*variation]
|
| 11 |
+
-> Tp=512 chunks (64-frame overlap crossfade) -> decoder.axmodel
|
| 12 |
+
-> waveform blocks, tail trimmed
|
| 13 |
+
-> edge fade 5 ms + sentence pauses + clip [-1,1]
|
| 14 |
+
-> 24 kHz mono float32
|
| 15 |
+
|
| 16 |
+
The same class drives both hardware targets: AX620E and AX637 AXMODELs share
|
| 17 |
+
the identical shape contract, so switching target = switching model paths.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
import numpy as np
|
| 25 |
+
|
| 26 |
+
from . import host_chain
|
| 27 |
+
from .backend import ModelSession
|
| 28 |
+
from .frontend import intersperse, text_to_token_ids
|
| 29 |
+
from .wav_io import write_wav
|
| 30 |
+
|
| 31 |
+
# Deterministic EXPORT parity baseline (export/model_meta.json numeric_baseline):
|
| 32 |
+
# cleaned_text_to_sequence("ðə kwɪk"), 7 phonemes.
|
| 33 |
+
DUMMY_PHONEME_IDS = [81, 83, 16, 53, 65, 102, 53]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class InflectTTS:
|
| 37 |
+
ENCODER_T = host_chain.ENCODER_T
|
| 38 |
+
DECODER_TP = host_chain.DECODER_TP
|
| 39 |
+
SAMPLE_RATE = host_chain.SAMPLE_RATE
|
| 40 |
+
|
| 41 |
+
def __init__(
|
| 42 |
+
self,
|
| 43 |
+
encoder_path: str | Path,
|
| 44 |
+
decoder_path: str | Path,
|
| 45 |
+
backend: str = "auto",
|
| 46 |
+
) -> None:
|
| 47 |
+
self.encoder = ModelSession(encoder_path, backend)
|
| 48 |
+
self.decoder = ModelSession(decoder_path, backend)
|
| 49 |
+
|
| 50 |
+
# ------------------------------------------------------------------
|
| 51 |
+
# Encoder + host chain
|
| 52 |
+
# ------------------------------------------------------------------
|
| 53 |
+
def _run_encoder(
|
| 54 |
+
self, token_ids: list[int], length_scale: float
|
| 55 |
+
) -> tuple[np.ndarray, np.ndarray, int]:
|
| 56 |
+
"""token ids (already interspersed) -> (m_p_e [T',C], logs_p_e, T')."""
|
| 57 |
+
x_len = len(token_ids)
|
| 58 |
+
if x_len > self.ENCODER_T:
|
| 59 |
+
raise ValueError(
|
| 60 |
+
f"token sequence length {x_len} exceeds encoder static T={self.ENCODER_T}"
|
| 61 |
+
)
|
| 62 |
+
tok_dtype = self.encoder.input_dtype("tokens")
|
| 63 |
+
len_dtype = self.encoder.input_dtype("x_lengths")
|
| 64 |
+
tokens = np.zeros((1, self.ENCODER_T), dtype=tok_dtype)
|
| 65 |
+
tokens[0, :x_len] = np.asarray(token_ids, dtype=tok_dtype)
|
| 66 |
+
x_lengths = np.asarray([x_len], dtype=len_dtype)
|
| 67 |
+
out = self.encoder.run({"tokens": tokens, "x_lengths": x_lengths})
|
| 68 |
+
m_p, logs_p, logw = out["m_p"], out["logs_p"], out["logw"]
|
| 69 |
+
# Only the first x_len frames are valid (rest is padding, x_mask-zeroed).
|
| 70 |
+
return host_chain.expand_priors(
|
| 71 |
+
logw, m_p, logs_p, x_len, length_scale=length_scale
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
def _decode_z_p(self, z_p: np.ndarray) -> np.ndarray:
|
| 75 |
+
"""z_p [C, T'] -> wav [T'*256] via chunked decoder."""
|
| 76 |
+
z_dtype = self.decoder.input_dtype("z_p")
|
| 77 |
+
|
| 78 |
+
def run_chunk(z_chunk: np.ndarray) -> np.ndarray:
|
| 79 |
+
out = self.decoder.run({"z_p": z_chunk[None].astype(z_dtype)})
|
| 80 |
+
return np.asarray(out["wav"], dtype=np.float32).reshape(-1)
|
| 81 |
+
|
| 82 |
+
return host_chain.decode_waveform(
|
| 83 |
+
z_p, run_chunk, self.DECODER_TP, host_chain.DECODER_OVERLAP
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
# ------------------------------------------------------------------
|
| 87 |
+
# Public API
|
| 88 |
+
# ------------------------------------------------------------------
|
| 89 |
+
def synthesize_tokens(
|
| 90 |
+
self,
|
| 91 |
+
token_ids: list[int],
|
| 92 |
+
*,
|
| 93 |
+
speed: float = 1.0,
|
| 94 |
+
variation: float = 0.667,
|
| 95 |
+
seed: int = 0,
|
| 96 |
+
) -> tuple[int, np.ndarray]:
|
| 97 |
+
"""Synthesize from raw phoneme ids (NOT interspersed; blank added here).
|
| 98 |
+
|
| 99 |
+
Skips the eSpeak text frontend — useful for tests or when the frontend
|
| 100 |
+
runs elsewhere. Mirrors one chunk of origin model.infer.
|
| 101 |
+
"""
|
| 102 |
+
if not 0.5 <= speed <= 2.0:
|
| 103 |
+
raise ValueError("speed must be between 0.5 and 2.0")
|
| 104 |
+
if not 0.0 <= variation <= 1.0:
|
| 105 |
+
raise ValueError("variation must be between 0.0 and 1.0")
|
| 106 |
+
sequence = intersperse(list(token_ids), 0)
|
| 107 |
+
m_p_e, logs_p_e, _ = self._run_encoder(sequence, 1.0 / speed)
|
| 108 |
+
z_p = host_chain.inject_noise(m_p_e, logs_p_e, variation, seed)
|
| 109 |
+
wav = self._decode_z_p(z_p)
|
| 110 |
+
return self.SAMPLE_RATE, np.clip(wav, -1.0, 1.0)
|
| 111 |
+
|
| 112 |
+
def _token_chunks(self, text: str) -> list[list[int]]:
|
| 113 |
+
"""text -> list of interspersed token id lists, each <= ENCODER_T-1."""
|
| 114 |
+
ids = text_to_token_ids(text) # interspersed, 2N+1
|
| 115 |
+
if len(ids) <= self.ENCODER_T - 1:
|
| 116 |
+
return [ids]
|
| 117 |
+
words = text.split()
|
| 118 |
+
if len(words) < 2:
|
| 119 |
+
raise ValueError(
|
| 120 |
+
f"single unbreakable chunk produces {len(ids)} tokens "
|
| 121 |
+
f"(> {self.ENCODER_T - 1}); shorten the text"
|
| 122 |
+
)
|
| 123 |
+
mid = len(words) // 2
|
| 124 |
+
return self._token_chunks(" ".join(words[:mid])) + self._token_chunks(
|
| 125 |
+
" ".join(words[mid:])
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
def synthesize(
|
| 129 |
+
self,
|
| 130 |
+
text: str,
|
| 131 |
+
*,
|
| 132 |
+
speed: float = 1.0,
|
| 133 |
+
variation: float = 0.667,
|
| 134 |
+
seed: int = 0,
|
| 135 |
+
) -> tuple[int, np.ndarray]:
|
| 136 |
+
"""Full text -> 24 kHz mono waveform (mirrors origin inference.py)."""
|
| 137 |
+
normalized = " ".join(text.split())
|
| 138 |
+
if not normalized:
|
| 139 |
+
raise ValueError("Text must not be empty.")
|
| 140 |
+
if not 0.5 <= speed <= 2.0:
|
| 141 |
+
raise ValueError("speed must be between 0.5 and 2.0")
|
| 142 |
+
if not 0.0 <= variation <= 1.0:
|
| 143 |
+
raise ValueError("variation must be between 0.0 and 1.0")
|
| 144 |
+
length_scale = 1.0 / speed
|
| 145 |
+
|
| 146 |
+
chunks = host_chain.split_text(normalized)
|
| 147 |
+
pieces: list[np.ndarray] = []
|
| 148 |
+
prev_ending = ""
|
| 149 |
+
index = 0
|
| 150 |
+
for chunk in chunks:
|
| 151 |
+
for token_ids in self._token_chunks(chunk):
|
| 152 |
+
if index:
|
| 153 |
+
pause = host_chain.boundary_pause_seconds(prev_ending)
|
| 154 |
+
pieces.append(
|
| 155 |
+
np.zeros(
|
| 156 |
+
host_chain.seconds_to_samples(pause), dtype=np.float32
|
| 157 |
+
)
|
| 158 |
+
)
|
| 159 |
+
m_p_e, logs_p_e, _ = self._run_encoder(token_ids, length_scale)
|
| 160 |
+
z_p = host_chain.inject_noise(m_p_e, logs_p_e, variation, seed + index)
|
| 161 |
+
wav = self._decode_z_p(z_p)
|
| 162 |
+
pieces.append(host_chain.edge_fade(wav, self.SAMPLE_RATE))
|
| 163 |
+
index += 1
|
| 164 |
+
prev_ending = chunk
|
| 165 |
+
waveform = np.clip(np.concatenate(pieces), -1.0, 1.0)
|
| 166 |
+
return self.SAMPLE_RATE, waveform
|
| 167 |
+
|
| 168 |
+
def save(self, text: str, output: str | Path, **kwargs) -> Path:
|
| 169 |
+
sample_rate, waveform = self.synthesize(text, **kwargs)
|
| 170 |
+
return write_wav(output, waveform, sample_rate)
|
python/inflect_ax_tts/wav_io.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""16-bit PCM WAV writer (stdlib only, no soundfile dependency)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import wave
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def write_wav(path: str | Path, waveform: np.ndarray, sample_rate: int = 24000) -> Path:
|
| 12 |
+
"""Write float32 mono waveform in [-1, 1] as 16-bit PCM WAV."""
|
| 13 |
+
destination = Path(path)
|
| 14 |
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
| 15 |
+
pcm = np.clip(np.asarray(waveform, dtype=np.float32), -1.0, 1.0)
|
| 16 |
+
pcm16 = (pcm * 32767.0).round().astype("<i2")
|
| 17 |
+
with wave.open(str(destination), "wb") as handle:
|
| 18 |
+
handle.setnchannels(1)
|
| 19 |
+
handle.setsampwidth(2)
|
| 20 |
+
handle.setframerate(sample_rate)
|
| 21 |
+
handle.writeframes(pcm16.tobytes())
|
| 22 |
+
return destination
|
python/requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Inflect AX TTS Python SDK dependencies
|
| 2 |
+
# Runtime (on-device / host with AX runtime):
|
| 3 |
+
numpy>=1.24
|
| 4 |
+
# pyaxengine (PyPI package name: axengine) — NPU runtime, required for .axmodel files:
|
| 5 |
+
axengine>=0.1.3
|
| 6 |
+
# Text frontend (only needed for text -> phonemes; not for synthesize_tokens):
|
| 7 |
+
phonemizer>=3.0
|
| 8 |
+
espeakng-loader>=0.2
|
| 9 |
+
num2words>=0.5
|
| 10 |
+
# Host numeric stand-in (run export/*.onnx on a machine without NPU):
|
| 11 |
+
onnxruntime>=1.16
|
python/tests/test_host_chain.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Host-chain unit tests for the Inflect AX TTS Python SDK.
|
| 3 |
+
|
| 4 |
+
Runs on a plain host without NPU: onnxruntime is used as a numeric stand-in
|
| 5 |
+
for the AXMODEL runtime (the SDK pipeline above the session layer is identical
|
| 6 |
+
for both backends; see README.md for what is NOT covered by this).
|
| 7 |
+
|
| 8 |
+
Runnable two ways:
|
| 9 |
+
python tests/test_host_chain.py # plain script, exit code 0 on pass
|
| 10 |
+
pytest tests/ # if pytest is installed
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import sys
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
|
| 20 |
+
SDK_ROOT = Path(__file__).resolve().parents[1]
|
| 21 |
+
PKG_DIR = Path(__file__).resolve().parents[2]
|
| 22 |
+
sys.path.insert(0, str(SDK_ROOT))
|
| 23 |
+
|
| 24 |
+
from inflect_ax_tts import DUMMY_PHONEME_IDS, InflectTTS # noqa: E402
|
| 25 |
+
from inflect_ax_tts import host_chain # noqa: E402
|
| 26 |
+
from inflect_ax_tts.frontend import intersperse # noqa: E402
|
| 27 |
+
|
| 28 |
+
CONVERT_DIR = PKG_DIR / "model_convert"
|
| 29 |
+
REF_NPZ = CONVERT_DIR / "export" / "reference_io.npz"
|
| 30 |
+
ENCODER_ONNX = CONVERT_DIR / "export" / "encoder.onnx"
|
| 31 |
+
DECODER_ONNX = CONVERT_DIR / "export" / "decoder.onnx"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _cosine(a: np.ndarray, b: np.ndarray) -> float:
|
| 35 |
+
a = np.asarray(a, dtype=np.float64).ravel()
|
| 36 |
+
b = np.asarray(b, dtype=np.float64).ravel()
|
| 37 |
+
assert a.shape == b.shape, f"shape {a.shape} vs {b.shape}"
|
| 38 |
+
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-30))
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _torch_reference_expand(logw, m_p, logs_p, x_len, length_scale):
|
| 42 |
+
"""Replicates export/export_onnx.py:host_expand with torch (ground truth)."""
|
| 43 |
+
import torch
|
| 44 |
+
|
| 45 |
+
def sequence_mask(lengths, max_length):
|
| 46 |
+
return torch.arange(max_length).unsqueeze(0) < lengths.unsqueeze(1)
|
| 47 |
+
|
| 48 |
+
x_lengths = torch.LongTensor([x_len])
|
| 49 |
+
x_mask = sequence_mask(x_lengths, logw.shape[-1]).unsqueeze(1).float()
|
| 50 |
+
w = torch.exp(torch.from_numpy(logw)) * x_mask * length_scale
|
| 51 |
+
w_ceil = torch.ceil(w)
|
| 52 |
+
y_lengths = torch.clamp_min(torch.sum(w_ceil, [1, 2]), 1).long()
|
| 53 |
+
y_len = int(y_lengths.item())
|
| 54 |
+
y_mask = sequence_mask(y_lengths, y_len).unsqueeze(1).float()
|
| 55 |
+
attn_mask = x_mask.unsqueeze(2) * y_mask.unsqueeze(-1)
|
| 56 |
+
# generate_path (origin/runtime/commons.py)
|
| 57 |
+
cum = torch.cumsum(w_ceil, -1).view(-1)
|
| 58 |
+
path = sequence_mask(cum, y_len).float().view(1, 1, -1, y_len)
|
| 59 |
+
path = path - torch.nn.functional.pad(path, (0, 0, 1, 0))[:, :, :-1]
|
| 60 |
+
attn = path.transpose(2, 3) * attn_mask # [1, 1, T', T]
|
| 61 |
+
m_p_e = torch.matmul(attn.squeeze(1), torch.from_numpy(m_p).transpose(1, 2))
|
| 62 |
+
logs_p_e = torch.matmul(attn.squeeze(1), torch.from_numpy(logs_p).transpose(1, 2))
|
| 63 |
+
return m_p_e[0].numpy(), logs_p_e[0].numpy(), y_len
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# ---------------------------------------------------------------------------
|
| 67 |
+
# Tests
|
| 68 |
+
# ---------------------------------------------------------------------------
|
| 69 |
+
|
| 70 |
+
def test_expand_priors_matches_torch_reference():
|
| 71 |
+
"""numpy host chain (durations + generate_path + matmul) vs torch."""
|
| 72 |
+
ref = np.load(REF_NPZ)
|
| 73 |
+
logw, m_p, logs_p = ref["logw_torch"], ref["m_p_torch"], ref["logs_p_torch"]
|
| 74 |
+
x_len = int(ref["x_lengths"][0])
|
| 75 |
+
for length_scale in (1.0, 0.8, 1.6):
|
| 76 |
+
exp_m, exp_l, y_len = host_chain.expand_priors(
|
| 77 |
+
logw, m_p, logs_p, x_len, length_scale
|
| 78 |
+
)
|
| 79 |
+
ref_m, ref_l, ref_y = _torch_reference_expand(
|
| 80 |
+
logw, m_p, logs_p, x_len, length_scale
|
| 81 |
+
)
|
| 82 |
+
assert y_len == ref_y, f"T' {y_len} vs torch {ref_y}"
|
| 83 |
+
assert np.allclose(exp_m, ref_m, atol=1e-5), (
|
| 84 |
+
f"m_p_e max diff {np.abs(exp_m - ref_m).max():.2e} @ls={length_scale}"
|
| 85 |
+
)
|
| 86 |
+
assert np.allclose(exp_l, ref_l, atol=1e-5), (
|
| 87 |
+
f"logs_p_e max diff {np.abs(exp_l - ref_l).max():.2e} @ls={length_scale}"
|
| 88 |
+
)
|
| 89 |
+
print("[PASS] expand_priors == torch host_expand (3 length scales)")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_encoder_padding_semantics_ort():
|
| 93 |
+
"""Padded static-shape encoder call == unpadded reference (first x_len frames)."""
|
| 94 |
+
import onnxruntime as ort
|
| 95 |
+
|
| 96 |
+
ref = np.load(REF_NPZ)
|
| 97 |
+
tokens = np.zeros((1, 256), dtype=np.int64)
|
| 98 |
+
x_len = int(ref["x_lengths"][0])
|
| 99 |
+
tokens[0, :x_len] = ref["tokens"][0]
|
| 100 |
+
sess = ort.InferenceSession(str(ENCODER_ONNX), providers=["CPUExecutionProvider"])
|
| 101 |
+
m_p, logs_p, logw = sess.run(
|
| 102 |
+
None, {"tokens": tokens, "x_lengths": np.asarray([x_len], dtype=np.int64)}
|
| 103 |
+
)
|
| 104 |
+
for name, got, want in (
|
| 105 |
+
("m_p", m_p, ref["m_p_torch"]),
|
| 106 |
+
("logs_p", logs_p, ref["logs_p_torch"]),
|
| 107 |
+
("logw", logw, ref["logw_torch"]),
|
| 108 |
+
):
|
| 109 |
+
cos = _cosine(got[..., :x_len], want[..., :x_len])
|
| 110 |
+
assert cos >= 0.999999, f"{name} cos {cos}"
|
| 111 |
+
print("[PASS] encoder T=256 padding: first-x_len frames match torch ref")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def test_decoder_chunk_compose_ort():
|
| 115 |
+
"""SDK chunked decode (Tp=512 path) vs EXPORT reference chain wav."""
|
| 116 |
+
import onnxruntime as ort
|
| 117 |
+
|
| 118 |
+
ref = np.load(REF_NPZ)
|
| 119 |
+
z_p = ref["z_p"][0] # [192, 51]
|
| 120 |
+
sess = ort.InferenceSession(str(DECODER_ONNX), providers=["CPUExecutionProvider"])
|
| 121 |
+
|
| 122 |
+
def run_chunk(z_chunk: np.ndarray) -> np.ndarray:
|
| 123 |
+
return sess.run(None, {"z_p": z_chunk[None].astype(np.float32)})[0].reshape(-1)
|
| 124 |
+
|
| 125 |
+
wav = host_chain.decode_waveform(z_p, run_chunk)
|
| 126 |
+
want = ref["wav_onnx_chain"].reshape(-1)
|
| 127 |
+
assert wav.shape == want.shape, f"{wav.shape} vs {want.shape}"
|
| 128 |
+
# Right zero-padding bleeds into the trailing frames (flow/dec conv
|
| 129 |
+
# receptive field; SIMULATE §4 uses the same metric convention): crop
|
| 130 |
+
# min(64, T'/4) frames off the right edge for the comparison.
|
| 131 |
+
t_prime = z_p.shape[1]
|
| 132 |
+
crop = min(64, t_prime // 4) * host_chain.HOP_LENGTH
|
| 133 |
+
cos = _cosine(wav[:-crop], want[:-crop])
|
| 134 |
+
assert cos >= 0.9999, f"decoder compose cos {cos}"
|
| 135 |
+
head = _cosine(wav[: (t_prime // 2) * host_chain.HOP_LENGTH],
|
| 136 |
+
want[: (t_prime // 2) * host_chain.HOP_LENGTH])
|
| 137 |
+
assert head >= 0.999999, f"decoder compose head cos {head}"
|
| 138 |
+
print(f"[PASS] decoder chunk compose vs EXPORT chain: cos={cos:.9f} (right-edge crop), head cos={head:.9f}")
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def test_decoder_multi_chunk_compose_ort():
|
| 142 |
+
"""T'>512 multi-chunk path: overlap crossfade vs single dynamic ORT call.
|
| 143 |
+
|
| 144 |
+
Uses synthetic z_p (not a real prior): checks stitching correctness only,
|
| 145 |
+
comparing against the dynamic-shape ONNX decoder run over the full length.
|
| 146 |
+
Boundary regions are excluded from the metric (conv receptive-field bleed
|
| 147 |
+
is expected; EXPORT_NOTES §5.3).
|
| 148 |
+
"""
|
| 149 |
+
import onnxruntime as ort
|
| 150 |
+
|
| 151 |
+
rng = np.random.default_rng(1234)
|
| 152 |
+
t_prime = 512 + 300
|
| 153 |
+
z_p = rng.standard_normal((192, t_prime), dtype=np.float32) * 0.5
|
| 154 |
+
sess = ort.InferenceSession(str(DECODER_ONNX), providers=["CPUExecutionProvider"])
|
| 155 |
+
want = sess.run(None, {"z_p": z_p[None]})[0].reshape(-1)
|
| 156 |
+
got = host_chain.decode_waveform(
|
| 157 |
+
z_p, lambda z: sess.run(None, {"z_p": z[None]})[0].reshape(-1)
|
| 158 |
+
)
|
| 159 |
+
assert got.shape == want.shape
|
| 160 |
+
# Exclude 32 frames around the chunk boundary (frame 512) and the edges.
|
| 161 |
+
hop = host_chain.HOP_LENGTH
|
| 162 |
+
lo, hi = (512 + 32) * hop, (t_prime - 32) * hop
|
| 163 |
+
interior = _cosine(got[lo:hi], want[lo:hi])
|
| 164 |
+
head = _cosine(got[: 480 * hop], want[: 480 * hop])
|
| 165 |
+
assert interior >= 0.999, f"multi-chunk interior cos {interior}"
|
| 166 |
+
assert head >= 0.999, f"multi-chunk head cos {head}"
|
| 167 |
+
print(f"[PASS] multi-chunk compose: head cos={head:.6f}, interior cos={interior:.6f}")
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def test_synthesize_tokens_end_to_end_ort():
|
| 171 |
+
"""Full SDK pipeline on ORT stand-in: length, determinism, finiteness."""
|
| 172 |
+
tts = InflectTTS(ENCODER_ONNX, DECODER_ONNX, backend="onnxruntime")
|
| 173 |
+
sr, wav1 = tts.synthesize_tokens(DUMMY_PHONEME_IDS, seed=0, variation=0.667)
|
| 174 |
+
_, wav2 = tts.synthesize_tokens(DUMMY_PHONEME_IDS, seed=0, variation=0.667)
|
| 175 |
+
_, wav3 = tts.synthesize_tokens(DUMMY_PHONEME_IDS, seed=1, variation=0.667)
|
| 176 |
+
ref = np.load(REF_NPZ)
|
| 177 |
+
assert sr == 24000
|
| 178 |
+
# Same Host chain as EXPORT baseline -> same T'=51 -> 13056 samples.
|
| 179 |
+
assert wav1.size == int(ref["wav_onnx_chain"].size), (
|
| 180 |
+
f"{wav1.size} vs baseline {ref['wav_onnx_chain'].size}"
|
| 181 |
+
)
|
| 182 |
+
assert np.array_equal(wav1, wav2), "same seed must be bit-reproducible"
|
| 183 |
+
assert not np.array_equal(wav1, wav3), "different seed must differ"
|
| 184 |
+
assert np.isfinite(wav1).all()
|
| 185 |
+
assert np.abs(wav1).max() <= 1.0
|
| 186 |
+
print(f"[PASS] e2e synthesize_tokens: {wav1.size} samples, deterministic, bounded")
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def test_frontend_symbol_mapping():
|
| 190 |
+
"""Symbol table mapping: literal IPA baseline string -> EXPORT baseline ids.
|
| 191 |
+
|
| 192 |
+
export/model_meta.json numeric_baseline was built with
|
| 193 |
+
cleaned_text_to_sequence("ðə kwɪk") — a literal IPA string, NOT eSpeak
|
| 194 |
+
output (eSpeak with_stress would add stress marks)."""
|
| 195 |
+
from inflect_ax_tts.symbols import symbols
|
| 196 |
+
|
| 197 |
+
symbol_to_id = {s: i for i, s in enumerate(symbols)}
|
| 198 |
+
ids = [symbol_to_id[ch] for ch in "ðə kwɪk"]
|
| 199 |
+
assert ids == DUMMY_PHONEME_IDS, f"{ids} vs {DUMMY_PHONEME_IDS}"
|
| 200 |
+
assert intersperse(ids, 0) == [0, 81, 0, 83, 0, 16, 0, 53, 0, 65, 0, 102, 0, 53, 0]
|
| 201 |
+
print("[PASS] symbol mapping: 'ðə kwɪk' -> baseline phoneme ids")
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def test_frontend_smoke():
|
| 205 |
+
"""eSpeak frontend structural check (skipped when espeak is unavailable)."""
|
| 206 |
+
try:
|
| 207 |
+
from inflect_ax_tts.frontend import text_to_token_ids
|
| 208 |
+
ids = text_to_token_ids("the quick")
|
| 209 |
+
except Exception as exc: # espeak missing on host -> skip, not a failure
|
| 210 |
+
print(f"[SKIP] frontend (espeak unavailable: {exc})")
|
| 211 |
+
return
|
| 212 |
+
assert len(ids) % 2 == 1, "interspersed sequence must be odd length"
|
| 213 |
+
assert ids[::2] == [0] * (len(ids) // 2 + 1), "blanks must interleave"
|
| 214 |
+
from inflect_ax_tts.symbols import symbols
|
| 215 |
+
|
| 216 |
+
assert all(0 <= i < len(symbols) for i in ids)
|
| 217 |
+
print(f"[PASS] frontend smoke: 'the quick' -> {len(ids)} interspersed ids")
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
ALL_TESTS = [
|
| 221 |
+
test_expand_priors_matches_torch_reference,
|
| 222 |
+
test_encoder_padding_semantics_ort,
|
| 223 |
+
test_decoder_chunk_compose_ort,
|
| 224 |
+
test_decoder_multi_chunk_compose_ort,
|
| 225 |
+
test_synthesize_tokens_end_to_end_ort,
|
| 226 |
+
test_frontend_symbol_mapping,
|
| 227 |
+
test_frontend_smoke,
|
| 228 |
+
]
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def main() -> int:
|
| 232 |
+
failures = 0
|
| 233 |
+
for test in ALL_TESTS:
|
| 234 |
+
try:
|
| 235 |
+
test()
|
| 236 |
+
except Exception as exc: # noqa: BLE001
|
| 237 |
+
failures += 1
|
| 238 |
+
print(f"[FAIL] {test.__name__}: {exc}")
|
| 239 |
+
print(f"{'ALL PASS' if not failures else f'{failures} FAILURES'} ({len(ALL_TESTS)} tests)")
|
| 240 |
+
return 1 if failures else 0
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
if __name__ == "__main__":
|
| 244 |
+
sys.exit(main())
|
run.sh
CHANGED
|
@@ -3,16 +3,28 @@ set -euo pipefail
|
|
| 3 |
TEXT="${1:-Hello world, this is Inflect Micro V2.}"
|
| 4 |
OUTPUT="${2:-output.wav}"
|
| 5 |
MODE="${3:-cli}"
|
| 6 |
-
CHIP="${4:-
|
| 7 |
|
| 8 |
-
|
| 9 |
-
cd "$SCRIPT_DIR/python"
|
| 10 |
|
| 11 |
-
if [ "$
|
|
|
|
|
|
|
| 12 |
echo "🌐 Starting OpenAI TTS Server on port ${5:-8000}..."
|
| 13 |
python3 -m inflect_tts_sdk.server --port "${5:-8000}" --chip "$CHIP"
|
| 14 |
-
else
|
| 15 |
echo "🎤 ${TEXT}"
|
| 16 |
python3 -m inflect_tts_sdk --text "${TEXT}" --output "${OUTPUT}" --chip "$CHIP"
|
| 17 |
echo "🔊 ${OUTPUT}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
fi
|
|
|
|
| 3 |
TEXT="${1:-Hello world, this is Inflect Micro V2.}"
|
| 4 |
OUTPUT="${2:-output.wav}"
|
| 5 |
MODE="${3:-cli}"
|
| 6 |
+
CHIP="${4:-ax620e}"
|
| 7 |
|
| 8 |
+
cd "$(dirname "$0")"
|
|
|
|
| 9 |
|
| 10 |
+
if [ "$CHIP" = "ax650" ]; then
|
| 11 |
+
cd python
|
| 12 |
+
if [ "$MODE" = "server" ]; then
|
| 13 |
echo "🌐 Starting OpenAI TTS Server on port ${5:-8000}..."
|
| 14 |
python3 -m inflect_tts_sdk.server --port "${5:-8000}" --chip "$CHIP"
|
| 15 |
+
else
|
| 16 |
echo "🎤 ${TEXT}"
|
| 17 |
python3 -m inflect_tts_sdk --text "${TEXT}" --output "${OUTPUT}" --chip "$CHIP"
|
| 18 |
echo "🔊 ${OUTPUT}"
|
| 19 |
+
fi
|
| 20 |
+
else
|
| 21 |
+
if [ "$MODE" = "server" ]; then
|
| 22 |
+
echo "⚠️ server 模式仅支持 ax650,已切换为 CLI 模式"
|
| 23 |
+
fi
|
| 24 |
+
echo "🎤 ${TEXT} (${CHIP}, encoder+decoder 全 NPU)"
|
| 25 |
+
python3 python/example.py \
|
| 26 |
+
--encoder "models/${CHIP}/encoder.axmodel" \
|
| 27 |
+
--decoder "models/${CHIP}/decoder.axmodel" \
|
| 28 |
+
--text "${TEXT}" --output "${OUTPUT}"
|
| 29 |
+
echo "🔊 ${OUTPUT}"
|
| 30 |
fi
|
setup.sh
CHANGED
|
@@ -1,11 +1,16 @@
|
|
| 1 |
#!/usr/bin/env bash
|
| 2 |
set -euo pipefail
|
| 3 |
-
echo "=== Inflect-Micro-v2 TTS Setup (AX650) ==="
|
| 4 |
-
echo "Installing dependencies..."
|
| 5 |
-
pip install -q numpy onnxruntime soundfile unidecode inflect num2words espeakng-loader fastapi uvicorn
|
| 6 |
|
| 7 |
-
echo "Installing
|
| 8 |
-
pip install
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
echo ""
|
| 11 |
-
echo "✅ Setup complete.
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
#!/usr/bin/env bash
|
| 2 |
set -euo pipefail
|
| 3 |
+
echo "=== Inflect-Micro-v2 TTS Setup (AX650 / AX620E / AX637) ==="
|
|
|
|
|
|
|
| 4 |
|
| 5 |
+
echo "Installing Python dependencies..."
|
| 6 |
+
pip install -q numpy onnxruntime soundfile unidecode inflect num2words \
|
| 7 |
+
espeakng-loader phonemizer fastapi uvicorn
|
| 8 |
+
|
| 9 |
+
echo "Installing axengine (pyaxengine)..."
|
| 10 |
+
pip install -q axengine || pip install "pyaxengine @ git+https://github.com/AXERA-TECH/pyaxengine.git"
|
| 11 |
|
| 12 |
echo ""
|
| 13 |
+
echo "✅ Setup complete. 用法:"
|
| 14 |
+
echo " bash run.sh 'Hello!' output.wav cli ax650 # AX650(老 SDK)"
|
| 15 |
+
echo " bash run.sh 'Hello!' output.wav cli ax620e # AX620E(新 SDK,双 NPU)"
|
| 16 |
+
echo " bash run.sh 'Hello!' output.wav cli ax637 # AX637(新 SDK,双 NPU)"
|