@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 +3 -1
- package/package.json +1 -1
- package/src/audio.ts +52 -15
- package/src/main.ts +1 -1
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
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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.
|
|
14
|
+
const VERSION = "0.4.2";
|
|
15
15
|
|
|
16
16
|
export const HELP_TEXT = `Yonde ${VERSION} — 配置驱动的双语听力材料生成器
|
|
17
17
|
|