@makuraryu/yonde 0.4.0 → 0.4.2

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
@@ -148,9 +148,11 @@ package_asset = "uisfx/sounds/cinematic/select.mp3"
148
148
 
149
149
  翻译缓存指纹包含输入内容、语言、模型、端点、提示词版本和分句规则,但不包含 API Key。音频缓存指纹包含文本、音色、语言、语速和音调。仅调整朗读顺序时,Yonde 会复用已有语音并重新合并。
150
150
 
151
+ 缓存文件采用原子写入;命中缓存时只检查文件元数据,避免 iCloud 为数千个小文件执行随机头部读取。最终拼接再按朗读顺序连续读取音频内容。
152
+
151
153
  翻译、语音生成和最终 MP3 合并都会显示单行进度条,包括完成比例、数量、耗时和 ETA。非交互终端按 5% 里程碑输出,避免日志刷屏。
152
154
 
153
- 所有语音和分隔音效会统一为 24 kHz、单声道、96 kbps,最终 MP3 采用无损快速拼接,不再把数小时音频完整重编码。
155
+ 所有语音和分隔音效会统一为 24 kHz、单声道、96 kbps,最终 MP3 采用无损快速拼接,不再把数小时音频完整重编码。合并中的大临时文件写在本机临时目录,完成后再一次性写入目标位置,避免 iCloud 持续同步一个不断增长的文件。
154
156
 
155
157
  最终合并使用状态文件保护原子写入。若进程在 ffmpeg 已完成后、最终重命名前退出,下次运行会直接恢复成品;若在合并中途退出,只会重做最终合并,已经生成的翻译和 TTS 缓存不会丢失。
156
158
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makuraryu/yonde",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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";
@@ -47,11 +48,18 @@ function specHash(spec: AudioSpec): string {
47
48
  .slice(0, 24);
48
49
  }
49
50
 
50
- async function synthesize(spec: AudioSpec, cacheDir: string, ffmpegPath: string): Promise<SynthesisResult> {
51
+ async function synthesize(
52
+ spec: AudioSpec,
53
+ cacheDir: string,
54
+ ffmpegPath: string,
55
+ existingCacheNames: ReadonlySet<string>,
56
+ ): Promise<SynthesisResult> {
51
57
  const hash = specHash(spec);
52
58
  const safeId = spec.kind.replace(/[^a-zA-Z0-9_-]/g, "_");
53
59
  const output = join(cacheDir, `${safeId}-${hash}.mp3`);
54
- if (await fileIsUsable(output)) return { path: output, cached: true };
60
+ // Cache files are atomically renamed after a successful synthesis. A single
61
+ // directory listing avoids thousands of slow per-file metadata reads on iCloud.
62
+ if (existingCacheNames.has(basename(output))) return { path: output, cached: true };
55
63
 
56
64
  const temporary = `${output}.${process.pid}.${randomUUID()}.part.mp3`;
57
65
  if (!/[\p{L}\p{N}]/u.test(spec.text)) {
@@ -111,17 +119,22 @@ export function audioMergeFingerprint(value: Omit<AudioManifest, "version" | "me
111
119
  return new Bun.CryptoHasher("sha256").update(JSON.stringify(value)).digest("hex");
112
120
  }
113
121
 
114
- export function mergeStateCanRecover(value: unknown, fingerprint: string, outputPath: string): value is MergeState {
122
+ function mergeTemporaryIsSafe(value: unknown, outputPath: string): value is MergeState {
115
123
  if (!value || typeof value !== "object") return false;
116
124
  const state = value as Partial<MergeState>;
117
- 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>;
118
134
  return state.version === 1
119
135
  && state.status === "complete"
120
136
  && state.fingerprint === fingerprint
121
- && typeof state.temporary === "string"
122
- && dirname(state.temporary) === dirname(outputPath)
123
- && basename(state.temporary).startsWith(prefix)
124
- && basename(state.temporary).endsWith(".tmp.mp3");
137
+ && typeof state.expectedSeconds === "number";
125
138
  }
126
139
 
127
140
  async function temporaryOutputs(outputPath: string): Promise<string[]> {
@@ -145,11 +158,23 @@ async function estimateDurationSeconds(items: AudioItem[]): Promise<number> {
145
158
  return Math.max(1, bytes * 8 / 96_000);
146
159
  }
147
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
+
148
172
  async function recoverCompletedMerge(outputPath: string, stateDir: string, fingerprint: string): Promise<boolean> {
149
173
  const statePath = join(stateDir, "audio-merge-state.json");
150
174
  const state = await readJson<unknown>(statePath);
151
175
  if (!mergeStateCanRecover(state, fingerprint, outputPath) || !(await fileIsUsable(state.temporary))) return false;
152
- await rename(state.temporary, outputPath);
176
+ await installMergedOutput(state.temporary, outputPath);
177
+ await rm(dirname(state.temporary), { recursive: true, force: true });
153
178
  await rm(statePath, { force: true });
154
179
  for (const stale of await temporaryOutputs(outputPath)) await rm(stale, { force: true });
155
180
  const progress = new ProgressBar("恢复", 1);
@@ -157,6 +182,13 @@ async function recoverCompletedMerge(outputPath: string, stateDir: string, finge
157
182
  return true;
158
183
  }
159
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
+
160
192
  async function readFfmpegProgress(stream: ReadableStream<Uint8Array>, progress: ProgressBar, totalSeconds: number): Promise<void> {
161
193
  const reader = stream.getReader();
162
194
  const decoder = new TextDecoder();
@@ -186,7 +218,8 @@ async function runFfmpeg(
186
218
  const paths = items.map((item) => relative(stateDir, item.path));
187
219
  if (paths.some((path) => path.startsWith("..") || /[\r\n\0]/.test(path))) throw new Error("音频缓存路径超出状态目录或含控制字符");
188
220
  await atomicWrite(listPath, paths.map((path) => `file '${escapeConcatPath(path)}'`).join("\n") + "\n");
189
- 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`);
190
223
  const mergeStatePath = join(stateDir, "audio-merge-state.json");
191
224
  const expectedSeconds = await estimateDurationSeconds(items);
192
225
  const progress = new ProgressBar("合并", expectedSeconds);
@@ -209,7 +242,7 @@ async function runFfmpeg(
209
242
  const stderr = await stderrReader;
210
243
  if (exitCode !== 0) {
211
244
  progress.fail(`退出码 ${exitCode}`);
212
- await rm(temporary, { force: true });
245
+ await rm(temporaryDir, { recursive: true, force: true });
213
246
  await rm(mergeStatePath, { force: true });
214
247
  const detail = stderr.trim().split("\n").at(-1);
215
248
  throw new Error(`ffmpeg 合并失败,退出码 ${exitCode}${detail ? `: ${detail}` : ""}`);
@@ -222,7 +255,8 @@ async function runFfmpeg(
222
255
  expectedSeconds,
223
256
  } satisfies MergeState);
224
257
  progress.finish(`${items.length} 个片段`);
225
- await rename(temporary, outputPath);
258
+ await installMergedOutput(temporary, outputPath);
259
+ await rm(temporaryDir, { recursive: true, force: true });
226
260
  await rm(mergeStatePath, { force: true });
227
261
  }
228
262
 
@@ -273,7 +307,9 @@ export async function buildAudio(
273
307
  if (!ffmpegPath) throw new Error("找不到 ffmpeg;请先安装 ffmpeg,或仅运行 --stage translate");
274
308
  const cacheDir = join(stateDir, "audio-cache");
275
309
  await mkdir(cacheDir, { recursive: true });
276
- for (const entry of await readdir(cacheDir)) if (entry.endsWith(".part.mp3")) await rm(join(cacheDir, entry), { force: true });
310
+ const cacheEntries = await readdir(cacheDir);
311
+ for (const entry of cacheEntries) if (entry.endsWith(".part.mp3")) await rm(join(cacheDir, entry), { force: true });
312
+ const existingCacheNames = new Set(cacheEntries.filter((entry) => entry.endsWith(".mp3")));
277
313
  const outputDir = dirname(outputPath);
278
314
  await mkdir(outputDir, { recursive: true });
279
315
 
@@ -316,7 +352,7 @@ export async function buildAudio(
316
352
  let results: SynthesisResult[];
317
353
  try {
318
354
  results = await runPool(
319
- entries.map(([, spec]) => () => synthesize(spec, cacheDir, ffmpegPath)),
355
+ entries.map(([, spec]) => () => synthesize(spec, cacheDir, ffmpegPath, existingCacheNames)),
320
356
  config.audio.concurrency,
321
357
  (result) => {
322
358
  completed += 1;
@@ -343,6 +379,7 @@ export async function buildAudio(
343
379
  };
344
380
  const mergeFingerprint = audioMergeFingerprint(manifestBody);
345
381
  if (await recoverCompletedMerge(outputPath, stateDir, mergeFingerprint)) return;
382
+ await discardStaleMerge(outputPath, stateDir);
346
383
  const stale = await temporaryOutputs(outputPath);
347
384
  if (stale.length) console.warn(`发现 ${stale.length} 个未完成的合并临时文件,将保留语音缓存并重新合并。`);
348
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.0";
14
+ const VERSION = "0.4.2";
15
15
 
16
16
  export const HELP_TEXT = `Yonde ${VERSION} — 配置驱动的双语听力材料生成器
17
17