@makuraryu/yonde 0.3.1 → 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 +20 -16
- package/package.json +1 -1
- package/src/audio.ts +173 -31
- package/src/config.ts +3 -6
- package/src/main.ts +11 -7
- 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`,且不会覆盖已有文件。
|
|
@@ -64,13 +64,11 @@ Yonde 支持以下环境变量:
|
|
|
64
64
|
| 变量 | 用途 |
|
|
65
65
|
| --- | --- |
|
|
66
66
|
| `YONDE_API_KEY` | 默认翻译 API Key |
|
|
67
|
-
| `DEEPSEEK_API_KEY` | API Key 兼容变量 |
|
|
68
67
|
| `YONDE_API_ENDPOINT` | 覆盖翻译 API 地址 |
|
|
69
|
-
| `
|
|
70
|
-
| `YONDE_MODEL` / `DEEPSEEK_MODEL` | 覆盖模型 |
|
|
68
|
+
| `YONDE_MODEL` | 覆盖模型 |
|
|
71
69
|
| `YONDE_TTS_CONCURRENCY` | 覆盖 TTS 并发数 |
|
|
72
70
|
|
|
73
|
-
也可以用 `translation.api.api_key_env`
|
|
71
|
+
也可以用 `translation.api.api_key_env` 明确指定一个 API Key 环境变量。若 TOML 中直接设置了 `translation.api.api_key`,它优先于环境变量;未设置时只读取 `api_key_env` 指定的变量(默认 `YONDE_API_KEY`)。Yonde 不会隐式读取 `DEEPSEEK_API_KEY` 或任何其他 Key 变量。Yonde 会提示检查含明文 Key 的文件权限。API Key 不会写入检查点、日志或音频清单。
|
|
74
72
|
|
|
75
73
|
## TOML 配置
|
|
76
74
|
|
|
@@ -150,6 +148,12 @@ package_asset = "uisfx/sounds/cinematic/select.mp3"
|
|
|
150
148
|
|
|
151
149
|
翻译缓存指纹包含输入内容、语言、模型、端点、提示词版本和分句规则,但不包含 API Key。音频缓存指纹包含文本、音色、语言、语速和音调。仅调整朗读顺序时,Yonde 会复用已有语音并重新合并。
|
|
152
150
|
|
|
151
|
+
翻译、语音生成和最终 MP3 合并都会显示单行进度条,包括完成比例、数量、耗时和 ETA。非交互终端按 5% 里程碑输出,避免日志刷屏。
|
|
152
|
+
|
|
153
|
+
所有语音和分隔音效会统一为 24 kHz、单声道、96 kbps,最终 MP3 采用无损快速拼接,不再把数小时音频完整重编码。
|
|
154
|
+
|
|
155
|
+
最终合并使用状态文件保护原子写入。若进程在 ffmpeg 已完成后、最终重命名前退出,下次运行会直接恢复成品;若在合并中途退出,只会重做最终合并,已经生成的翻译和 TTS 缓存不会丢失。
|
|
156
|
+
|
|
153
157
|
## 本地开发
|
|
154
158
|
|
|
155
159
|
```bash
|
|
@@ -161,7 +165,7 @@ bun run check
|
|
|
161
165
|
bun run src/main.ts --help
|
|
162
166
|
```
|
|
163
167
|
|
|
164
|
-
## npm
|
|
168
|
+
## npm 发布
|
|
165
169
|
|
|
166
170
|
```bash
|
|
167
171
|
bun run check
|
|
@@ -170,7 +174,7 @@ npm pack --dry-run
|
|
|
170
174
|
npm publish --access public
|
|
171
175
|
```
|
|
172
176
|
|
|
173
|
-
|
|
177
|
+
发布后可通过 `bunx @makuraryu/yonde@latest` 直接运行;GitHub 包标识适合测试尚未发布的默认分支。
|
|
174
178
|
|
|
175
179
|
## License
|
|
176
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/config.ts
CHANGED
|
@@ -241,8 +241,7 @@ function environmentOverrides(): UnknownRecord {
|
|
|
241
241
|
const translation: UnknownRecord = { api };
|
|
242
242
|
const audio: UnknownRecord = {};
|
|
243
243
|
if (process.env.YONDE_API_ENDPOINT) api.endpoint = process.env.YONDE_API_ENDPOINT;
|
|
244
|
-
|
|
245
|
-
if (process.env.YONDE_MODEL ?? process.env.DEEPSEEK_MODEL) api.model = process.env.YONDE_MODEL ?? process.env.DEEPSEEK_MODEL;
|
|
244
|
+
if (process.env.YONDE_MODEL) api.model = process.env.YONDE_MODEL;
|
|
246
245
|
if (process.env.YONDE_TTS_CONCURRENCY) audio.concurrency = Number(process.env.YONDE_TTS_CONCURRENCY);
|
|
247
246
|
return { translation, audio };
|
|
248
247
|
}
|
|
@@ -284,10 +283,8 @@ export async function loadConfig(explicitPath?: string, cwd = process.cwd()): Pr
|
|
|
284
283
|
}
|
|
285
284
|
|
|
286
285
|
export function resolveApiKey(config: AppConfig): string | undefined {
|
|
287
|
-
return
|
|
288
|
-
?? process.env.
|
|
289
|
-
?? process.env.DEEPSEEK_API_KEY
|
|
290
|
-
?? config.translation.api.apiKey;
|
|
286
|
+
return config.translation.api.apiKey
|
|
287
|
+
?? process.env[config.translation.api.apiKeyEnv];
|
|
291
288
|
}
|
|
292
289
|
|
|
293
290
|
export function translationFingerprint(config: AppConfig): string {
|
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,17 +37,21 @@ 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
|
|
48
48
|
YONDE_API_ENDPOINT 覆盖翻译 API 地址
|
|
49
49
|
YONDE_MODEL 覆盖翻译模型
|
|
50
|
-
YONDE_TTS_CONCURRENCY 覆盖 TTS
|
|
50
|
+
YONDE_TTS_CONCURRENCY 覆盖 TTS 并发数
|
|
51
|
+
|
|
52
|
+
API Key 优先级:
|
|
53
|
+
translation.api.api_key > api_key_env 明确指定的环境变量
|
|
54
|
+
默认 api_key_env 为 YONDE_API_KEY;不会读取其他 Key 变量`;
|
|
51
55
|
|
|
52
56
|
function usage(exitCode = 1): never {
|
|
53
57
|
const output = exitCode === 0 ? console.log : console.error;
|
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
|
}
|