inoryQwQ commited on
Commit
5eee449
·
verified ·
1 Parent(s): 4910a6f

三芯片合并:AX620E/AX637 升级 encoder+decoder 全 NPU,新增新一代 SDK;AX650 保持老 SDK

Browse files
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
- | Chip | Encoder | Decoder | Precision |
13
- |------|---------|---------|-----------|
14
- | **AX650** | NPU (axmodel) | NPU (axmodel) | U16 |
15
- | **AX620E** | NPU (axmodel) | CPU (onnxruntime) | U16 |
16
- | **AX637** | NPU (axmodel) | CPU (onnxruntime) | U16 |
17
 
18
- ## 🎧 Audio Samples (AX650 NPU)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- ## 🚀 Quick Start
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/ — full NPU (encoder + decoder axmodel)
47
- ax620e/ — encoder axmodel + decoder ONNX
48
- ax637/ — encoder axmodel + decoder ONNX
49
- python/ — Python SDK
50
- cpp/ — C++ SDK
51
- samples/ — audio samples
 
 
 
 
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:-ax650}"
7
 
8
- SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
9
- cd "$SCRIPT_DIR/python"
10
 
11
- if [ "$MODE" = "server" ]; then
 
 
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 pyaxengine..."
8
- pip install "pyaxengine @ git+https://github.com/AXERA-TECH/pyaxengine.git"
 
 
 
 
9
 
10
  echo ""
11
- echo "✅ Setup complete. Run: bash run.sh 'Hello!' output.wav"
 
 
 
 
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)"