@makuraryu/yonde 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Makuraryu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,177 @@
1
+ # Yonde
2
+
3
+ Yonde(読んで)是一个配置驱动的 Bun CLI:把日文文本翻译成逐句对照的双语听力稿,再通过 Edge TTS 与 ffmpeg 合成为可自定义朗读顺序的 MP3。
4
+
5
+ ## 快速开始
6
+
7
+ 需要 [Bun](https://bun.sh/) 1.3 或更新版本。生成 MP3 时还需要 `ffmpeg`;只运行翻译阶段则不需要。
8
+
9
+ ```bash
10
+ bunx github:Makuraryu/Yonde init
11
+
12
+ # 默认配置从这个环境变量读取 DeepSeek API Key
13
+ export YONDE_API_KEY="your-api-key"
14
+
15
+ bunx github:Makuraryu/Yonde input.txt
16
+ ```
17
+
18
+ 这会直接从公开 GitHub 仓库的默认分支安装并运行,不需要 npm 发布。版本记录可在 [GitHub Releases](https://github.com/Makuraryu/Yonde/releases) 查看。
19
+
20
+ ## 命令
21
+
22
+ ```text
23
+ yonde <输入.txt> [选项]
24
+ yonde init [配置文件]
25
+ yonde config check [--config <配置文件>]
26
+ ```
27
+
28
+ 常用示例:
29
+
30
+ ```bash
31
+ # 翻译并生成音频
32
+ bunx github:Makuraryu/Yonde input.txt
33
+
34
+ # 只翻译,或使用已有翻译生成音频
35
+ bunx github:Makuraryu/Yonde input.txt --stage translate
36
+ bunx github:Makuraryu/Yonde input.txt --stage audio
37
+
38
+ # 指定配置和输出目录
39
+ bunx github:Makuraryu/Yonde input.txt --config ./custom.toml --output-dir ./build
40
+
41
+ # 检查最终合并后的配置
42
+ bunx github:Makuraryu/Yonde config check
43
+ bunx github:Makuraryu/Yonde config check --config ./custom.toml
44
+
45
+ # 查看完整帮助
46
+ bunx github:Makuraryu/Yonde --help
47
+ ```
48
+
49
+ `init` 默认创建 `./yonde.toml`,且不会覆盖已有文件。
50
+
51
+ ## 配置发现与优先级
52
+
53
+ 高优先级覆盖低优先级:
54
+
55
+ 1. CLI 参数,例如 `--config`、`--output-dir`、`--stage`
56
+ 2. 环境变量
57
+ 3. `--config` 指定的 TOML
58
+ 4. 当前目录的 `yonde.toml`
59
+ 5. `~/.config/yonde/config.toml`
60
+ 6. 内置默认值
61
+
62
+ Yonde 支持以下环境变量:
63
+
64
+ | 变量 | 用途 |
65
+ | --- | --- |
66
+ | `YONDE_API_KEY` | 默认翻译 API Key |
67
+ | `DEEPSEEK_API_KEY` | API Key 兼容变量 |
68
+ | `YONDE_API_ENDPOINT` | 覆盖翻译 API 地址 |
69
+ | `DEEPSEEK_BASE_URL` | API 基础地址兼容变量 |
70
+ | `YONDE_MODEL` / `DEEPSEEK_MODEL` | 覆盖模型 |
71
+ | `YONDE_TTS_CONCURRENCY` | 覆盖 TTS 并发数 |
72
+
73
+ 也可以用 `translation.api.api_key_env` 指定任意 API Key 环境变量。允许在 TOML 中直接写 `api_key`,但不推荐;Yonde 会提示检查文件权限。API Key 不会写入检查点、日志或音频清单。
74
+
75
+ ## TOML 配置
76
+
77
+ 运行 `yonde init` 会生成带全部默认值的模板。主要结构如下:
78
+
79
+ ```toml
80
+ version = 1
81
+
82
+ [translation]
83
+ source_language = "ja"
84
+ target_language = "zh-Hans"
85
+ context_paragraphs = 6
86
+ batch_size = 12
87
+ temperature = 0.2
88
+
89
+ [translation.api]
90
+ endpoint = "https://api.deepseek.com/chat/completions"
91
+ model = "deepseek-chat"
92
+ api_key_env = "YONDE_API_KEY"
93
+ timeout_ms = 120000
94
+
95
+ [text]
96
+ sentence_endings = ["。", "、", "!", "?"]
97
+
98
+ [audio]
99
+ concurrency = 8
100
+ max_chunk_chars = 380
101
+ paragraph_sequence = ["source_full", "sentences", "separator"]
102
+ sentence_sequence = ["source_slow", "target_normal", "source_repeat"]
103
+
104
+ [audio.profiles.source_full]
105
+ text = "source"
106
+ voice = "ja-JP-KeitaNeural"
107
+ language = "ja-JP"
108
+ rate = "+0%"
109
+ pitch = "-5%"
110
+
111
+ [audio.profiles.source_slow]
112
+ text = "source"
113
+ voice = "ja-JP-KeitaNeural"
114
+ language = "ja-JP"
115
+ rate = "-20%"
116
+ pitch = "-5%"
117
+
118
+ [audio.profiles.target_normal]
119
+ text = "target"
120
+ voice = "zh-CN-YunxiNeural"
121
+ language = "zh-CN"
122
+ rate = "+0%"
123
+ pitch = "+0Hz"
124
+
125
+ [audio.profiles.source_repeat]
126
+ text = "source"
127
+ voice = "ja-JP-NanamiNeural"
128
+ language = "ja-JP"
129
+ rate = "+0%"
130
+ pitch = "+0Hz"
131
+
132
+ [audio.separator]
133
+ enabled = true
134
+ package_asset = "uisfx/sounds/cinematic/select.mp3"
135
+ ```
136
+
137
+ `paragraph_sequence` 可引用任意 profile,以及特殊项 `sentences` 和 `separator`。`sentence_sequence` 可重复引用同一 profile,例如 `["source_slow", "source_slow", "target_normal"]` 就会把慢速源文读两遍。profile 的 `text` 可选 `source` 或 `target`,因此翻译语言与语音可以独立配置。
138
+
139
+ 默认顺序是:每段源文全文 → 每句源文慢速、译文正常、源文复读 → 段落分隔音。
140
+
141
+ ## 输出与缓存
142
+
143
+ 默认输出到输入文件旁的 `output/`:
144
+
145
+ - `<文件名>.listening.txt`:逐句双语听力稿
146
+ - `<文件名>.listening.mp3`:合成音频
147
+ - `.state/.../translation.json`:翻译检查点和动态术语表
148
+ - `.state/.../audio-cache/`:TTS 片段缓存
149
+ - `.state/.../audio-manifest.json`:实际音色、顺序和片段清单
150
+
151
+ 翻译缓存指纹包含输入内容、语言、模型、端点、提示词版本和分句规则,但不包含 API Key。音频缓存指纹包含文本、音色、语言、语速和音调。仅调整朗读顺序时,Yonde 会复用已有语音并重新合并。
152
+
153
+ ## 本地开发
154
+
155
+ ```bash
156
+ git clone https://github.com/Makuraryu/Yonde.git
157
+ cd Yonde
158
+ bun install
159
+ bun test
160
+ bun run check
161
+ bun run src/main.ts --help
162
+ ```
163
+
164
+ ## npm 发布(可选)
165
+
166
+ ```bash
167
+ bun run check
168
+ bun test
169
+ npm pack --dry-run
170
+ npm publish --access public
171
+ ```
172
+
173
+ Yonde 不依赖 npm 发布即可通过上面的 `github:Makuraryu/Yonde` 包标识运行。npm 发布只是提供更短的 registry 包名。
174
+
175
+ ## License
176
+
177
+ [MIT](./LICENSE)
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@makuraryu/yonde",
3
+ "version": "0.3.1",
4
+ "description": "Config-driven Bun CLI for creating bilingual Japanese listening scripts and MP3 audio",
5
+ "type": "module",
6
+ "bin": {
7
+ "yonde": "src/main.ts"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "README.md"
12
+ ],
13
+ "scripts": {
14
+ "build": "bun build src/main.ts --target=bun --outdir=dist",
15
+ "check": "tsc --noEmit",
16
+ "test": "bun test",
17
+ "prepack": "bun run check && bun test"
18
+ },
19
+ "engines": {
20
+ "bun": ">=1.3.0"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/Makuraryu/Yonde.git"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/Makuraryu/Yonde/issues"
31
+ },
32
+ "homepage": "https://github.com/Makuraryu/Yonde#readme",
33
+ "license": "MIT",
34
+ "keywords": [
35
+ "bun",
36
+ "cli",
37
+ "translation",
38
+ "tts",
39
+ "japanese"
40
+ ],
41
+ "dependencies": {
42
+ "node-edge-tts": "1.2.10",
43
+ "uisfx": "0.4.0"
44
+ },
45
+ "devDependencies": {
46
+ "@types/bun": "latest",
47
+ "typescript": "^5.9.2"
48
+ }
49
+ }
package/src/audio.ts ADDED
@@ -0,0 +1,210 @@
1
+ import { EdgeTTS } from "node-edge-tts";
2
+ import { copyFile, lstat, mkdir, readdir, rename, rm } from "node:fs/promises";
3
+ import { randomUUID } from "node:crypto";
4
+ import { basename, dirname, join, relative } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import type { AppConfig, VoiceProfile } from "./config";
7
+ import type { TranslatedParagraph } from "./text";
8
+ import { splitForTts } from "./text";
9
+ import { atomicWrite, writeJson } from "./state";
10
+
11
+ type AudioItem = { kind: string; text?: string; path: string };
12
+ type AudioSpec = { kind: string; text: string; profile: VoiceProfile };
13
+ type PlannedItem = { type: "audio"; spec: AudioSpec } | { type: "separator" };
14
+
15
+ async function fileIsUsable(path: string): Promise<boolean> {
16
+ try {
17
+ const metadata = await lstat(path);
18
+ if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size <= 512) return false;
19
+ const header = new Uint8Array(await Bun.file(path).slice(0, 3).arrayBuffer());
20
+ return (header[0] === 0x49 && header[1] === 0x44 && header[2] === 0x33)
21
+ || (header[0] === 0xff && (header[1] & 0xe0) === 0xe0);
22
+ } catch { return false; }
23
+ }
24
+
25
+ function specHash(spec: AudioSpec): string {
26
+ return new Bun.CryptoHasher("sha256")
27
+ .update(JSON.stringify({ content: spec.text, profile: spec.profile, outputFormat: "audio-24khz-96kbitrate-mono-mp3" }))
28
+ .digest("hex")
29
+ .slice(0, 24);
30
+ }
31
+
32
+ async function synthesize(spec: AudioSpec, cacheDir: string, ffmpegPath: string): Promise<string> {
33
+ const hash = specHash(spec);
34
+ const safeId = spec.kind.replace(/[^a-zA-Z0-9_-]/g, "_");
35
+ const output = join(cacheDir, `${safeId}-${hash}.mp3`);
36
+ if (await fileIsUsable(output)) return output;
37
+
38
+ const temporary = `${output}.${process.pid}.${randomUUID()}.part.mp3`;
39
+ if (!/[\p{L}\p{N}]/u.test(spec.text)) {
40
+ const silence = Bun.spawn([
41
+ ffmpegPath, "-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i",
42
+ "anullsrc=r=24000:cl=mono", "-t", "0.7", "-c:a", "libmp3lame", "-b:a", "96k", temporary,
43
+ ]);
44
+ if (await silence.exited !== 0) throw new Error(`无法为纯标点片段生成静音: ${spec.text}`);
45
+ await rename(temporary, output);
46
+ return output;
47
+ }
48
+ for (let attempt = 1; attempt <= 6; attempt += 1) {
49
+ try {
50
+ await rm(temporary, { force: true });
51
+ const tts = new EdgeTTS({
52
+ voice: spec.profile.voice,
53
+ lang: spec.profile.language,
54
+ outputFormat: "audio-24khz-96kbitrate-mono-mp3",
55
+ saveSubtitles: false,
56
+ rate: spec.profile.rate,
57
+ pitch: spec.profile.pitch,
58
+ timeout: 60_000,
59
+ });
60
+ await tts.ttsPromise(spec.text, temporary);
61
+ if (!(await fileIsUsable(temporary))) throw new Error("TTS 输出为空");
62
+ await rename(temporary, output);
63
+ return output;
64
+ } catch (error) {
65
+ await rm(temporary, { force: true });
66
+ if (attempt === 6) throw new Error(`${spec.kind} 生成失败(${spec.text.slice(0, 40)}): ${error instanceof Error ? error.message : String(error)}`);
67
+ await Bun.sleep(Math.min(12_000, 1000 * 2 ** (attempt - 1)));
68
+ }
69
+ }
70
+ throw new Error("无法生成语音");
71
+ }
72
+
73
+ async function runPool<T>(jobs: Array<() => Promise<T>>, concurrency: number): Promise<T[]> {
74
+ const results = new Array<T>(jobs.length);
75
+ let next = 0;
76
+ let completed = 0;
77
+ async function worker() {
78
+ while (true) {
79
+ const index = next++;
80
+ if (index >= jobs.length) return;
81
+ results[index] = await jobs[index]();
82
+ completed += 1;
83
+ process.stdout.write(`\r[TTS] ${completed}/${jobs.length}`);
84
+ }
85
+ }
86
+ await Promise.all(Array.from({ length: Math.min(concurrency, jobs.length) }, worker));
87
+ if (jobs.length) process.stdout.write("\n");
88
+ return results;
89
+ }
90
+
91
+ function escapeConcatPath(path: string): string {
92
+ return path.replace(/'/g, "'\\''");
93
+ }
94
+
95
+ async function runFfmpeg(items: AudioItem[], outputPath: string, stateDir: string, ffmpegPath: string): Promise<void> {
96
+ const listPath = join(stateDir, "concat.txt");
97
+ const paths = items.map((item) => relative(stateDir, item.path));
98
+ if (paths.some((path) => path.startsWith("..") || /[\r\n\0]/.test(path))) throw new Error("音频缓存路径超出状态目录或含控制字符");
99
+ await atomicWrite(listPath, paths.map((path) => `file '${escapeConcatPath(path)}'`).join("\n") + "\n");
100
+ const temporary = `${outputPath}.${process.pid}.${randomUUID()}.tmp.mp3`;
101
+ await rm(temporary, { force: true });
102
+ const processResult = Bun.spawn([
103
+ 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" });
107
+ const exitCode = await processResult.exited;
108
+ if (exitCode !== 0) {
109
+ await rm(temporary, { force: true });
110
+ throw new Error(`ffmpeg 合并失败,退出码 ${exitCode}`);
111
+ }
112
+ await rename(temporary, outputPath);
113
+ }
114
+
115
+ function profileText(profile: VoiceProfile, source: string, target: string): string {
116
+ return profile.text === "target" ? target : source;
117
+ }
118
+
119
+ function addProfile(plan: PlannedItem[], id: string, source: string, target: string, config: AppConfig): void {
120
+ if (!Object.hasOwn(config.audio.profiles, id)) throw new Error(`未知音频 profile: ${id}`);
121
+ const profile = config.audio.profiles[id];
122
+ const text = profileText(profile, source, target);
123
+ for (const chunk of splitForTts(text, config.audio.maxChunkChars, config.text.sentenceEndings)) {
124
+ plan.push({ type: "audio", spec: { kind: id, text: chunk, profile } });
125
+ }
126
+ }
127
+
128
+ export function buildPlan(paragraphs: TranslatedParagraph[], config: AppConfig): PlannedItem[] {
129
+ const plan: PlannedItem[] = [];
130
+ paragraphs.forEach((paragraph, paragraphIndex) => {
131
+ for (const token of config.audio.paragraphSequence) {
132
+ if (token === "sentences") {
133
+ paragraph.sentences.forEach((sentence, sentenceIndex) => {
134
+ for (const id of config.audio.sentenceSequence) addProfile(plan, id, sentence, paragraph.translations[sentenceIndex], config);
135
+ });
136
+ } else if (token === "separator") {
137
+ if (config.audio.separator.enabled && paragraphIndex < paragraphs.length - 1) plan.push({ type: "separator" });
138
+ } else {
139
+ addProfile(plan, token, paragraph.original, paragraph.translations.join(""), config);
140
+ }
141
+ }
142
+ });
143
+ return plan;
144
+ }
145
+
146
+ function resolveSeparatorAsset(specifier: string): string {
147
+ const url = import.meta.resolve(specifier);
148
+ if (!url.startsWith("file:")) throw new Error(`分隔音效不是本地文件: ${specifier}`);
149
+ return fileURLToPath(url);
150
+ }
151
+
152
+ export async function buildAudio(
153
+ paragraphs: TranslatedParagraph[],
154
+ outputPath: string,
155
+ stateDir: string,
156
+ config: AppConfig,
157
+ ): Promise<void> {
158
+ const ffmpegPath = Bun.which("ffmpeg");
159
+ if (!ffmpegPath) throw new Error("找不到 ffmpeg;请先安装 ffmpeg,或仅运行 --stage translate");
160
+ const cacheDir = join(stateDir, "audio-cache");
161
+ 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 });
163
+ const outputDir = dirname(outputPath);
164
+ 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
+
170
+ const plan = buildPlan(paragraphs, config);
171
+ const uniqueSpecs = new Map<string, AudioSpec>();
172
+ for (const item of plan) if (item.type === "audio") uniqueSpecs.set(specHash(item.spec), item.spec);
173
+ if (uniqueSpecs.size === 0) throw new Error("音频顺序没有产生任何可朗读片段");
174
+
175
+ let separatorPath: string | undefined;
176
+ if (plan.some((item) => item.type === "separator")) {
177
+ separatorPath = join(stateDir, "separator.mp3");
178
+ if (!(await fileIsUsable(separatorPath))) {
179
+ const source = resolveSeparatorAsset(config.audio.separator.packageAsset);
180
+ if (!(await fileIsUsable(source))) throw new Error(`找不到可用的分隔音效: ${source}`);
181
+ const temporarySeparator = `${separatorPath}.${randomUUID()}.tmp`;
182
+ try {
183
+ await copyFile(source, temporarySeparator);
184
+ await rename(temporarySeparator, separatorPath);
185
+ } catch (error) {
186
+ await rm(temporarySeparator, { force: true });
187
+ throw error;
188
+ }
189
+ }
190
+ }
191
+
192
+ 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]]));
196
+ const items: AudioItem[] = plan.map((item) => item.type === "separator"
197
+ ? { kind: "separator", path: separatorPath! }
198
+ : { kind: item.spec.kind, text: item.spec.text, path: generated.get(specHash(item.spec))! });
199
+
200
+ await writeJson(join(stateDir, "audio-manifest.json"), {
201
+ version: 2,
202
+ profiles: config.audio.profiles,
203
+ paragraphSequence: config.audio.paragraphSequence,
204
+ sentenceSequence: config.audio.sentenceSequence,
205
+ separator: config.audio.separator,
206
+ items,
207
+ });
208
+ console.log(`[合并] ${items.length} 个音频片段`);
209
+ await runFfmpeg(items, outputPath, stateDir, ffmpegPath);
210
+ }
package/src/config.ts ADDED
@@ -0,0 +1,306 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+
5
+ export type VoiceProfile = {
6
+ text: "source" | "target";
7
+ voice: string;
8
+ language: string;
9
+ rate: string;
10
+ pitch: string;
11
+ };
12
+
13
+ export type AppConfig = {
14
+ version: 1;
15
+ translation: {
16
+ sourceLanguage: string;
17
+ targetLanguage: string;
18
+ contextParagraphs: number;
19
+ batchSize: number;
20
+ temperature: number;
21
+ api: {
22
+ endpoint: string;
23
+ model: string;
24
+ apiKeyEnv: string;
25
+ apiKey?: string;
26
+ timeoutMs: number;
27
+ };
28
+ };
29
+ text: { sentenceEndings: string[] };
30
+ audio: {
31
+ concurrency: number;
32
+ maxChunkChars: number;
33
+ paragraphSequence: string[];
34
+ sentenceSequence: string[];
35
+ profiles: Record<string, VoiceProfile>;
36
+ separator: { enabled: boolean; packageAsset: string };
37
+ };
38
+ };
39
+
40
+ export const DEFAULT_CONFIG: AppConfig = {
41
+ version: 1,
42
+ translation: {
43
+ sourceLanguage: "ja",
44
+ targetLanguage: "zh-Hans",
45
+ contextParagraphs: 6,
46
+ batchSize: 12,
47
+ temperature: 0.2,
48
+ api: {
49
+ endpoint: "https://api.deepseek.com/chat/completions",
50
+ model: "deepseek-chat",
51
+ apiKeyEnv: "YONDE_API_KEY",
52
+ timeoutMs: 120_000,
53
+ },
54
+ },
55
+ text: { sentenceEndings: ["。", "、", "!", "?"] },
56
+ audio: {
57
+ concurrency: 8,
58
+ maxChunkChars: 380,
59
+ paragraphSequence: ["source_full", "sentences", "separator"],
60
+ sentenceSequence: ["source_slow", "target_normal", "source_repeat"],
61
+ profiles: {
62
+ source_full: { text: "source", voice: "ja-JP-KeitaNeural", language: "ja-JP", rate: "+0%", pitch: "-5%" },
63
+ source_slow: { text: "source", voice: "ja-JP-KeitaNeural", language: "ja-JP", rate: "-20%", pitch: "-5%" },
64
+ target_normal: { text: "target", voice: "zh-CN-YunxiNeural", language: "zh-CN", rate: "+0%", pitch: "+0Hz" },
65
+ source_repeat: { text: "source", voice: "ja-JP-NanamiNeural", language: "ja-JP", rate: "+0%", pitch: "+0Hz" },
66
+ },
67
+ separator: { enabled: true, packageAsset: "uisfx/sounds/cinematic/select.mp3" },
68
+ },
69
+ };
70
+
71
+ export const DEFAULT_CONFIG_TOML = `version = 1
72
+
73
+ [translation]
74
+ source_language = "ja"
75
+ target_language = "zh-Hans"
76
+ context_paragraphs = 6
77
+ batch_size = 12
78
+ temperature = 0.2
79
+
80
+ [translation.api]
81
+ endpoint = "https://api.deepseek.com/chat/completions"
82
+ model = "deepseek-chat"
83
+ api_key_env = "YONDE_API_KEY"
84
+ # api_key = "不推荐:优先使用上面的环境变量"
85
+ timeout_ms = 120000
86
+
87
+ [text]
88
+ sentence_endings = ["。", "、", "!", "?"]
89
+
90
+ [audio]
91
+ concurrency = 8
92
+ max_chunk_chars = 380
93
+ paragraph_sequence = ["source_full", "sentences", "separator"]
94
+ sentence_sequence = ["source_slow", "target_normal", "source_repeat"]
95
+
96
+ [audio.profiles.source_full]
97
+ text = "source"
98
+ voice = "ja-JP-KeitaNeural"
99
+ language = "ja-JP"
100
+ rate = "+0%"
101
+ pitch = "-5%"
102
+
103
+ [audio.profiles.source_slow]
104
+ text = "source"
105
+ voice = "ja-JP-KeitaNeural"
106
+ language = "ja-JP"
107
+ rate = "-20%"
108
+ pitch = "-5%"
109
+
110
+ [audio.profiles.target_normal]
111
+ text = "target"
112
+ voice = "zh-CN-YunxiNeural"
113
+ language = "zh-CN"
114
+ rate = "+0%"
115
+ pitch = "+0Hz"
116
+
117
+ [audio.profiles.source_repeat]
118
+ text = "source"
119
+ voice = "ja-JP-NanamiNeural"
120
+ language = "ja-JP"
121
+ rate = "+0%"
122
+ pitch = "+0Hz"
123
+
124
+ [audio.separator]
125
+ enabled = true
126
+ package_asset = "uisfx/sounds/cinematic/select.mp3"
127
+ `;
128
+
129
+ type UnknownRecord = Record<string, unknown>;
130
+
131
+ function isRecord(value: unknown): value is UnknownRecord {
132
+ return typeof value === "object" && value !== null && !Array.isArray(value);
133
+ }
134
+
135
+ function camelKey(key: string): string {
136
+ return key.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase());
137
+ }
138
+
139
+ function normalizeKeys(value: unknown): unknown {
140
+ if (Array.isArray(value)) return value.map(normalizeKeys);
141
+ if (!isRecord(value)) return value;
142
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => {
143
+ const normalized = camelKey(key);
144
+ if (["__proto__", "prototype", "constructor"].includes(normalized)) throw new Error(`禁止的配置项: ${key}`);
145
+ return [normalized, normalizeKeys(child)];
146
+ }));
147
+ }
148
+
149
+ function merge(base: unknown, override: unknown): unknown {
150
+ if (!isRecord(base) || !isRecord(override)) return override;
151
+ const result: UnknownRecord = { ...base };
152
+ for (const [key, value] of Object.entries(override)) {
153
+ result[key] = key in result ? merge(result[key], value) : value;
154
+ }
155
+ return result;
156
+ }
157
+
158
+ function requireString(value: unknown, path: string): asserts value is string {
159
+ if (typeof value !== "string" || !value.trim()) throw new Error(`${path} 必须是非空字符串`);
160
+ }
161
+
162
+ function requireNumber(value: unknown, path: string, minimum: number, maximum: number): asserts value is number {
163
+ if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) {
164
+ throw new Error(`${path} 必须是 ${minimum} 到 ${maximum} 之间的数字`);
165
+ }
166
+ }
167
+
168
+ function requireStringArray(value: unknown, path: string): asserts value is string[] {
169
+ if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== "string" || !item)) {
170
+ throw new Error(`${path} 必须是非空字符串数组`);
171
+ }
172
+ }
173
+
174
+ function rejectUnknown(record: UnknownRecord, allowed: string[], path: string): void {
175
+ const unknown = Object.keys(record).find((key) => !allowed.includes(key));
176
+ if (unknown) throw new Error(`未知配置项: ${path}${unknown}`);
177
+ }
178
+
179
+ export function validateConfig(value: unknown): AppConfig {
180
+ if (!isRecord(value)) throw new Error("配置根节点必须是 TOML 表");
181
+ rejectUnknown(value, ["version", "translation", "text", "audio"], "");
182
+ if (isRecord(value.translation)) rejectUnknown(value.translation, ["sourceLanguage", "targetLanguage", "contextParagraphs", "batchSize", "temperature", "api"], "translation.");
183
+ if (isRecord(value.translation) && isRecord(value.translation.api)) rejectUnknown(value.translation.api, ["endpoint", "model", "apiKeyEnv", "apiKey", "timeoutMs"], "translation.api.");
184
+ if (isRecord(value.text)) rejectUnknown(value.text, ["sentenceEndings"], "text.");
185
+ if (isRecord(value.audio)) rejectUnknown(value.audio, ["concurrency", "maxChunkChars", "paragraphSequence", "sentenceSequence", "profiles", "separator"], "audio.");
186
+ if (isRecord(value.audio) && isRecord(value.audio.separator)) rejectUnknown(value.audio.separator, ["enabled", "packageAsset"], "audio.separator.");
187
+ if (isRecord(value.audio) && isRecord(value.audio.profiles)) {
188
+ for (const [id, profile] of Object.entries(value.audio.profiles)) {
189
+ if (isRecord(profile)) rejectUnknown(profile, ["text", "voice", "language", "rate", "pitch"], `audio.profiles.${id}.`);
190
+ }
191
+ }
192
+ const config = value as unknown as AppConfig;
193
+ if (config.version !== 1) throw new Error("仅支持 version = 1");
194
+ if (!config.translation || !config.translation.api) throw new Error("缺少 [translation] 或 [translation.api]");
195
+ requireString(config.translation.sourceLanguage, "translation.source_language");
196
+ requireString(config.translation.targetLanguage, "translation.target_language");
197
+ requireNumber(config.translation.contextParagraphs, "translation.context_paragraphs", 0, 100);
198
+ requireNumber(config.translation.batchSize, "translation.batch_size", 1, 100);
199
+ requireNumber(config.translation.temperature, "translation.temperature", 0, 2);
200
+ requireString(config.translation.api.endpoint, "translation.api.endpoint");
201
+ let endpoint: URL;
202
+ try { endpoint = new URL(config.translation.api.endpoint); } catch { throw new Error("translation.api.endpoint 必须是有效 URL"); }
203
+ const localHttp = endpoint.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(endpoint.hostname);
204
+ if (endpoint.protocol !== "https:" && !localHttp) throw new Error("translation.api.endpoint 必须使用 HTTPS(本机 localhost 可使用 HTTP)");
205
+ if (endpoint.username || endpoint.password) throw new Error("translation.api.endpoint 不得包含用户名或密码");
206
+ requireString(config.translation.api.model, "translation.api.model");
207
+ requireString(config.translation.api.apiKeyEnv, "translation.api.api_key_env");
208
+ if (config.translation.api.apiKey !== undefined) requireString(config.translation.api.apiKey, "translation.api.api_key");
209
+ requireNumber(config.translation.api.timeoutMs, "translation.api.timeout_ms", 1000, 600_000);
210
+ requireStringArray(config.text?.sentenceEndings, "text.sentence_endings");
211
+ if (config.text.sentenceEndings.some((ending) => [...ending].length !== 1)) throw new Error("text.sentence_endings 中每项必须是单个字符");
212
+ requireNumber(config.audio?.concurrency, "audio.concurrency", 1, 64);
213
+ requireNumber(config.audio?.maxChunkChars, "audio.max_chunk_chars", 20, 5000);
214
+ requireStringArray(config.audio?.paragraphSequence, "audio.paragraph_sequence");
215
+ requireStringArray(config.audio?.sentenceSequence, "audio.sentence_sequence");
216
+ if (!isRecord(config.audio.profiles) || Object.keys(config.audio.profiles).length === 0) throw new Error("audio.profiles 至少需要一个 profile");
217
+ for (const [id, profile] of Object.entries(config.audio.profiles)) {
218
+ if (!isRecord(profile) || !["source", "target"].includes(String(profile.text))) throw new Error(`audio.profiles.${id}.text 必须是 source 或 target`);
219
+ requireString(profile.voice, `audio.profiles.${id}.voice`);
220
+ requireString(profile.language, `audio.profiles.${id}.language`);
221
+ requireString(profile.rate, `audio.profiles.${id}.rate`);
222
+ requireString(profile.pitch, `audio.profiles.${id}.pitch`);
223
+ }
224
+ for (const token of config.audio.paragraphSequence) {
225
+ if (token !== "sentences" && token !== "separator" && !Object.hasOwn(config.audio.profiles, token)) throw new Error(`audio.paragraph_sequence 引用了未知项: ${token}`);
226
+ }
227
+ if (config.audio.paragraphSequence.every((token) => token === "separator")) throw new Error("audio.paragraph_sequence 至少需要一个 profile 或 sentences");
228
+ for (const token of config.audio.sentenceSequence) {
229
+ if (!Object.hasOwn(config.audio.profiles, token)) throw new Error(`audio.sentence_sequence 引用了未知 profile: ${token}`);
230
+ }
231
+ if (!config.audio.separator || typeof config.audio.separator.enabled !== "boolean") throw new Error("audio.separator.enabled 必须是布尔值");
232
+ requireString(config.audio.separator.packageAsset, "audio.separator.package_asset");
233
+ if (!/^uisfx\/sounds\/[a-zA-Z0-9_/-]+\.mp3$/.test(config.audio.separator.packageAsset) || config.audio.separator.packageAsset.includes("..")) {
234
+ throw new Error("audio.separator.package_asset 只允许 uisfx/sounds/ 下的 MP3 资源");
235
+ }
236
+ return config;
237
+ }
238
+
239
+ function environmentOverrides(): UnknownRecord {
240
+ const api: UnknownRecord = {};
241
+ const translation: UnknownRecord = { api };
242
+ const audio: UnknownRecord = {};
243
+ if (process.env.YONDE_API_ENDPOINT) api.endpoint = process.env.YONDE_API_ENDPOINT;
244
+ else if (process.env.DEEPSEEK_BASE_URL) api.endpoint = `${process.env.DEEPSEEK_BASE_URL.replace(/\/$/, "")}/chat/completions`;
245
+ if (process.env.YONDE_MODEL ?? process.env.DEEPSEEK_MODEL) api.model = process.env.YONDE_MODEL ?? process.env.DEEPSEEK_MODEL;
246
+ if (process.env.YONDE_TTS_CONCURRENCY) audio.concurrency = Number(process.env.YONDE_TTS_CONCURRENCY);
247
+ return { translation, audio };
248
+ }
249
+
250
+ async function existing(path: string): Promise<boolean> {
251
+ return Bun.file(path).exists();
252
+ }
253
+
254
+ export async function loadConfig(explicitPath?: string, cwd = process.cwd()): Promise<{ config: AppConfig; path?: string; warnings: string[] }> {
255
+ const userPath = join(homedir(), ".config", "yonde", "config.toml");
256
+ const projectPath = join(cwd, "yonde.toml");
257
+ let combined: unknown = DEFAULT_CONFIG;
258
+ let loadedPath: string | undefined;
259
+
260
+ const candidates = [userPath, projectPath];
261
+ if (explicitPath) candidates.push(resolve(cwd, explicitPath));
262
+ for (const candidate of [...new Set(candidates)]) {
263
+ if (!(await existing(candidate))) {
264
+ if (explicitPath && candidate === resolve(cwd, explicitPath)) throw new Error(`找不到配置文件: ${candidate}`);
265
+ continue;
266
+ }
267
+ const parsed = normalizeKeys(Bun.TOML.parse(await Bun.file(candidate).text()));
268
+ combined = merge(combined, parsed);
269
+ loadedPath = candidate;
270
+ }
271
+ combined = merge(combined, environmentOverrides());
272
+ const config = validateConfig(combined);
273
+ const warnings: string[] = [];
274
+ if (config.translation.api.apiKey && loadedPath) {
275
+ try {
276
+ const mode = (await stat(loadedPath)).mode & 0o777;
277
+ if ((mode & 0o077) !== 0) warnings.push(`配置内含 api_key,但 ${loadedPath} 权限为 ${mode.toString(8)};建议 chmod 600`);
278
+ else warnings.push("配置内含明文 api_key;建议改用 api_key_env 指定的环境变量");
279
+ } catch {
280
+ warnings.push("配置内含明文 api_key;建议改用环境变量");
281
+ }
282
+ }
283
+ return { config, path: loadedPath, warnings };
284
+ }
285
+
286
+ export function resolveApiKey(config: AppConfig): string | undefined {
287
+ return process.env[config.translation.api.apiKeyEnv]
288
+ ?? process.env.YONDE_API_KEY
289
+ ?? process.env.DEEPSEEK_API_KEY
290
+ ?? config.translation.api.apiKey;
291
+ }
292
+
293
+ export function translationFingerprint(config: AppConfig): string {
294
+ const safe = {
295
+ sourceLanguage: config.translation.sourceLanguage,
296
+ targetLanguage: config.translation.targetLanguage,
297
+ contextParagraphs: config.translation.contextParagraphs,
298
+ batchSize: config.translation.batchSize,
299
+ temperature: config.translation.temperature,
300
+ endpoint: config.translation.api.endpoint,
301
+ model: config.translation.api.model,
302
+ sentenceEndings: config.text.sentenceEndings,
303
+ promptVersion: 2,
304
+ };
305
+ return new Bun.CryptoHasher("sha256").update(JSON.stringify(safe)).digest("hex");
306
+ }
package/src/main.ts ADDED
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env bun
2
+ import { mkdir, open } from "node:fs/promises";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { buildAudio } from "./audio";
5
+ import { DEFAULT_CONFIG_TOML, loadConfig, translationFingerprint } from "./config";
6
+ import { atomicWrite, readJson, writeJson, type TranslationState } from "./state";
7
+ import { parseParagraphs, renderListeningText } from "./text";
8
+ import { translateAll } from "./translate";
9
+
10
+ type Stage = "translate" | "audio" | "all";
11
+ type RunArgs = { command: "run"; input: string; stage: Stage; outputDir?: string; configPath?: string };
12
+ type CliArgs = RunArgs | { command: "init"; path: string } | { command: "config-check"; configPath?: string } | { command: "help" } | { command: "version" };
13
+
14
+ const VERSION = "0.3.1";
15
+
16
+ export const HELP_TEXT = `Yonde ${VERSION} — 配置驱动的双语听力材料生成器
17
+
18
+ 用法:
19
+ yonde <输入.txt> [选项]
20
+ yonde init [配置文件]
21
+ yonde config check [--config <配置文件>]
22
+
23
+ 命令:
24
+ <输入.txt> 翻译文本并生成双语听力稿与 MP3
25
+ init 创建配置模板(默认: ./yonde.toml,不覆盖已有文件)
26
+ config check 发现、合并并校验配置,但不生成内容
27
+
28
+ 选项:
29
+ --stage <阶段> 执行阶段: translate、audio 或 all(默认: all)
30
+ --config <文件> 使用指定 TOML 配置;优先于项目和用户配置
31
+ --output-dir <目录> 输出目录(默认: 输入文件旁的 output/)
32
+ -h, --help 显示帮助
33
+ -v, --version 显示版本
34
+
35
+ 配置优先级(高 → 低):
36
+ CLI 选项 > 环境变量 > --config 文件 > ./yonde.toml
37
+ > ~/.config/yonde/config.toml > 内置默认值
38
+
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
45
+
46
+ 环境变量:
47
+ YONDE_API_KEY 默认翻译 API Key
48
+ YONDE_API_ENDPOINT 覆盖翻译 API 地址
49
+ YONDE_MODEL 覆盖翻译模型
50
+ YONDE_TTS_CONCURRENCY 覆盖 TTS 并发数`;
51
+
52
+ function usage(exitCode = 1): never {
53
+ const output = exitCode === 0 ? console.log : console.error;
54
+ output(HELP_TEXT);
55
+ process.exit(exitCode);
56
+ }
57
+
58
+ function optionValue(args: string[], index: number): string {
59
+ const value = args[index + 1];
60
+ if (!value || value.startsWith("--")) usage();
61
+ return value;
62
+ }
63
+
64
+ export function parseArgs(args: string[]): CliArgs {
65
+ if (args.length === 0 || args.includes("--help") || args.includes("-h")) return { command: "help" };
66
+ if (args.includes("--version") || args.includes("-v")) return { command: "version" };
67
+ if (args[0] === "init") {
68
+ if (args.length > 2 || args[1]?.startsWith("--")) usage();
69
+ return { command: "init", path: args[1] ?? "yonde.toml" };
70
+ }
71
+ if (args[0] === "config") {
72
+ if (args[1] !== "check") usage();
73
+ let configPath: string | undefined;
74
+ for (let index = 2; index < args.length; index += 1) {
75
+ if (args[index] === "--config") configPath = optionValue(args, index++);
76
+ else usage();
77
+ }
78
+ return { command: "config-check", configPath };
79
+ }
80
+
81
+ let input: string | undefined;
82
+ let stage: Stage = "all";
83
+ let outputDir: string | undefined;
84
+ let configPath: string | undefined;
85
+ for (let index = 0; index < args.length; index += 1) {
86
+ const arg = args[index];
87
+ if (arg === "--stage") stage = optionValue(args, index++) as Stage;
88
+ else if (arg === "--output-dir") outputDir = optionValue(args, index++);
89
+ else if (arg === "--config") configPath = optionValue(args, index++);
90
+ else if (!arg.startsWith("--") && !input) input = arg;
91
+ else usage();
92
+ }
93
+ if (!input || !["translate", "audio", "all"].includes(stage)) usage();
94
+ return { command: "run", input, stage, outputDir, configPath };
95
+ }
96
+
97
+ async function initialize(path: string): Promise<void> {
98
+ const output = resolve(path);
99
+ await mkdir(dirname(output), { recursive: true });
100
+ let handle;
101
+ try {
102
+ handle = await open(output, "wx", 0o600);
103
+ await handle.writeFile(DEFAULT_CONFIG_TOML, "utf8");
104
+ } catch (error) {
105
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`不会覆盖已有文件: ${output}`);
106
+ throw error;
107
+ } finally {
108
+ await handle?.close();
109
+ }
110
+ console.log(`已创建配置: ${output}`);
111
+ }
112
+
113
+ async function checkConfig(configPath?: string): Promise<void> {
114
+ const loaded = await loadConfig(configPath);
115
+ for (const warning of loaded.warnings) console.warn(`警告: ${warning}`);
116
+ console.log(`配置有效: ${loaded.path ?? "内置默认配置"}`);
117
+ console.log(`翻译: ${loaded.config.translation.sourceLanguage} → ${loaded.config.translation.targetLanguage}`);
118
+ console.log(`API: ${loaded.config.translation.api.endpoint} (${loaded.config.translation.api.model})`);
119
+ console.log(`段落顺序: ${loaded.config.audio.paragraphSequence.join(" → ")}`);
120
+ console.log(`逐句顺序: ${loaded.config.audio.sentenceSequence.join(" → ")}`);
121
+ }
122
+
123
+ function stateIsValid(state: unknown, paragraphs: ReturnType<typeof parseParagraphs>, inputHash: string, configHash: string): state is TranslationState {
124
+ if (!state || typeof state !== "object") return false;
125
+ const candidate = state as Partial<TranslationState>;
126
+ if (candidate.version !== 2 || candidate.inputHash !== inputHash || candidate.configHash !== configHash) return false;
127
+ if (!Array.isArray(candidate.paragraphs) || JSON.stringify(candidate.paragraphs) !== JSON.stringify(paragraphs)) return false;
128
+ if (!Array.isArray(candidate.translated) || candidate.translated.length > paragraphs.length) return false;
129
+ for (let index = 0; index < candidate.translated.length; index += 1) {
130
+ const item = candidate.translated[index];
131
+ if (!item || item.index !== paragraphs[index].index || item.original !== paragraphs[index].original) return false;
132
+ if (JSON.stringify(item.sentences) !== JSON.stringify(paragraphs[index].sentences)) return false;
133
+ if (!Array.isArray(item.translations) || item.translations.length !== item.sentences.length || item.translations.some((text) => typeof text !== "string")) return false;
134
+ }
135
+ if (!Array.isArray(candidate.glossary) || candidate.glossary.some((entry) => !entry || typeof entry.source !== "string" || typeof entry.target !== "string")) return false;
136
+ if (typeof candidate.complete !== "boolean" || candidate.complete !== (candidate.translated.length === paragraphs.length)) return false;
137
+ return typeof candidate.updatedAt === "string";
138
+ }
139
+
140
+ async function run(args: RunArgs): Promise<void> {
141
+ const loaded = await loadConfig(args.configPath);
142
+ for (const warning of loaded.warnings) console.warn(`警告: ${warning}`);
143
+ const { config } = loaded;
144
+ const inputPath = resolve(args.input);
145
+ const inputFile = Bun.file(inputPath);
146
+ if (!(await inputFile.exists())) throw new Error(`找不到输入文件: ${inputPath}`);
147
+ const inputText = await inputFile.text();
148
+ const inputHash = new Bun.CryptoHasher("sha256").update(inputText).digest("hex");
149
+ const configHash = translationFingerprint(config);
150
+ const stem = basename(inputPath).replace(/\.txt$/i, "");
151
+ const outputDir = resolve(args.outputDir ?? join(dirname(inputPath), "output"));
152
+ const stateDir = join(outputDir, ".state", `${stem}-${inputHash.slice(0, 10)}-${configHash.slice(0, 10)}`);
153
+ const statePath = join(stateDir, "translation.json");
154
+ const textOutput = join(outputDir, `${stem}.listening.txt`);
155
+ const audioOutput = join(outputDir, `${stem}.listening.mp3`);
156
+ await mkdir(stateDir, { recursive: true });
157
+
158
+ const parsedParagraphs = parseParagraphs(inputText, config.text.sentenceEndings);
159
+ const savedState = await readJson<unknown>(statePath);
160
+ let state: TranslationState;
161
+ if (!savedState) {
162
+ state = {
163
+ version: 2,
164
+ inputPath,
165
+ inputHash,
166
+ configHash,
167
+ paragraphs: parsedParagraphs,
168
+ translated: [],
169
+ glossary: [],
170
+ complete: false,
171
+ updatedAt: new Date().toISOString(),
172
+ };
173
+ await writeJson(statePath, state);
174
+ } else if (!stateIsValid(savedState, parsedParagraphs, inputHash, configHash)) {
175
+ throw new Error(`检查点格式无效,或与当前输入/配置不匹配: ${statePath}`);
176
+ } else {
177
+ state = savedState;
178
+ }
179
+ console.log(`输入: ${inputPath}`);
180
+ console.log(`配置: ${loaded.path ?? "内置默认配置"}`);
181
+ console.log(`语言: ${config.translation.sourceLanguage} → ${config.translation.targetLanguage}`);
182
+ console.log(`段落: ${state.paragraphs.length},已翻译: ${state.translated.length}`);
183
+ console.log(`检查点: ${statePath}`);
184
+
185
+ if (args.stage === "translate" || args.stage === "all") {
186
+ state = await translateAll(state, statePath, config);
187
+ await atomicWrite(textOutput, `${renderListeningText(state.translated, config.audio.sentenceSequence, config.audio.profiles)}\n`);
188
+ console.log(`听力文本: ${textOutput}`);
189
+ }
190
+
191
+ if (args.stage === "audio" || args.stage === "all") {
192
+ if (!state.complete) throw new Error("翻译尚未完成;请先运行 --stage translate(会从检查点续跑)");
193
+ if (!(await Bun.file(textOutput).exists())) {
194
+ await atomicWrite(textOutput, `${renderListeningText(state.translated, config.audio.sentenceSequence, config.audio.profiles)}\n`);
195
+ }
196
+ await buildAudio(state.translated, audioOutput, stateDir, config);
197
+ console.log(`MP3: ${audioOutput}`);
198
+ }
199
+ console.log("完成。");
200
+ }
201
+
202
+ async function main() {
203
+ const args = parseArgs(process.argv.slice(2));
204
+ if (args.command === "help") usage(0);
205
+ if (args.command === "version") return console.log(VERSION);
206
+ if (args.command === "init") return initialize(args.path);
207
+ if (args.command === "config-check") return checkConfig(args.configPath);
208
+ return run(args);
209
+ }
210
+
211
+ if (import.meta.main) {
212
+ main().catch((error) => {
213
+ console.error(`失败: ${error instanceof Error ? error.message : String(error)}`);
214
+ process.exit(1);
215
+ });
216
+ }
package/src/state.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { open, mkdir, rename, rm } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import { dirname } from "node:path";
4
+ import type { Paragraph, TranslatedParagraph } from "./text";
5
+
6
+ export type GlossaryEntry = { source: string; target: string };
7
+
8
+ export type TranslationState = {
9
+ version: 2;
10
+ inputPath: string;
11
+ inputHash: string;
12
+ configHash: string;
13
+ paragraphs: Paragraph[];
14
+ translated: TranslatedParagraph[];
15
+ glossary: GlossaryEntry[];
16
+ complete: boolean;
17
+ updatedAt: string;
18
+ };
19
+
20
+ export async function readJson<T>(path: string): Promise<T | null> {
21
+ try {
22
+ return (await Bun.file(path).json()) as T;
23
+ } catch (error) {
24
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
25
+ throw error;
26
+ }
27
+ }
28
+
29
+ export async function atomicWrite(path: string, content: string): Promise<void> {
30
+ await mkdir(dirname(path), { recursive: true });
31
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
32
+ const handle = await open(temporary, "wx", 0o600);
33
+ try {
34
+ await handle.writeFile(content, "utf8");
35
+ await handle.sync();
36
+ } finally {
37
+ await handle.close();
38
+ }
39
+ try {
40
+ await rename(temporary, path);
41
+ } catch (error) {
42
+ await rm(temporary, { force: true });
43
+ throw error;
44
+ }
45
+ }
46
+
47
+ export async function writeJson(path: string, value: unknown): Promise<void> {
48
+ await atomicWrite(path, `${JSON.stringify(value, null, 2)}\n`);
49
+ }
package/src/text.ts ADDED
@@ -0,0 +1,76 @@
1
+ import type { VoiceProfile } from "./config";
2
+
3
+ export type Paragraph = {
4
+ index: number;
5
+ original: string;
6
+ sentences: string[];
7
+ };
8
+
9
+ export type TranslatedParagraph = Paragraph & {
10
+ translations: string[];
11
+ };
12
+
13
+ const DEFAULT_ENDINGS = ["。", "、", "!", "?"];
14
+
15
+ function escapeCharacterClass(value: string): string {
16
+ return value.replace(/[\\\]\-^]/g, "\\$&");
17
+ }
18
+
19
+ export function splitSentences(paragraph: string, endings = DEFAULT_ENDINGS): string[] {
20
+ const normalized = paragraph.replace(/\r\n?/g, "\n");
21
+ const punctuation = endings.map(escapeCharacterClass).join("");
22
+ const pattern = new RegExp(`[^${punctuation}\\n]+[${punctuation}]+[」』)】〉》”’"]*|[^${punctuation}\\n]+(?=\\n|$)`, "gu");
23
+ const matches = normalized.match(pattern);
24
+ return (matches ?? [normalized]).map((part) => part.trim()).filter(Boolean);
25
+ }
26
+
27
+ export function parseParagraphs(text: string, endings = DEFAULT_ENDINGS): Paragraph[] {
28
+ return text
29
+ .replace(/\r\n?/g, "\n")
30
+ .trim()
31
+ .split(/\n\s*\n+/)
32
+ .map((original) => original.trim())
33
+ .filter(Boolean)
34
+ .map((original, index) => ({ index, original, sentences: splitSentences(original, endings) }));
35
+ }
36
+
37
+ export function renderListeningText(
38
+ paragraphs: TranslatedParagraph[],
39
+ sentenceSequence = ["source", "target", "source"],
40
+ profiles?: Record<string, VoiceProfile>,
41
+ ): string {
42
+ const sequence = profiles
43
+ ? sentenceSequence.map((id) => profiles[id]?.text).filter((value): value is "source" | "target" => Boolean(value))
44
+ : sentenceSequence;
45
+ return paragraphs
46
+ .map((paragraph) => {
47
+ const lines = ["[段落全文]", paragraph.original, "[逐句]"];
48
+ paragraph.sentences.forEach((sentence, index) => {
49
+ for (const text of sequence) lines.push(`-[${text === "target" ? paragraph.translations[index] : sentence}]`);
50
+ });
51
+ lines.push("[/逐句]");
52
+ return lines.join("\n");
53
+ })
54
+ .join("\n\n");
55
+ }
56
+
57
+ export function splitForTts(text: string, maxChars = 380, endings = DEFAULT_ENDINGS): string[] {
58
+ if (text.length <= maxChars) return [text];
59
+ const sentences = splitSentences(text, endings);
60
+ const chunks: string[] = [];
61
+ let current = "";
62
+ for (const sentence of sentences) {
63
+ if (sentence.length > maxChars) {
64
+ if (current) chunks.push(current);
65
+ for (let start = 0; start < sentence.length; start += maxChars) chunks.push(sentence.slice(start, start + maxChars));
66
+ current = "";
67
+ } else if (current && current.length + sentence.length > maxChars) {
68
+ chunks.push(current);
69
+ current = sentence;
70
+ } else {
71
+ current += sentence;
72
+ }
73
+ }
74
+ if (current) chunks.push(current);
75
+ return chunks;
76
+ }
@@ -0,0 +1,175 @@
1
+ import type { AppConfig } from "./config";
2
+ import { resolveApiKey } from "./config";
3
+ import type { GlossaryEntry, TranslationState } from "./state";
4
+ import { writeJson } from "./state";
5
+ import type { Paragraph, TranslatedParagraph } from "./text";
6
+
7
+ type ChatCompletionResponse = {
8
+ choices?: Array<{ message?: { content?: string } }>;
9
+ error?: { message?: string };
10
+ };
11
+
12
+ type ModelTranslation = {
13
+ translations: string[];
14
+ glossaryUpdates?: GlossaryEntry[];
15
+ };
16
+
17
+ function systemPrompt(config: AppConfig): string {
18
+ return `你是文学翻译者。任务是把 ${config.translation.sourceLanguage} 文学片段翻译成自然、准确、适合听力学习的 ${config.translation.targetLanguage}。
19
+ 必须保持叙述人称、人物称谓、专有名词和文体与已有上下文一致。不要解释,不要合并或拆分输入片段。
20
+ 只输出合法 JSON:{"translations":["逐项译文"],"glossaryUpdates":[{"source":"源语言词","target":"固定目标语言译法"}]}。
21
+ translations 必须与输入 sentences 数量和顺序完全相同。glossaryUpdates 只收录值得跨段保持一致的人名、称谓、专名或反复出现的关键词。`;
22
+ }
23
+
24
+ function mergeGlossary(current: GlossaryEntry[], updates: GlossaryEntry[]): GlossaryEntry[] {
25
+ const merged = new Map(current.map((entry) => [entry.source, entry.target]));
26
+ for (const entry of updates) {
27
+ if (entry?.source?.trim() && entry?.target?.trim()) merged.set(entry.source.trim(), entry.target.trim());
28
+ }
29
+ return [...merged].map(([source, target]) => ({ source, target })).slice(-120);
30
+ }
31
+
32
+ function parseModelJson(content: string): ModelTranslation {
33
+ const cleaned = content.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
34
+ const start = cleaned.indexOf("{");
35
+ const end = cleaned.lastIndexOf("}");
36
+ if (start < 0 || end < start) throw new Error("模型响应中没有 JSON 对象");
37
+ return JSON.parse(cleaned.slice(start, end + 1)) as ModelTranslation;
38
+ }
39
+
40
+ async function translateBatch(
41
+ paragraph: Paragraph,
42
+ sentenceOffset: number,
43
+ sentences: string[],
44
+ earlierInParagraph: Array<{ text: string; translation: string }>,
45
+ translated: TranslatedParagraph[],
46
+ glossary: GlossaryEntry[],
47
+ apiKey: string,
48
+ config: AppConfig,
49
+ ): Promise<ModelTranslation> {
50
+ const context = translated.slice(-config.translation.contextParagraphs).map((item) => ({
51
+ original: item.original,
52
+ translations: item.translations,
53
+ }));
54
+ const payload = {
55
+ model: config.translation.api.model,
56
+ temperature: config.translation.temperature,
57
+ response_format: { type: "json_object" },
58
+ messages: [
59
+ { role: "system", content: systemPrompt(config) },
60
+ {
61
+ role: "user",
62
+ content: JSON.stringify({
63
+ sourceLanguage: config.translation.sourceLanguage,
64
+ targetLanguage: config.translation.targetLanguage,
65
+ rollingBilingualContext: context,
66
+ establishedGlossary: glossary,
67
+ paragraph: paragraph.original,
68
+ earlierInThisParagraph: earlierInParagraph,
69
+ sentences: sentences.map((text, index) => ({ index: sentenceOffset + index, text })),
70
+ }),
71
+ },
72
+ ],
73
+ };
74
+
75
+ const response = await fetch(config.translation.api.endpoint, {
76
+ method: "POST",
77
+ headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
78
+ body: JSON.stringify(payload),
79
+ signal: AbortSignal.timeout(config.translation.api.timeoutMs),
80
+ });
81
+ const raw = await response.text();
82
+ let body: ChatCompletionResponse;
83
+ try { body = JSON.parse(raw) as ChatCompletionResponse; } catch { throw new Error(`翻译 API HTTP ${response.status} 返回了非 JSON 响应`); }
84
+ if (!response.ok) throw new Error(`翻译 API HTTP ${response.status}: ${body.error?.message ?? response.statusText}`);
85
+ const content = body.choices?.[0]?.message?.content;
86
+ if (!content) throw new Error("翻译 API 返回了空响应");
87
+ const parsed = parseModelJson(content);
88
+ if (!Array.isArray(parsed.translations) || parsed.translations.length !== sentences.length) {
89
+ throw new Error(`译文数量不符:期望 ${sentences.length},得到 ${parsed.translations?.length ?? 0}`);
90
+ }
91
+ if (parsed.translations.some((item) => typeof item !== "string" || !item.trim())) throw new Error("译文包含空项");
92
+ return parsed;
93
+ }
94
+
95
+ async function translateOne(
96
+ paragraph: Paragraph,
97
+ translated: TranslatedParagraph[],
98
+ glossary: GlossaryEntry[],
99
+ apiKey: string,
100
+ config: AppConfig,
101
+ ): Promise<ModelTranslation> {
102
+ const translations: string[] = [];
103
+ const glossaryUpdates: GlossaryEntry[] = [];
104
+
105
+ async function alignedBatch(offset: number, sentences: string[]): Promise<ModelTranslation> {
106
+ let result: ModelTranslation | undefined;
107
+ let lastError: unknown;
108
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
109
+ try {
110
+ result = await translateBatch(
111
+ paragraph,
112
+ offset,
113
+ sentences,
114
+ paragraph.sentences.slice(0, offset).map((text, index) => ({ text, translation: translations[index] })),
115
+ translated,
116
+ glossary,
117
+ apiKey,
118
+ config,
119
+ );
120
+ break;
121
+ } catch (error) {
122
+ lastError = error;
123
+ if (attempt < 2) await Bun.sleep(750);
124
+ }
125
+ }
126
+ if (result) return result;
127
+ if (sentences.length === 1) throw lastError;
128
+
129
+ const middle = Math.ceil(sentences.length / 2);
130
+ const left = await alignedBatch(offset, sentences.slice(0, middle));
131
+ translations.push(...left.translations);
132
+ glossaryUpdates.push(...(left.glossaryUpdates ?? []));
133
+ return alignedBatch(offset + middle, sentences.slice(middle));
134
+ }
135
+
136
+ const batchSize = config.translation.batchSize;
137
+ for (let offset = 0; offset < paragraph.sentences.length; offset += batchSize) {
138
+ const result = await alignedBatch(offset, paragraph.sentences.slice(offset, offset + batchSize));
139
+ translations.push(...result.translations);
140
+ glossaryUpdates.push(...(result.glossaryUpdates ?? []));
141
+ }
142
+ return { translations, glossaryUpdates };
143
+ }
144
+
145
+ export async function translateAll(state: TranslationState, statePath: string, config: AppConfig): Promise<TranslationState> {
146
+ const apiKey = resolveApiKey(config);
147
+ if (!apiKey) throw new Error(`缺少 API Key;请设置环境变量 ${config.translation.api.apiKeyEnv},或在 translation.api.api_key 中配置`);
148
+
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));
163
+ }
164
+ }
165
+ }
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("完成");
173
+ }
174
+ return state;
175
+ }