@makuraryu/yonde 0.4.2 → 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 +2 -0
- package/package.json +1 -1
- package/src/main.ts +1 -1
- package/src/translate.ts +30 -2
package/README.md
CHANGED
|
@@ -152,6 +152,8 @@ package_asset = "uisfx/sounds/cinematic/select.mp3"
|
|
|
152
152
|
|
|
153
153
|
翻译、语音生成和最终 MP3 合并都会显示单行进度条,包括完成比例、数量、耗时和 ETA。非交互终端按 5% 里程碑输出,避免日志刷屏。
|
|
154
154
|
|
|
155
|
+
如果模型把一个输入句子的译文错误拆成多项,Yonde 会先带着数量校验错误进行严格重试;仍不符合时,仅对单句结果执行顺序合并,避免检查点永久卡在同一段。
|
|
156
|
+
|
|
155
157
|
所有语音和分隔音效会统一为 24 kHz、单声道、96 kbps,最终 MP3 采用无损快速拼接,不再把数小时音频完整重编码。合并中的大临时文件写在本机临时目录,完成后再一次性写入目标位置,避免 iCloud 持续同步一个不断增长的文件。
|
|
156
158
|
|
|
157
159
|
最终合并使用状态文件保护原子写入。若进程在 ffmpeg 已完成后、最终重命名前退出,下次运行会直接恢复成品;若在合并中途退出,只会重做最终合并,已经生成的翻译和 TTS 缓存不会丢失。
|
package/package.json
CHANGED
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));
|