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