


























@@ -0,0 +1,134 @@
1+import { spawn } from "node:child_process";
2+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3+import { join } from "node:path";
4+import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/sandbox";
5+6+/** Container token (file-extension shape, no leading dot) the host knows how
7+ * to pre-transcode into. Update in lockstep with `pickAfconvertRecipe`. */
8+export type HostTranscodableContainer = "caf";
9+10+export type TranscodeOutcome =
11+| { ok: true; buffer: Buffer }
12+| {
13+ok: false;
14+reason:
15+| "platform-unsupported"
16+| "invalid-extension"
17+| "noop-same-container"
18+| "no-recipe"
19+| "transcoder-failed";
20+detail?: string;
21+};
22+23+/**
24+ * Best-effort audio container transcode using macOS `afconvert`.
25+ *
26+ * Used by the TTS pipeline to pre-encode synthesized audio into a channel's
27+ * preferred container (see `ChannelTtsVoiceDeliveryCapabilities.preferAudioFileFormat`)
28+ * so the channel's downstream does not have to perform a container
29+ * conversion of its own. Returns a discriminated outcome so callers can
30+ * distinguish "we didn't try" (platform/recipe/noop) from "we tried and the
31+ * transcoder failed", which is the case worth logging.
32+ *
33+ * Currently only macOS is supported because `afconvert` is the only widely
34+ * available encoder we ship a recipe for.
35+ */
36+export async function transcodeAudioBuffer(params: {
37+audioBuffer: Buffer;
38+sourceExtension: string;
39+targetExtension: string;
40+timeoutMs?: number;
41+}): Promise<TranscodeOutcome> {
42+// Validate inputs first so callers get a specific reason regardless of
43+// host platform. Platform-unsupported is the gate immediately before the
44+// actual `afconvert` invocation.
45+const source = normalizeExt(params.sourceExtension);
46+const target = normalizeExt(params.targetExtension);
47+if (!source || !target) {
48+return { ok: false, reason: "invalid-extension" };
49+}
50+if (source === target) {
51+return { ok: false, reason: "noop-same-container" };
52+}
53+const recipe = pickAfconvertRecipe(source, target);
54+if (!recipe) {
55+return { ok: false, reason: "no-recipe" };
56+}
57+if (process.platform !== "darwin") {
58+return { ok: false, reason: "platform-unsupported" };
59+}
60+61+const tmpRoot = resolvePreferredOpenClawTmpDir();
62+mkdirSync(tmpRoot, { recursive: true, mode: 0o700 });
63+const tmpDir = mkdtempSync(join(tmpRoot, "tts-transcode-"));
64+const inPath = join(tmpDir, `in.${source}`);
65+const outPath = join(tmpDir, `out.${target}`);
66+try {
67+writeFileSync(inPath, params.audioBuffer, { mode: 0o600 });
68+const result = await runAfconvert({
69+args: [...recipe, inPath, outPath],
70+timeoutMs: params.timeoutMs ?? 5000,
71+});
72+if (!result.ok) {
73+return { ok: false, reason: "transcoder-failed", detail: result.detail };
74+}
75+return { ok: true, buffer: readFileSync(outPath) };
76+} catch (err) {
77+return { ok: false, reason: "transcoder-failed", detail: (err as Error).message };
78+} finally {
79+try {
80+rmSync(tmpDir, { recursive: true, force: true });
81+} catch {
82+// best-effort cleanup
83+}
84+}
85+}
86+87+function normalizeExt(ext: string): string | undefined {
88+// Pattern matches the sibling helper in src/media/audio-transcode.ts: a short
89+// alphanumeric extension token. Keeps the value safe to interpolate into
90+// tmp-file names below without introducing a path-traversal surface.
91+const trimmed = ext.trim().toLowerCase().replace(/^\./, "");
92+return /^[a-z0-9]{1,12}$/.test(trimmed) ? trimmed : undefined;
93+}
94+95+function pickAfconvertRecipe(source: string, target: string): string[] | undefined {
96+// Currently only the MP3→CAF path used by BlueBubbles voice memos. Keep
97+// this in lockstep with `HostTranscodableContainer` above so a typo at the
98+// channel-capability declaration site is a compile-time error.
99+if (target === "caf") {
100+// Opus-in-CAF, mono, 24 kHz. Validated against macOS 15.x Messages.app's
101+// native voice-memo CAF descriptor (1 ch, 24000 Hz, opus); other CAF
102+// flavors (PCM, AAC) get downgraded to plain audio attachments along the
103+// BlueBubbles → Messages.app path. If iMessage stops rendering the result
104+// as a voice memo after a system update, try forcing frames-per-packet
105+// explicitly via `opus@24000#480` and re-validate. See #72506.
106+return ["-f", "caff", "-d", "opus@24000", "-c", "1"];
107+}
108+return undefined;
109+}
110+111+function runAfconvert(params: {
112+args: string[];
113+timeoutMs: number;
114+}): Promise<{ ok: true } | { ok: false; detail: string }> {
115+return new Promise((resolve) => {
116+const child = spawn("/usr/bin/afconvert", params.args, { stdio: "ignore" });
117+const timer = setTimeout(() => {
118+child.kill("SIGKILL");
119+resolve({ ok: false, detail: `timeout-${params.timeoutMs}ms` });
120+}, params.timeoutMs);
121+child.once("error", (err) => {
122+clearTimeout(timer);
123+resolve({ ok: false, detail: err.message });
124+});
125+child.once("exit", (code) => {
126+clearTimeout(timer);
127+if (code === 0) {
128+resolve({ ok: true });
129+} else {
130+resolve({ ok: false, detail: `exit-${code ?? "unknown"}` });
131+}
132+});
133+});
134+}
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。