edge-tts-rs-node 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 edge-tts-rs-node contributors
4
+ Copyright (c) 2026 edge-tts-rust contributors
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,203 @@
1
- # Temporary Holding Version
1
+ # edge-tts-rs-node
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 把 Rust 的 [`edge-tts-rust`](https://crates.io/crates/edge-tts-rust) 封装成 Node.js / Electron 主进程可直接调用的 N-API 原生扩展。
4
+
5
+ 对外 API 与 [`node-edge-tts`](https://www.npmjs.com/package/node-edge-tts) 保持一致,可以按同样的方式 `new EdgeTTS(options)`,调用 `ttsPromise`、`toBuffer`、`streamAudio`、`getVoices`。
6
+
7
+ - 原生实现来自 `edge-tts-rust`(Rust + tokio + rustls),Node 侧只做一层薄包装。
8
+ - N-API(Node-API)ABI 稳定,**Electron 下不需要 `electron-rebuild`**。
9
+ - 复用 Rust 侧的连接池:同一个 `EdgeTTS` 实例的多次请求会复用已预热的 WebSocket。
10
+ - 支持流式合成、逐词字幕时间戳、语音列表。
11
+
12
+ ---
13
+
14
+ ## 目录结构
15
+
16
+ ```
17
+ src/lib.rs # N-API 绑定(Rust)
18
+ index.js # EdgeTTS 类,node-edge-tts 兼容层
19
+ index.d.ts # 对外类型定义
20
+ native.js # napi build 生成的加载器(提交到仓库)
21
+ test/index.js # 与 node-edge-tts 测试文件等价的联网测试
22
+ test/smoke.js # 不联网的冒烟测试
23
+ .cargo/config.toml# 国内 crates 镜像(不需要时可删除)
24
+ ```
25
+
26
+ ## 构建
27
+
28
+ 前置条件:
29
+
30
+ - Node.js >= 18
31
+ - Rust 工具链(`rustup`,Windows 使用 `stable-x86_64-pc-windows-msvc`)
32
+ - Windows:Visual Studio Build Tools(含 “使用 C++ 的桌面开发”);macOS:Xcode Command Line Tools;Linux:`gcc`/`clang` 等
33
+
34
+ ```bash
35
+ npm install
36
+ npm run build # 等价于 napi build --platform --release --js native.js --dts native.d.ts
37
+ npm run test:smoke # 不联网,验证扩展可加载
38
+ npm test # 联网合成 test/test.mp3 和 test/test.mp3.json
39
+ ```
40
+
41
+ Windows 上会生成 `edge-tts-rs-node.win32-x64-msvc.node`。
42
+
43
+ > 国内网络下 `crates.io` 直连可能超时。仓库内的 `.cargo/config.toml` 已把 Cargo 源指向 `rsproxy.cn`,不需要时删除该文件即可。
44
+
45
+ ## 快速开始
46
+
47
+ 和 `node-edge-tts` 完全一致的用法:
48
+
49
+ ```js
50
+ const path = require('node:path')
51
+ const { EdgeTTS } = require('edge-tts-rs-node')
52
+
53
+ const tts = new EdgeTTS({
54
+ outputFormat: 'audio-24khz-96kbitrate-mono-mp3',
55
+ saveSubtitles: true,
56
+ timeout: 10000,
57
+ })
58
+
59
+ await tts.ttsPromise('你好,世界', path.join(__dirname, 'output.mp3'))
60
+ await tts.close()
61
+ ```
62
+
63
+ `saveSubtitles: true` 时会额外写出 `output.mp3.json`:
64
+
65
+ ```json
66
+ [
67
+ { "part": "你好", "start": 100, "end": 500 },
68
+ { "part": ",世界", "start": 500, "end": 1200 }
69
+ ]
70
+ ```
71
+
72
+ `start` / `end` 单位是毫秒。
73
+
74
+ ## API
75
+
76
+ ### `new EdgeTTS(options?)`
77
+
78
+ | 选项 | 类型 | 默认值 | 说明 |
79
+ | --- | --- | --- | --- |
80
+ | `voice` | `string` | `zh-CN-XiaoyiNeural` | 语音短名或完整 Microsoft voice 标识 |
81
+ | `lang` | `string` | `zh-CN` | 仅为兼容 `node-edge-tts` 保留;实际语言由 `voice` 决定 |
82
+ | `outputFormat` | `string` | `audio-24khz-48kbitrate-mono-mp3` | 输出格式,如 `audio-24khz-96kbitrate-mono-mp3`、`webm-24khz-16bit-mono-opus` |
83
+ | `saveSubtitles` | `boolean` | `false` | `ttsPromise` 是否写 `.json` 字幕;`streamAudio` 是否产出字幕对象 |
84
+ | `rate` | `string` | `default` | 语速,如 `+10%`、`-10%` |
85
+ | `pitch` | `string` | `default` | 音调,如 `+10Hz`、`-10Hz` |
86
+ | `volume` | `string` | `default` | 音量,如 `+10%`、`-50%` |
87
+ | `timeout` | `number` | `10000` | WebSocket 连接超时(毫秒) |
88
+ | `receiveTimeout` | `number` | `60000` | 单帧接收超时(毫秒) |
89
+ | `wsPoolSize` | `number` | `1` | 保持预热的空闲 WebSocket 数量;设为 `0` 关闭连接池 |
90
+ | `proxy` | `string` | — | **暂不支持**,传入会直接报错(见“与 node-edge-tts 的差异”) |
91
+
92
+ ### `ttsPromise(text, audioPath): Promise<void>`
93
+
94
+ 合成文本并写入 `audioPath`。开启 `saveSubtitles` 时同时写入 `${audioPath}.json`。
95
+
96
+ ### `toBuffer(text): Promise<Buffer>`
97
+
98
+ 合成并返回音频 Buffer,不落盘。
99
+
100
+ ### `streamAudio(text): AsyncGenerator<Buffer | SubtitleCue>`
101
+
102
+ 边接收边产出:
103
+
104
+ - `Buffer`:音频分片;
105
+ - `{ part, start, end }`:逐词时间戳(仅当 `saveSubtitles: true`)。
106
+
107
+ 提前 `break` 会取消请求并关闭 WebSocket:
108
+
109
+ ```js
110
+ const tts = new EdgeTTS({ voice: 'en-US-AriaNeural', saveSubtitles: true })
111
+
112
+ for await (const chunk of tts.streamAudio('Hello world')) {
113
+ if (Buffer.isBuffer(chunk)) {
114
+ process.stdout.write(chunk)
115
+ } else {
116
+ console.error(chunk) // { part: 'Hello', start: 100, end: 487 }
117
+ }
118
+ }
119
+
120
+ await tts.close()
121
+ ```
122
+
123
+ ### `getVoices(): Promise<Voice[]>` / `listVoices()`
124
+
125
+ 返回 Edge Read Aloud 的语音列表(Microsoft 原始字段:`Name`、`ShortName`、`Gender`、`Locale` 等,另有 `VoiceTag`)。
126
+
127
+ ### `close(): Promise<void>`
128
+
129
+ 释放原生客户端与连接池。建议在应用退出或不再使用时调用;实例被 GC 时也会兜底关闭。
130
+
131
+ ## Electron 主进程
132
+
133
+ N-API 是 ABI 稳定的,同一个 `.node` 文件可以在 Node 与 Electron 中直接加载,无需为 Electron 重新编译。
134
+
135
+ ```js
136
+ // electron 主进程
137
+ const { app, ipcMain } = require('electron')
138
+ const path = require('node:path')
139
+ const { EdgeTTS } = require('edge-tts-rs-node')
140
+
141
+ const tts = new EdgeTTS({ voice: 'zh-CN-XiaoyiNeural' })
142
+
143
+ ipcMain.handle('tts:save', async (_event, text) => {
144
+ const file = path.join(app.getPath('userData'), `tts-${Date.now()}.mp3`)
145
+ await tts.ttsPromise(text, file)
146
+ return file
147
+ })
148
+
149
+ app.on('before-quit', () => {
150
+ tts.close()
151
+ })
152
+ ```
153
+
154
+ ### 打包注意事项
155
+
156
+ 原生模块不能从 `asar` 包内直接 `dlopen`,需要解包:
157
+
158
+ ```jsonc
159
+ // electron-builder
160
+ "build": {
161
+ "asarUnpack": ["**/*.node"]
162
+ }
163
+ ```
164
+
165
+ `electron-forge` 同理,将 `**/*.node` 加入 `packagerConfig.asar.unpack`。
166
+
167
+ Node 侧版本要求:Electron 12+ / Node 14+ 均支持 N-API 8;本包 `engines` 标为 Node >= 18。
168
+
169
+ ## 多平台分发
170
+
171
+ 当前仓库的 `npm run build` 只构建当前平台。发布到 npm 并支持多平台时,可以使用 napi-rs 的平台子包方案:
172
+
173
+ 1. 在各平台(或 CI)分别执行 `napi build --platform --release`;
174
+ 2. 用 `napi create-npm-dirs` / 官方 GitHub Actions 模板生成 `edge-tts-rs-node-win32-x64-msvc` 等平台包;
175
+ 3. `native.js` 会自动按 `process.platform` / `process.arch` 选择本地文件或平台包。
176
+
177
+ ## 与 `node-edge-tts` 的差异
178
+
179
+ | 项目 | 说明 |
180
+ | --- | --- |
181
+ | `proxy` | `edge-tts-rust 0.1.7` 没有代理支持,传入 `proxy` 会抛出明确的错误。如需代理,需要给 Rust crate 增加代理连接(HTTP CONNECT + TLS 隧道)后重新封装。 |
182
+ | `lang` | 仅保存,不参与 SSML 生成;语言由 `voice` 的 locale 决定,因此请保证两者匹配。 |
183
+ | `timeout` | 语义为连接超时;另外提供 `receiveTimeout` 控制单帧接收。`node-edge-tts` 的 `timeout` 是整体超时。 |
184
+ | `ttsPromise` | 内部一次性合成后写盘,音频会在内存中聚合。超长文本建议改用 `streamAudio` 边收边写。 |
185
+ | 字幕 | 使用 Rust 侧返回的逐词边界并补齐原文空白/标点,输出格式与 `node-edge-tts` 的 `.json` 一致。 |
186
+ | 错误 | 统一为 `Error`,消息带 `EdgeTTS:` 前缀。 |
187
+ | 语音列表 | `getVoices()` 返回数组,字段为 Microsoft 原始命名(`ShortName` 等)。 |
188
+
189
+ ## 实现说明
190
+
191
+ Rust 侧(`src/lib.rs`)提供:
192
+
193
+ - `createClient` / `closeClient`:客户端生命周期与连接池;
194
+ - `synthesize`:一次性合成,返回 `{ audio, boundaries }`;
195
+ - `listVoices`:语音列表;
196
+ - `startStream` / `cancelStream`:通过 `ThreadsafeFunction` 把流式事件推回 JS,支持提前取消;
197
+ - `normalizeVoice` / `defaultExtensionForFormat`:与 crate 行为一致的工具函数。
198
+
199
+ 异步任务运行在 napi-rs 内置的 tokio runtime 上(`tokio_rt` feature)。流式事件通过 TSFN 按序回调,JS 包装层再转成 `AsyncGenerator`。
200
+
201
+ ## License
202
+
203
+ MIT。底层 [`edge-tts-rust`](https://github.com/initsnow/edge-tts-rust) 同样为 MIT。
Binary file
package/index.d.ts ADDED
@@ -0,0 +1,88 @@
1
+ /// <reference types="node" />
2
+
3
+ export interface EdgeTTSOptions {
4
+ /** Voice short name or full Microsoft voice identifier. Default: `zh-CN-XiaoyiNeural`. */
5
+ voice?: string
6
+ /** Kept for `node-edge-tts` compatibility. The effective language comes from `voice`. */
7
+ lang?: string
8
+ /** Audio output format. Default: `audio-24khz-48kbitrate-mono-mp3`. */
9
+ outputFormat?: string
10
+ /** Write `${audioPath}.json` word-boundary subtitles from `ttsPromise`. Default: `false`. */
11
+ saveSubtitles?: boolean
12
+ /** Not supported by edge-tts-rust 0.1.7; passing one rejects with an error. */
13
+ proxy?: string
14
+ /** Rate, for example `+10%` / `-10%` / `default`. */
15
+ rate?: string
16
+ /** Pitch, for example `+10Hz` / `-10Hz` / `default`. */
17
+ pitch?: string
18
+ /** Volume, for example `+10%` / `-50%` / `default`. */
19
+ volume?: string
20
+ /** WebSocket connect timeout in milliseconds. Default: `10000`. */
21
+ timeout?: number
22
+ /** Per-frame receive timeout in milliseconds. Default: `60000`. */
23
+ receiveTimeout?: number
24
+ /** Idle WebSocket connections kept warm between requests. Default: `1`. */
25
+ wsPoolSize?: number
26
+ }
27
+
28
+ export interface SubtitleCue {
29
+ part: string
30
+ /** Milliseconds from the start of the audio. */
31
+ start: number
32
+ /** Milliseconds from the start of the audio. */
33
+ end: number
34
+ }
35
+
36
+ export interface Voice {
37
+ Name: string
38
+ ShortName: string
39
+ Gender: string
40
+ Locale: string
41
+ SuggestedCodec?: string
42
+ FriendlyName?: string
43
+ Status?: string
44
+ VoiceTag?: {
45
+ ContentCategories?: string[]
46
+ VoicePersonalities?: string[]
47
+ }
48
+ [key: string]: unknown
49
+ }
50
+
51
+ export declare class EdgeTTS {
52
+ constructor(options?: EdgeTTSOptions)
53
+
54
+ voice: string
55
+ lang: string
56
+ outputFormat: string
57
+ saveSubtitles: boolean
58
+ rate: string
59
+ pitch: string
60
+ volume: string
61
+ proxy?: string
62
+ timeout: number
63
+ receiveTimeout: number
64
+ wsPoolSize: number
65
+
66
+ /** Synthesize `text` and return the audio bytes. */
67
+ toBuffer(text: string): Promise<Buffer>
68
+
69
+ /** Synthesize `text` to `audioPath`, optionally writing `${audioPath}.json` subtitles. */
70
+ ttsPromise(text: string, audioPath: string): Promise<void>
71
+
72
+ /** List the voices served by the Edge Read Aloud endpoint. */
73
+ getVoices(): Promise<Voice[]>
74
+
75
+ /** Alias of `getVoices`. */
76
+ listVoices(): Promise<Voice[]>
77
+
78
+ /**
79
+ * Stream audio chunks and, when `saveSubtitles` is enabled, word boundary
80
+ * cues. Break out of the loop to stop the request.
81
+ */
82
+ streamAudio(text: string): AsyncGenerator<Buffer | SubtitleCue, void, undefined>
83
+
84
+ /** Release the native client and its pooled WebSocket connections. */
85
+ close(): Promise<void>
86
+ }
87
+
88
+ export default EdgeTTS
package/index.js ADDED
@@ -0,0 +1,293 @@
1
+ 'use strict'
2
+
3
+ const fs = require('node:fs/promises')
4
+ const native = require('./native.js')
5
+
6
+ const DEFAULT_OPTIONS = {
7
+ voice: 'zh-CN-XiaoyiNeural',
8
+ lang: 'zh-CN',
9
+ outputFormat: 'audio-24khz-48kbitrate-mono-mp3',
10
+ saveSubtitles: false,
11
+ rate: 'default',
12
+ pitch: 'default',
13
+ volume: 'default',
14
+ timeout: 10000,
15
+ receiveTimeout: 60000,
16
+ wsPoolSize: 1,
17
+ }
18
+
19
+ const clientFinalizer =
20
+ typeof FinalizationRegistry === 'function'
21
+ ? new FinalizationRegistry((clientId) => {
22
+ try {
23
+ native.closeClient(clientId)
24
+ } catch {
25
+ // The environment is already tearing down.
26
+ }
27
+ })
28
+ : null
29
+
30
+ /**
31
+ * Drop-in style replacement for `node-edge-tts`, backed by the Rust
32
+ * `edge-tts-rust` crate.
33
+ */
34
+ class EdgeTTS {
35
+ constructor(options = {}) {
36
+ const opts = options || {}
37
+
38
+ this.voice = opts.voice ?? DEFAULT_OPTIONS.voice
39
+ this.lang = opts.lang ?? DEFAULT_OPTIONS.lang
40
+ this.outputFormat = opts.outputFormat ?? DEFAULT_OPTIONS.outputFormat
41
+ this.saveSubtitles = Boolean(opts.saveSubtitles)
42
+ this.rate = opts.rate ?? DEFAULT_OPTIONS.rate
43
+ this.pitch = opts.pitch ?? DEFAULT_OPTIONS.pitch
44
+ this.volume = opts.volume ?? DEFAULT_OPTIONS.volume
45
+ this.proxy = opts.proxy
46
+ this.timeout = opts.timeout ?? DEFAULT_OPTIONS.timeout
47
+ this.receiveTimeout = opts.receiveTimeout ?? DEFAULT_OPTIONS.receiveTimeout
48
+ this.wsPoolSize = opts.wsPoolSize ?? DEFAULT_OPTIONS.wsPoolSize
49
+
50
+ this._clientId = null
51
+ this._clientPromise = null
52
+ this._closed = false
53
+ }
54
+
55
+ _nativeCreateOptions() {
56
+ return {
57
+ proxy: this.proxy,
58
+ connectTimeoutMs: Math.max(1, Math.floor(Number(this.timeout) || 0)),
59
+ receiveTimeoutMs: Math.max(1, Math.floor(Number(this.receiveTimeout) || 0)),
60
+ wsPoolSize: Math.max(0, Math.floor(Number(this.wsPoolSize) || 0)),
61
+ }
62
+ }
63
+
64
+ _speakOptions() {
65
+ return {
66
+ voice: this.voice,
67
+ rate: this.rate,
68
+ volume: this.volume,
69
+ pitch: this.pitch,
70
+ outputFormat: this.outputFormat,
71
+ boundary: 'word',
72
+ }
73
+ }
74
+
75
+ async _ensureClient() {
76
+ if (this._closed) {
77
+ throw new Error('EdgeTTS: instance has already been closed')
78
+ }
79
+ if (this._clientId !== null) {
80
+ return this._clientId
81
+ }
82
+ if (!this._clientPromise) {
83
+ this._clientPromise = native
84
+ .createClient(this._nativeCreateOptions())
85
+ .then((clientId) => {
86
+ if (this._closed) {
87
+ native.closeClient(clientId)
88
+ throw new Error('EdgeTTS: instance has already been closed')
89
+ }
90
+ this._clientId = clientId
91
+ if (clientFinalizer) {
92
+ clientFinalizer.register(this, clientId, this)
93
+ }
94
+ return clientId
95
+ })
96
+ .finally(() => {
97
+ this._clientPromise = null
98
+ })
99
+ }
100
+ return this._clientPromise
101
+ }
102
+
103
+ async _synthesize(text) {
104
+ if (typeof text !== 'string') {
105
+ throw new TypeError('EdgeTTS: text must be a string')
106
+ }
107
+ const clientId = await this._ensureClient()
108
+ const result = await native.synthesize(clientId, text, this._speakOptions())
109
+ return {
110
+ audio: result.audio,
111
+ boundaries: result.boundaries || [],
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Synthesize `text` and return the audio as a Buffer.
117
+ * @param {string} text
118
+ * @returns {Promise<Buffer>}
119
+ */
120
+ async toBuffer(text) {
121
+ const { audio } = await this._synthesize(text)
122
+ return audio
123
+ }
124
+
125
+ /**
126
+ * Synthesize `text` into `audioPath`. When `saveSubtitles` is enabled a
127
+ * `${audioPath}.json` file is written next to the audio, matching
128
+ * `node-edge-tts`.
129
+ * @param {string} text
130
+ * @param {string} audioPath
131
+ * @returns {Promise<void>}
132
+ */
133
+ async ttsPromise(text, audioPath) {
134
+ const { audio, boundaries } = await this._synthesize(text)
135
+ await fs.writeFile(audioPath, audio)
136
+
137
+ if (this.saveSubtitles) {
138
+ const subtitles = correctSubtitleParts(
139
+ boundaries.map(({ part, start, end }) => ({ part, start, end })),
140
+ text,
141
+ )
142
+ await fs.writeFile(
143
+ `${audioPath}.json`,
144
+ JSON.stringify(subtitles, null, ' '),
145
+ 'utf-8',
146
+ )
147
+ }
148
+ }
149
+
150
+ /**
151
+ * List the voices offered by the Edge service.
152
+ * @returns {Promise<Array<object>>}
153
+ */
154
+ async getVoices() {
155
+ const clientId = await this._ensureClient()
156
+ return native.listVoices(clientId)
157
+ }
158
+
159
+ /** Alias of `getVoices`. */
160
+ async listVoices() {
161
+ return this.getVoices()
162
+ }
163
+
164
+ /**
165
+ * Stream audio chunks and (when `saveSubtitles` is enabled) word boundary
166
+ * cues as they arrive. Yields `Buffer` objects and
167
+ * `{ part, start, end }` objects.
168
+ * @param {string} text
169
+ * @returns {AsyncGenerator<Buffer | { part: string, start: number, end: number }>}
170
+ */
171
+ async *streamAudio(text) {
172
+ if (typeof text !== 'string') {
173
+ throw new TypeError('EdgeTTS: text must be a string')
174
+ }
175
+ const clientId = await this._ensureClient()
176
+
177
+ const queue = []
178
+ let notify = null
179
+ let finished = false
180
+ let failure = null
181
+
182
+ const wake = () => {
183
+ const resolve = notify
184
+ notify = null
185
+ if (resolve) resolve()
186
+ }
187
+
188
+ const callback = (error, event) => {
189
+ if (error) {
190
+ failure = error instanceof Error ? error : new Error(String(error))
191
+ } else if (event) {
192
+ if (event.error) {
193
+ failure = new Error(event.error)
194
+ } else if (event.audio) {
195
+ queue.push(Buffer.from(event.audio))
196
+ } else if (event.boundary && this.saveSubtitles) {
197
+ queue.push({
198
+ part: event.boundary.part,
199
+ start: event.boundary.start,
200
+ end: event.boundary.end,
201
+ })
202
+ } else if (event.done) {
203
+ finished = true
204
+ }
205
+ }
206
+ wake()
207
+ }
208
+
209
+ const streamId = await native.startStream(
210
+ clientId,
211
+ text,
212
+ this._speakOptions(),
213
+ callback,
214
+ )
215
+
216
+ try {
217
+ while (true) {
218
+ if (failure) throw failure
219
+ if (queue.length > 0) {
220
+ yield queue.shift()
221
+ continue
222
+ }
223
+ if (finished) return
224
+ await new Promise((resolve) => {
225
+ notify = resolve
226
+ })
227
+ }
228
+ } finally {
229
+ if (!finished) {
230
+ try {
231
+ native.cancelStream(streamId)
232
+ } catch {
233
+ // Already finished.
234
+ }
235
+ }
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Release the native client and its pooled WebSocket connections.
241
+ * @returns {Promise<void>}
242
+ */
243
+ async close() {
244
+ this._closed = true
245
+ if (this._clientPromise) {
246
+ try {
247
+ await this._clientPromise
248
+ } catch {
249
+ // Creation failed; nothing to release.
250
+ }
251
+ }
252
+ if (this._clientId !== null) {
253
+ if (clientFinalizer) clientFinalizer.unregister(this)
254
+ native.closeClient(this._clientId)
255
+ this._clientId = null
256
+ }
257
+ }
258
+ }
259
+
260
+ /**
261
+ * `node-edge-tts` reconstructs subtitle cues from the original text so that
262
+ * skipped whitespace and punctuation end up in the cue. Kept for parity.
263
+ */
264
+ function correctSubtitleParts(subtitles, text) {
265
+ if (subtitles.length === 0) return subtitles
266
+
267
+ const chars = text.split('')
268
+ let charIndex = 0
269
+
270
+ subtitles.forEach((cue, index) => {
271
+ let fullPart = ''
272
+ let stepIndex = 0
273
+ const nextPart = subtitles[index + 1]?.part
274
+
275
+ for (let i = charIndex; i < chars.length; i++) {
276
+ if (chars[i] === cue.part[stepIndex]) {
277
+ fullPart += chars[i]
278
+ stepIndex += 1
279
+ } else if (nextPart && chars[i] === nextPart[0]) {
280
+ charIndex = i
281
+ break
282
+ } else {
283
+ fullPart += chars[i]
284
+ }
285
+ }
286
+
287
+ cue.part = fullPart
288
+ })
289
+
290
+ return subtitles
291
+ }
292
+
293
+ module.exports = { EdgeTTS, default: EdgeTTS }
package/native.d.ts ADDED
@@ -0,0 +1,55 @@
1
+ /* tslint:disable */
2
+ /* eslint-disable */
3
+
4
+ /* auto-generated by NAPI-RS */
5
+
6
+ export interface NativeOptions {
7
+ voice?: string
8
+ rate?: string
9
+ volume?: string
10
+ pitch?: string
11
+ outputFormat?: string
12
+ /** `"word"` (default) or `"sentence"`. */
13
+ boundary?: string
14
+ connectTimeoutMs?: number
15
+ receiveTimeoutMs?: number
16
+ wsPoolSize?: number
17
+ proxy?: string
18
+ }
19
+ export interface BoundaryJs {
20
+ part: string
21
+ /** Milliseconds from the beginning of the audio. */
22
+ start: number
23
+ /** Milliseconds from the beginning of the audio. */
24
+ end: number
25
+ /** `"word"` or `"sentence"`. */
26
+ kind: string
27
+ }
28
+ export interface SynthesisResultJs {
29
+ audio: Buffer
30
+ boundaries: Array<BoundaryJs>
31
+ }
32
+ export interface StreamEventJs {
33
+ audio?: Buffer
34
+ boundary?: BoundaryJs
35
+ /** Set when the stream failed. The `done` event still follows. */
36
+ error?: string
37
+ done?: boolean
38
+ }
39
+ /**
40
+ * Create a client. Kept alive until `closeClient` is called (or the process
41
+ * exits). Reusing one client across requests keeps the warmed WebSocket pool.
42
+ */
43
+ export declare function createClient(options?: NativeOptions | undefined | null): Promise<number>
44
+ export declare function closeClient(clientId: number): boolean
45
+ export declare function synthesize(clientId: number, text: string, options?: NativeOptions | undefined | null): Promise<SynthesisResultJs>
46
+ export declare function listVoices(clientId: number): Promise<any>
47
+ /**
48
+ * Start a streaming synthesis. `callback` is invoked as `(err, event)` on the
49
+ * JavaScript thread. The returned id can be passed to `cancelStream`.
50
+ */
51
+ export declare function startStream(clientId: number, text: string, options: NativeOptions | undefined | null, callback: (err: Error | null, arg: StreamEventJs) => any): Promise<number>
52
+ /** Cancel an in-flight stream. Returns `false` if it already finished. */
53
+ export declare function cancelStream(streamId: number): boolean
54
+ export declare function normalizeVoice(voice: string): string
55
+ export declare function defaultExtensionForFormat(outputFormat: string): string
package/native.js ADDED
@@ -0,0 +1,322 @@
1
+ /* tslint:disable */
2
+ /* eslint-disable */
3
+ /* prettier-ignore */
4
+
5
+ /* auto-generated by NAPI-RS */
6
+
7
+ const { existsSync, readFileSync } = require('fs')
8
+ const { join } = require('path')
9
+
10
+ const { platform, arch } = process
11
+
12
+ let nativeBinding = null
13
+ let localFileExisted = false
14
+ let loadError = null
15
+
16
+ function isMusl() {
17
+ // For Node 10
18
+ if (!process.report || typeof process.report.getReport !== 'function') {
19
+ try {
20
+ const lddPath = require('child_process').execSync('which ldd').toString().trim()
21
+ return readFileSync(lddPath, 'utf8').includes('musl')
22
+ } catch (e) {
23
+ return true
24
+ }
25
+ } else {
26
+ const { glibcVersionRuntime } = process.report.getReport().header
27
+ return !glibcVersionRuntime
28
+ }
29
+ }
30
+
31
+ switch (platform) {
32
+ case 'android':
33
+ switch (arch) {
34
+ case 'arm64':
35
+ localFileExisted = existsSync(join(__dirname, 'edge-tts-rs-node.android-arm64.node'))
36
+ try {
37
+ if (localFileExisted) {
38
+ nativeBinding = require('./edge-tts-rs-node.android-arm64.node')
39
+ } else {
40
+ nativeBinding = require('edge-tts-rs-node-android-arm64')
41
+ }
42
+ } catch (e) {
43
+ loadError = e
44
+ }
45
+ break
46
+ case 'arm':
47
+ localFileExisted = existsSync(join(__dirname, 'edge-tts-rs-node.android-arm-eabi.node'))
48
+ try {
49
+ if (localFileExisted) {
50
+ nativeBinding = require('./edge-tts-rs-node.android-arm-eabi.node')
51
+ } else {
52
+ nativeBinding = require('edge-tts-rs-node-android-arm-eabi')
53
+ }
54
+ } catch (e) {
55
+ loadError = e
56
+ }
57
+ break
58
+ default:
59
+ throw new Error(`Unsupported architecture on Android ${arch}`)
60
+ }
61
+ break
62
+ case 'win32':
63
+ switch (arch) {
64
+ case 'x64':
65
+ localFileExisted = existsSync(
66
+ join(__dirname, 'edge-tts-rs-node.win32-x64-msvc.node')
67
+ )
68
+ try {
69
+ if (localFileExisted) {
70
+ nativeBinding = require('./edge-tts-rs-node.win32-x64-msvc.node')
71
+ } else {
72
+ nativeBinding = require('edge-tts-rs-node-win32-x64-msvc')
73
+ }
74
+ } catch (e) {
75
+ loadError = e
76
+ }
77
+ break
78
+ case 'ia32':
79
+ localFileExisted = existsSync(
80
+ join(__dirname, 'edge-tts-rs-node.win32-ia32-msvc.node')
81
+ )
82
+ try {
83
+ if (localFileExisted) {
84
+ nativeBinding = require('./edge-tts-rs-node.win32-ia32-msvc.node')
85
+ } else {
86
+ nativeBinding = require('edge-tts-rs-node-win32-ia32-msvc')
87
+ }
88
+ } catch (e) {
89
+ loadError = e
90
+ }
91
+ break
92
+ case 'arm64':
93
+ localFileExisted = existsSync(
94
+ join(__dirname, 'edge-tts-rs-node.win32-arm64-msvc.node')
95
+ )
96
+ try {
97
+ if (localFileExisted) {
98
+ nativeBinding = require('./edge-tts-rs-node.win32-arm64-msvc.node')
99
+ } else {
100
+ nativeBinding = require('edge-tts-rs-node-win32-arm64-msvc')
101
+ }
102
+ } catch (e) {
103
+ loadError = e
104
+ }
105
+ break
106
+ default:
107
+ throw new Error(`Unsupported architecture on Windows: ${arch}`)
108
+ }
109
+ break
110
+ case 'darwin':
111
+ localFileExisted = existsSync(join(__dirname, 'edge-tts-rs-node.darwin-universal.node'))
112
+ try {
113
+ if (localFileExisted) {
114
+ nativeBinding = require('./edge-tts-rs-node.darwin-universal.node')
115
+ } else {
116
+ nativeBinding = require('edge-tts-rs-node-darwin-universal')
117
+ }
118
+ break
119
+ } catch {}
120
+ switch (arch) {
121
+ case 'x64':
122
+ localFileExisted = existsSync(join(__dirname, 'edge-tts-rs-node.darwin-x64.node'))
123
+ try {
124
+ if (localFileExisted) {
125
+ nativeBinding = require('./edge-tts-rs-node.darwin-x64.node')
126
+ } else {
127
+ nativeBinding = require('edge-tts-rs-node-darwin-x64')
128
+ }
129
+ } catch (e) {
130
+ loadError = e
131
+ }
132
+ break
133
+ case 'arm64':
134
+ localFileExisted = existsSync(
135
+ join(__dirname, 'edge-tts-rs-node.darwin-arm64.node')
136
+ )
137
+ try {
138
+ if (localFileExisted) {
139
+ nativeBinding = require('./edge-tts-rs-node.darwin-arm64.node')
140
+ } else {
141
+ nativeBinding = require('edge-tts-rs-node-darwin-arm64')
142
+ }
143
+ } catch (e) {
144
+ loadError = e
145
+ }
146
+ break
147
+ default:
148
+ throw new Error(`Unsupported architecture on macOS: ${arch}`)
149
+ }
150
+ break
151
+ case 'freebsd':
152
+ if (arch !== 'x64') {
153
+ throw new Error(`Unsupported architecture on FreeBSD: ${arch}`)
154
+ }
155
+ localFileExisted = existsSync(join(__dirname, 'edge-tts-rs-node.freebsd-x64.node'))
156
+ try {
157
+ if (localFileExisted) {
158
+ nativeBinding = require('./edge-tts-rs-node.freebsd-x64.node')
159
+ } else {
160
+ nativeBinding = require('edge-tts-rs-node-freebsd-x64')
161
+ }
162
+ } catch (e) {
163
+ loadError = e
164
+ }
165
+ break
166
+ case 'linux':
167
+ switch (arch) {
168
+ case 'x64':
169
+ if (isMusl()) {
170
+ localFileExisted = existsSync(
171
+ join(__dirname, 'edge-tts-rs-node.linux-x64-musl.node')
172
+ )
173
+ try {
174
+ if (localFileExisted) {
175
+ nativeBinding = require('./edge-tts-rs-node.linux-x64-musl.node')
176
+ } else {
177
+ nativeBinding = require('edge-tts-rs-node-linux-x64-musl')
178
+ }
179
+ } catch (e) {
180
+ loadError = e
181
+ }
182
+ } else {
183
+ localFileExisted = existsSync(
184
+ join(__dirname, 'edge-tts-rs-node.linux-x64-gnu.node')
185
+ )
186
+ try {
187
+ if (localFileExisted) {
188
+ nativeBinding = require('./edge-tts-rs-node.linux-x64-gnu.node')
189
+ } else {
190
+ nativeBinding = require('edge-tts-rs-node-linux-x64-gnu')
191
+ }
192
+ } catch (e) {
193
+ loadError = e
194
+ }
195
+ }
196
+ break
197
+ case 'arm64':
198
+ if (isMusl()) {
199
+ localFileExisted = existsSync(
200
+ join(__dirname, 'edge-tts-rs-node.linux-arm64-musl.node')
201
+ )
202
+ try {
203
+ if (localFileExisted) {
204
+ nativeBinding = require('./edge-tts-rs-node.linux-arm64-musl.node')
205
+ } else {
206
+ nativeBinding = require('edge-tts-rs-node-linux-arm64-musl')
207
+ }
208
+ } catch (e) {
209
+ loadError = e
210
+ }
211
+ } else {
212
+ localFileExisted = existsSync(
213
+ join(__dirname, 'edge-tts-rs-node.linux-arm64-gnu.node')
214
+ )
215
+ try {
216
+ if (localFileExisted) {
217
+ nativeBinding = require('./edge-tts-rs-node.linux-arm64-gnu.node')
218
+ } else {
219
+ nativeBinding = require('edge-tts-rs-node-linux-arm64-gnu')
220
+ }
221
+ } catch (e) {
222
+ loadError = e
223
+ }
224
+ }
225
+ break
226
+ case 'arm':
227
+ if (isMusl()) {
228
+ localFileExisted = existsSync(
229
+ join(__dirname, 'edge-tts-rs-node.linux-arm-musleabihf.node')
230
+ )
231
+ try {
232
+ if (localFileExisted) {
233
+ nativeBinding = require('./edge-tts-rs-node.linux-arm-musleabihf.node')
234
+ } else {
235
+ nativeBinding = require('edge-tts-rs-node-linux-arm-musleabihf')
236
+ }
237
+ } catch (e) {
238
+ loadError = e
239
+ }
240
+ } else {
241
+ localFileExisted = existsSync(
242
+ join(__dirname, 'edge-tts-rs-node.linux-arm-gnueabihf.node')
243
+ )
244
+ try {
245
+ if (localFileExisted) {
246
+ nativeBinding = require('./edge-tts-rs-node.linux-arm-gnueabihf.node')
247
+ } else {
248
+ nativeBinding = require('edge-tts-rs-node-linux-arm-gnueabihf')
249
+ }
250
+ } catch (e) {
251
+ loadError = e
252
+ }
253
+ }
254
+ break
255
+ case 'riscv64':
256
+ if (isMusl()) {
257
+ localFileExisted = existsSync(
258
+ join(__dirname, 'edge-tts-rs-node.linux-riscv64-musl.node')
259
+ )
260
+ try {
261
+ if (localFileExisted) {
262
+ nativeBinding = require('./edge-tts-rs-node.linux-riscv64-musl.node')
263
+ } else {
264
+ nativeBinding = require('edge-tts-rs-node-linux-riscv64-musl')
265
+ }
266
+ } catch (e) {
267
+ loadError = e
268
+ }
269
+ } else {
270
+ localFileExisted = existsSync(
271
+ join(__dirname, 'edge-tts-rs-node.linux-riscv64-gnu.node')
272
+ )
273
+ try {
274
+ if (localFileExisted) {
275
+ nativeBinding = require('./edge-tts-rs-node.linux-riscv64-gnu.node')
276
+ } else {
277
+ nativeBinding = require('edge-tts-rs-node-linux-riscv64-gnu')
278
+ }
279
+ } catch (e) {
280
+ loadError = e
281
+ }
282
+ }
283
+ break
284
+ case 's390x':
285
+ localFileExisted = existsSync(
286
+ join(__dirname, 'edge-tts-rs-node.linux-s390x-gnu.node')
287
+ )
288
+ try {
289
+ if (localFileExisted) {
290
+ nativeBinding = require('./edge-tts-rs-node.linux-s390x-gnu.node')
291
+ } else {
292
+ nativeBinding = require('edge-tts-rs-node-linux-s390x-gnu')
293
+ }
294
+ } catch (e) {
295
+ loadError = e
296
+ }
297
+ break
298
+ default:
299
+ throw new Error(`Unsupported architecture on Linux: ${arch}`)
300
+ }
301
+ break
302
+ default:
303
+ throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`)
304
+ }
305
+
306
+ if (!nativeBinding) {
307
+ if (loadError) {
308
+ throw loadError
309
+ }
310
+ throw new Error(`Failed to load native binding`)
311
+ }
312
+
313
+ const { createClient, closeClient, synthesize, listVoices, startStream, cancelStream, normalizeVoice, defaultExtensionForFormat } = nativeBinding
314
+
315
+ module.exports.createClient = createClient
316
+ module.exports.closeClient = closeClient
317
+ module.exports.synthesize = synthesize
318
+ module.exports.listVoices = listVoices
319
+ module.exports.startStream = startStream
320
+ module.exports.cancelStream = cancelStream
321
+ module.exports.normalizeVoice = normalizeVoice
322
+ module.exports.defaultExtensionForFormat = defaultExtensionForFormat
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "edge-tts-rs-node",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Node.js / Electron bindings for the edge-tts-rust crate, with a node-edge-tts compatible API",
5
+ "license": "MIT",
6
+ "main": "index.js",
7
+ "types": "index.d.ts",
8
+ "files": [
9
+ "index.js",
10
+ "index.d.ts",
11
+ "native.js",
12
+ "native.d.ts",
13
+ "*.node",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
20
+ "scripts": {
21
+ "build": "napi build --platform --release --js native.js --dts native.d.ts",
22
+ "build:debug": "napi build --platform --js native.js --dts native.d.ts",
23
+ "test": "node test/index.js",
24
+ "test:smoke": "node test/smoke.js"
25
+ },
26
+ "devDependencies": {
27
+ "@napi-rs/cli": "^2.18.4"
28
+ },
29
+ "napi": {
30
+ "name": "edge-tts-rs-node",
31
+ "triples": {
32
+ "defaults": true,
33
+ "additional": []
34
+ }
35
+ },
36
+ "keywords": [
37
+ "edge-tts",
38
+ "tts",
39
+ "text-to-speech",
40
+ "rust",
41
+ "napi",
42
+ "electron"
43
+ ]
44
+ }