@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makuraryu/yonde",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Config-driven Bun CLI for creating bilingual Japanese listening scripts and MP3 audio",
5
5
  "type": "module",
6
6
  "bin": {
package/src/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.2";
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 Error(`译文数量不符:期望 ${sentences.length},得到 ${parsed.translations?.length ?? 0}`);
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) throw lastError;
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));