@makuraryu/yonde 0.4.1 → 0.4.3
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 +3 -1
- package/package.json +1 -1
- package/src/audio.ts +39 -11
- package/src/main.ts +1 -1
- package/src/translate.ts +30 -2
package/README.md
CHANGED
|
@@ -152,7 +152,9 @@ package_asset = "uisfx/sounds/cinematic/select.mp3"
|
|
|
152
152
|
|
|
153
153
|
翻译、语音生成和最终 MP3 合并都会显示单行进度条,包括完成比例、数量、耗时和 ETA。非交互终端按 5% 里程碑输出,避免日志刷屏。
|
|
154
154
|
|
|
155
|
-
|
|
155
|
+
如果模型把一个输入句子的译文错误拆成多项,Yonde 会先带着数量校验错误进行严格重试;仍不符合时,仅对单句结果执行顺序合并,避免检查点永久卡在同一段。
|
|
156
|
+
|
|
157
|
+
所有语音和分隔音效会统一为 24 kHz、单声道、96 kbps,最终 MP3 采用无损快速拼接,不再把数小时音频完整重编码。合并中的大临时文件写在本机临时目录,完成后再一次性写入目标位置,避免 iCloud 持续同步一个不断增长的文件。
|
|
156
158
|
|
|
157
159
|
最终合并使用状态文件保护原子写入。若进程在 ffmpeg 已完成后、最终重命名前退出,下次运行会直接恢复成品;若在合并中途退出,只会重做最终合并,已经生成的翻译和 TTS 缓存不会丢失。
|
|
158
160
|
|
package/package.json
CHANGED
package/src/audio.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { EdgeTTS } from "node-edge-tts";
|
|
2
|
-
import { lstat, mkdir, readdir, rename, rm } from "node:fs/promises";
|
|
2
|
+
import { copyFile, lstat, mkdir, mkdtemp, readdir, rename, rm } from "node:fs/promises";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
4
5
|
import { basename, dirname, join, relative } from "node:path";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
import type { AppConfig, VoiceProfile } from "./config";
|
|
@@ -118,17 +119,22 @@ export function audioMergeFingerprint(value: Omit<AudioManifest, "version" | "me
|
|
|
118
119
|
return new Bun.CryptoHasher("sha256").update(JSON.stringify(value)).digest("hex");
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
|
|
122
|
+
function mergeTemporaryIsSafe(value: unknown, outputPath: string): value is MergeState {
|
|
122
123
|
if (!value || typeof value !== "object") return false;
|
|
123
124
|
const state = value as Partial<MergeState>;
|
|
124
|
-
|
|
125
|
+
return typeof state.temporary === "string"
|
|
126
|
+
&& dirname(dirname(state.temporary)) === tmpdir()
|
|
127
|
+
&& basename(dirname(state.temporary)).startsWith("yonde-merge-")
|
|
128
|
+
&& basename(state.temporary) === `${basename(outputPath)}.tmp.mp3`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function mergeStateCanRecover(value: unknown, fingerprint: string, outputPath: string): value is MergeState {
|
|
132
|
+
if (!mergeTemporaryIsSafe(value, outputPath)) return false;
|
|
133
|
+
const state = value as Partial<MergeState>;
|
|
125
134
|
return state.version === 1
|
|
126
135
|
&& state.status === "complete"
|
|
127
136
|
&& state.fingerprint === fingerprint
|
|
128
|
-
&& typeof state.
|
|
129
|
-
&& dirname(state.temporary) === dirname(outputPath)
|
|
130
|
-
&& basename(state.temporary).startsWith(prefix)
|
|
131
|
-
&& basename(state.temporary).endsWith(".tmp.mp3");
|
|
137
|
+
&& typeof state.expectedSeconds === "number";
|
|
132
138
|
}
|
|
133
139
|
|
|
134
140
|
async function temporaryOutputs(outputPath: string): Promise<string[]> {
|
|
@@ -152,11 +158,23 @@ async function estimateDurationSeconds(items: AudioItem[]): Promise<number> {
|
|
|
152
158
|
return Math.max(1, bytes * 8 / 96_000);
|
|
153
159
|
}
|
|
154
160
|
|
|
161
|
+
async function installMergedOutput(source: string, outputPath: string): Promise<void> {
|
|
162
|
+
const staged = `${outputPath}.${process.pid}.${randomUUID()}.tmp.mp3`;
|
|
163
|
+
try {
|
|
164
|
+
await copyFile(source, staged);
|
|
165
|
+
await rename(staged, outputPath);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
await rm(staged, { force: true });
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
155
172
|
async function recoverCompletedMerge(outputPath: string, stateDir: string, fingerprint: string): Promise<boolean> {
|
|
156
173
|
const statePath = join(stateDir, "audio-merge-state.json");
|
|
157
174
|
const state = await readJson<unknown>(statePath);
|
|
158
175
|
if (!mergeStateCanRecover(state, fingerprint, outputPath) || !(await fileIsUsable(state.temporary))) return false;
|
|
159
|
-
await
|
|
176
|
+
await installMergedOutput(state.temporary, outputPath);
|
|
177
|
+
await rm(dirname(state.temporary), { recursive: true, force: true });
|
|
160
178
|
await rm(statePath, { force: true });
|
|
161
179
|
for (const stale of await temporaryOutputs(outputPath)) await rm(stale, { force: true });
|
|
162
180
|
const progress = new ProgressBar("恢复", 1);
|
|
@@ -164,6 +182,13 @@ async function recoverCompletedMerge(outputPath: string, stateDir: string, finge
|
|
|
164
182
|
return true;
|
|
165
183
|
}
|
|
166
184
|
|
|
185
|
+
async function discardStaleMerge(outputPath: string, stateDir: string): Promise<void> {
|
|
186
|
+
const statePath = join(stateDir, "audio-merge-state.json");
|
|
187
|
+
const state = await readJson<unknown>(statePath);
|
|
188
|
+
if (mergeTemporaryIsSafe(state, outputPath)) await rm(dirname(state.temporary), { recursive: true, force: true });
|
|
189
|
+
await rm(statePath, { force: true });
|
|
190
|
+
}
|
|
191
|
+
|
|
167
192
|
async function readFfmpegProgress(stream: ReadableStream<Uint8Array>, progress: ProgressBar, totalSeconds: number): Promise<void> {
|
|
168
193
|
const reader = stream.getReader();
|
|
169
194
|
const decoder = new TextDecoder();
|
|
@@ -193,7 +218,8 @@ async function runFfmpeg(
|
|
|
193
218
|
const paths = items.map((item) => relative(stateDir, item.path));
|
|
194
219
|
if (paths.some((path) => path.startsWith("..") || /[\r\n\0]/.test(path))) throw new Error("音频缓存路径超出状态目录或含控制字符");
|
|
195
220
|
await atomicWrite(listPath, paths.map((path) => `file '${escapeConcatPath(path)}'`).join("\n") + "\n");
|
|
196
|
-
const
|
|
221
|
+
const temporaryDir = await mkdtemp(join(tmpdir(), "yonde-merge-"));
|
|
222
|
+
const temporary = join(temporaryDir, `${basename(outputPath)}.tmp.mp3`);
|
|
197
223
|
const mergeStatePath = join(stateDir, "audio-merge-state.json");
|
|
198
224
|
const expectedSeconds = await estimateDurationSeconds(items);
|
|
199
225
|
const progress = new ProgressBar("合并", expectedSeconds);
|
|
@@ -216,7 +242,7 @@ async function runFfmpeg(
|
|
|
216
242
|
const stderr = await stderrReader;
|
|
217
243
|
if (exitCode !== 0) {
|
|
218
244
|
progress.fail(`退出码 ${exitCode}`);
|
|
219
|
-
await rm(
|
|
245
|
+
await rm(temporaryDir, { recursive: true, force: true });
|
|
220
246
|
await rm(mergeStatePath, { force: true });
|
|
221
247
|
const detail = stderr.trim().split("\n").at(-1);
|
|
222
248
|
throw new Error(`ffmpeg 合并失败,退出码 ${exitCode}${detail ? `: ${detail}` : ""}`);
|
|
@@ -229,7 +255,8 @@ async function runFfmpeg(
|
|
|
229
255
|
expectedSeconds,
|
|
230
256
|
} satisfies MergeState);
|
|
231
257
|
progress.finish(`${items.length} 个片段`);
|
|
232
|
-
await
|
|
258
|
+
await installMergedOutput(temporary, outputPath);
|
|
259
|
+
await rm(temporaryDir, { recursive: true, force: true });
|
|
233
260
|
await rm(mergeStatePath, { force: true });
|
|
234
261
|
}
|
|
235
262
|
|
|
@@ -352,6 +379,7 @@ export async function buildAudio(
|
|
|
352
379
|
};
|
|
353
380
|
const mergeFingerprint = audioMergeFingerprint(manifestBody);
|
|
354
381
|
if (await recoverCompletedMerge(outputPath, stateDir, mergeFingerprint)) return;
|
|
382
|
+
await discardStaleMerge(outputPath, stateDir);
|
|
355
383
|
const stale = await temporaryOutputs(outputPath);
|
|
356
384
|
if (stale.length) console.warn(`发现 ${stale.length} 个未完成的合并临时文件,将保留语音缓存并重新合并。`);
|
|
357
385
|
for (const path of stale) await rm(path, { force: true });
|
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.4.
|
|
14
|
+
const VERSION = "0.4.3";
|
|
15
15
|
|
|
16
16
|
export const HELP_TEXT = `Yonde ${VERSION} — 配置驱动的双语听力材料生成器
|
|
17
17
|
|
package/src/translate.ts
CHANGED
|
@@ -15,6 +15,17 @@ type ModelTranslation = {
|
|
|
15
15
|
glossaryUpdates?: GlossaryEntry[];
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
class TranslationCountError extends Error {
|
|
19
|
+
constructor(readonly result: ModelTranslation, expected: number) {
|
|
20
|
+
super(`译文数量不符:期望 ${expected},得到 ${Array.isArray(result.translations) ? result.translations.length : 0}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function coalesceSingletonTranslations(value: unknown): string | undefined {
|
|
25
|
+
if (!Array.isArray(value) || value.length < 2 || value.some((item) => typeof item !== "string" || !item.trim())) return undefined;
|
|
26
|
+
return value.map((item) => item.trim()).join("");
|
|
27
|
+
}
|
|
28
|
+
|
|
18
29
|
function systemPrompt(config: AppConfig): string {
|
|
19
30
|
return `你是文学翻译者。任务是把 ${config.translation.sourceLanguage} 文学片段翻译成自然、准确、适合听力学习的 ${config.translation.targetLanguage}。
|
|
20
31
|
必须保持叙述人称、人物称谓、专有名词和文体与已有上下文一致。不要解释,不要合并或拆分输入片段。
|
|
@@ -47,6 +58,7 @@ async function translateBatch(
|
|
|
47
58
|
glossary: GlossaryEntry[],
|
|
48
59
|
apiKey: string,
|
|
49
60
|
config: AppConfig,
|
|
61
|
+
validationFeedback?: string,
|
|
50
62
|
): Promise<ModelTranslation> {
|
|
51
63
|
const context = translated.slice(-config.translation.contextParagraphs).map((item) => ({
|
|
52
64
|
original: item.original,
|
|
@@ -67,6 +79,8 @@ async function translateBatch(
|
|
|
67
79
|
establishedGlossary: glossary,
|
|
68
80
|
paragraph: paragraph.original,
|
|
69
81
|
earlierInThisParagraph: earlierInParagraph,
|
|
82
|
+
requiredTranslationCount: sentences.length,
|
|
83
|
+
validationFeedback,
|
|
70
84
|
sentences: sentences.map((text, index) => ({ index: sentenceOffset + index, text })),
|
|
71
85
|
}),
|
|
72
86
|
},
|
|
@@ -87,7 +101,7 @@ async function translateBatch(
|
|
|
87
101
|
if (!content) throw new Error("翻译 API 返回了空响应");
|
|
88
102
|
const parsed = parseModelJson(content);
|
|
89
103
|
if (!Array.isArray(parsed.translations) || parsed.translations.length !== sentences.length) {
|
|
90
|
-
throw new
|
|
104
|
+
throw new TranslationCountError(parsed, sentences.length);
|
|
91
105
|
}
|
|
92
106
|
if (parsed.translations.some((item) => typeof item !== "string" || !item.trim())) throw new Error("译文包含空项");
|
|
93
107
|
return parsed;
|
|
@@ -106,6 +120,8 @@ async function translateOne(
|
|
|
106
120
|
async function alignedBatch(offset: number, sentences: string[]): Promise<ModelTranslation> {
|
|
107
121
|
let result: ModelTranslation | undefined;
|
|
108
122
|
let lastError: unknown;
|
|
123
|
+
let repairableResult: ModelTranslation | undefined;
|
|
124
|
+
let validationFeedback: string | undefined;
|
|
109
125
|
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
110
126
|
try {
|
|
111
127
|
result = await translateBatch(
|
|
@@ -117,15 +133,27 @@ async function translateOne(
|
|
|
117
133
|
glossary,
|
|
118
134
|
apiKey,
|
|
119
135
|
config,
|
|
136
|
+
validationFeedback,
|
|
120
137
|
);
|
|
121
138
|
break;
|
|
122
139
|
} catch (error) {
|
|
123
140
|
lastError = error;
|
|
141
|
+
if (error instanceof TranslationCountError) {
|
|
142
|
+
validationFeedback = `${error.message}。请重新输出,translations 必须严格只有 ${sentences.length} 项;不要把同一个句子拆成多项,也不要另行翻译整个 paragraph。`;
|
|
143
|
+
if (sentences.length === 1 && coalesceSingletonTranslations(error.result.translations)) repairableResult = error.result;
|
|
144
|
+
}
|
|
124
145
|
if (attempt < 2) await Bun.sleep(750);
|
|
125
146
|
}
|
|
126
147
|
}
|
|
127
148
|
if (result) return result;
|
|
128
|
-
if (sentences.length === 1)
|
|
149
|
+
if (sentences.length === 1) {
|
|
150
|
+
const repaired = coalesceSingletonTranslations(repairableResult?.translations);
|
|
151
|
+
if (repaired) return {
|
|
152
|
+
translations: [repaired],
|
|
153
|
+
glossaryUpdates: Array.isArray(repairableResult?.glossaryUpdates) ? repairableResult.glossaryUpdates : [],
|
|
154
|
+
};
|
|
155
|
+
throw lastError;
|
|
156
|
+
}
|
|
129
157
|
|
|
130
158
|
const middle = Math.ceil(sentences.length / 2);
|
|
131
159
|
const left = await alignedBatch(offset, sentences.slice(0, middle));
|