@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 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 github:Makuraryu/Yonde init
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 github:Makuraryu/Yonde input.txt
15
+ bunx @makuraryu/yonde@latest input.txt
16
16
  ```
17
17
 
18
- 这会直接从公开 GitHub 仓库的默认分支安装并运行,不需要 npm 发布。版本记录可在 [GitHub Releases](https://github.com/Makuraryu/Yonde/releases) 查看。
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 github:Makuraryu/Yonde input.txt
32
+ bunx @makuraryu/yonde@latest input.txt
33
33
 
34
34
  # 只翻译,或使用已有翻译生成音频
35
- bunx github:Makuraryu/Yonde input.txt --stage translate
36
- bunx github:Makuraryu/Yonde input.txt --stage audio
35
+ bunx @makuraryu/yonde@latest input.txt --stage translate
36
+ bunx @makuraryu/yonde@latest input.txt --stage audio
37
37
 
38
38
  # 指定配置和输出目录
39
- bunx github:Makuraryu/Yonde input.txt --config ./custom.toml --output-dir ./build
39
+ bunx @makuraryu/yonde@latest input.txt --config ./custom.toml --output-dir ./build
40
40
 
41
41
  # 检查最终合并后的配置
42
- bunx github:Makuraryu/Yonde config check
43
- bunx github:Makuraryu/Yonde config check --config ./custom.toml
42
+ bunx @makuraryu/yonde@latest config check
43
+ bunx @makuraryu/yonde@latest config check --config ./custom.toml
44
44
 
45
45
  # 查看完整帮助
46
- bunx github:Makuraryu/Yonde --help
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
- Yonde 不依赖 npm 发布即可通过上面的 `github:Makuraryu/Yonde` 包标识运行。npm 发布只是提供更短的 registry 包名。
179
+ 发布后可通过 `bunx @makuraryu/yonde@latest` 直接运行;GitHub 包标识适合测试尚未发布的默认分支。
172
180
 
173
181
  ## License
174
182
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makuraryu/yonde",
3
- "version": "0.3.3",
3
+ "version": "0.4.1",
4
4
  "description": "Config-driven Bun CLI for creating bilingual Japanese listening scripts and MP3 audio",
5
5
  "type": "module",
6
6
  "bin": {
package/src/audio.ts CHANGED
@@ -1,16 +1,34 @@
1
1
  import { EdgeTTS } from "node-edge-tts";
2
- import { copyFile, lstat, mkdir, readdir, rename, rm } from "node:fs/promises";
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 { atomicWrite, writeJson } from "./state";
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(spec: AudioSpec, cacheDir: string, ffmpegPath: string): Promise<string> {
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
- if (await fileIsUsable(output)) return output;
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
- completed += 1;
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
- async function runFfmpeg(items: AudioItem[], outputPath: string, stateDir: string, ffmpegPath: string): Promise<void> {
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
- "-af", "aresample=24000,aformat=sample_fmts=s16:channel_layouts=mono",
105
- "-c:a", "libmp3lame", "-b:a", "96k", temporary,
106
- ], { stdout: "inherit", stderr: "inherit" });
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
- throw new Error(`ffmpeg 合并失败,退出码 ${exitCode}`);
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
- for (const entry of await readdir(cacheDir)) if (entry.endsWith(".part.mp3")) await rm(join(cacheDir, entry), { force: true });
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
- await copyFile(source, temporarySeparator);
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
- console.log(`[TTS] ${entries.length} 个唯一片段,并发 ${config.audio.concurrency}(已有缓存会跳过)`);
194
- const paths = await runPool(entries.map(([, spec]) => () => synthesize(spec, cacheDir, ffmpegPath)), config.audio.concurrency);
195
- const generated = new Map(entries.map(([hash], index) => [hash, paths[index]]));
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
- await writeJson(join(stateDir, "audio-manifest.json"), {
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
- console.log(`[合并] ${items.length} 个音频片段`);
209
- await runFfmpeg(items, outputPath, stateDir, ffmpegPath);
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.3.3";
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 github:Makuraryu/Yonde input.txt
41
- bunx github:Makuraryu/Yonde input.txt --stage translate
42
- bunx github:Makuraryu/Yonde input.txt --config ./yonde.toml
43
- bunx github:Makuraryu/Yonde config check
44
- bunx github:Makuraryu/Yonde init
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
@@ -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
- for (let index = state.translated.length; index < state.paragraphs.length; index += 1) {
150
- const paragraph = state.paragraphs[index];
151
- process.stdout.write(`[翻译 ${index + 1}/${state.paragraphs.length}] ${paragraph.original.slice(0, 28)}… `);
152
- let result: ModelTranslation | undefined;
153
- let lastError: unknown;
154
- for (let attempt = 1; attempt <= 4; attempt += 1) {
155
- try {
156
- result = await translateOne(paragraph, state.translated, state.glossary, apiKey, config);
157
- break;
158
- } catch (error) {
159
- lastError = error;
160
- if (attempt < 4) {
161
- process.stdout.write(`重试 ${attempt}/3 `);
162
- await Bun.sleep(1000 * 2 ** (attempt - 1));
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
- if (!result) throw lastError;
167
- state.translated.push({ ...paragraph, translations: result.translations.map((item) => item.trim()) });
168
- state.glossary = mergeGlossary(state.glossary, result.glossaryUpdates ?? []);
169
- state.complete = state.translated.length === state.paragraphs.length;
170
- state.updatedAt = new Date().toISOString();
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
  }