verbalcoding 0.2.12 → 0.2.13

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.
Files changed (169) hide show
  1. package/.env.example +74 -4
  2. package/README.es.md +3 -1
  3. package/README.fr.md +3 -1
  4. package/README.ja.md +3 -1
  5. package/README.ko.md +4 -2
  6. package/README.md +4 -2
  7. package/README.ru.md +3 -1
  8. package/README.zh.md +3 -1
  9. package/app-node/agent_adapters.test.mjs +14 -0
  10. package/app-node/agent_routing.mjs +148 -0
  11. package/app-node/agent_routing.test.mjs +138 -0
  12. package/app-node/agent_turn.mjs +86 -0
  13. package/app-node/agent_turn.test.mjs +109 -0
  14. package/app-node/bridge_context.mjs +73 -0
  15. package/app-node/bridge_context.test.mjs +54 -0
  16. package/app-node/bridge_state.mjs +4 -0
  17. package/app-node/bridge_wireup.test.mjs +462 -0
  18. package/app-node/cli_install.test.mjs +31 -0
  19. package/app-node/cross_agent_routing.test.mjs +78 -0
  20. package/app-node/discord_command_router.mjs +204 -0
  21. package/app-node/discord_command_router.test.mjs +311 -0
  22. package/app-node/discord_voice_setup.mjs +251 -0
  23. package/app-node/discord_voice_setup.test.mjs +86 -0
  24. package/app-node/hermes_profiles.test.mjs +12 -1
  25. package/app-node/install_config.mjs +110 -3
  26. package/app-node/install_config.test.mjs +8 -0
  27. package/app-node/instance_doctor.test.mjs +9 -0
  28. package/app-node/instances.test.mjs +8 -1
  29. package/app-node/main.mjs +488 -1368
  30. package/app-node/mcp_tools.test.mjs +7 -0
  31. package/app-node/notification_handler.mjs +89 -0
  32. package/app-node/notification_handler.test.mjs +187 -0
  33. package/app-node/plan_dispatcher.mjs +215 -0
  34. package/app-node/plan_dispatcher.test.mjs +101 -0
  35. package/app-node/plan_mode.mjs +36 -7
  36. package/app-node/plan_mode.test.mjs +78 -0
  37. package/app-node/progress_handler.mjs +220 -0
  38. package/app-node/progress_handler.test.mjs +193 -0
  39. package/app-node/progress_speech.mjs +54 -32
  40. package/app-node/progress_speech.test.mjs +12 -3
  41. package/app-node/project_sessions.mjs +5 -2
  42. package/app-node/project_sessions.test.mjs +7 -0
  43. package/app-node/research_mode.mjs +282 -0
  44. package/app-node/research_mode.test.mjs +264 -0
  45. package/app-node/restart_notice.mjs +3 -0
  46. package/app-node/restart_notice.test.mjs +11 -0
  47. package/app-node/session_ontology.mjs +271 -0
  48. package/app-node/session_ontology.test.mjs +130 -0
  49. package/app-node/smart_progress.mjs +1 -1
  50. package/app-node/stream_sentencer.mjs +32 -2
  51. package/app-node/stream_sentencer.test.mjs +65 -0
  52. package/app-node/streaming_tts_queue.mjs +5 -1
  53. package/app-node/streaming_tts_queue.test.mjs +7 -1
  54. package/app-node/stt_whisper.mjs +24 -0
  55. package/app-node/stt_whisper.test.mjs +32 -0
  56. package/app-node/text_routing.mjs +4 -2
  57. package/app-node/tts_backends.mjs +537 -3
  58. package/app-node/tts_backends.test.mjs +454 -0
  59. package/app-node/tts_player.mjs +164 -0
  60. package/app-node/tts_player.test.mjs +202 -0
  61. package/app-node/tts_runtime.mjs +134 -0
  62. package/app-node/tts_runtime.test.mjs +89 -0
  63. package/app-node/tts_settings.mjs +150 -3
  64. package/app-node/tts_settings.test.mjs +204 -0
  65. package/app-node/tts_voice_config.mjs +136 -2
  66. package/app-node/tts_voice_config.test.mjs +94 -0
  67. package/app-node/utterance_router.mjs +216 -0
  68. package/app-node/utterance_router.test.mjs +236 -0
  69. package/app-node/voice_autojoin.mjs +37 -0
  70. package/app-node/voice_autojoin.test.mjs +59 -0
  71. package/app-node/voice_io.mjs +272 -0
  72. package/app-node/voice_io.test.mjs +102 -0
  73. package/app-node/voice_turn_runner.mjs +449 -0
  74. package/app-node/voice_turn_runner.test.mjs +289 -0
  75. package/docs/CONFIGURATION.md +12 -2
  76. package/docs/HARNESSES.md +58 -0
  77. package/docs/HARNESS_AIDER.md +50 -0
  78. package/docs/HARNESS_CLAUDE.md +56 -0
  79. package/docs/HARNESS_CODEX.md +56 -0
  80. package/docs/HARNESS_CURSOR.md +45 -0
  81. package/docs/HARNESS_GEMINI.md +45 -0
  82. package/docs/HARNESS_HERMES.md +57 -0
  83. package/docs/HARNESS_OPENCLAW.md +44 -0
  84. package/docs/HARNESS_OPENCODE.md +44 -0
  85. package/docs/README.md +1 -0
  86. package/docs/ROADMAP.md +20 -5
  87. package/docs/TTS_BACKENDS.md +227 -0
  88. package/docs/USAGE.md +22 -0
  89. package/docs/i18n/AGENTS.es.md +34 -0
  90. package/docs/i18n/AGENTS.fr.md +34 -0
  91. package/docs/i18n/AGENTS.ja.md +34 -0
  92. package/docs/i18n/AGENTS.ko.md +34 -0
  93. package/docs/i18n/AGENTS.ru.md +34 -0
  94. package/docs/i18n/AGENTS.zh.md +34 -0
  95. package/docs/i18n/HARNESSES.es.md +58 -0
  96. package/docs/i18n/HARNESSES.fr.md +58 -0
  97. package/docs/i18n/HARNESSES.ja.md +58 -0
  98. package/docs/i18n/HARNESSES.ko.md +58 -0
  99. package/docs/i18n/HARNESSES.ru.md +58 -0
  100. package/docs/i18n/HARNESSES.zh.md +58 -0
  101. package/docs/i18n/HARNESS_AIDER.es.md +48 -0
  102. package/docs/i18n/HARNESS_AIDER.fr.md +48 -0
  103. package/docs/i18n/HARNESS_AIDER.ja.md +50 -0
  104. package/docs/i18n/HARNESS_AIDER.ko.md +50 -0
  105. package/docs/i18n/HARNESS_AIDER.ru.md +48 -0
  106. package/docs/i18n/HARNESS_AIDER.zh.md +48 -0
  107. package/docs/i18n/HARNESS_CLAUDE.es.md +55 -0
  108. package/docs/i18n/HARNESS_CLAUDE.fr.md +55 -0
  109. package/docs/i18n/HARNESS_CLAUDE.ja.md +56 -0
  110. package/docs/i18n/HARNESS_CLAUDE.ko.md +56 -0
  111. package/docs/i18n/HARNESS_CLAUDE.ru.md +55 -0
  112. package/docs/i18n/HARNESS_CLAUDE.zh.md +56 -0
  113. package/docs/i18n/HARNESS_CODEX.es.md +55 -0
  114. package/docs/i18n/HARNESS_CODEX.fr.md +55 -0
  115. package/docs/i18n/HARNESS_CODEX.ja.md +56 -0
  116. package/docs/i18n/HARNESS_CODEX.ko.md +56 -0
  117. package/docs/i18n/HARNESS_CODEX.ru.md +55 -0
  118. package/docs/i18n/HARNESS_CODEX.zh.md +56 -0
  119. package/docs/i18n/HARNESS_CURSOR.es.md +42 -0
  120. package/docs/i18n/HARNESS_CURSOR.fr.md +42 -0
  121. package/docs/i18n/HARNESS_CURSOR.ja.md +45 -0
  122. package/docs/i18n/HARNESS_CURSOR.ko.md +45 -0
  123. package/docs/i18n/HARNESS_CURSOR.ru.md +42 -0
  124. package/docs/i18n/HARNESS_CURSOR.zh.md +42 -0
  125. package/docs/i18n/HARNESS_GEMINI.es.md +44 -0
  126. package/docs/i18n/HARNESS_GEMINI.fr.md +44 -0
  127. package/docs/i18n/HARNESS_GEMINI.ja.md +45 -0
  128. package/docs/i18n/HARNESS_GEMINI.ko.md +45 -0
  129. package/docs/i18n/HARNESS_GEMINI.ru.md +44 -0
  130. package/docs/i18n/HARNESS_GEMINI.zh.md +45 -0
  131. package/docs/i18n/HARNESS_HERMES.es.md +54 -0
  132. package/docs/i18n/HARNESS_HERMES.fr.md +54 -0
  133. package/docs/i18n/HARNESS_HERMES.ja.md +57 -0
  134. package/docs/i18n/HARNESS_HERMES.ko.md +57 -0
  135. package/docs/i18n/HARNESS_HERMES.ru.md +54 -0
  136. package/docs/i18n/HARNESS_HERMES.zh.md +57 -0
  137. package/docs/i18n/HARNESS_OPENCLAW.es.md +41 -0
  138. package/docs/i18n/HARNESS_OPENCLAW.fr.md +41 -0
  139. package/docs/i18n/HARNESS_OPENCLAW.ja.md +44 -0
  140. package/docs/i18n/HARNESS_OPENCLAW.ko.md +44 -0
  141. package/docs/i18n/HARNESS_OPENCLAW.ru.md +41 -0
  142. package/docs/i18n/HARNESS_OPENCLAW.zh.md +42 -0
  143. package/docs/i18n/HARNESS_OPENCODE.es.md +41 -0
  144. package/docs/i18n/HARNESS_OPENCODE.fr.md +41 -0
  145. package/docs/i18n/HARNESS_OPENCODE.ja.md +44 -0
  146. package/docs/i18n/HARNESS_OPENCODE.ko.md +44 -0
  147. package/docs/i18n/HARNESS_OPENCODE.ru.md +41 -0
  148. package/docs/i18n/HARNESS_OPENCODE.zh.md +44 -0
  149. package/docs/superpowers/plans/2026-05-14-cross-agent-voice-transfer.md +625 -0
  150. package/docs/superpowers/plans/2026-05-21-audio-overview-narrated-diffs.md +95 -0
  151. package/docs/superpowers/plans/2026-05-21-autoresearch-ontology.md +83 -0
  152. package/docs/superpowers/plans/2026-05-21-phase11-push-to-talk-wakeword-v2.md +77 -0
  153. package/docs/superpowers/plans/2026-05-21-phase12-multi-user-voice.md +147 -0
  154. package/docs/superpowers/plans/2026-05-21-phase14-verbalbench.md +136 -0
  155. package/docs/superpowers/plans/2026-05-21-phase15-phone-companion.md +72 -0
  156. package/integrations/fireredtts2/mlx_llm.py +183 -0
  157. package/integrations/fireredtts2/synth.py +156 -0
  158. package/integrations/fireredtts2/synth_mlx.py +196 -0
  159. package/integrations/mlxaudio/synth.py +74 -0
  160. package/integrations/neuttsair/synth.py +104 -0
  161. package/integrations/omnivoice/synth.py +110 -0
  162. package/package.json +6 -1
  163. package/scripts/cli.mjs +84 -0
  164. package/scripts/doctor.mjs +104 -4
  165. package/scripts/install.mjs +5 -1
  166. package/scripts/install_fireredtts2.sh +109 -0
  167. package/scripts/install_mlxaudio.sh +34 -0
  168. package/scripts/install_mossttsnano.sh +46 -0
  169. package/scripts/postinstall.mjs +34 -0
@@ -0,0 +1,202 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createTtsPlayer } from './tts_player.mjs';
4
+ import { createBridge } from './bridge_context.mjs';
5
+
6
+ function makeDeps(overrides = {}) {
7
+ const bridge = createBridge();
8
+ bridge.ttsBackend = {
9
+ name: 'fake',
10
+ synthesize: async () => '/tmp/fake.wav',
11
+ };
12
+ return {
13
+ bridge,
14
+ settings: { tts: { maxChars: 200, volume: 1 } },
15
+ log: () => {},
16
+ warn: () => {},
17
+ sleep: async () => {},
18
+ sendText: async () => {},
19
+ refreshTtsRuntimeConfig: async () => {},
20
+ waitEvent: async () => {},
21
+ isAbortError: () => false,
22
+ STREAMING_TTS_ENABLED: true,
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ test('createTtsPlayer exposes the expected functions', () => {
28
+ const player = createTtsPlayer(makeDeps());
29
+ for (const name of ['synthTTS', 'playAudio', 'speakText', 'beginStreamingTurn', 'endStreamingTurn', 'stopPlaybackForBargeIn']) {
30
+ assert.equal(typeof player[name], 'function', `${name} is exposed`);
31
+ }
32
+ });
33
+
34
+ test('stopPlaybackForBargeIn is a no-op when nothing is speaking', () => {
35
+ const deps = makeDeps();
36
+ const { stopPlaybackForBargeIn } = createTtsPlayer(deps);
37
+ assert.equal(deps.bridge.speaking, false);
38
+ assert.equal(stopPlaybackForBargeIn('user-1', 'manual'), false);
39
+ assert.equal(deps.bridge.speechPlaybackGeneration, 0);
40
+ });
41
+
42
+ test('stopPlaybackForBargeIn stops the active player and increments generation', () => {
43
+ let stopped = false;
44
+ const deps = makeDeps();
45
+ deps.bridge.speaking = true;
46
+ deps.bridge.player = { stop: () => { stopped = true; } };
47
+ const { stopPlaybackForBargeIn } = createTtsPlayer(deps);
48
+ const out = stopPlaybackForBargeIn('user-1', 'live-barge-in');
49
+ assert.equal(out, true);
50
+ assert.equal(stopped, true);
51
+ assert.equal(deps.bridge.speaking, false);
52
+ assert.equal(deps.bridge.speechPlaybackGeneration, 1);
53
+ });
54
+
55
+ test('playAudio is a no-op when no voice connection', async () => {
56
+ const deps = makeDeps();
57
+ deps.bridge.connection = null;
58
+ const { playAudio } = createTtsPlayer(deps);
59
+ await playAudio('/tmp/fake.wav', { deleteAfter: false });
60
+ // No throw and no state mutation expected.
61
+ assert.equal(deps.bridge.speaking, false);
62
+ });
63
+
64
+ test('beginStreamingTurn returns false when streaming disabled', () => {
65
+ const deps = makeDeps({ STREAMING_TTS_ENABLED: false });
66
+ deps.bridge.connection = { subscribe: () => {} };
67
+ const { beginStreamingTurn } = createTtsPlayer(deps);
68
+ assert.equal(beginStreamingTurn(new AbortController().signal), false);
69
+ assert.equal(deps.bridge.activeSentencer, null);
70
+ assert.equal(deps.bridge.activeStreamingQueue, null);
71
+ });
72
+
73
+ test('beginStreamingTurn returns false when no voice connection', () => {
74
+ const deps = makeDeps();
75
+ deps.bridge.connection = null;
76
+ const { beginStreamingTurn } = createTtsPlayer(deps);
77
+ assert.equal(beginStreamingTurn(new AbortController().signal), false);
78
+ });
79
+
80
+ test('synthTTS retries on transient failure and surfaces the final error', async () => {
81
+ let calls = 0;
82
+ const deps = makeDeps();
83
+ deps.bridge.ttsBackend = {
84
+ name: 'flaky',
85
+ synthesize: async () => {
86
+ calls++;
87
+ const e = new Error('boom');
88
+ throw e;
89
+ },
90
+ };
91
+ const { synthTTS } = createTtsPlayer(deps);
92
+ await assert.rejects(() => synthTTS('hi', null), /boom/);
93
+ assert.equal(calls, 3);
94
+ });
95
+
96
+ test('synthTTS aborts immediately on signal abort', async () => {
97
+ let calls = 0;
98
+ const deps = makeDeps({ isAbortError: e => e.name === 'AbortError' });
99
+ deps.bridge.ttsBackend = {
100
+ name: 'aborty',
101
+ synthesize: async () => {
102
+ calls++;
103
+ const e = new Error('aborted');
104
+ e.name = 'AbortError';
105
+ throw e;
106
+ },
107
+ };
108
+ const { synthTTS } = createTtsPlayer(deps);
109
+ await assert.rejects(() => synthTTS('hi', null), /aborted/);
110
+ assert.equal(calls, 1, 'no retries after AbortError');
111
+ });
112
+
113
+ // --- speakText full chunked synth/play flow ------------------------------
114
+ //
115
+ // synthTTS internally calls fs.statSync on the file the backend returns,
116
+ // so these tests create a real temp file the fake synthesize() can return.
117
+
118
+ import fsReal from 'node:fs';
119
+ import osReal from 'node:os';
120
+ import pathReal from 'node:path';
121
+
122
+ const __tempRoots = [];
123
+ test.after(() => {
124
+ for (const root of __tempRoots) try { fsReal.rmSync(root, { recursive: true, force: true }); } catch {}
125
+ });
126
+
127
+ function makeFakeWav() {
128
+ const dir = fsReal.mkdtempSync(pathReal.join(osReal.tmpdir(), 'vc-tts-test-'));
129
+ __tempRoots.push(dir);
130
+ const file = pathReal.join(dir, 'fake.wav');
131
+ fsReal.writeFileSync(file, Buffer.from('RIFF....fake-wav-bytes'));
132
+ return file;
133
+ }
134
+
135
+ test('speakText mirrors text to channel, then synths + plays each chunk in order', async () => {
136
+ const fakePath = makeFakeWav();
137
+ const synthCalls = [];
138
+ const sentTexts = [];
139
+ const stages = {};
140
+ const deps = makeDeps({
141
+ sendText: async t => { sentTexts.push(t); },
142
+ });
143
+ deps.bridge.connection = { subscribe: () => {} };
144
+ deps.bridge.player = { play: () => {}, stop: () => {}, on: () => {} };
145
+ deps.bridge.ttsBackend = {
146
+ name: 'fake',
147
+ synthesize: async text => { synthCalls.push(text); return fakePath; },
148
+ };
149
+ deps.waitEvent = async () => {};
150
+
151
+ const { speakText } = createTtsPlayer(deps);
152
+ const metricsTurn = { stage: (label, ms, extra) => { stages[label] = { ms, extra }; } };
153
+ await speakText('hello world', null, metricsTurn, { mirrorText: true });
154
+
155
+ assert.equal(synthCalls.length, 1, 'one synth call for one chunk');
156
+ assert.equal(synthCalls[0], 'hello world');
157
+ assert.ok(sentTexts.some(t => /hello world/.test(t)), 'mirror text sent to channel');
158
+ assert.ok(stages.tts_synth, 'tts_synth stage recorded');
159
+ assert.ok(stages.tts_play, 'tts_play stage recorded');
160
+ assert.ok(stages.tts_total, 'tts_total stage recorded');
161
+ assert.equal(stages.tts_synth.extra.ttsChunks, 1, 'extra carries chunk count');
162
+ });
163
+
164
+ test('speakText with mirrorText:false does NOT echo to the text channel', async () => {
165
+ const fakePath = makeFakeWav();
166
+ const sentTexts = [];
167
+ const deps = makeDeps({ sendText: async t => { sentTexts.push(t); } });
168
+ deps.bridge.connection = { subscribe: () => {} };
169
+ deps.bridge.player = { play: () => {}, stop: () => {}, on: () => {} };
170
+ deps.bridge.ttsBackend = { name: 'fake', synthesize: async () => fakePath };
171
+ deps.waitEvent = async () => {};
172
+
173
+ const { speakText } = createTtsPlayer(deps);
174
+ await speakText('quiet', null, null, { mirrorText: false });
175
+ assert.equal(sentTexts.length, 0, 'no mirror when mirrorText:false');
176
+ });
177
+
178
+ test('speakText skips play when speechPlaybackGeneration changes mid-flight', async () => {
179
+ const fakePath = makeFakeWav();
180
+ let synthCalls = 0;
181
+ let playCalls = 0;
182
+ const deps = makeDeps();
183
+ deps.bridge.connection = { subscribe: () => {} };
184
+ deps.bridge.player = { play: () => { playCalls++; }, stop: () => {}, on: () => {} };
185
+ const beforeGen = deps.bridge.speechPlaybackGeneration;
186
+ // Bump the generation during synth — when playChunkedTTSWithPrefetch's
187
+ // play wrapper checks playbackStopped(), it'll see the mismatch and skip.
188
+ deps.bridge.ttsBackend = {
189
+ name: 'fake',
190
+ synthesize: async () => {
191
+ synthCalls++;
192
+ deps.bridge.speechPlaybackGeneration = beforeGen + 1;
193
+ return fakePath;
194
+ },
195
+ };
196
+ deps.waitEvent = async () => {};
197
+
198
+ const { speakText } = createTtsPlayer(deps);
199
+ await speakText('hello world', null, null, { mirrorText: false });
200
+ assert.equal(synthCalls, 1, 'synth runs once');
201
+ assert.equal(playCalls, 0, 'play is skipped because generation changed');
202
+ });
@@ -0,0 +1,134 @@
1
+ // TTS runtime helpers: on-demand backend installation and binary lookup.
2
+ //
3
+ // Phase 5c extraction from main.mjs.
4
+ // - ensureSelectedTtsBackendInstalled triggers per-backend install scripts
5
+ // (mlxaudio, fireredtts2, mossttsnano) when their python venv / binary
6
+ // is missing, then persists the resolved env paths.
7
+ // - commandIsInstalled walks PATH (with Windows PATHEXT support) and
8
+ // caches results on bridge.installedBinaryCache.
9
+ //
10
+ // refreshTtsRuntimeConfig deliberately stays in main.mjs because it
11
+ // references ttsFallbackNotice (also in main.mjs) which itself references
12
+ // speakText from ttsPlayer — moving it here would force a thunk dance.
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+
17
+ export function createTtsRuntime(deps) {
18
+ const {
19
+ bridge,
20
+ ROOT,
21
+ execFileAsync,
22
+ speakText,
23
+ warn,
24
+ persistEnvValues,
25
+ } = deps;
26
+
27
+ async function ensureSelectedTtsBackendInstalled(selection, signal) {
28
+ if (selection.backend === 'mlxaudio') {
29
+ const mlxPython = process.env.MLXAUDIO_PYTHON || './.venv-mlxaudio/bin/python';
30
+ const mlxPath = path.isAbsolute(mlxPython) ? mlxPython : path.resolve(ROOT, mlxPython);
31
+ if (fs.existsSync(mlxPath)) return { ok: true, installed: false };
32
+ await speakText('MLX Audio가 아직 설치 안 돼 있어서 지금 설치할게. 처음엔 모델 다운로드가 걸릴 수 있어.', signal, null, { mirrorText: true });
33
+ try {
34
+ await execFileAsync('bash', [path.join(ROOT, 'scripts', 'install_mlxaudio.sh'), '--yes'], {
35
+ cwd: ROOT,
36
+ timeout: Number(process.env.MLXAUDIO_INSTALL_TIMEOUT_MS || '1800000'),
37
+ maxBuffer: 1024 * 1024,
38
+ });
39
+ process.env.MLXAUDIO_PYTHON = './.venv-mlxaudio/bin/python';
40
+ persistEnvValues({ MLXAUDIO_PYTHON: './.venv-mlxaudio/bin/python' });
41
+ return { ok: true, installed: true };
42
+ } catch (error) {
43
+ const tail = String(error?.stderr || error?.stdout || error?.message || error).slice(-900);
44
+ warn('MLX Audio auto-install failed', tail);
45
+ await speakText(`MLX Audio 자동 설치가 실패했어. Edge fallback은 유지할게. 로그 꼬리: ${tail}`, signal, null, { mirrorText: true });
46
+ return { ok: false, installed: false, error };
47
+ }
48
+ }
49
+ if (selection.backend === 'fireredtts2') {
50
+ const fireCommand = process.env.FIREREDTTS2_COMMAND || './.local/bin/fireredtts2';
51
+ const firePath = path.isAbsolute(fireCommand) ? fireCommand : path.resolve(ROOT, fireCommand);
52
+ const fireModel = path.resolve(ROOT, process.env.FIREREDTTS2_PRETRAINED_DIR || 'pretrained_models/FireRedTTS2');
53
+ if (fs.existsSync(firePath) && fs.existsSync(fireModel)) return { ok: true, installed: false };
54
+ await speakText('FireRedTTS-2가 아직 설치 안 돼 있어서 지금 설치할게. 모델 다운로드 때문에 오래 걸릴 수 있어.', signal, null, { mirrorText: true });
55
+ try {
56
+ await execFileAsync('bash', [path.join(ROOT, 'scripts', 'install_fireredtts2.sh'), '--yes'], {
57
+ cwd: ROOT,
58
+ timeout: Number(process.env.FIREREDTTS2_INSTALL_TIMEOUT_MS || '3600000'),
59
+ maxBuffer: 1024 * 1024,
60
+ });
61
+ process.env.FIREREDTTS2_COMMAND = './.local/bin/fireredtts2';
62
+ process.env.FIREREDTTS2_PRETRAINED_DIR = 'pretrained_models/FireRedTTS2';
63
+ persistEnvValues({
64
+ FIREREDTTS2_COMMAND: './.local/bin/fireredtts2',
65
+ FIREREDTTS2_PRETRAINED_DIR: 'pretrained_models/FireRedTTS2',
66
+ });
67
+ return { ok: true, installed: true };
68
+ } catch (error) {
69
+ const tail = String(error?.stderr || error?.stdout || error?.message || error).slice(-900);
70
+ warn('FireRedTTS-2 auto-install failed', tail);
71
+ await speakText(`FireRedTTS-2 자동 설치가 실패했어. Edge fallback은 유지할게. 로그 꼬리: ${tail}`, signal, null, { mirrorText: true });
72
+ return { ok: false, installed: false, error };
73
+ }
74
+ }
75
+ if (selection.backend === 'mossttsnano') {
76
+ const mossCommand = process.env.MOSSTTSNANO_COMMAND || './.local/bin/mossttsnano';
77
+ const mossPath = path.isAbsolute(mossCommand) ? mossCommand : path.resolve(ROOT, mossCommand);
78
+ const mossScript = path.resolve(ROOT, process.env.MOSSTTSNANO_SCRIPT || 'vendor/MOSS-TTS-Nano/infer.py');
79
+ if (fs.existsSync(mossPath) && fs.existsSync(mossScript)) return { ok: true, installed: false };
80
+ await speakText('MOSS-TTS-Nano가 아직 설치 안 돼 있어서 지금 설치할게. 처음엔 모델 다운로드가 걸릴 수 있어.', signal, null, { mirrorText: true });
81
+ try {
82
+ await execFileAsync('bash', [path.join(ROOT, 'scripts', 'install_mossttsnano.sh'), '--yes'], {
83
+ cwd: ROOT,
84
+ timeout: Number(process.env.MOSSTTSNANO_INSTALL_TIMEOUT_MS || '1800000'),
85
+ maxBuffer: 1024 * 1024,
86
+ });
87
+ process.env.MOSSTTSNANO_COMMAND = './.venv-mossttsnano/bin/python';
88
+ process.env.MOSSTTSNANO_SCRIPT = 'vendor/MOSS-TTS-Nano/infer.py';
89
+ persistEnvValues({
90
+ MOSSTTSNANO_COMMAND: './.venv-mossttsnano/bin/python',
91
+ MOSSTTSNANO_SCRIPT: 'vendor/MOSS-TTS-Nano/infer.py',
92
+ });
93
+ return { ok: true, installed: true };
94
+ } catch (error) {
95
+ const tail = String(error?.stderr || error?.stdout || error?.message || error).slice(-900);
96
+ warn('MOSS-TTS-Nano auto-install failed', tail);
97
+ await speakText(`MOSS-TTS-Nano 자동 설치가 실패했어. Edge fallback은 유지할게. 로그 꼬리: ${tail}`, signal, null, { mirrorText: true });
98
+ return { ok: false, installed: false, error };
99
+ }
100
+ }
101
+ return { ok: true, installed: false };
102
+ }
103
+
104
+ function commandIsInstalled(binary, { cwd = process.cwd() } = {}) {
105
+ if (!binary) return false;
106
+ const isWindows = process.platform === 'win32';
107
+ const exts = isWindows
108
+ ? String(process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
109
+ : [''];
110
+ function existsExecutable(candidate) {
111
+ try { fs.accessSync(candidate, fs.constants.X_OK); return true; } catch { return false; }
112
+ }
113
+ function existsAnyExt(candidate) {
114
+ if (existsExecutable(candidate)) return true;
115
+ if (isWindows && !/\.[^\\/.]+$/.test(candidate)) {
116
+ return exts.some(ext => existsExecutable(candidate + ext));
117
+ }
118
+ return false;
119
+ }
120
+ if (path.isAbsolute(binary)) return existsAnyExt(binary);
121
+ const hasPathSep = binary.includes('/') || (isWindows && binary.includes('\\'));
122
+ if (hasPathSep) return existsAnyExt(path.resolve(cwd, binary));
123
+ if (bridge.installedBinaryCache.has(binary)) return bridge.installedBinaryCache.get(binary);
124
+ const pathEntries = String(process.env.PATH || '').split(path.delimiter).filter(Boolean);
125
+ const found = pathEntries.some(dir => existsAnyExt(path.join(dir, binary)));
126
+ bridge.installedBinaryCache.set(binary, found);
127
+ return found;
128
+ }
129
+
130
+ return {
131
+ ensureSelectedTtsBackendInstalled,
132
+ commandIsInstalled,
133
+ };
134
+ }
@@ -0,0 +1,89 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createTtsRuntime } from './tts_runtime.mjs';
4
+ import { createBridge } from './bridge_context.mjs';
5
+
6
+ function makeDeps(overrides = {}) {
7
+ return {
8
+ bridge: createBridge(),
9
+ ROOT: '/tmp/vc-root',
10
+ execFileAsync: async () => ({ stdout: '', stderr: '' }),
11
+ speakText: async () => {},
12
+ warn: () => {},
13
+ persistEnvValues: () => {},
14
+ ...overrides,
15
+ };
16
+ }
17
+
18
+ test('createTtsRuntime exposes the expected functions', () => {
19
+ const rt = createTtsRuntime(makeDeps());
20
+ for (const name of ['ensureSelectedTtsBackendInstalled', 'commandIsInstalled']) {
21
+ assert.equal(typeof rt[name], 'function', `${name} is exposed`);
22
+ }
23
+ });
24
+
25
+ test('ensureSelectedTtsBackendInstalled is a no-op for built-in backends', async () => {
26
+ const { ensureSelectedTtsBackendInstalled } = createTtsRuntime(makeDeps());
27
+ const result = await ensureSelectedTtsBackendInstalled({ backend: 'edge' }, null);
28
+ assert.deepEqual(result, { ok: true, installed: false });
29
+ });
30
+
31
+ test('ensureSelectedTtsBackendInstalled triggers install when mlxaudio binary missing', async () => {
32
+ const calls = [];
33
+ const speakCalls = [];
34
+ const persisted = [];
35
+ const deps = makeDeps({
36
+ execFileAsync: async (...args) => { calls.push(args); return { stdout: '', stderr: '' }; },
37
+ speakText: async msg => { speakCalls.push(msg); },
38
+ persistEnvValues: v => persisted.push(v),
39
+ });
40
+ const prev = process.env.MLXAUDIO_PYTHON;
41
+ process.env.MLXAUDIO_PYTHON = '/path/that/does/not/exist/mlxaudio';
42
+ try {
43
+ const { ensureSelectedTtsBackendInstalled } = createTtsRuntime(deps);
44
+ const result = await ensureSelectedTtsBackendInstalled({ backend: 'mlxaudio' }, null);
45
+ assert.equal(result.ok, true);
46
+ assert.equal(result.installed, true);
47
+ assert.equal(calls.length, 1, 'bash install script invoked once');
48
+ assert.match(calls[0][0], /bash/);
49
+ assert.ok(calls[0][1][0].endsWith('install_mlxaudio.sh'));
50
+ assert.equal(persisted.length, 1);
51
+ assert.equal(persisted[0].MLXAUDIO_PYTHON, './.venv-mlxaudio/bin/python');
52
+ } finally {
53
+ if (prev === undefined) delete process.env.MLXAUDIO_PYTHON;
54
+ else process.env.MLXAUDIO_PYTHON = prev;
55
+ }
56
+ });
57
+
58
+ test('ensureSelectedTtsBackendInstalled reports install failure', async () => {
59
+ const deps = makeDeps({
60
+ execFileAsync: async () => { const e = new Error('install boom'); e.stderr = 'boom'; throw e; },
61
+ });
62
+ const prev = process.env.MLXAUDIO_PYTHON;
63
+ process.env.MLXAUDIO_PYTHON = '/path/that/does/not/exist/mlxaudio';
64
+ try {
65
+ const { ensureSelectedTtsBackendInstalled } = createTtsRuntime(deps);
66
+ const result = await ensureSelectedTtsBackendInstalled({ backend: 'mlxaudio' }, null);
67
+ assert.equal(result.ok, false);
68
+ assert.equal(result.installed, false);
69
+ assert.ok(result.error);
70
+ } finally {
71
+ if (prev === undefined) delete process.env.MLXAUDIO_PYTHON;
72
+ else process.env.MLXAUDIO_PYTHON = prev;
73
+ }
74
+ });
75
+
76
+ test('commandIsInstalled returns false for empty input', () => {
77
+ const { commandIsInstalled } = createTtsRuntime(makeDeps());
78
+ assert.equal(commandIsInstalled(''), false);
79
+ assert.equal(commandIsInstalled(null), false);
80
+ });
81
+
82
+ test('commandIsInstalled caches results per binary on bridge', () => {
83
+ const deps = makeDeps();
84
+ const { commandIsInstalled } = createTtsRuntime(deps);
85
+ // Probe with a binary name extremely unlikely to exist
86
+ commandIsInstalled('vc-test-no-such-binary-xyz123');
87
+ assert.ok(deps.bridge.installedBinaryCache.has('vc-test-no-such-binary-xyz123'));
88
+ assert.equal(deps.bridge.installedBinaryCache.get('vc-test-no-such-binary-xyz123'), false);
89
+ });
@@ -1,5 +1,56 @@
1
1
  import path from 'node:path';
2
2
 
3
+ export const SUPPORTED_TTS_BACKENDS = [
4
+ 'edge',
5
+ 'openvoice',
6
+ 'speechswift',
7
+ 'supertonic',
8
+ 'omnivoice',
9
+ 'qwen3tts',
10
+ 'mlxaudio',
11
+ 'fireredtts2',
12
+ 'mossttsnano',
13
+ 'mossttsnano_mlx',
14
+ 'neuttsair',
15
+ ];
16
+
17
+ export const TTS_BACKEND_ALIASES = new Map([
18
+ ['q3', 'qwen3tts'],
19
+ ['qwen3', 'qwen3tts'],
20
+ ['qwen3-tts', 'qwen3tts'],
21
+ ['qtts', 'qwen3tts'],
22
+ ['mlx', 'mlxaudio'],
23
+ ['mlx-audio', 'mlxaudio'],
24
+ ['qwen3-mlx', 'mlxaudio'],
25
+ ['neutts', 'neuttsair'],
26
+ ['neutts-air', 'neuttsair'],
27
+ ['neutts air', 'neuttsair'],
28
+ ['neuttsair', 'neuttsair'],
29
+ ['neu-tts-air', 'neuttsair'],
30
+ ['neu tts air', 'neuttsair'],
31
+ ['neutts-air-backend', 'neuttsair'],
32
+ ['firered', 'fireredtts2'],
33
+ ['fireredtts', 'fireredtts2'],
34
+ ['firered-tts-2', 'fireredtts2'],
35
+ ['fireredtts-2', 'fireredtts2'],
36
+ ['moss', 'mossttsnano'],
37
+ ['moss-tts', 'mossttsnano'],
38
+ ['mossnano', 'mossttsnano'],
39
+ ['moss-tts-nano', 'mossttsnano'],
40
+ ['openmoss', 'mossttsnano'],
41
+ ['moss-mlx', 'mossttsnano_mlx'],
42
+ ['moss mlx', 'mossttsnano_mlx'],
43
+ ['mossttsnano-mlx', 'mossttsnano_mlx'],
44
+ ['mossttsnano_mlx', 'mossttsnano_mlx'],
45
+ ['openmoss-mlx', 'mossttsnano_mlx'],
46
+ ]);
47
+
48
+ export function normalizeTtsBackendName(value, fallback = 'edge') {
49
+ const requested = String(value || '').trim().toLowerCase();
50
+ const normalized = TTS_BACKEND_ALIASES.get(requested) || requested;
51
+ return SUPPORTED_TTS_BACKENDS.includes(normalized) ? normalized : fallback;
52
+ }
53
+
3
54
  function boolEnv(value, fallback = false) {
4
55
  if (value == null || value === '') return fallback;
5
56
  return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase());
@@ -16,9 +67,7 @@ function resolveUnderRoot(root, value, fallback) {
16
67
  }
17
68
 
18
69
  export function buildTtsSettings(env = process.env, root = process.cwd()) {
19
- const requestedBackend = String(env.TTS_BACKEND || 'edge').trim().toLowerCase();
20
- const supportedBackends = new Set(['edge', 'openvoice', 'speechswift', 'supertonic']);
21
- const backend = supportedBackends.has(requestedBackend) ? requestedBackend : 'edge';
70
+ const backend = normalizeTtsBackendName(env.TTS_BACKEND || 'edge');
22
71
  return {
23
72
  backend,
24
73
  maxChars: positiveNumber(env.TTS_MAX_CHARS, 495),
@@ -68,5 +117,103 @@ export function buildTtsSettings(env = process.env, root = process.cwd()) {
68
117
  intraOpThreads: env.SUPERTONIC_INTRA_OP_THREADS || '',
69
118
  interOpThreads: env.SUPERTONIC_INTER_OP_THREADS || '',
70
119
  },
120
+ omnivoice: {
121
+ python: resolveUnderRoot(root, env.OMNIVOICE_PYTHON, path.join('.venv-omnivoice', 'bin', 'python')),
122
+ model: env.OMNIVOICE_MODEL || 'k2-fsa/OmniVoice',
123
+ device: env.OMNIVOICE_DEVICE || 'mps',
124
+ dtype: env.OMNIVOICE_DTYPE || 'float16',
125
+ refAudio: resolveUnderRoot(root, env.OMNIVOICE_REF_AUDIO || env.OPENVOICE_REF_AUDIO, path.join('voice-samples', 'user-reference.wav')),
126
+ refText: env.OMNIVOICE_REF_TEXT || '',
127
+ language: env.OMNIVOICE_LANGUAGE || env.VOICE_LANGUAGE || 'ko',
128
+ speaker: env.OMNIVOICE_SPEAKER || '',
129
+ timeoutMs: positiveNumber(env.OMNIVOICE_TIMEOUT_MS, 180000),
130
+ useForProgress: boolEnv(env.OMNIVOICE_PROGRESS, false),
131
+ },
132
+ qwen3tts: {
133
+ command: env.QWEN3TTS_COMMAND || env.QTTS_COMMAND || 'audio',
134
+ mode: env.QWEN3TTS_MODE || 'custom',
135
+ model: env.QWEN3TTS_MODEL || '',
136
+ language: env.QWEN3TTS_LANGUAGE || env.VOICE_LANGUAGE || 'korean',
137
+ speaker: env.QWEN3TTS_SPEAKER || 'sohee',
138
+ instruct: env.QWEN3TTS_INSTRUCT || '',
139
+ refAudio: env.QWEN3TTS_REF_AUDIO ? resolveUnderRoot(root, env.QWEN3TTS_REF_AUDIO, '') : '',
140
+ refText: env.QWEN3TTS_REF_TEXT || '',
141
+ stream: boolEnv(env.QWEN3TTS_STREAM, true),
142
+ timeoutMs: positiveNumber(env.QWEN3TTS_TIMEOUT_MS, 120000),
143
+ useForProgress: boolEnv(env.QWEN3TTS_PROGRESS, false),
144
+ },
145
+ mlxaudio: {
146
+ python: resolveUnderRoot(root, env.MLXAUDIO_PYTHON, path.join('.venv-mlxaudio', 'bin', 'python')),
147
+ model: env.MLXAUDIO_MODEL || 'mlx-community/Qwen3-TTS-12Hz-1.7B-Base-8bit',
148
+ voice: env.MLXAUDIO_VOICE || 'Chelsie',
149
+ langCode: env.MLXAUDIO_LANG_CODE || env.VOICE_LANGUAGE || 'ko',
150
+ stream: boolEnv(env.MLXAUDIO_STREAM, false),
151
+ timeoutMs: positiveNumber(env.MLXAUDIO_TIMEOUT_MS, 180000),
152
+ useForProgress: boolEnv(env.MLXAUDIO_PROGRESS, false),
153
+ },
154
+ neuttsair: {
155
+ python: resolveUnderRoot(root, env.NEUTTSAIR_PYTHON || env.NEUTTS_AIR_PYTHON, path.join('.venv-neuttsair', 'bin', 'python')),
156
+ script: resolveUnderRoot(root, env.NEUTTSAIR_SCRIPT || env.NEUTTS_AIR_SCRIPT, path.join('integrations', 'neuttsair', 'synth.py')),
157
+ backboneRepo: env.NEUTTSAIR_BACKBONE_REPO || env.NEUTTS_AIR_BACKBONE_REPO || env.NEUTTSAIR_BACKBONE || 'neuphonic/neutts-air-q4-gguf',
158
+ backboneDevice: env.NEUTTSAIR_BACKBONE_DEVICE || env.NEUTTS_AIR_BACKBONE_DEVICE || env.NEUTTSAIR_DEVICE || 'mps',
159
+ codecRepo: env.NEUTTSAIR_CODEC_REPO || env.NEUTTS_AIR_CODEC_REPO || env.NEUTTSAIR_CODEC || 'neuphonic/neucodec',
160
+ codecDevice: env.NEUTTSAIR_CODEC_DEVICE || env.NEUTTS_AIR_CODEC_DEVICE || env.NEUTTSAIR_DEVICE || 'mps',
161
+ refAudio: resolveUnderRoot(root, env.NEUTTSAIR_REF_AUDIO || env.NEUTTS_AIR_REF_AUDIO || env.OPENVOICE_REF_AUDIO, path.join('voice-samples', 'user-reference.wav')),
162
+ refText: env.NEUTTSAIR_REF_TEXT || env.NEUTTS_AIR_REF_TEXT || '',
163
+ refTextFile: (env.NEUTTSAIR_REF_TEXT_FILE || env.NEUTTS_AIR_REF_TEXT_FILE) ? resolveUnderRoot(root, env.NEUTTSAIR_REF_TEXT_FILE || env.NEUTTS_AIR_REF_TEXT_FILE, '') : '',
164
+ language: env.NEUTTSAIR_LANGUAGE || env.NEUTTS_AIR_LANGUAGE || env.VOICE_LANGUAGE || 'en',
165
+ sampleRate: positiveNumber(env.NEUTTSAIR_SAMPLE_RATE || env.NEUTTS_AIR_SAMPLE_RATE, 24000),
166
+ cacheRef: boolEnv(env.NEUTTSAIR_CACHE_REF || env.NEUTTS_AIR_CACHE_REF, true),
167
+ timeoutMs: positiveNumber(env.NEUTTSAIR_TIMEOUT_MS || env.NEUTTS_AIR_TIMEOUT_MS, 120000),
168
+ useForProgress: boolEnv(env.NEUTTSAIR_PROGRESS || env.NEUTTS_AIR_PROGRESS, false),
169
+ },
170
+ fireredtts2: {
171
+ command: env.FIREREDTTS2_COMMAND || './.local/bin/fireredtts2',
172
+ pretrainedDir: resolveUnderRoot(root, env.FIREREDTTS2_PRETRAINED_DIR, path.join('pretrained_models', 'FireRedTTS2')),
173
+ device: env.FIREREDTTS2_DEVICE || 'auto',
174
+ genType: env.FIREREDTTS2_GEN_TYPE || 'monologue',
175
+ speaker: env.FIREREDTTS2_SPEAKER || 'S1',
176
+ promptAudio: env.FIREREDTTS2_PROMPT_AUDIO ? resolveUnderRoot(root, env.FIREREDTTS2_PROMPT_AUDIO, '') : '',
177
+ promptText: env.FIREREDTTS2_PROMPT_TEXT || '',
178
+ useBf16: boolEnv(env.FIREREDTTS2_BF16, false),
179
+ timeoutMs: positiveNumber(env.FIREREDTTS2_TIMEOUT_MS, 180000),
180
+ useForProgress: boolEnv(env.FIREREDTTS2_PROGRESS, false),
181
+ },
182
+ mossttsnano: {
183
+ command: env.MOSSTTSNANO_COMMAND || './.venv-mossttsnano/bin/python',
184
+ script: resolveUnderRoot(root, env.MOSSTTSNANO_SCRIPT, path.join('vendor', 'MOSS-TTS-Nano', 'infer.py')),
185
+ checkpoint: env.MOSSTTSNANO_CHECKPOINT || env.MOSS_TTS_NANO_CHECKPOINT || 'OpenMOSS-Team/MOSS-TTS-Nano',
186
+ audioTokenizer: env.MOSSTTSNANO_AUDIO_TOKENIZER || env.MOSS_TTS_NANO_AUDIO_TOKENIZER || '',
187
+ mode: env.MOSSTTSNANO_MODE || 'continuation',
188
+ language: env.MOSSTTSNANO_LANGUAGE || env.VOICE_LANGUAGE || 'ko',
189
+ device: env.MOSSTTSNANO_DEVICE || 'auto',
190
+ dtype: env.MOSSTTSNANO_DTYPE || 'auto',
191
+ promptAudio: env.MOSSTTSNANO_PROMPT_AUDIO ? resolveUnderRoot(root, env.MOSSTTSNANO_PROMPT_AUDIO, '') : '',
192
+ promptText: env.MOSSTTSNANO_PROMPT_TEXT || '',
193
+ maxNewFrames: positiveNumber(env.MOSSTTSNANO_MAX_NEW_FRAMES, 375),
194
+ seed: env.MOSSTTSNANO_SEED || '',
195
+ timeoutMs: positiveNumber(env.MOSSTTSNANO_TIMEOUT_MS, 120000),
196
+ useForProgress: boolEnv(env.MOSSTTSNANO_PROGRESS, false),
197
+ },
198
+ mossttsnano_mlx: {
199
+ python: env.MOSSTTSNANO_MLX_PYTHON || env.MOSSTTSNANO_COMMAND || './.venv-mossttsnano/bin/python',
200
+ script: resolveUnderRoot(root, env.MOSSTTSNANO_MLX_SCRIPT, path.join('integrations', 'mossttsnano_mlx', 'synth.py')),
201
+ workerScript: resolveUnderRoot(root, env.MOSSTTSNANO_MLX_WORKER_SCRIPT, path.join('integrations', 'mossttsnano_mlx', 'worker.py')),
202
+ workerEnabled: boolEnv(env.MOSSTTSNANO_MLX_WORKER, false),
203
+ workerStartupTimeoutMs: positiveNumber(env.MOSSTTSNANO_MLX_WORKER_STARTUP_TIMEOUT_MS, 120000),
204
+ torchInferScript: resolveUnderRoot(root, env.MOSSTTSNANO_SCRIPT, path.join('vendor', 'MOSS-TTS-Nano', 'infer.py')),
205
+ checkpoint: env.MOSSTTSNANO_CHECKPOINT || env.MOSS_TTS_NANO_CHECKPOINT || 'OpenMOSS-Team/MOSS-TTS-Nano',
206
+ audioTokenizer: env.MOSSTTSNANO_AUDIO_TOKENIZER || env.MOSS_TTS_NANO_AUDIO_TOKENIZER || 'OpenMOSS-Team/MOSS-Audio-Tokenizer-Nano',
207
+ mode: env.MOSSTTSNANO_MODE || 'voice_clone',
208
+ language: env.MOSSTTSNANO_LANGUAGE || env.VOICE_LANGUAGE || 'ko',
209
+ torchDevice: env.MOSSTTSNANO_TORCH_DEVICE || env.MOSSTTSNANO_DEVICE || 'cpu',
210
+ torchDtype: env.MOSSTTSNANO_TORCH_DTYPE || env.MOSSTTSNANO_DTYPE || 'float32',
211
+ promptAudio: env.MOSSTTSNANO_PROMPT_AUDIO ? resolveUnderRoot(root, env.MOSSTTSNANO_PROMPT_AUDIO, '') : '',
212
+ promptText: env.MOSSTTSNANO_PROMPT_TEXT || '',
213
+ maxNewFrames: positiveNumber(env.MOSSTTSNANO_MAX_NEW_FRAMES, 120),
214
+ seed: env.MOSSTTSNANO_SEED || '',
215
+ timeoutMs: positiveNumber(env.MOSSTTSNANO_MLX_TIMEOUT_MS || env.MOSSTTSNANO_TIMEOUT_MS, 180000),
216
+ useForProgress: boolEnv(env.MOSSTTSNANO_MLX_PROGRESS || env.MOSSTTSNANO_PROGRESS, false),
217
+ },
71
218
  };
72
219
  }