@makuraryu/yonde 0.3.3 → 0.4.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/README.md +18 -12
- package/package.json +1 -1
- package/src/audio.ts +173 -31
- package/src/main.ts +6 -6
- package/src/progress.ts +102 -0
- package/src/translate.ts +29 -22
package/README.md
CHANGED
|
@@ -7,15 +7,15 @@ Yonde(読んで)是一个配置驱动的 Bun CLI:把日文文本翻译成
|
|
|
7
7
|
需要 [Bun](https://bun.sh/) 1.3 或更新版本。生成 MP3 时还需要 `ffmpeg`;只运行翻译阶段则不需要。
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
bunx
|
|
10
|
+
bunx @makuraryu/yonde@latest init
|
|
11
11
|
|
|
12
12
|
# 默认配置从这个环境变量读取 DeepSeek API Key
|
|
13
13
|
export YONDE_API_KEY="your-api-key"
|
|
14
14
|
|
|
15
|
-
bunx
|
|
15
|
+
bunx @makuraryu/yonde@latest input.txt
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
`bunx` 会从 npm 获取最新版并直接运行,无需全局安装。也可以使用 `bunx github:Makuraryu/Yonde` 试用 GitHub 默认分支上的未发布代码。版本记录见 [GitHub Releases](https://github.com/Makuraryu/Yonde/releases)。
|
|
19
19
|
|
|
20
20
|
## 命令
|
|
21
21
|
|
|
@@ -29,21 +29,21 @@ yonde config check [--config <配置文件>]
|
|
|
29
29
|
|
|
30
30
|
```bash
|
|
31
31
|
# 翻译并生成音频
|
|
32
|
-
bunx
|
|
32
|
+
bunx @makuraryu/yonde@latest input.txt
|
|
33
33
|
|
|
34
34
|
# 只翻译,或使用已有翻译生成音频
|
|
35
|
-
bunx
|
|
36
|
-
bunx
|
|
35
|
+
bunx @makuraryu/yonde@latest input.txt --stage translate
|
|
36
|
+
bunx @makuraryu/yonde@latest input.txt --stage audio
|
|
37
37
|
|
|
38
38
|
# 指定配置和输出目录
|
|
39
|
-
bunx
|
|
39
|
+
bunx @makuraryu/yonde@latest input.txt --config ./custom.toml --output-dir ./build
|
|
40
40
|
|
|
41
41
|
# 检查最终合并后的配置
|
|
42
|
-
bunx
|
|
43
|
-
bunx
|
|
42
|
+
bunx @makuraryu/yonde@latest config check
|
|
43
|
+
bunx @makuraryu/yonde@latest config check --config ./custom.toml
|
|
44
44
|
|
|
45
45
|
# 查看完整帮助
|
|
46
|
-
bunx
|
|
46
|
+
bunx @makuraryu/yonde@latest --help
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
`init` 默认创建 `./yonde.toml`,且不会覆盖已有文件。
|
|
@@ -148,6 +148,12 @@ package_asset = "uisfx/sounds/cinematic/select.mp3"
|
|
|
148
148
|
|
|
149
149
|
翻译缓存指纹包含输入内容、语言、模型、端点、提示词版本和分句规则,但不包含 API Key。音频缓存指纹包含文本、音色、语言、语速和音调。仅调整朗读顺序时,Yonde 会复用已有语音并重新合并。
|
|
150
150
|
|
|
151
|
+
翻译、语音生成和最终 MP3 合并都会显示单行进度条,包括完成比例、数量、耗时和 ETA。非交互终端按 5% 里程碑输出,避免日志刷屏。
|
|
152
|
+
|
|
153
|
+
所有语音和分隔音效会统一为 24 kHz、单声道、96 kbps,最终 MP3 采用无损快速拼接,不再把数小时音频完整重编码。
|
|
154
|
+
|
|
155
|
+
最终合并使用状态文件保护原子写入。若进程在 ffmpeg 已完成后、最终重命名前退出,下次运行会直接恢复成品;若在合并中途退出,只会重做最终合并,已经生成的翻译和 TTS 缓存不会丢失。
|
|
156
|
+
|
|
151
157
|
## 本地开发
|
|
152
158
|
|
|
153
159
|
```bash
|
|
@@ -159,7 +165,7 @@ bun run check
|
|
|
159
165
|
bun run src/main.ts --help
|
|
160
166
|
```
|
|
161
167
|
|
|
162
|
-
## npm
|
|
168
|
+
## npm 发布
|
|
163
169
|
|
|
164
170
|
```bash
|
|
165
171
|
bun run check
|
|
@@ -168,7 +174,7 @@ npm pack --dry-run
|
|
|
168
174
|
npm publish --access public
|
|
169
175
|
```
|
|
170
176
|
|
|
171
|
-
|
|
177
|
+
发布后可通过 `bunx @makuraryu/yonde@latest` 直接运行;GitHub 包标识适合测试尚未发布的默认分支。
|
|
172
178
|
|
|
173
179
|
## License
|
|
174
180
|
|
package/package.json
CHANGED
package/src/audio.ts
CHANGED
|
@@ -1,16 +1,34 @@
|
|
|
1
1
|
import { EdgeTTS } from "node-edge-tts";
|
|
2
|
-
import {
|
|
2
|
+
import { lstat, mkdir, readdir, rename, rm } from "node:fs/promises";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { basename, dirname, join, relative } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import type { AppConfig, VoiceProfile } from "./config";
|
|
7
7
|
import type { TranslatedParagraph } from "./text";
|
|
8
8
|
import { splitForTts } from "./text";
|
|
9
|
-
import {
|
|
9
|
+
import { ProgressBar } from "./progress";
|
|
10
|
+
import { atomicWrite, readJson, writeJson } from "./state";
|
|
10
11
|
|
|
11
12
|
type AudioItem = { kind: string; text?: string; path: string };
|
|
12
13
|
type AudioSpec = { kind: string; text: string; profile: VoiceProfile };
|
|
13
14
|
type PlannedItem = { type: "audio"; spec: AudioSpec } | { type: "separator" };
|
|
15
|
+
type SynthesisResult = { path: string; cached: boolean };
|
|
16
|
+
type AudioManifest = {
|
|
17
|
+
version: 3;
|
|
18
|
+
mergeFingerprint: string;
|
|
19
|
+
profiles: AppConfig["audio"]["profiles"];
|
|
20
|
+
paragraphSequence: string[];
|
|
21
|
+
sentenceSequence: string[];
|
|
22
|
+
separator: AppConfig["audio"]["separator"];
|
|
23
|
+
items: AudioItem[];
|
|
24
|
+
};
|
|
25
|
+
type MergeState = {
|
|
26
|
+
version: 1;
|
|
27
|
+
fingerprint: string;
|
|
28
|
+
temporary: string;
|
|
29
|
+
status: "merging" | "complete";
|
|
30
|
+
expectedSeconds: number;
|
|
31
|
+
};
|
|
14
32
|
|
|
15
33
|
async function fileIsUsable(path: string): Promise<boolean> {
|
|
16
34
|
try {
|
|
@@ -29,11 +47,11 @@ function specHash(spec: AudioSpec): string {
|
|
|
29
47
|
.slice(0, 24);
|
|
30
48
|
}
|
|
31
49
|
|
|
32
|
-
async function synthesize(spec: AudioSpec, cacheDir: string, ffmpegPath: string): Promise<
|
|
50
|
+
async function synthesize(spec: AudioSpec, cacheDir: string, ffmpegPath: string): Promise<SynthesisResult> {
|
|
33
51
|
const hash = specHash(spec);
|
|
34
52
|
const safeId = spec.kind.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
35
53
|
const output = join(cacheDir, `${safeId}-${hash}.mp3`);
|
|
36
|
-
if (await fileIsUsable(output)) return output;
|
|
54
|
+
if (await fileIsUsable(output)) return { path: output, cached: true };
|
|
37
55
|
|
|
38
56
|
const temporary = `${output}.${process.pid}.${randomUUID()}.part.mp3`;
|
|
39
57
|
if (!/[\p{L}\p{N}]/u.test(spec.text)) {
|
|
@@ -43,7 +61,7 @@ async function synthesize(spec: AudioSpec, cacheDir: string, ffmpegPath: string)
|
|
|
43
61
|
]);
|
|
44
62
|
if (await silence.exited !== 0) throw new Error(`无法为纯标点片段生成静音: ${spec.text}`);
|
|
45
63
|
await rename(temporary, output);
|
|
46
|
-
return output;
|
|
64
|
+
return { path: output, cached: false };
|
|
47
65
|
}
|
|
48
66
|
for (let attempt = 1; attempt <= 6; attempt += 1) {
|
|
49
67
|
try {
|
|
@@ -60,7 +78,7 @@ async function synthesize(spec: AudioSpec, cacheDir: string, ffmpegPath: string)
|
|
|
60
78
|
await tts.ttsPromise(spec.text, temporary);
|
|
61
79
|
if (!(await fileIsUsable(temporary))) throw new Error("TTS 输出为空");
|
|
62
80
|
await rename(temporary, output);
|
|
63
|
-
return output;
|
|
81
|
+
return { path: output, cached: false };
|
|
64
82
|
} catch (error) {
|
|
65
83
|
await rm(temporary, { force: true });
|
|
66
84
|
if (attempt === 6) throw new Error(`${spec.kind} 生成失败(${spec.text.slice(0, 40)}): ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -70,21 +88,18 @@ async function synthesize(spec: AudioSpec, cacheDir: string, ffmpegPath: string)
|
|
|
70
88
|
throw new Error("无法生成语音");
|
|
71
89
|
}
|
|
72
90
|
|
|
73
|
-
async function runPool<T>(jobs: Array<() => Promise<T>>, concurrency: number): Promise<T[]> {
|
|
91
|
+
async function runPool<T>(jobs: Array<() => Promise<T>>, concurrency: number, onCompleted: (value: T) => void): Promise<T[]> {
|
|
74
92
|
const results = new Array<T>(jobs.length);
|
|
75
93
|
let next = 0;
|
|
76
|
-
let completed = 0;
|
|
77
94
|
async function worker() {
|
|
78
95
|
while (true) {
|
|
79
96
|
const index = next++;
|
|
80
97
|
if (index >= jobs.length) return;
|
|
81
98
|
results[index] = await jobs[index]();
|
|
82
|
-
|
|
83
|
-
process.stdout.write(`\r[TTS] ${completed}/${jobs.length}`);
|
|
99
|
+
onCompleted(results[index]);
|
|
84
100
|
}
|
|
85
101
|
}
|
|
86
102
|
await Promise.all(Array.from({ length: Math.min(concurrency, jobs.length) }, worker));
|
|
87
|
-
if (jobs.length) process.stdout.write("\n");
|
|
88
103
|
return results;
|
|
89
104
|
}
|
|
90
105
|
|
|
@@ -92,24 +107,123 @@ function escapeConcatPath(path: string): string {
|
|
|
92
107
|
return path.replace(/'/g, "'\\''");
|
|
93
108
|
}
|
|
94
109
|
|
|
95
|
-
|
|
110
|
+
export function audioMergeFingerprint(value: Omit<AudioManifest, "version" | "mergeFingerprint">): string {
|
|
111
|
+
return new Bun.CryptoHasher("sha256").update(JSON.stringify(value)).digest("hex");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function mergeStateCanRecover(value: unknown, fingerprint: string, outputPath: string): value is MergeState {
|
|
115
|
+
if (!value || typeof value !== "object") return false;
|
|
116
|
+
const state = value as Partial<MergeState>;
|
|
117
|
+
const prefix = `${basename(outputPath)}.`;
|
|
118
|
+
return state.version === 1
|
|
119
|
+
&& state.status === "complete"
|
|
120
|
+
&& state.fingerprint === fingerprint
|
|
121
|
+
&& typeof state.temporary === "string"
|
|
122
|
+
&& dirname(state.temporary) === dirname(outputPath)
|
|
123
|
+
&& basename(state.temporary).startsWith(prefix)
|
|
124
|
+
&& basename(state.temporary).endsWith(".tmp.mp3");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function temporaryOutputs(outputPath: string): Promise<string[]> {
|
|
128
|
+
const prefix = `${basename(outputPath)}.`;
|
|
129
|
+
return (await readdir(dirname(outputPath)))
|
|
130
|
+
.filter((entry) => entry.startsWith(prefix) && entry.endsWith(".tmp.mp3"))
|
|
131
|
+
.map((entry) => join(dirname(outputPath), entry));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function estimateDurationSeconds(items: AudioItem[]): Promise<number> {
|
|
135
|
+
const sizes = new Map<string, number>();
|
|
136
|
+
let bytes = 0;
|
|
137
|
+
for (const item of items) {
|
|
138
|
+
let size = sizes.get(item.path);
|
|
139
|
+
if (size === undefined) {
|
|
140
|
+
size = (await lstat(item.path)).size;
|
|
141
|
+
sizes.set(item.path, size);
|
|
142
|
+
}
|
|
143
|
+
bytes += size;
|
|
144
|
+
}
|
|
145
|
+
return Math.max(1, bytes * 8 / 96_000);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function recoverCompletedMerge(outputPath: string, stateDir: string, fingerprint: string): Promise<boolean> {
|
|
149
|
+
const statePath = join(stateDir, "audio-merge-state.json");
|
|
150
|
+
const state = await readJson<unknown>(statePath);
|
|
151
|
+
if (!mergeStateCanRecover(state, fingerprint, outputPath) || !(await fileIsUsable(state.temporary))) return false;
|
|
152
|
+
await rename(state.temporary, outputPath);
|
|
153
|
+
await rm(statePath, { force: true });
|
|
154
|
+
for (const stale of await temporaryOutputs(outputPath)) await rm(stale, { force: true });
|
|
155
|
+
const progress = new ProgressBar("恢复", 1);
|
|
156
|
+
progress.finish("已恢复完成的合并结果");
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function readFfmpegProgress(stream: ReadableStream<Uint8Array>, progress: ProgressBar, totalSeconds: number): Promise<void> {
|
|
161
|
+
const reader = stream.getReader();
|
|
162
|
+
const decoder = new TextDecoder();
|
|
163
|
+
let pending = "";
|
|
164
|
+
while (true) {
|
|
165
|
+
const { done, value } = await reader.read();
|
|
166
|
+
if (done) break;
|
|
167
|
+
pending += decoder.decode(value, { stream: true });
|
|
168
|
+
const lines = pending.split(/\r?\n/);
|
|
169
|
+
pending = lines.pop() ?? "";
|
|
170
|
+
for (const line of lines) {
|
|
171
|
+
if (!line.startsWith("out_time_us=")) continue;
|
|
172
|
+
const seconds = Number(line.slice("out_time_us=".length)) / 1_000_000;
|
|
173
|
+
if (Number.isFinite(seconds)) progress.update(Math.min(seconds, totalSeconds), `音频 ${Math.floor(seconds / 60)} 分钟`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function runFfmpeg(
|
|
179
|
+
items: AudioItem[],
|
|
180
|
+
outputPath: string,
|
|
181
|
+
stateDir: string,
|
|
182
|
+
ffmpegPath: string,
|
|
183
|
+
fingerprint: string,
|
|
184
|
+
): Promise<void> {
|
|
96
185
|
const listPath = join(stateDir, "concat.txt");
|
|
97
186
|
const paths = items.map((item) => relative(stateDir, item.path));
|
|
98
187
|
if (paths.some((path) => path.startsWith("..") || /[\r\n\0]/.test(path))) throw new Error("音频缓存路径超出状态目录或含控制字符");
|
|
99
188
|
await atomicWrite(listPath, paths.map((path) => `file '${escapeConcatPath(path)}'`).join("\n") + "\n");
|
|
100
189
|
const temporary = `${outputPath}.${process.pid}.${randomUUID()}.tmp.mp3`;
|
|
190
|
+
const mergeStatePath = join(stateDir, "audio-merge-state.json");
|
|
191
|
+
const expectedSeconds = await estimateDurationSeconds(items);
|
|
192
|
+
const progress = new ProgressBar("合并", expectedSeconds);
|
|
101
193
|
await rm(temporary, { force: true });
|
|
194
|
+
await writeJson(mergeStatePath, {
|
|
195
|
+
version: 1,
|
|
196
|
+
fingerprint,
|
|
197
|
+
temporary,
|
|
198
|
+
status: "merging",
|
|
199
|
+
expectedSeconds,
|
|
200
|
+
} satisfies MergeState);
|
|
102
201
|
const processResult = Bun.spawn([
|
|
103
202
|
ffmpegPath, "-hide_banner", "-loglevel", "error", "-y", "-f", "concat", "-safe", "1", "-i", listPath,
|
|
104
|
-
"-
|
|
105
|
-
|
|
106
|
-
|
|
203
|
+
"-c:a", "copy", "-progress", "pipe:1", "-nostats", temporary,
|
|
204
|
+
], { stdout: "pipe", stderr: "pipe" });
|
|
205
|
+
const progressReader = readFfmpegProgress(processResult.stdout, progress, expectedSeconds);
|
|
206
|
+
const stderrReader = new Response(processResult.stderr).text();
|
|
107
207
|
const exitCode = await processResult.exited;
|
|
208
|
+
await progressReader;
|
|
209
|
+
const stderr = await stderrReader;
|
|
108
210
|
if (exitCode !== 0) {
|
|
211
|
+
progress.fail(`退出码 ${exitCode}`);
|
|
109
212
|
await rm(temporary, { force: true });
|
|
110
|
-
|
|
213
|
+
await rm(mergeStatePath, { force: true });
|
|
214
|
+
const detail = stderr.trim().split("\n").at(-1);
|
|
215
|
+
throw new Error(`ffmpeg 合并失败,退出码 ${exitCode}${detail ? `: ${detail}` : ""}`);
|
|
111
216
|
}
|
|
217
|
+
await writeJson(mergeStatePath, {
|
|
218
|
+
version: 1,
|
|
219
|
+
fingerprint,
|
|
220
|
+
temporary,
|
|
221
|
+
status: "complete",
|
|
222
|
+
expectedSeconds,
|
|
223
|
+
} satisfies MergeState);
|
|
224
|
+
progress.finish(`${items.length} 个片段`);
|
|
112
225
|
await rename(temporary, outputPath);
|
|
226
|
+
await rm(mergeStatePath, { force: true });
|
|
113
227
|
}
|
|
114
228
|
|
|
115
229
|
function profileText(profile: VoiceProfile, source: string, target: string): string {
|
|
@@ -162,10 +276,6 @@ export async function buildAudio(
|
|
|
162
276
|
for (const entry of await readdir(cacheDir)) if (entry.endsWith(".part.mp3")) await rm(join(cacheDir, entry), { force: true });
|
|
163
277
|
const outputDir = dirname(outputPath);
|
|
164
278
|
await mkdir(outputDir, { recursive: true });
|
|
165
|
-
const stalePrefix = `${basename(outputPath)}.`;
|
|
166
|
-
for (const entry of await readdir(outputDir)) {
|
|
167
|
-
if (entry.startsWith(stalePrefix) && entry.endsWith(".tmp.mp3")) await rm(join(outputDir, entry), { force: true });
|
|
168
|
-
}
|
|
169
279
|
|
|
170
280
|
const plan = buildPlan(paragraphs, config);
|
|
171
281
|
const uniqueSpecs = new Map<string, AudioSpec>();
|
|
@@ -174,13 +284,23 @@ export async function buildAudio(
|
|
|
174
284
|
|
|
175
285
|
let separatorPath: string | undefined;
|
|
176
286
|
if (plan.some((item) => item.type === "separator")) {
|
|
177
|
-
separatorPath = join(stateDir, "separator.mp3");
|
|
287
|
+
separatorPath = join(stateDir, "separator-24khz-96k-mono.mp3");
|
|
178
288
|
if (!(await fileIsUsable(separatorPath))) {
|
|
179
289
|
const source = resolveSeparatorAsset(config.audio.separator.packageAsset);
|
|
180
290
|
if (!(await fileIsUsable(source))) throw new Error(`找不到可用的分隔音效: ${source}`);
|
|
181
|
-
const temporarySeparator = `${separatorPath}.${randomUUID()}.tmp`;
|
|
291
|
+
const temporarySeparator = `${separatorPath}.${randomUUID()}.tmp.mp3`;
|
|
182
292
|
try {
|
|
183
|
-
|
|
293
|
+
const conversion = Bun.spawn([
|
|
294
|
+
ffmpegPath, "-hide_banner", "-loglevel", "error", "-y", "-i", source,
|
|
295
|
+
"-ar", "24000", "-ac", "1", "-c:a", "libmp3lame", "-b:a", "96k", temporarySeparator,
|
|
296
|
+
], { stdout: "ignore", stderr: "pipe" });
|
|
297
|
+
const stderrReader = new Response(conversion.stderr).text();
|
|
298
|
+
const exitCode = await conversion.exited;
|
|
299
|
+
const stderr = await stderrReader;
|
|
300
|
+
if (exitCode !== 0 || !(await fileIsUsable(temporarySeparator))) {
|
|
301
|
+
const detail = stderr.trim().split("\n").at(-1);
|
|
302
|
+
throw new Error(`分隔音效标准化失败${detail ? `: ${detail}` : ""}`);
|
|
303
|
+
}
|
|
184
304
|
await rename(temporarySeparator, separatorPath);
|
|
185
305
|
} catch (error) {
|
|
186
306
|
await rm(temporarySeparator, { force: true });
|
|
@@ -190,21 +310,43 @@ export async function buildAudio(
|
|
|
190
310
|
}
|
|
191
311
|
|
|
192
312
|
const entries = [...uniqueSpecs.entries()];
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
313
|
+
const synthesisProgress = new ProgressBar("语音", entries.length);
|
|
314
|
+
let completed = 0;
|
|
315
|
+
let cached = 0;
|
|
316
|
+
let results: SynthesisResult[];
|
|
317
|
+
try {
|
|
318
|
+
results = await runPool(
|
|
319
|
+
entries.map(([, spec]) => () => synthesize(spec, cacheDir, ffmpegPath)),
|
|
320
|
+
config.audio.concurrency,
|
|
321
|
+
(result) => {
|
|
322
|
+
completed += 1;
|
|
323
|
+
if (result.cached) cached += 1;
|
|
324
|
+
if (completed < entries.length) synthesisProgress.update(completed, `缓存 ${cached} · 新生成 ${completed - cached}`);
|
|
325
|
+
},
|
|
326
|
+
);
|
|
327
|
+
synthesisProgress.finish(`缓存 ${cached} · 新生成 ${completed - cached}`);
|
|
328
|
+
} catch (error) {
|
|
329
|
+
synthesisProgress.fail(`完成 ${completed}/${entries.length}`);
|
|
330
|
+
throw error;
|
|
331
|
+
}
|
|
332
|
+
const generated = new Map(entries.map(([hash], index) => [hash, results[index].path]));
|
|
196
333
|
const items: AudioItem[] = plan.map((item) => item.type === "separator"
|
|
197
334
|
? { kind: "separator", path: separatorPath! }
|
|
198
335
|
: { kind: item.spec.kind, text: item.spec.text, path: generated.get(specHash(item.spec))! });
|
|
199
336
|
|
|
200
|
-
|
|
201
|
-
version: 2,
|
|
337
|
+
const manifestBody = {
|
|
202
338
|
profiles: config.audio.profiles,
|
|
203
339
|
paragraphSequence: config.audio.paragraphSequence,
|
|
204
340
|
sentenceSequence: config.audio.sentenceSequence,
|
|
205
341
|
separator: config.audio.separator,
|
|
206
342
|
items,
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
await
|
|
343
|
+
};
|
|
344
|
+
const mergeFingerprint = audioMergeFingerprint(manifestBody);
|
|
345
|
+
if (await recoverCompletedMerge(outputPath, stateDir, mergeFingerprint)) return;
|
|
346
|
+
const stale = await temporaryOutputs(outputPath);
|
|
347
|
+
if (stale.length) console.warn(`发现 ${stale.length} 个未完成的合并临时文件,将保留语音缓存并重新合并。`);
|
|
348
|
+
for (const path of stale) await rm(path, { force: true });
|
|
349
|
+
const manifest: AudioManifest = { version: 3, mergeFingerprint, ...manifestBody };
|
|
350
|
+
await writeJson(join(stateDir, "audio-manifest.json"), manifest);
|
|
351
|
+
await runFfmpeg(items, outputPath, stateDir, ffmpegPath, mergeFingerprint);
|
|
210
352
|
}
|
package/src/main.ts
CHANGED
|
@@ -11,7 +11,7 @@ type Stage = "translate" | "audio" | "all";
|
|
|
11
11
|
type RunArgs = { command: "run"; input: string; stage: Stage; outputDir?: string; configPath?: string };
|
|
12
12
|
type CliArgs = RunArgs | { command: "init"; path: string } | { command: "config-check"; configPath?: string } | { command: "help" } | { command: "version" };
|
|
13
13
|
|
|
14
|
-
const VERSION = "0.
|
|
14
|
+
const VERSION = "0.4.0";
|
|
15
15
|
|
|
16
16
|
export const HELP_TEXT = `Yonde ${VERSION} — 配置驱动的双语听力材料生成器
|
|
17
17
|
|
|
@@ -37,11 +37,11 @@ export const HELP_TEXT = `Yonde ${VERSION} — 配置驱动的双语听力材料
|
|
|
37
37
|
> ~/.config/yonde/config.toml > 内置默认值
|
|
38
38
|
|
|
39
39
|
示例:
|
|
40
|
-
bunx
|
|
41
|
-
bunx
|
|
42
|
-
bunx
|
|
43
|
-
bunx
|
|
44
|
-
bunx
|
|
40
|
+
bunx @makuraryu/yonde@latest input.txt
|
|
41
|
+
bunx @makuraryu/yonde@latest input.txt --stage translate
|
|
42
|
+
bunx @makuraryu/yonde@latest input.txt --config ./yonde.toml
|
|
43
|
+
bunx @makuraryu/yonde@latest config check
|
|
44
|
+
bunx @makuraryu/yonde@latest init
|
|
45
45
|
|
|
46
46
|
环境变量:
|
|
47
47
|
YONDE_API_KEY 默认翻译 API Key
|
package/src/progress.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
type ProgressFormat = {
|
|
2
|
+
label: string;
|
|
3
|
+
current: number;
|
|
4
|
+
total: number;
|
|
5
|
+
elapsedMs: number;
|
|
6
|
+
detail?: string;
|
|
7
|
+
done?: boolean;
|
|
8
|
+
failed?: boolean;
|
|
9
|
+
barWidth?: number;
|
|
10
|
+
color?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function duration(seconds: number): string {
|
|
14
|
+
if (!Number.isFinite(seconds) || seconds < 0) return "--:--";
|
|
15
|
+
const value = Math.round(seconds);
|
|
16
|
+
const hours = Math.floor(value / 3600);
|
|
17
|
+
const minutes = Math.floor((value % 3600) / 60);
|
|
18
|
+
const remainder = value % 60;
|
|
19
|
+
return hours > 0
|
|
20
|
+
? `${hours}:${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`
|
|
21
|
+
: `${minutes}:${String(remainder).padStart(2, "0")}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function paint(value: string, code: number, enabled: boolean): string {
|
|
25
|
+
return enabled ? `\u001b[${code}m${value}\u001b[0m` : value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function formatProgress(options: ProgressFormat): string {
|
|
29
|
+
const total = Math.max(0, options.total);
|
|
30
|
+
const current = Math.max(0, Math.min(options.current, total || options.current));
|
|
31
|
+
const ratio = total > 0 ? Math.min(1, current / total) : 1;
|
|
32
|
+
const width = Math.max(8, options.barWidth ?? 24);
|
|
33
|
+
const filled = Math.min(width, Math.floor(ratio * width));
|
|
34
|
+
const head = filled < width && current > 0 ? "╸" : "";
|
|
35
|
+
const bar = "━".repeat(filled) + head + "─".repeat(Math.max(0, width - filled - head.length));
|
|
36
|
+
const percent = `${Math.round(ratio * 100)}%`.padStart(4);
|
|
37
|
+
const count = total > 0 ? `${Math.round(current)}/${Math.round(total)}` : `${Math.round(current)}`;
|
|
38
|
+
const elapsed = duration(options.elapsedMs / 1000);
|
|
39
|
+
const etaSeconds = current > 0 && current < total ? (options.elapsedMs / 1000) * (total - current) / current : 0;
|
|
40
|
+
const eta = current > 0 && current < total ? ` 余 ${duration(etaSeconds)}` : "";
|
|
41
|
+
const icon = options.failed ? paint("✗", 31, Boolean(options.color)) : options.done ? paint("✓", 32, Boolean(options.color)) : paint("●", 36, Boolean(options.color));
|
|
42
|
+
const coloredBar = paint(bar, options.failed ? 31 : options.done ? 32 : 36, Boolean(options.color));
|
|
43
|
+
const detail = options.detail ? ` ${options.detail}` : "";
|
|
44
|
+
return `${icon} ${options.label.padEnd(4, " ")} ${coloredBar} ${percent} ${count} ${elapsed}${eta}${detail}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class ProgressBar {
|
|
48
|
+
private current: number;
|
|
49
|
+
private readonly startedAt = Date.now();
|
|
50
|
+
private lastRenderedAt = 0;
|
|
51
|
+
private lastBucket = -1;
|
|
52
|
+
|
|
53
|
+
constructor(
|
|
54
|
+
private readonly label: string,
|
|
55
|
+
private readonly total: number,
|
|
56
|
+
initial = 0,
|
|
57
|
+
private readonly stream: NodeJS.WriteStream = process.stdout,
|
|
58
|
+
) {
|
|
59
|
+
this.current = initial;
|
|
60
|
+
this.render(undefined, true);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
update(current: number, detail?: string, force = false): void {
|
|
64
|
+
this.current = current;
|
|
65
|
+
this.render(detail, force);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
finish(detail?: string): void {
|
|
69
|
+
this.current = this.total;
|
|
70
|
+
this.render(detail, true, true);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
fail(detail?: string): void {
|
|
74
|
+
this.render(detail, true, false, true);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private render(detail?: string, force = false, done = false, failed = false): void {
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
const ratio = this.total > 0 ? Math.min(1, this.current / this.total) : 1;
|
|
80
|
+
const bucket = Math.floor(ratio * 20);
|
|
81
|
+
const interactive = Boolean(this.stream.isTTY);
|
|
82
|
+
if (!force && interactive && now - this.lastRenderedAt < 80) return;
|
|
83
|
+
if (!force && !interactive && bucket === this.lastBucket) return;
|
|
84
|
+
this.lastRenderedAt = now;
|
|
85
|
+
this.lastBucket = bucket;
|
|
86
|
+
const terminalWidth = interactive ? this.stream.columns ?? 100 : 100;
|
|
87
|
+
const barWidth = Math.max(10, Math.min(30, terminalWidth - 66));
|
|
88
|
+
const line = formatProgress({
|
|
89
|
+
label: this.label,
|
|
90
|
+
current: this.current,
|
|
91
|
+
total: this.total,
|
|
92
|
+
elapsedMs: now - this.startedAt,
|
|
93
|
+
detail,
|
|
94
|
+
done,
|
|
95
|
+
failed,
|
|
96
|
+
barWidth,
|
|
97
|
+
color: interactive && !process.env.NO_COLOR,
|
|
98
|
+
});
|
|
99
|
+
if (interactive) this.stream.write(`\r\u001b[2K${line}${done || failed ? "\n" : ""}`);
|
|
100
|
+
else this.stream.write(`${line}\n`);
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/translate.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { resolveApiKey } from "./config";
|
|
|
3
3
|
import type { GlossaryEntry, TranslationState } from "./state";
|
|
4
4
|
import { writeJson } from "./state";
|
|
5
5
|
import type { Paragraph, TranslatedParagraph } from "./text";
|
|
6
|
+
import { ProgressBar } from "./progress";
|
|
6
7
|
|
|
7
8
|
type ChatCompletionResponse = {
|
|
8
9
|
choices?: Array<{ message?: { content?: string } }>;
|
|
@@ -145,31 +146,37 @@ async function translateOne(
|
|
|
145
146
|
export async function translateAll(state: TranslationState, statePath: string, config: AppConfig): Promise<TranslationState> {
|
|
146
147
|
const apiKey = resolveApiKey(config);
|
|
147
148
|
if (!apiKey) throw new Error(`缺少 API Key;请设置环境变量 ${config.translation.api.apiKeyEnv},或在 translation.api.api_key 中配置`);
|
|
149
|
+
const progress = new ProgressBar("翻译", state.paragraphs.length, state.translated.length);
|
|
148
150
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
151
|
+
try {
|
|
152
|
+
for (let index = state.translated.length; index < state.paragraphs.length; index += 1) {
|
|
153
|
+
const paragraph = state.paragraphs[index];
|
|
154
|
+
let result: ModelTranslation | undefined;
|
|
155
|
+
let lastError: unknown;
|
|
156
|
+
for (let attempt = 1; attempt <= 4; attempt += 1) {
|
|
157
|
+
try {
|
|
158
|
+
result = await translateOne(paragraph, state.translated, state.glossary, apiKey, config);
|
|
159
|
+
break;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
lastError = error;
|
|
162
|
+
if (attempt < 4) {
|
|
163
|
+
progress.update(index, `第 ${index + 1} 段 · 重试 ${attempt}/3`, true);
|
|
164
|
+
await Bun.sleep(1000 * 2 ** (attempt - 1));
|
|
165
|
+
}
|
|
163
166
|
}
|
|
164
167
|
}
|
|
168
|
+
if (!result) throw lastError;
|
|
169
|
+
state.translated.push({ ...paragraph, translations: result.translations.map((item) => item.trim()) });
|
|
170
|
+
state.glossary = mergeGlossary(state.glossary, result.glossaryUpdates ?? []);
|
|
171
|
+
state.complete = state.translated.length === state.paragraphs.length;
|
|
172
|
+
state.updatedAt = new Date().toISOString();
|
|
173
|
+
await writeJson(statePath, state);
|
|
174
|
+
if (!state.complete) progress.update(state.translated.length, `术语 ${state.glossary.length}`);
|
|
165
175
|
}
|
|
166
|
-
|
|
167
|
-
state
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
await writeJson(statePath, state);
|
|
172
|
-
console.log("完成");
|
|
176
|
+
progress.finish(state.translated.length ? `术语 ${state.glossary.length}` : "无段落");
|
|
177
|
+
return state;
|
|
178
|
+
} catch (error) {
|
|
179
|
+
progress.fail(`停在 ${state.translated.length}/${state.paragraphs.length}`);
|
|
180
|
+
throw error;
|
|
173
181
|
}
|
|
174
|
-
return state;
|
|
175
182
|
}
|