@makuraryu/yonde 0.3.3 → 0.4.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.
- package/README.md +20 -12
- package/package.json +1 -1
- package/src/audio.ts +183 -32
- 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,14 @@ package_asset = "uisfx/sounds/cinematic/select.mp3"
|
|
|
148
148
|
|
|
149
149
|
翻译缓存指纹包含输入内容、语言、模型、端点、提示词版本和分句规则,但不包含 API Key。音频缓存指纹包含文本、音色、语言、语速和音调。仅调整朗读顺序时,Yonde 会复用已有语音并重新合并。
|
|
150
150
|
|
|
151
|
+
缓存文件采用原子写入;命中缓存时只检查文件元数据,避免 iCloud 为数千个小文件执行随机头部读取。最终拼接再按朗读顺序连续读取音频内容。
|
|
152
|
+
|
|
153
|
+
翻译、语音生成和最终 MP3 合并都会显示单行进度条,包括完成比例、数量、耗时和 ETA。非交互终端按 5% 里程碑输出,避免日志刷屏。
|
|
154
|
+
|
|
155
|
+
所有语音和分隔音效会统一为 24 kHz、单声道、96 kbps,最终 MP3 采用无损快速拼接,不再把数小时音频完整重编码。
|
|
156
|
+
|
|
157
|
+
最终合并使用状态文件保护原子写入。若进程在 ffmpeg 已完成后、最终重命名前退出,下次运行会直接恢复成品;若在合并中途退出,只会重做最终合并,已经生成的翻译和 TTS 缓存不会丢失。
|
|
158
|
+
|
|
151
159
|
## 本地开发
|
|
152
160
|
|
|
153
161
|
```bash
|
|
@@ -159,7 +167,7 @@ bun run check
|
|
|
159
167
|
bun run src/main.ts --help
|
|
160
168
|
```
|
|
161
169
|
|
|
162
|
-
## npm
|
|
170
|
+
## npm 发布
|
|
163
171
|
|
|
164
172
|
```bash
|
|
165
173
|
bun run check
|
|
@@ -168,7 +176,7 @@ npm pack --dry-run
|
|
|
168
176
|
npm publish --access public
|
|
169
177
|
```
|
|
170
178
|
|
|
171
|
-
|
|
179
|
+
发布后可通过 `bunx @makuraryu/yonde@latest` 直接运行;GitHub 包标识适合测试尚未发布的默认分支。
|
|
172
180
|
|
|
173
181
|
## License
|
|
174
182
|
|
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,18 @@ function specHash(spec: AudioSpec): string {
|
|
|
29
47
|
.slice(0, 24);
|
|
30
48
|
}
|
|
31
49
|
|
|
32
|
-
async function synthesize(
|
|
50
|
+
async function synthesize(
|
|
51
|
+
spec: AudioSpec,
|
|
52
|
+
cacheDir: string,
|
|
53
|
+
ffmpegPath: string,
|
|
54
|
+
existingCacheNames: ReadonlySet<string>,
|
|
55
|
+
): Promise<SynthesisResult> {
|
|
33
56
|
const hash = specHash(spec);
|
|
34
57
|
const safeId = spec.kind.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
35
58
|
const output = join(cacheDir, `${safeId}-${hash}.mp3`);
|
|
36
|
-
|
|
59
|
+
// Cache files are atomically renamed after a successful synthesis. A single
|
|
60
|
+
// directory listing avoids thousands of slow per-file metadata reads on iCloud.
|
|
61
|
+
if (existingCacheNames.has(basename(output))) return { path: output, cached: true };
|
|
37
62
|
|
|
38
63
|
const temporary = `${output}.${process.pid}.${randomUUID()}.part.mp3`;
|
|
39
64
|
if (!/[\p{L}\p{N}]/u.test(spec.text)) {
|
|
@@ -43,7 +68,7 @@ async function synthesize(spec: AudioSpec, cacheDir: string, ffmpegPath: string)
|
|
|
43
68
|
]);
|
|
44
69
|
if (await silence.exited !== 0) throw new Error(`无法为纯标点片段生成静音: ${spec.text}`);
|
|
45
70
|
await rename(temporary, output);
|
|
46
|
-
return output;
|
|
71
|
+
return { path: output, cached: false };
|
|
47
72
|
}
|
|
48
73
|
for (let attempt = 1; attempt <= 6; attempt += 1) {
|
|
49
74
|
try {
|
|
@@ -60,7 +85,7 @@ async function synthesize(spec: AudioSpec, cacheDir: string, ffmpegPath: string)
|
|
|
60
85
|
await tts.ttsPromise(spec.text, temporary);
|
|
61
86
|
if (!(await fileIsUsable(temporary))) throw new Error("TTS 输出为空");
|
|
62
87
|
await rename(temporary, output);
|
|
63
|
-
return output;
|
|
88
|
+
return { path: output, cached: false };
|
|
64
89
|
} catch (error) {
|
|
65
90
|
await rm(temporary, { force: true });
|
|
66
91
|
if (attempt === 6) throw new Error(`${spec.kind} 生成失败(${spec.text.slice(0, 40)}): ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -70,21 +95,18 @@ async function synthesize(spec: AudioSpec, cacheDir: string, ffmpegPath: string)
|
|
|
70
95
|
throw new Error("无法生成语音");
|
|
71
96
|
}
|
|
72
97
|
|
|
73
|
-
async function runPool<T>(jobs: Array<() => Promise<T>>, concurrency: number): Promise<T[]> {
|
|
98
|
+
async function runPool<T>(jobs: Array<() => Promise<T>>, concurrency: number, onCompleted: (value: T) => void): Promise<T[]> {
|
|
74
99
|
const results = new Array<T>(jobs.length);
|
|
75
100
|
let next = 0;
|
|
76
|
-
let completed = 0;
|
|
77
101
|
async function worker() {
|
|
78
102
|
while (true) {
|
|
79
103
|
const index = next++;
|
|
80
104
|
if (index >= jobs.length) return;
|
|
81
105
|
results[index] = await jobs[index]();
|
|
82
|
-
|
|
83
|
-
process.stdout.write(`\r[TTS] ${completed}/${jobs.length}`);
|
|
106
|
+
onCompleted(results[index]);
|
|
84
107
|
}
|
|
85
108
|
}
|
|
86
109
|
await Promise.all(Array.from({ length: Math.min(concurrency, jobs.length) }, worker));
|
|
87
|
-
if (jobs.length) process.stdout.write("\n");
|
|
88
110
|
return results;
|
|
89
111
|
}
|
|
90
112
|
|
|
@@ -92,24 +114,123 @@ function escapeConcatPath(path: string): string {
|
|
|
92
114
|
return path.replace(/'/g, "'\\''");
|
|
93
115
|
}
|
|
94
116
|
|
|
95
|
-
|
|
117
|
+
export function audioMergeFingerprint(value: Omit<AudioManifest, "version" | "mergeFingerprint">): string {
|
|
118
|
+
return new Bun.CryptoHasher("sha256").update(JSON.stringify(value)).digest("hex");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function mergeStateCanRecover(value: unknown, fingerprint: string, outputPath: string): value is MergeState {
|
|
122
|
+
if (!value || typeof value !== "object") return false;
|
|
123
|
+
const state = value as Partial<MergeState>;
|
|
124
|
+
const prefix = `${basename(outputPath)}.`;
|
|
125
|
+
return state.version === 1
|
|
126
|
+
&& state.status === "complete"
|
|
127
|
+
&& state.fingerprint === fingerprint
|
|
128
|
+
&& typeof state.temporary === "string"
|
|
129
|
+
&& dirname(state.temporary) === dirname(outputPath)
|
|
130
|
+
&& basename(state.temporary).startsWith(prefix)
|
|
131
|
+
&& basename(state.temporary).endsWith(".tmp.mp3");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function temporaryOutputs(outputPath: string): Promise<string[]> {
|
|
135
|
+
const prefix = `${basename(outputPath)}.`;
|
|
136
|
+
return (await readdir(dirname(outputPath)))
|
|
137
|
+
.filter((entry) => entry.startsWith(prefix) && entry.endsWith(".tmp.mp3"))
|
|
138
|
+
.map((entry) => join(dirname(outputPath), entry));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function estimateDurationSeconds(items: AudioItem[]): Promise<number> {
|
|
142
|
+
const sizes = new Map<string, number>();
|
|
143
|
+
let bytes = 0;
|
|
144
|
+
for (const item of items) {
|
|
145
|
+
let size = sizes.get(item.path);
|
|
146
|
+
if (size === undefined) {
|
|
147
|
+
size = (await lstat(item.path)).size;
|
|
148
|
+
sizes.set(item.path, size);
|
|
149
|
+
}
|
|
150
|
+
bytes += size;
|
|
151
|
+
}
|
|
152
|
+
return Math.max(1, bytes * 8 / 96_000);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function recoverCompletedMerge(outputPath: string, stateDir: string, fingerprint: string): Promise<boolean> {
|
|
156
|
+
const statePath = join(stateDir, "audio-merge-state.json");
|
|
157
|
+
const state = await readJson<unknown>(statePath);
|
|
158
|
+
if (!mergeStateCanRecover(state, fingerprint, outputPath) || !(await fileIsUsable(state.temporary))) return false;
|
|
159
|
+
await rename(state.temporary, outputPath);
|
|
160
|
+
await rm(statePath, { force: true });
|
|
161
|
+
for (const stale of await temporaryOutputs(outputPath)) await rm(stale, { force: true });
|
|
162
|
+
const progress = new ProgressBar("恢复", 1);
|
|
163
|
+
progress.finish("已恢复完成的合并结果");
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function readFfmpegProgress(stream: ReadableStream<Uint8Array>, progress: ProgressBar, totalSeconds: number): Promise<void> {
|
|
168
|
+
const reader = stream.getReader();
|
|
169
|
+
const decoder = new TextDecoder();
|
|
170
|
+
let pending = "";
|
|
171
|
+
while (true) {
|
|
172
|
+
const { done, value } = await reader.read();
|
|
173
|
+
if (done) break;
|
|
174
|
+
pending += decoder.decode(value, { stream: true });
|
|
175
|
+
const lines = pending.split(/\r?\n/);
|
|
176
|
+
pending = lines.pop() ?? "";
|
|
177
|
+
for (const line of lines) {
|
|
178
|
+
if (!line.startsWith("out_time_us=")) continue;
|
|
179
|
+
const seconds = Number(line.slice("out_time_us=".length)) / 1_000_000;
|
|
180
|
+
if (Number.isFinite(seconds)) progress.update(Math.min(seconds, totalSeconds), `音频 ${Math.floor(seconds / 60)} 分钟`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function runFfmpeg(
|
|
186
|
+
items: AudioItem[],
|
|
187
|
+
outputPath: string,
|
|
188
|
+
stateDir: string,
|
|
189
|
+
ffmpegPath: string,
|
|
190
|
+
fingerprint: string,
|
|
191
|
+
): Promise<void> {
|
|
96
192
|
const listPath = join(stateDir, "concat.txt");
|
|
97
193
|
const paths = items.map((item) => relative(stateDir, item.path));
|
|
98
194
|
if (paths.some((path) => path.startsWith("..") || /[\r\n\0]/.test(path))) throw new Error("音频缓存路径超出状态目录或含控制字符");
|
|
99
195
|
await atomicWrite(listPath, paths.map((path) => `file '${escapeConcatPath(path)}'`).join("\n") + "\n");
|
|
100
196
|
const temporary = `${outputPath}.${process.pid}.${randomUUID()}.tmp.mp3`;
|
|
197
|
+
const mergeStatePath = join(stateDir, "audio-merge-state.json");
|
|
198
|
+
const expectedSeconds = await estimateDurationSeconds(items);
|
|
199
|
+
const progress = new ProgressBar("合并", expectedSeconds);
|
|
101
200
|
await rm(temporary, { force: true });
|
|
201
|
+
await writeJson(mergeStatePath, {
|
|
202
|
+
version: 1,
|
|
203
|
+
fingerprint,
|
|
204
|
+
temporary,
|
|
205
|
+
status: "merging",
|
|
206
|
+
expectedSeconds,
|
|
207
|
+
} satisfies MergeState);
|
|
102
208
|
const processResult = Bun.spawn([
|
|
103
209
|
ffmpegPath, "-hide_banner", "-loglevel", "error", "-y", "-f", "concat", "-safe", "1", "-i", listPath,
|
|
104
|
-
"-
|
|
105
|
-
|
|
106
|
-
|
|
210
|
+
"-c:a", "copy", "-progress", "pipe:1", "-nostats", temporary,
|
|
211
|
+
], { stdout: "pipe", stderr: "pipe" });
|
|
212
|
+
const progressReader = readFfmpegProgress(processResult.stdout, progress, expectedSeconds);
|
|
213
|
+
const stderrReader = new Response(processResult.stderr).text();
|
|
107
214
|
const exitCode = await processResult.exited;
|
|
215
|
+
await progressReader;
|
|
216
|
+
const stderr = await stderrReader;
|
|
108
217
|
if (exitCode !== 0) {
|
|
218
|
+
progress.fail(`退出码 ${exitCode}`);
|
|
109
219
|
await rm(temporary, { force: true });
|
|
110
|
-
|
|
220
|
+
await rm(mergeStatePath, { force: true });
|
|
221
|
+
const detail = stderr.trim().split("\n").at(-1);
|
|
222
|
+
throw new Error(`ffmpeg 合并失败,退出码 ${exitCode}${detail ? `: ${detail}` : ""}`);
|
|
111
223
|
}
|
|
224
|
+
await writeJson(mergeStatePath, {
|
|
225
|
+
version: 1,
|
|
226
|
+
fingerprint,
|
|
227
|
+
temporary,
|
|
228
|
+
status: "complete",
|
|
229
|
+
expectedSeconds,
|
|
230
|
+
} satisfies MergeState);
|
|
231
|
+
progress.finish(`${items.length} 个片段`);
|
|
112
232
|
await rename(temporary, outputPath);
|
|
233
|
+
await rm(mergeStatePath, { force: true });
|
|
113
234
|
}
|
|
114
235
|
|
|
115
236
|
function profileText(profile: VoiceProfile, source: string, target: string): string {
|
|
@@ -159,13 +280,11 @@ export async function buildAudio(
|
|
|
159
280
|
if (!ffmpegPath) throw new Error("找不到 ffmpeg;请先安装 ffmpeg,或仅运行 --stage translate");
|
|
160
281
|
const cacheDir = join(stateDir, "audio-cache");
|
|
161
282
|
await mkdir(cacheDir, { recursive: true });
|
|
162
|
-
|
|
283
|
+
const cacheEntries = await readdir(cacheDir);
|
|
284
|
+
for (const entry of cacheEntries) if (entry.endsWith(".part.mp3")) await rm(join(cacheDir, entry), { force: true });
|
|
285
|
+
const existingCacheNames = new Set(cacheEntries.filter((entry) => entry.endsWith(".mp3")));
|
|
163
286
|
const outputDir = dirname(outputPath);
|
|
164
287
|
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
288
|
|
|
170
289
|
const plan = buildPlan(paragraphs, config);
|
|
171
290
|
const uniqueSpecs = new Map<string, AudioSpec>();
|
|
@@ -174,13 +293,23 @@ export async function buildAudio(
|
|
|
174
293
|
|
|
175
294
|
let separatorPath: string | undefined;
|
|
176
295
|
if (plan.some((item) => item.type === "separator")) {
|
|
177
|
-
separatorPath = join(stateDir, "separator.mp3");
|
|
296
|
+
separatorPath = join(stateDir, "separator-24khz-96k-mono.mp3");
|
|
178
297
|
if (!(await fileIsUsable(separatorPath))) {
|
|
179
298
|
const source = resolveSeparatorAsset(config.audio.separator.packageAsset);
|
|
180
299
|
if (!(await fileIsUsable(source))) throw new Error(`找不到可用的分隔音效: ${source}`);
|
|
181
|
-
const temporarySeparator = `${separatorPath}.${randomUUID()}.tmp`;
|
|
300
|
+
const temporarySeparator = `${separatorPath}.${randomUUID()}.tmp.mp3`;
|
|
182
301
|
try {
|
|
183
|
-
|
|
302
|
+
const conversion = Bun.spawn([
|
|
303
|
+
ffmpegPath, "-hide_banner", "-loglevel", "error", "-y", "-i", source,
|
|
304
|
+
"-ar", "24000", "-ac", "1", "-c:a", "libmp3lame", "-b:a", "96k", temporarySeparator,
|
|
305
|
+
], { stdout: "ignore", stderr: "pipe" });
|
|
306
|
+
const stderrReader = new Response(conversion.stderr).text();
|
|
307
|
+
const exitCode = await conversion.exited;
|
|
308
|
+
const stderr = await stderrReader;
|
|
309
|
+
if (exitCode !== 0 || !(await fileIsUsable(temporarySeparator))) {
|
|
310
|
+
const detail = stderr.trim().split("\n").at(-1);
|
|
311
|
+
throw new Error(`分隔音效标准化失败${detail ? `: ${detail}` : ""}`);
|
|
312
|
+
}
|
|
184
313
|
await rename(temporarySeparator, separatorPath);
|
|
185
314
|
} catch (error) {
|
|
186
315
|
await rm(temporarySeparator, { force: true });
|
|
@@ -190,21 +319,43 @@ export async function buildAudio(
|
|
|
190
319
|
}
|
|
191
320
|
|
|
192
321
|
const entries = [...uniqueSpecs.entries()];
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
322
|
+
const synthesisProgress = new ProgressBar("语音", entries.length);
|
|
323
|
+
let completed = 0;
|
|
324
|
+
let cached = 0;
|
|
325
|
+
let results: SynthesisResult[];
|
|
326
|
+
try {
|
|
327
|
+
results = await runPool(
|
|
328
|
+
entries.map(([, spec]) => () => synthesize(spec, cacheDir, ffmpegPath, existingCacheNames)),
|
|
329
|
+
config.audio.concurrency,
|
|
330
|
+
(result) => {
|
|
331
|
+
completed += 1;
|
|
332
|
+
if (result.cached) cached += 1;
|
|
333
|
+
if (completed < entries.length) synthesisProgress.update(completed, `缓存 ${cached} · 新生成 ${completed - cached}`);
|
|
334
|
+
},
|
|
335
|
+
);
|
|
336
|
+
synthesisProgress.finish(`缓存 ${cached} · 新生成 ${completed - cached}`);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
synthesisProgress.fail(`完成 ${completed}/${entries.length}`);
|
|
339
|
+
throw error;
|
|
340
|
+
}
|
|
341
|
+
const generated = new Map(entries.map(([hash], index) => [hash, results[index].path]));
|
|
196
342
|
const items: AudioItem[] = plan.map((item) => item.type === "separator"
|
|
197
343
|
? { kind: "separator", path: separatorPath! }
|
|
198
344
|
: { kind: item.spec.kind, text: item.spec.text, path: generated.get(specHash(item.spec))! });
|
|
199
345
|
|
|
200
|
-
|
|
201
|
-
version: 2,
|
|
346
|
+
const manifestBody = {
|
|
202
347
|
profiles: config.audio.profiles,
|
|
203
348
|
paragraphSequence: config.audio.paragraphSequence,
|
|
204
349
|
sentenceSequence: config.audio.sentenceSequence,
|
|
205
350
|
separator: config.audio.separator,
|
|
206
351
|
items,
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
await
|
|
352
|
+
};
|
|
353
|
+
const mergeFingerprint = audioMergeFingerprint(manifestBody);
|
|
354
|
+
if (await recoverCompletedMerge(outputPath, stateDir, mergeFingerprint)) return;
|
|
355
|
+
const stale = await temporaryOutputs(outputPath);
|
|
356
|
+
if (stale.length) console.warn(`发现 ${stale.length} 个未完成的合并临时文件,将保留语音缓存并重新合并。`);
|
|
357
|
+
for (const path of stale) await rm(path, { force: true });
|
|
358
|
+
const manifest: AudioManifest = { version: 3, mergeFingerprint, ...manifestBody };
|
|
359
|
+
await writeJson(join(stateDir, "audio-manifest.json"), manifest);
|
|
360
|
+
await runFfmpeg(items, outputPath, stateDir, ffmpegPath, mergeFingerprint);
|
|
210
361
|
}
|
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.1";
|
|
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
|
}
|