@laitszkin/apollo-toolkit 3.13.2 → 3.14.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/AGENTS.md +7 -7
- package/CHANGELOG.md +27 -0
- package/CLAUDE.md +8 -8
- package/analyse-app-logs/SKILL.md +3 -3
- package/bin/apollo-toolkit.ts +7 -0
- package/codex/codex-memory-manager/SKILL.md +2 -2
- package/codex/learn-skill-from-conversations/SKILL.md +3 -3
- package/dist/bin/apollo-toolkit.d.ts +2 -0
- package/dist/bin/apollo-toolkit.js +7 -0
- package/dist/lib/cli.d.ts +41 -0
- package/dist/lib/cli.js +655 -0
- package/dist/lib/installer.d.ts +59 -0
- package/dist/lib/installer.js +404 -0
- package/dist/lib/tool-runner.d.ts +19 -0
- package/dist/lib/tool-runner.js +536 -0
- package/dist/lib/tools/architecture.d.ts +2 -0
- package/dist/lib/tools/architecture.js +34 -0
- package/dist/lib/tools/create-specs.d.ts +2 -0
- package/dist/lib/tools/create-specs.js +175 -0
- package/dist/lib/tools/docs-to-voice.d.ts +2 -0
- package/dist/lib/tools/docs-to-voice.js +705 -0
- package/dist/lib/tools/enforce-video-aspect-ratio.d.ts +2 -0
- package/dist/lib/tools/enforce-video-aspect-ratio.js +312 -0
- package/dist/lib/tools/extract-conversations.d.ts +2 -0
- package/dist/lib/tools/extract-conversations.js +105 -0
- package/dist/lib/tools/extract-pdf-text.d.ts +2 -0
- package/dist/lib/tools/extract-pdf-text.js +92 -0
- package/dist/lib/tools/filter-logs.d.ts +2 -0
- package/dist/lib/tools/filter-logs.js +94 -0
- package/dist/lib/tools/find-github-issues.d.ts +2 -0
- package/dist/lib/tools/find-github-issues.js +176 -0
- package/dist/lib/tools/generate-storyboard-images.d.ts +2 -0
- package/dist/lib/tools/generate-storyboard-images.js +419 -0
- package/dist/lib/tools/log-cli-utils.d.ts +35 -0
- package/dist/lib/tools/log-cli-utils.js +233 -0
- package/dist/lib/tools/open-github-issue.d.ts +2 -0
- package/dist/lib/tools/open-github-issue.js +750 -0
- package/dist/lib/tools/read-github-issue.d.ts +2 -0
- package/dist/lib/tools/read-github-issue.js +134 -0
- package/dist/lib/tools/render-error-book.d.ts +2 -0
- package/dist/lib/tools/render-error-book.js +265 -0
- package/dist/lib/tools/render-katex.d.ts +2 -0
- package/dist/lib/tools/render-katex.js +294 -0
- package/dist/lib/tools/review-threads.d.ts +2 -0
- package/dist/lib/tools/review-threads.js +491 -0
- package/dist/lib/tools/search-logs.d.ts +2 -0
- package/dist/lib/tools/search-logs.js +164 -0
- package/dist/lib/tools/sync-memory-index.d.ts +2 -0
- package/dist/lib/tools/sync-memory-index.js +113 -0
- package/dist/lib/tools/validate-openai-agent-config.d.ts +2 -0
- package/dist/lib/tools/validate-openai-agent-config.js +184 -0
- package/dist/lib/tools/validate-skill-frontmatter.d.ts +2 -0
- package/dist/lib/tools/validate-skill-frontmatter.js +118 -0
- package/dist/lib/types.d.ts +82 -0
- package/dist/lib/types.js +2 -0
- package/dist/lib/updater.d.ts +34 -0
- package/dist/lib/updater.js +112 -0
- package/dist/lib/utils/format.d.ts +2 -0
- package/dist/lib/utils/format.js +6 -0
- package/dist/lib/utils/terminal.d.ts +12 -0
- package/dist/lib/utils/terminal.js +26 -0
- package/docs-to-voice/SKILL.md +0 -1
- package/generate-spec/SKILL.md +1 -1
- package/katex/SKILL.md +1 -2
- package/lib/cli.ts +780 -0
- package/lib/installer.ts +466 -0
- package/lib/tool-runner.ts +561 -0
- package/lib/tools/architecture.ts +34 -0
- package/lib/tools/create-specs.ts +204 -0
- package/lib/tools/docs-to-voice.ts +799 -0
- package/lib/tools/enforce-video-aspect-ratio.ts +368 -0
- package/lib/tools/extract-conversations.ts +114 -0
- package/lib/tools/extract-pdf-text.ts +99 -0
- package/lib/tools/filter-logs.ts +118 -0
- package/lib/tools/find-github-issues.ts +211 -0
- package/lib/tools/generate-storyboard-images.ts +455 -0
- package/lib/tools/log-cli-utils.ts +262 -0
- package/lib/tools/open-github-issue.ts +930 -0
- package/lib/tools/read-github-issue.ts +179 -0
- package/lib/tools/render-error-book.ts +300 -0
- package/lib/tools/render-katex.ts +325 -0
- package/lib/tools/review-threads.ts +590 -0
- package/lib/tools/search-logs.ts +200 -0
- package/lib/tools/sync-memory-index.ts +114 -0
- package/lib/tools/validate-openai-agent-config.ts +209 -0
- package/lib/tools/validate-skill-frontmatter.ts +124 -0
- package/lib/types.ts +90 -0
- package/lib/updater.ts +165 -0
- package/lib/utils/format.ts +7 -0
- package/lib/utils/terminal.ts +22 -0
- package/open-github-issue/SKILL.md +2 -2
- package/optimise-skill/SKILL.md +1 -1
- package/package.json +13 -4
- package/resources/project-architecture/assets/architecture.css +764 -0
- package/resources/project-architecture/assets/viewer.client.js +144 -0
- package/resources/project-architecture/index.html +42 -0
- package/review-spec-related-changes/SKILL.md +1 -1
- package/solve-issues-found-during-review/SKILL.md +2 -1
- package/tsconfig.json +28 -0
- package/analyse-app-logs/scripts/__pycache__/filter_logs_by_time.cpython-312.pyc +0 -0
- package/analyse-app-logs/scripts/__pycache__/log_cli_utils.cpython-312.pyc +0 -0
- package/analyse-app-logs/scripts/__pycache__/search_logs.cpython-312.pyc +0 -0
- package/analyse-app-logs/scripts/filter_logs_by_time.py +0 -64
- package/analyse-app-logs/scripts/log_cli_utils.py +0 -112
- package/analyse-app-logs/scripts/search_logs.py +0 -137
- package/analyse-app-logs/tests/test_filter_logs_by_time.py +0 -95
- package/analyse-app-logs/tests/test_search_logs.py +0 -100
- package/codex/codex-memory-manager/scripts/extract_recent_conversations.py +0 -369
- package/codex/codex-memory-manager/scripts/sync_memory_index.py +0 -130
- package/codex/codex-memory-manager/tests/test_extract_recent_conversations.py +0 -177
- package/codex/codex-memory-manager/tests/test_memory_template.py +0 -37
- package/codex/codex-memory-manager/tests/test_sync_memory_index.py +0 -84
- package/codex/learn-skill-from-conversations/scripts/extract_recent_conversations.py +0 -369
- package/codex/learn-skill-from-conversations/tests/test_extract_recent_conversations.py +0 -177
- package/docs-to-voice/scripts/__pycache__/docs_to_voice.cpython-312.pyc +0 -0
- package/docs-to-voice/scripts/docs_to_voice.py +0 -1385
- package/docs-to-voice/scripts/docs_to_voice.sh +0 -11
- package/docs-to-voice/tests/test_docs_to_voice_api_max_chars.py +0 -210
- package/docs-to-voice/tests/test_docs_to_voice_sentence_timeline.py +0 -115
- package/docs-to-voice/tests/test_docs_to_voice_settings.py +0 -43
- package/docs-to-voice/tests/test_docs_to_voice_shell_wrapper.py +0 -51
- package/docs-to-voice/tests/test_docs_to_voice_speech_rate.py +0 -57
- package/generate-spec/scripts/__pycache__/create-specscpython-312.pyc +0 -0
- package/generate-spec/scripts/create-specs +0 -215
- package/generate-spec/tests/test_create_specs.py +0 -200
- package/init-project-html/scripts/architecture-bootstrap-render.js +0 -16
- package/init-project-html/scripts/architecture.js +0 -296
- package/katex/scripts/__pycache__/render_katex.cpython-312.pyc +0 -0
- package/katex/scripts/render_katex.py +0 -247
- package/katex/scripts/render_katex.sh +0 -11
- package/katex/tests/test_render_katex.py +0 -174
- package/learning-error-book/scripts/render_error_book_json_to_pdf.py +0 -590
- package/learning-error-book/tests/test_render_error_book_json_to_pdf.py +0 -134
- package/open-github-issue/scripts/__pycache__/open_github_issue.cpython-312.pyc +0 -0
- package/open-github-issue/scripts/open_github_issue.py +0 -705
- package/open-github-issue/tests/test_open_github_issue.py +0 -381
- package/openai-text-to-image-storyboard/scripts/generate_storyboard_images.py +0 -763
- package/openai-text-to-image-storyboard/tests/test_generate_storyboard_images.py +0 -177
- package/read-github-issue/scripts/__pycache__/find_issues.cpython-312.pyc +0 -0
- package/read-github-issue/scripts/__pycache__/read_issue.cpython-312.pyc +0 -0
- package/read-github-issue/scripts/find_issues.py +0 -148
- package/read-github-issue/scripts/read_issue.py +0 -108
- package/read-github-issue/tests/test_find_issues.py +0 -127
- package/read-github-issue/tests/test_read_issue.py +0 -109
- package/resolve-review-comments/scripts/__pycache__/review_threads.cpython-312.pyc +0 -0
- package/resolve-review-comments/scripts/review_threads.py +0 -425
- package/resolve-review-comments/tests/test_review_threads.py +0 -74
- package/scripts/validate_openai_agent_config.py +0 -209
- package/scripts/validate_skill_frontmatter.py +0 -131
- package/text-to-short-video/scripts/__pycache__/enforce_video_aspect_ratio.cpython-312.pyc +0 -0
- package/text-to-short-video/scripts/enforce_video_aspect_ratio.py +0 -350
- package/text-to-short-video/tests/test_enforce_video_aspect_ratio.py +0 -194
- package/weekly-financial-event-report/scripts/extract_pdf_text_pdfkit.swift +0 -99
- 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
|
+
}
|