@laitszkin/apollo-toolkit 3.13.2 → 3.14.1

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 (154) hide show
  1. package/AGENTS.md +7 -7
  2. package/CHANGELOG.md +36 -0
  3. package/CLAUDE.md +8 -8
  4. package/analyse-app-logs/SKILL.md +3 -3
  5. package/bin/apollo-toolkit.ts +7 -0
  6. package/codex/codex-memory-manager/SKILL.md +2 -2
  7. package/codex/learn-skill-from-conversations/SKILL.md +3 -3
  8. package/dist/bin/apollo-toolkit.d.ts +2 -0
  9. package/dist/bin/apollo-toolkit.js +7 -0
  10. package/dist/lib/cli.d.ts +41 -0
  11. package/dist/lib/cli.js +655 -0
  12. package/dist/lib/installer.d.ts +59 -0
  13. package/dist/lib/installer.js +404 -0
  14. package/dist/lib/tool-runner.d.ts +19 -0
  15. package/dist/lib/tool-runner.js +536 -0
  16. package/dist/lib/tools/architecture.d.ts +2 -0
  17. package/dist/lib/tools/architecture.js +23 -0
  18. package/dist/lib/tools/create-specs.d.ts +2 -0
  19. package/dist/lib/tools/create-specs.js +175 -0
  20. package/dist/lib/tools/docs-to-voice.d.ts +2 -0
  21. package/dist/lib/tools/docs-to-voice.js +705 -0
  22. package/dist/lib/tools/enforce-video-aspect-ratio.d.ts +2 -0
  23. package/dist/lib/tools/enforce-video-aspect-ratio.js +312 -0
  24. package/dist/lib/tools/extract-conversations.d.ts +2 -0
  25. package/dist/lib/tools/extract-conversations.js +105 -0
  26. package/dist/lib/tools/extract-pdf-text.d.ts +2 -0
  27. package/dist/lib/tools/extract-pdf-text.js +92 -0
  28. package/dist/lib/tools/filter-logs.d.ts +2 -0
  29. package/dist/lib/tools/filter-logs.js +94 -0
  30. package/dist/lib/tools/find-github-issues.d.ts +2 -0
  31. package/dist/lib/tools/find-github-issues.js +176 -0
  32. package/dist/lib/tools/generate-storyboard-images.d.ts +2 -0
  33. package/dist/lib/tools/generate-storyboard-images.js +419 -0
  34. package/dist/lib/tools/log-cli-utils.d.ts +35 -0
  35. package/dist/lib/tools/log-cli-utils.js +233 -0
  36. package/dist/lib/tools/open-github-issue.d.ts +2 -0
  37. package/dist/lib/tools/open-github-issue.js +750 -0
  38. package/dist/lib/tools/read-github-issue.d.ts +2 -0
  39. package/dist/lib/tools/read-github-issue.js +134 -0
  40. package/dist/lib/tools/render-error-book.d.ts +2 -0
  41. package/dist/lib/tools/render-error-book.js +265 -0
  42. package/dist/lib/tools/render-katex.d.ts +2 -0
  43. package/dist/lib/tools/render-katex.js +294 -0
  44. package/dist/lib/tools/review-threads.d.ts +2 -0
  45. package/dist/lib/tools/review-threads.js +491 -0
  46. package/dist/lib/tools/search-logs.d.ts +2 -0
  47. package/dist/lib/tools/search-logs.js +164 -0
  48. package/dist/lib/tools/sync-memory-index.d.ts +2 -0
  49. package/dist/lib/tools/sync-memory-index.js +113 -0
  50. package/dist/lib/tools/validate-openai-agent-config.d.ts +2 -0
  51. package/dist/lib/tools/validate-openai-agent-config.js +190 -0
  52. package/dist/lib/tools/validate-skill-frontmatter.d.ts +2 -0
  53. package/dist/lib/tools/validate-skill-frontmatter.js +118 -0
  54. package/dist/lib/types.d.ts +82 -0
  55. package/dist/lib/types.js +2 -0
  56. package/dist/lib/updater.d.ts +34 -0
  57. package/dist/lib/updater.js +112 -0
  58. package/dist/lib/utils/format.d.ts +2 -0
  59. package/dist/lib/utils/format.js +6 -0
  60. package/dist/lib/utils/terminal.d.ts +12 -0
  61. package/dist/lib/utils/terminal.js +26 -0
  62. package/docs-to-voice/SKILL.md +0 -1
  63. package/generate-spec/SKILL.md +1 -1
  64. package/katex/SKILL.md +1 -2
  65. package/lib/cli.ts +780 -0
  66. package/lib/installer.ts +466 -0
  67. package/lib/tool-runner.ts +561 -0
  68. package/lib/tools/architecture.ts +20 -0
  69. package/lib/tools/create-specs.ts +204 -0
  70. package/lib/tools/docs-to-voice.ts +799 -0
  71. package/lib/tools/enforce-video-aspect-ratio.ts +368 -0
  72. package/lib/tools/extract-conversations.ts +114 -0
  73. package/lib/tools/extract-pdf-text.ts +99 -0
  74. package/lib/tools/filter-logs.ts +118 -0
  75. package/lib/tools/find-github-issues.ts +211 -0
  76. package/lib/tools/generate-storyboard-images.ts +455 -0
  77. package/lib/tools/log-cli-utils.ts +262 -0
  78. package/lib/tools/open-github-issue.ts +930 -0
  79. package/lib/tools/read-github-issue.ts +179 -0
  80. package/lib/tools/render-error-book.ts +300 -0
  81. package/lib/tools/render-katex.ts +325 -0
  82. package/lib/tools/review-threads.ts +590 -0
  83. package/lib/tools/search-logs.ts +200 -0
  84. package/lib/tools/sync-memory-index.ts +114 -0
  85. package/lib/tools/validate-openai-agent-config.ts +213 -0
  86. package/lib/tools/validate-skill-frontmatter.ts +124 -0
  87. package/lib/types.ts +90 -0
  88. package/lib/updater.ts +165 -0
  89. package/lib/utils/format.ts +7 -0
  90. package/lib/utils/terminal.ts +22 -0
  91. package/open-github-issue/SKILL.md +2 -2
  92. package/optimise-skill/SKILL.md +1 -1
  93. package/package.json +13 -4
  94. package/resources/project-architecture/assets/architecture.css +764 -0
  95. package/resources/project-architecture/assets/viewer.client.js +144 -0
  96. package/resources/project-architecture/index.html +42 -0
  97. package/review-spec-related-changes/SKILL.md +1 -1
  98. package/solve-issues-found-during-review/SKILL.md +2 -1
  99. package/tsconfig.json +28 -0
  100. package/analyse-app-logs/scripts/__pycache__/filter_logs_by_time.cpython-312.pyc +0 -0
  101. package/analyse-app-logs/scripts/__pycache__/log_cli_utils.cpython-312.pyc +0 -0
  102. package/analyse-app-logs/scripts/__pycache__/search_logs.cpython-312.pyc +0 -0
  103. package/analyse-app-logs/scripts/filter_logs_by_time.py +0 -64
  104. package/analyse-app-logs/scripts/log_cli_utils.py +0 -112
  105. package/analyse-app-logs/scripts/search_logs.py +0 -137
  106. package/analyse-app-logs/tests/test_filter_logs_by_time.py +0 -95
  107. package/analyse-app-logs/tests/test_search_logs.py +0 -100
  108. package/codex/codex-memory-manager/scripts/extract_recent_conversations.py +0 -369
  109. package/codex/codex-memory-manager/scripts/sync_memory_index.py +0 -130
  110. package/codex/codex-memory-manager/tests/test_extract_recent_conversations.py +0 -177
  111. package/codex/codex-memory-manager/tests/test_memory_template.py +0 -37
  112. package/codex/codex-memory-manager/tests/test_sync_memory_index.py +0 -84
  113. package/codex/learn-skill-from-conversations/scripts/extract_recent_conversations.py +0 -369
  114. package/codex/learn-skill-from-conversations/tests/test_extract_recent_conversations.py +0 -177
  115. package/docs-to-voice/scripts/__pycache__/docs_to_voice.cpython-312.pyc +0 -0
  116. package/docs-to-voice/scripts/docs_to_voice.py +0 -1385
  117. package/docs-to-voice/scripts/docs_to_voice.sh +0 -11
  118. package/docs-to-voice/tests/test_docs_to_voice_api_max_chars.py +0 -210
  119. package/docs-to-voice/tests/test_docs_to_voice_sentence_timeline.py +0 -115
  120. package/docs-to-voice/tests/test_docs_to_voice_settings.py +0 -43
  121. package/docs-to-voice/tests/test_docs_to_voice_shell_wrapper.py +0 -51
  122. package/docs-to-voice/tests/test_docs_to_voice_speech_rate.py +0 -57
  123. package/generate-spec/scripts/__pycache__/create-specscpython-312.pyc +0 -0
  124. package/generate-spec/scripts/create-specs +0 -215
  125. package/generate-spec/tests/test_create_specs.py +0 -200
  126. package/init-project-html/scripts/architecture-bootstrap-render.js +0 -16
  127. package/init-project-html/scripts/architecture.js +0 -296
  128. package/katex/scripts/__pycache__/render_katex.cpython-312.pyc +0 -0
  129. package/katex/scripts/render_katex.py +0 -247
  130. package/katex/scripts/render_katex.sh +0 -11
  131. package/katex/tests/test_render_katex.py +0 -174
  132. package/learning-error-book/scripts/render_error_book_json_to_pdf.py +0 -590
  133. package/learning-error-book/tests/test_render_error_book_json_to_pdf.py +0 -134
  134. package/open-github-issue/scripts/__pycache__/open_github_issue.cpython-312.pyc +0 -0
  135. package/open-github-issue/scripts/open_github_issue.py +0 -705
  136. package/open-github-issue/tests/test_open_github_issue.py +0 -381
  137. package/openai-text-to-image-storyboard/scripts/generate_storyboard_images.py +0 -763
  138. package/openai-text-to-image-storyboard/tests/test_generate_storyboard_images.py +0 -177
  139. package/read-github-issue/scripts/__pycache__/find_issues.cpython-312.pyc +0 -0
  140. package/read-github-issue/scripts/__pycache__/read_issue.cpython-312.pyc +0 -0
  141. package/read-github-issue/scripts/find_issues.py +0 -148
  142. package/read-github-issue/scripts/read_issue.py +0 -108
  143. package/read-github-issue/tests/test_find_issues.py +0 -127
  144. package/read-github-issue/tests/test_read_issue.py +0 -109
  145. package/resolve-review-comments/scripts/__pycache__/review_threads.cpython-312.pyc +0 -0
  146. package/resolve-review-comments/scripts/review_threads.py +0 -425
  147. package/resolve-review-comments/tests/test_review_threads.py +0 -74
  148. package/scripts/validate_openai_agent_config.py +0 -209
  149. package/scripts/validate_skill_frontmatter.py +0 -131
  150. package/text-to-short-video/scripts/__pycache__/enforce_video_aspect_ratio.cpython-312.pyc +0 -0
  151. package/text-to-short-video/scripts/enforce_video_aspect_ratio.py +0 -350
  152. package/text-to-short-video/tests/test_enforce_video_aspect_ratio.py +0 -194
  153. package/weekly-financial-event-report/scripts/extract_pdf_text_pdfkit.swift +0 -99
  154. package/weekly-financial-event-report/tests/test_extract_pdf_text_pdfkit.py +0 -64
@@ -0,0 +1,705 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.docsToVoiceHandler = docsToVoiceHandler;
7
+ const node_child_process_1 = require("node:child_process");
8
+ const node_fs_1 = __importDefault(require("node:fs"));
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const node_https_1 = __importDefault(require("node:https"));
11
+ const node_http_1 = __importDefault(require("node:http"));
12
+ const DEFAULT_API_ENDPOINT = 'https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation';
13
+ const DEFAULT_API_MODEL = 'qwen3-tts';
14
+ const DEFAULT_API_VOICE = 'Cherry';
15
+ function parseArgs(args) {
16
+ const parsed = {
17
+ inputText: null,
18
+ inputFile: null,
19
+ projectDir: '.',
20
+ projectName: null,
21
+ outputName: null,
22
+ mode: 'say',
23
+ voice: null,
24
+ rate: null,
25
+ speechRate: null,
26
+ apiEndpoint: DEFAULT_API_ENDPOINT,
27
+ apiModel: DEFAULT_API_MODEL,
28
+ apiVoice: DEFAULT_API_VOICE,
29
+ apiKey: null,
30
+ maxChars: null,
31
+ noAutoProsody: false,
32
+ force: false,
33
+ help: false,
34
+ };
35
+ for (let i = 0; i < args.length; i++) {
36
+ const arg = args[i];
37
+ if (arg === '--help' || arg === '-h') {
38
+ parsed.help = true;
39
+ continue;
40
+ }
41
+ if (arg.startsWith('--')) {
42
+ const eqIndex = arg.indexOf('=');
43
+ let key;
44
+ let value;
45
+ if (eqIndex !== -1) {
46
+ key = arg.slice(2, eqIndex);
47
+ value = arg.slice(eqIndex + 1);
48
+ }
49
+ else {
50
+ key = arg.slice(2);
51
+ value = args[++i] || '';
52
+ }
53
+ switch (key) {
54
+ case 'text':
55
+ parsed.inputText = value;
56
+ break;
57
+ case 'input':
58
+ case 'input-file':
59
+ parsed.inputFile = value;
60
+ break;
61
+ case 'project-dir':
62
+ parsed.projectDir = value;
63
+ break;
64
+ case 'project-name':
65
+ parsed.projectName = value;
66
+ break;
67
+ case 'output-name':
68
+ parsed.outputName = value;
69
+ break;
70
+ case 'engine':
71
+ case 'mode':
72
+ parsed.mode = value.toLowerCase();
73
+ break;
74
+ case 'voice':
75
+ parsed.voice = value;
76
+ break;
77
+ case 'rate':
78
+ parsed.rate = value;
79
+ break;
80
+ case 'speech-rate':
81
+ parsed.speechRate = value;
82
+ break;
83
+ case 'api-endpoint':
84
+ parsed.apiEndpoint = value;
85
+ break;
86
+ case 'api-model':
87
+ parsed.apiModel = value;
88
+ break;
89
+ case 'api-voice':
90
+ parsed.apiVoice = value;
91
+ break;
92
+ case 'api-key':
93
+ parsed.apiKey = value;
94
+ break;
95
+ case 'max-chars':
96
+ parsed.maxChars = value;
97
+ break;
98
+ case 'no-auto-prosody':
99
+ parsed.noAutoProsody = true;
100
+ break;
101
+ case 'force':
102
+ parsed.force = true;
103
+ break;
104
+ }
105
+ }
106
+ }
107
+ return parsed;
108
+ }
109
+ function readInputText(opts) {
110
+ if (opts.inputFile) {
111
+ const inputPath = node_path_1.default.resolve(opts.inputFile);
112
+ if (!node_fs_1.default.existsSync(inputPath)) {
113
+ throw new Error(`Input file not found: ${inputPath}`);
114
+ }
115
+ return node_fs_1.default.readFileSync(inputPath, 'utf-8');
116
+ }
117
+ return opts.inputText || '';
118
+ }
119
+ function splitSentences(rawText) {
120
+ const endings = new Set(['。', '!', '?', '!', '?', ';', ';']);
121
+ const sentences = [];
122
+ for (const rawLine of rawText.split('\n')) {
123
+ const line = rawLine.trim();
124
+ if (!line)
125
+ continue;
126
+ let current = [];
127
+ for (const char of line) {
128
+ current.push(char);
129
+ if (endings.has(char)) {
130
+ const sentence = current.join('').trim();
131
+ if (sentence)
132
+ sentences.push(sentence);
133
+ current = [];
134
+ }
135
+ }
136
+ const tail = current.join('').trim();
137
+ if (tail)
138
+ sentences.push(tail);
139
+ }
140
+ return sentences;
141
+ }
142
+ function sentenceWeight(sentence) {
143
+ const compact = sentence.replace(/\s+/g, '');
144
+ if (!compact)
145
+ return 1.0;
146
+ let total = 0.0;
147
+ for (const char of compact) {
148
+ if (/[A-Za-z0-9]/.test(char)) {
149
+ total += 0.55;
150
+ }
151
+ else if (/[一-鿿]/.test(char)) {
152
+ total += 1.0;
153
+ }
154
+ else if (',,、::'.includes(char)) {
155
+ total += 0.25;
156
+ }
157
+ else if ('。.!!??;;'.includes(char)) {
158
+ total += 0.45;
159
+ }
160
+ else {
161
+ total += 0.65;
162
+ }
163
+ }
164
+ return Math.max(total, 1.0);
165
+ }
166
+ function srtTime(seconds) {
167
+ const ms = Math.max(0, Math.round(seconds * 1000));
168
+ const h = Math.floor(ms / 3600000);
169
+ const m = Math.floor((ms % 3600000) / 60000);
170
+ const s = Math.floor((ms % 60000) / 1000);
171
+ const ml = ms % 1000;
172
+ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')},${String(ml).padStart(3, '0')}`;
173
+ }
174
+ function readDurationSeconds(filePath) {
175
+ try {
176
+ const ext = node_path_1.default.extname(filePath).toLowerCase();
177
+ if (ext === '.wav') {
178
+ const header = Buffer.alloc(44);
179
+ const fd = node_fs_1.default.openSync(filePath, 'r');
180
+ node_fs_1.default.readSync(fd, header, 0, 44, 0);
181
+ node_fs_1.default.closeSync(fd);
182
+ const dataSize = header.readUInt32LE(40);
183
+ const sampleRate = header.readUInt32LE(24);
184
+ const channels = header.readUInt16LE(22);
185
+ const bitsPerSample = header.readUInt16LE(34);
186
+ const bytesPerSec = sampleRate * channels * (bitsPerSample / 8);
187
+ if (bytesPerSec > 0) {
188
+ return dataSize / bytesPerSec;
189
+ }
190
+ }
191
+ }
192
+ catch {
193
+ // fallback to afinfo
194
+ }
195
+ // Try afinfo on macOS
196
+ try {
197
+ const output = (0, node_child_process_1.execSync)(`afinfo "${filePath}" 2>/dev/null`, {
198
+ encoding: 'utf-8',
199
+ timeout: 5000,
200
+ });
201
+ const match = output.match(/estimated duration:\s*([0-9.]+)\s*sec/i) ||
202
+ output.match(/duration:\s*([0-9.]+)\s*sec/i);
203
+ if (match)
204
+ return parseFloat(match[1]);
205
+ }
206
+ catch {
207
+ // ignore
208
+ }
209
+ return null;
210
+ }
211
+ function writeTimelineFiles(sourceText, audioPath, sentenceDurations) {
212
+ const sentences = splitSentences(sourceText);
213
+ if (sentences.length === 0) {
214
+ const stripped = sourceText.trim();
215
+ if (stripped)
216
+ sentences.push(stripped);
217
+ }
218
+ if (sentences.length === 0)
219
+ return;
220
+ const durationSeconds = readDurationSeconds(audioPath) || sentences.length * 2;
221
+ const entries = [];
222
+ let cursor = 0;
223
+ if (sentenceDurations && sentenceDurations.length === sentences.length) {
224
+ const totalDuration = sentenceDurations.reduce((a, b) => a + b, 0);
225
+ const scale = totalDuration > 0 ? durationSeconds / totalDuration : 1;
226
+ for (let i = 0; i < sentences.length; i++) {
227
+ const end = i === sentences.length - 1
228
+ ? durationSeconds
229
+ : cursor + sentenceDurations[i] * scale;
230
+ entries.push({
231
+ index: i + 1,
232
+ text: sentences[i],
233
+ startSeconds: Math.round(cursor * 1000) / 1000,
234
+ endSeconds: Math.round(Math.max(end, cursor) * 1000) / 1000,
235
+ startMs: Math.round(cursor * 1000),
236
+ endMs: Math.round(Math.max(end, cursor) * 1000),
237
+ });
238
+ cursor = Math.max(end, cursor);
239
+ }
240
+ }
241
+ else {
242
+ const weights = sentences.map(sentenceWeight);
243
+ const totalWeight = weights.reduce((a, b) => a + b, 0) || sentences.length;
244
+ for (let i = 0; i < sentences.length; i++) {
245
+ const end = i === sentences.length - 1
246
+ ? durationSeconds
247
+ : cursor + (durationSeconds * weights[i] / totalWeight);
248
+ entries.push({
249
+ index: i + 1,
250
+ text: sentences[i],
251
+ startSeconds: Math.round(cursor * 1000) / 1000,
252
+ endSeconds: Math.round(Math.max(end, cursor) * 1000) / 1000,
253
+ startMs: Math.round(cursor * 1000),
254
+ endMs: Math.round(Math.max(end, cursor) * 1000),
255
+ });
256
+ cursor = Math.max(end, cursor);
257
+ }
258
+ }
259
+ // Ensure last entry ends at total duration
260
+ if (entries.length > 0) {
261
+ entries[entries.length - 1].endSeconds = Math.round(durationSeconds * 1000) / 1000;
262
+ entries[entries.length - 1].endMs = Math.round(durationSeconds * 1000);
263
+ }
264
+ const timelineBase = audioPath.replace(/\.[^.]+$/, '');
265
+ // Write JSON timeline
266
+ const jsonPayload = {
267
+ audio_file: node_path_1.default.basename(audioPath),
268
+ audio_path: audioPath,
269
+ audio_duration_seconds: Math.round(durationSeconds * 1000) / 1000,
270
+ timing_mode: sentenceDurations ? 'sentence-audio' : 'estimated',
271
+ generated_at: new Date().toISOString(),
272
+ sentences: entries,
273
+ };
274
+ node_fs_1.default.writeFileSync(`${timelineBase}.timeline.json`, JSON.stringify(jsonPayload, null, 2) + '\n', 'utf-8');
275
+ // Write SRT
276
+ const srtLines = [];
277
+ for (const entry of entries) {
278
+ srtLines.push(String(entry.index));
279
+ srtLines.push(`${srtTime(entry.startSeconds)} --> ${srtTime(entry.endSeconds)}`);
280
+ srtLines.push(entry.text);
281
+ srtLines.push('');
282
+ }
283
+ node_fs_1.default.writeFileSync(`${timelineBase}.srt`, srtLines.join('\n').trim() + '\n', 'utf-8');
284
+ }
285
+ function buildAutoProsodyText(rawText) {
286
+ return rawText
287
+ .replace(/\n{2,}/g, ' [[slnc 260]] ')
288
+ .replace(/\n/g, ' [[slnc 90]] ')
289
+ .replace(/[,,、::]/g, (m) => `${m} [[slnc 120]] `)
290
+ .replace(/[。.]/g, (m) => `${m} [[slnc 180]] `)
291
+ .replace(/[??]/g, (m) => `${m} [[slnc 190]] `)
292
+ .replace(/[!!]/g, (m) => `${m} [[slnc 150]] `)
293
+ .replace(/[ \t]{2,}/g, ' ');
294
+ }
295
+ function applySpeechRateToAudio(outputPath, speechRate) {
296
+ if (Math.abs(speechRate - 1.0) < 1e-9)
297
+ return;
298
+ const tmpPath = `${outputPath}.rate_tmp${node_path_1.default.extname(outputPath)}`;
299
+ try {
300
+ (0, node_child_process_1.execSync)(`ffmpeg -hide_banner -loglevel error -y -i "${outputPath}" -filter:a "atempo=${speechRate}" "${tmpPath}"`, { stdio: 'ignore', timeout: 120000 });
301
+ if (node_fs_1.default.existsSync(tmpPath) && node_fs_1.default.statSync(tmpPath).size > 0) {
302
+ node_fs_1.default.renameSync(tmpPath, outputPath);
303
+ }
304
+ }
305
+ catch (err) {
306
+ if (node_fs_1.default.existsSync(tmpPath))
307
+ node_fs_1.default.unlinkSync(tmpPath);
308
+ throw new Error(`ffmpeg failed while applying --speech-rate: ${err instanceof Error ? err.message : 'unknown error'}`);
309
+ }
310
+ }
311
+ function splitTextForTts(text, maxChars) {
312
+ text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim();
313
+ if (!text)
314
+ return [];
315
+ if (!maxChars || text.length <= maxChars)
316
+ return [text];
317
+ const chunks = [];
318
+ const paragraphs = text.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean);
319
+ for (const paragraph of paragraphs) {
320
+ const sentences = paragraph
321
+ .split(/(?<=[。!?!?;;.!?])/)
322
+ .map((s) => s.trim())
323
+ .filter(Boolean);
324
+ let current = '';
325
+ for (const sentence of sentences) {
326
+ if (sentence.length > maxChars) {
327
+ if (current) {
328
+ chunks.push(current);
329
+ current = '';
330
+ }
331
+ // split oversized sentence
332
+ for (let i = 0; i < sentence.length; i += maxChars) {
333
+ chunks.push(sentence.slice(i, i + maxChars));
334
+ }
335
+ continue;
336
+ }
337
+ const candidate = current ? `${current} ${sentence}` : sentence;
338
+ if (candidate.length <= maxChars) {
339
+ current = candidate;
340
+ }
341
+ else {
342
+ chunks.push(current);
343
+ current = sentence;
344
+ }
345
+ }
346
+ if (current)
347
+ chunks.push(current);
348
+ }
349
+ return chunks;
350
+ }
351
+ function concatAudioFiles(partPaths, outputPath) {
352
+ if (partPaths.length === 0) {
353
+ throw new Error('No chunk audio generated for concatenation.');
354
+ }
355
+ if (partPaths.length === 1) {
356
+ node_fs_1.default.copyFileSync(partPaths[0], outputPath);
357
+ return;
358
+ }
359
+ // Use ffmpeg concat
360
+ const listContent = partPaths.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join('\n');
361
+ const listFile = node_path_1.default.join(node_fs_1.default.mkdtempSync('docs-to-voice-'), 'concat.txt');
362
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(listFile), { recursive: true });
363
+ node_fs_1.default.writeFileSync(listFile, listContent + '\n', 'utf-8');
364
+ try {
365
+ (0, node_child_process_1.execSync)(`ffmpeg -hide_banner -loglevel error -y -f concat -safe 0 -i "${listFile}" -c:a copy "${outputPath}"`, { stdio: 'ignore', timeout: 120000 });
366
+ }
367
+ catch (err) {
368
+ throw new Error(`ffmpeg concat failed: ${err instanceof Error ? err.message : 'unknown error'}`);
369
+ }
370
+ finally {
371
+ try {
372
+ node_fs_1.default.unlinkSync(listFile);
373
+ node_fs_1.default.rmdirSync(node_path_1.default.dirname(listFile));
374
+ }
375
+ catch { /* ignore */ }
376
+ }
377
+ }
378
+ function downloadBinary(url, outputPath) {
379
+ return new Promise((resolve, reject) => {
380
+ const protocol = url.startsWith('https') ? node_https_1.default : node_http_1.default;
381
+ protocol.get(url, { timeout: 300000 }, (response) => {
382
+ const chunks = [];
383
+ response.on('data', (chunk) => chunks.push(chunk));
384
+ response.on('end', () => {
385
+ node_fs_1.default.writeFileSync(outputPath, Buffer.concat(chunks));
386
+ resolve();
387
+ });
388
+ response.on('error', reject);
389
+ }).on('error', reject);
390
+ });
391
+ }
392
+ function requestAlibabaCloudTTS(endpoint, apiKey, model, voice, text) {
393
+ return new Promise((resolve, reject) => {
394
+ const payload = JSON.stringify({
395
+ model,
396
+ input: { text, voice },
397
+ });
398
+ const urlObj = new URL(endpoint);
399
+ const client = urlObj.protocol === 'https:' ? node_https_1.default : node_http_1.default;
400
+ const options = {
401
+ hostname: urlObj.hostname,
402
+ port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
403
+ path: urlObj.pathname,
404
+ method: 'POST',
405
+ headers: {
406
+ 'Authorization': `Bearer ${apiKey}`,
407
+ 'Content-Type': 'application/json',
408
+ },
409
+ timeout: 300000,
410
+ };
411
+ const req = client.request(options, (res) => {
412
+ const chunks = [];
413
+ res.on('data', (chunk) => chunks.push(chunk));
414
+ res.on('end', () => {
415
+ const rawPayload = Buffer.concat(chunks).toString('utf-8');
416
+ try {
417
+ const responseJson = JSON.parse(rawPayload);
418
+ const output = responseJson.output || {};
419
+ const audio = output.audio || {};
420
+ const audioUrl = audio.url || '';
421
+ const audioData = audio.data || '';
422
+ const audioFormat = audio.format || audio.mime_type || '';
423
+ if (!audioUrl && !audioData) {
424
+ reject(new Error('API response does not contain output.audio.url or output.audio.data'));
425
+ return;
426
+ }
427
+ resolve({ audioUrl, audioData, audioFormat });
428
+ }
429
+ catch {
430
+ reject(new Error('API response is not valid JSON.'));
431
+ }
432
+ });
433
+ res.on('error', reject);
434
+ });
435
+ req.on('error', reject);
436
+ req.on('timeout', () => { req.destroy(); reject(new Error('API request timed out')); });
437
+ req.write(payload);
438
+ req.end();
439
+ });
440
+ }
441
+ async function docsToVoiceHandler(args, context) {
442
+ const stdout = context.stdout || process.stdout;
443
+ const stderr = context.stderr || process.stderr;
444
+ try {
445
+ const opts = parseArgs(args);
446
+ if (opts.help) {
447
+ stdout.write(`Usage: apltk docs-to-voice [options]
448
+
449
+ Convert text into audio and sentence timelines.
450
+
451
+ Options:
452
+ --input, --input-file <path> Path to input text file
453
+ --text <string> Raw text input
454
+ --project-dir <path> Root project directory (default: .)
455
+ --project-name <name> Folder name under DIR/audio/
456
+ --output-name <name> Output filename
457
+ --engine, --mode <mode> TTS mode: say (default) | api
458
+ --voice <name> macOS say voice
459
+ --rate <wpm> macOS say rate
460
+ --speech-rate <factor> Speech rate multiplier (e.g. 1.2)
461
+ --api-endpoint <url> Alibaba Cloud TTS endpoint
462
+ --api-model <name> Alibaba Cloud model (default: qwen3-tts)
463
+ --api-voice <name> Alibaba Cloud voice (default: Cherry)
464
+ --api-key <key> Alibaba Cloud API key
465
+ --max-chars <n> Max chars per TTS chunk (0 disables)
466
+ --no-auto-prosody Disable punctuation pause enhancement
467
+ --force Overwrite existing files
468
+ `);
469
+ return 0;
470
+ }
471
+ if (opts.mode !== 'say' && opts.mode !== 'api') {
472
+ stderr.write('Error: --mode must be one of: say, api\n');
473
+ return 1;
474
+ }
475
+ const sourceText = readInputText(opts);
476
+ if (!sourceText.trim()) {
477
+ stderr.write('Error: No text content found for conversion.\n');
478
+ return 1;
479
+ }
480
+ // Resolve output directory
481
+ const projectDir = node_path_1.default.resolve(opts.projectDir);
482
+ const projectName = opts.projectName || node_path_1.default.basename(projectDir);
483
+ if (!projectName) {
484
+ stderr.write('Error: Unable to determine project name.\n');
485
+ return 1;
486
+ }
487
+ const outputDir = node_path_1.default.join(projectDir, 'audio', projectName);
488
+ node_fs_1.default.mkdirSync(outputDir, { recursive: true });
489
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
490
+ const outputName = opts.outputName || `voice-${timestamp}`;
491
+ const hasExtension = outputName.includes('.');
492
+ if (opts.mode === 'say') {
493
+ // macOS say mode
494
+ const textChunks = splitTextForTts(sourceText, opts.maxChars ? parseInt(opts.maxChars, 10) || null : null);
495
+ if (textChunks.length === 0) {
496
+ stderr.write('Error: No text content found for conversion.\n');
497
+ return 1;
498
+ }
499
+ // Check if `say` is available
500
+ try {
501
+ (0, node_child_process_1.execSync)('which say', { stdio: 'ignore' });
502
+ }
503
+ catch {
504
+ stderr.write("Error: macOS 'say' command not found.\n");
505
+ return 1;
506
+ }
507
+ const finalOutputName = hasExtension ? outputName : `${outputName}.aiff`;
508
+ const outputPath = node_path_1.default.join(outputDir, finalOutputName);
509
+ if (node_fs_1.default.existsSync(outputPath) && !opts.force) {
510
+ stderr.write(`Error: Output already exists: ${outputPath}. Use --force to overwrite.\n`);
511
+ return 1;
512
+ }
513
+ // Build prosody-enhanced text
514
+ const chunks = opts.noAutoProsody ? textChunks : textChunks.map(buildAutoProsodyText);
515
+ if (chunks.length === 1) {
516
+ // Single say command
517
+ const sayArgs = ['-o', outputPath];
518
+ if (opts.voice)
519
+ sayArgs.push('-v', opts.voice);
520
+ if (opts.rate)
521
+ sayArgs.push('-r', opts.rate);
522
+ const tmpFile = node_path_1.default.join(node_fs_1.default.mkdtempSync('docs-to-voice-'), 'input.txt');
523
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(tmpFile), { recursive: true });
524
+ node_fs_1.default.writeFileSync(tmpFile, chunks[0], 'utf-8');
525
+ sayArgs.push('-f', tmpFile);
526
+ try {
527
+ (0, node_child_process_1.execSync)(`say ${sayArgs.map((a) => (a.includes(' ') ? `"${a}"` : a)).join(' ')}`, {
528
+ stdio: 'ignore',
529
+ timeout: 300000,
530
+ });
531
+ }
532
+ catch (err) {
533
+ const msg = err instanceof Error ? err.message : 'unknown error';
534
+ throw new Error(`say mode failed: ${msg}`);
535
+ }
536
+ finally {
537
+ try {
538
+ node_fs_1.default.unlinkSync(tmpFile);
539
+ node_fs_1.default.rmdirSync(node_path_1.default.dirname(tmpFile));
540
+ }
541
+ catch { /* ignore */ }
542
+ }
543
+ }
544
+ else {
545
+ // Multiple chunks: generate then concat
546
+ const tempDir = node_fs_1.default.mkdtempSync('docs-to-voice-say-');
547
+ const partPaths = [];
548
+ const partExt = node_path_1.default.extname(outputPath) || '.aiff';
549
+ try {
550
+ for (let i = 0; i < chunks.length; i++) {
551
+ const partPath = node_path_1.default.join(tempDir, `part-${String(i + 1).padStart(4, '0')}${partExt}`);
552
+ const sayArgs = ['-o', partPath];
553
+ if (opts.voice)
554
+ sayArgs.push('-v', opts.voice);
555
+ if (opts.rate)
556
+ sayArgs.push('-r', opts.rate);
557
+ const tmpFile = node_path_1.default.join(tempDir, `chunk-${i}.txt`);
558
+ node_fs_1.default.writeFileSync(tmpFile, chunks[i], 'utf-8');
559
+ sayArgs.push('-f', tmpFile);
560
+ (0, node_child_process_1.execSync)(`say ${sayArgs.map((a) => (a.includes(' ') ? `"${a}"` : a)).join(' ')}`, { stdio: 'ignore', timeout: 300000 });
561
+ partPaths.push(partPath);
562
+ }
563
+ concatAudioFiles(partPaths, outputPath);
564
+ }
565
+ finally {
566
+ try {
567
+ node_fs_1.default.rmSync(tempDir, { recursive: true });
568
+ }
569
+ catch { /* ignore */ }
570
+ }
571
+ }
572
+ // Apply speech rate if requested
573
+ if (opts.speechRate) {
574
+ const rate = parseFloat(opts.speechRate);
575
+ if (rate > 0)
576
+ applySpeechRateToAudio(outputPath, rate);
577
+ }
578
+ // Write timeline files
579
+ writeTimelineFiles(sourceText, outputPath, null);
580
+ stdout.write(`${outputPath}\n`);
581
+ }
582
+ else {
583
+ // API mode
584
+ const apiKey = opts.apiKey;
585
+ if (!apiKey) {
586
+ stderr.write('Error: --api-key is required for api mode.\n');
587
+ return 1;
588
+ }
589
+ const sentences = splitSentences(sourceText);
590
+ if (sentences.length === 0) {
591
+ stderr.write('Error: No text content found for conversion.\n');
592
+ return 1;
593
+ }
594
+ const maxChars = opts.maxChars ? parseInt(opts.maxChars, 10) || null : null;
595
+ const requestItems = [];
596
+ for (let si = 0; si < sentences.length; si++) {
597
+ const sentence = sentences[si];
598
+ if (maxChars && sentence.length > maxChars) {
599
+ for (let i = 0; i < sentence.length; i += maxChars) {
600
+ requestItems.push({ sentenceIndex: si, text: sentence.slice(i, i + maxChars) });
601
+ }
602
+ }
603
+ else {
604
+ requestItems.push({ sentenceIndex: si, text: sentence });
605
+ }
606
+ }
607
+ if (requestItems.length === 0) {
608
+ stderr.write('Error: No text content found for conversion.\n');
609
+ return 1;
610
+ }
611
+ const tempDir = node_fs_1.default.mkdtempSync('docs-to-voice-api-');
612
+ const partPaths = [];
613
+ let partExt = '';
614
+ const sentenceDurations = new Array(sentences.length).fill(0);
615
+ const sentenceDurationKnown = new Array(sentences.length).fill(true);
616
+ try {
617
+ for (let i = 0; i < requestItems.length; i++) {
618
+ const item = requestItems[i];
619
+ const apiResult = await requestAlibabaCloudTTS(opts.apiEndpoint, apiKey, opts.apiModel, opts.apiVoice, item.text);
620
+ const currentExt = apiResult.audioFormat || 'wav';
621
+ if (!partExt)
622
+ partExt = currentExt;
623
+ const partPath = node_path_1.default.join(tempDir, `part-${String(i + 1).padStart(4, '0')}.${currentExt}`);
624
+ if (apiResult.audioUrl) {
625
+ await downloadBinary(apiResult.audioUrl, partPath);
626
+ }
627
+ else if (apiResult.audioData) {
628
+ node_fs_1.default.writeFileSync(partPath, Buffer.from(apiResult.audioData, 'base64'));
629
+ }
630
+ else {
631
+ throw new Error('No audio data in API response.');
632
+ }
633
+ if (!node_fs_1.default.existsSync(partPath) || node_fs_1.default.statSync(partPath).size === 0) {
634
+ throw new Error(`Failed to generate audio chunk ${i + 1}.`);
635
+ }
636
+ partPaths.push(partPath);
637
+ const partDuration = readDurationSeconds(partPath);
638
+ if (partDuration === null || partDuration <= 0) {
639
+ sentenceDurationKnown[item.sentenceIndex] = false;
640
+ }
641
+ else {
642
+ sentenceDurations[item.sentenceIndex] += partDuration;
643
+ }
644
+ }
645
+ const finalOutputName = hasExtension
646
+ ? outputName
647
+ : `${outputName}.${partExt || 'wav'}`;
648
+ const outputPath = node_path_1.default.join(outputDir, finalOutputName);
649
+ if (node_fs_1.default.existsSync(outputPath) && !opts.force) {
650
+ stderr.write(`Error: Output already exists: ${outputPath}. Use --force to overwrite.\n`);
651
+ return 1;
652
+ }
653
+ concatAudioFiles(partPaths, outputPath);
654
+ // Build timeline durations
655
+ let timelineDurations = null;
656
+ const unknownIndexes = sentenceDurationKnown
657
+ .map((known, idx) => (known ? -1 : idx))
658
+ .filter((idx) => idx >= 0);
659
+ if (unknownIndexes.length === 0 && sentenceDurations.reduce((a, b) => a + b, 0) > 0) {
660
+ timelineDurations = sentenceDurations;
661
+ }
662
+ else if (unknownIndexes.length > 0) {
663
+ const outputDuration = readDurationSeconds(outputPath);
664
+ const knownTotal = sentenceDurations.reduce((sum, val, idx) => (sentenceDurationKnown[idx] ? sum + val : sum), 0);
665
+ if (outputDuration && outputDuration > knownTotal) {
666
+ const remaining = outputDuration - knownTotal;
667
+ const unknownWeights = unknownIndexes.map((idx) => sentenceWeight(sentences[idx]));
668
+ const totalUnknownWeight = unknownWeights.reduce((a, b) => a + b, 0);
669
+ if (totalUnknownWeight > 0) {
670
+ for (let wi = 0; wi < unknownIndexes.length; wi++) {
671
+ sentenceDurations[unknownIndexes[wi]] +=
672
+ remaining * (unknownWeights[wi] / totalUnknownWeight);
673
+ }
674
+ timelineDurations = sentenceDurations;
675
+ }
676
+ }
677
+ }
678
+ // Apply speech rate if requested
679
+ if (opts.speechRate) {
680
+ const rate = parseFloat(opts.speechRate);
681
+ if (rate > 0) {
682
+ applySpeechRateToAudio(outputPath, rate);
683
+ if (timelineDurations) {
684
+ timelineDurations = timelineDurations.map((d) => d / rate);
685
+ }
686
+ }
687
+ }
688
+ writeTimelineFiles(sourceText, outputPath, timelineDurations);
689
+ stdout.write(`${outputPath}\n`);
690
+ }
691
+ finally {
692
+ try {
693
+ node_fs_1.default.rmSync(tempDir, { recursive: true });
694
+ }
695
+ catch { /* ignore */ }
696
+ }
697
+ }
698
+ return 0;
699
+ }
700
+ catch (err) {
701
+ const msg = err instanceof Error ? err.message : 'Unknown error';
702
+ stderr.write(`Error: ${msg}\n`);
703
+ return 1;
704
+ }
705
+ }