fix(qqbot): remove native ffmpeg subprocess fallback

This commit is contained in:
Vincent Koc
2026-05-02 16:38:07 -07:00
parent d1c5e750ed
commit 85520f664d
3 changed files with 11 additions and 170 deletions

View File

@@ -3,18 +3,16 @@
* 音频格式转换工具。
*
* Handles SILK ↔ PCM ↔ WAV ↔ MP3 conversions for QQ Bot voice messaging.
* Prefers ffmpeg when available; falls back to WASM decoders (silk-wasm,
* mpg123-decoder) for environments without native tooling.
* Uses WASM decoders (silk-wasm, mpg123-decoder) and direct QQ-native uploads
* without launching native subprocesses.
*
* Self-contained within engine/ — no framework SDK dependency.
*/
import { execFile } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import { formatErrorMessage } from "./format.js";
import { debugLog, debugError, debugWarn } from "./log.js";
import { detectFfmpeg, isWindows } from "./platform.js";
import { normalizeLowercaseStringOrEmpty as normalizeLowercase } from "./string-normalize.js";
type SilkWasm = typeof import("silk-wasm");
@@ -184,7 +182,7 @@ function normalizeFormats(formats: string[]): string[] {
/**
* Convert a local audio file to Base64-encoded SILK for QQ API upload.
*
* Attempts conversion via ffmpeg → WASM decoders → null fallback chain.
* Attempts conversion via direct QQ-native upload → WASM decoders → null fallback chain.
*/
export async function audioFileToSilkBase64(
filePath: string,
@@ -234,25 +232,6 @@ export async function audioFileToSilkBase64(
const targetRate = 24000;
const ffmpegCmd = await detectFfmpeg();
if (ffmpegCmd) {
try {
debugLog(
`[audio-convert] ffmpeg (${ffmpegCmd}): converting ${ext} (${buf.length} bytes) → PCM s16le ${targetRate}Hz`,
);
const pcmBuf = await ffmpegToPCM(ffmpegCmd, filePath, targetRate);
if (pcmBuf.length === 0) {
debugError(`[audio-convert] ffmpeg produced empty PCM output`);
return null;
}
const { silkBuffer } = await pcmToSilk(pcmBuf, targetRate);
debugLog(`[audio-convert] ffmpeg: ${ext} → SILK done (${silkBuffer.length} bytes)`);
return silkBuffer.toString("base64");
} catch (err) {
debugError(`[audio-convert] ffmpeg conversion failed: ${formatErrorMessage(err)}`);
}
}
debugLog(`[audio-convert] fallback: trying WASM decoders for ${ext}`);
if (ext === ".pcm") {
@@ -278,12 +257,9 @@ export async function audioFileToSilkBase64(
}
}
const installHint = isWindows()
? "Install ffmpeg with choco install ffmpeg, scoop install ffmpeg, or from https://ffmpeg.org"
: process.platform === "darwin"
? "Install ffmpeg with brew install ffmpeg"
: "Install ffmpeg with sudo apt install ffmpeg or sudo yum install ffmpeg";
debugError(`[audio-convert] unsupported format: ${ext} (no ffmpeg available). ${installHint}`);
debugError(
`[audio-convert] unsupported format without native subprocess conversion: ${ext}. Use QQ-native voice formats or WAV/MP3/PCM inputs.`,
);
return null;
}
@@ -386,48 +362,7 @@ async function pcmToSilk(
};
}
/** Use ffmpeg to convert any audio to mono 24 kHz PCM s16le. */
function ffmpegToPCM(
ffmpegCmd: string,
inputPath: string,
sampleRate: number = 24000,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const args = [
"-i",
inputPath,
"-f",
"s16le",
"-ar",
String(sampleRate),
"-ac",
"1",
"-acodec",
"pcm_s16le",
"-v",
"error",
"pipe:1",
];
execFile(
ffmpegCmd,
args,
{
maxBuffer: 50 * 1024 * 1024,
encoding: "buffer",
...(isWindows() ? { windowsHide: true } : {}),
},
(err, stdout) => {
if (err) {
reject(new Error(`ffmpeg failed: ${err.message}`));
return;
}
resolve(stdout as unknown as Buffer);
},
);
});
}
/** Decode MP3 to PCM via mpg123-decoder WASM (fallback when ffmpeg is unavailable). */
/** Decode MP3 to PCM via mpg123-decoder WASM. */
async function wasmDecodeMp3ToPCM(buf: Buffer, targetRate: number): Promise<Buffer | null> {
try {
const { MPEGDecoder } = await import("mpg123-decoder");
@@ -502,7 +437,7 @@ async function wasmDecodeMp3ToPCM(buf: Buffer, targetRate: number): Promise<Buff
}
}
/** Parse a standard PCM WAV and extract mono 24 kHz PCM data (fallback without ffmpeg). */
/** Parse a standard PCM WAV and extract mono 24 kHz PCM data. */
export function parseWavFallback(buf: Buffer): Buffer | null {
if (buf.length < 44) {
return null;

View File

@@ -12,9 +12,7 @@ import {
getHomeDir,
getTempDir,
getQQBotDataDir,
getPlatform,
isWindows,
detectFfmpeg,
checkSilkWasmAvailable,
} from "./platform.js";
@@ -25,7 +23,6 @@ interface DiagnosticReport {
homeDir: string;
tempDir: string;
dataDir: string;
ffmpeg: string | null;
silkWasm: boolean;
warnings: string[];
}
@@ -44,17 +41,6 @@ export async function runDiagnostics(): Promise<DiagnosticReport> {
const tempDir = getTempDir();
const dataDir = getQQBotDataDir();
const ffmpegPath = await detectFfmpeg();
if (!ffmpegPath) {
warnings.push(
isWindows()
? "⚠️ ffmpeg is not installed. Audio/video conversion will be limited. Install it with choco install ffmpeg, scoop install ffmpeg, or from https://ffmpeg.org."
: getPlatform() === "darwin"
? "⚠️ ffmpeg is not installed. Audio/video conversion will be limited. Install it with brew install ffmpeg."
: "⚠️ ffmpeg is not installed. Audio/video conversion will be limited. Install it with sudo apt install ffmpeg or sudo yum install ffmpeg.",
);
}
const silkWasm = await checkSilkWasmAvailable();
if (!silkWasm) {
warnings.push(
@@ -85,7 +71,6 @@ export async function runDiagnostics(): Promise<DiagnosticReport> {
homeDir,
tempDir,
dataDir,
ffmpeg: ffmpegPath,
silkWasm,
warnings,
};
@@ -95,7 +80,6 @@ export async function runDiagnostics(): Promise<DiagnosticReport> {
debugLog(` Node: ${nodeVersion}`);
debugLog(` Home: ${homeDir}`);
debugLog(` Data dir: ${dataDir}`);
debugLog(` ffmpeg: ${ffmpegPath ?? "not installed"}`);
debugLog(` silk-wasm: ${silkWasm ? "available" : "unavailable"}`);
if (warnings.length > 0) {
debugLog(" --- Warnings ---");

View File

@@ -2,18 +2,16 @@
* Cross-platform path and detection helpers for core/ modules.
*
* Provides home/data/media directory helpers, platform detection,
* ffmpeg/silk-wasm availability checks — all without importing
* `openclaw/plugin-sdk`. The temp-directory fallback is delegated
* to the PlatformAdapter.
* silk-wasm availability checks — all without importing `openclaw/plugin-sdk`.
* The temp-directory fallback is delegated to the PlatformAdapter.
*/
import { execFile } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { getPlatformAdapter } from "../adapter/index.js";
import { formatErrorMessage } from "./format.js";
import { debugLog, debugWarn } from "./log.js";
import { debugWarn } from "./log.js";
/**
* Resolve the current user's home directory safely across platforms.
@@ -109,82 +107,6 @@ export function getTempDir(): string {
return getPlatformAdapter().getTempDir();
}
// ---- ffmpeg detection ----
let _ffmpegPath: string | null | undefined;
let _ffmpegCheckPromise: Promise<string | null> | null = null;
/** Detect ffmpeg and return an executable path when available. */
export function detectFfmpeg(): Promise<string | null> {
if (_ffmpegPath !== undefined) {
return Promise.resolve(_ffmpegPath);
}
if (_ffmpegCheckPromise) {
return _ffmpegCheckPromise;
}
_ffmpegCheckPromise = (async () => {
const envPath = process.env.FFMPEG_PATH;
if (envPath) {
const ok = await testExecutable(envPath, ["-version"]);
if (ok) {
_ffmpegPath = envPath;
debugLog(`[platform] ffmpeg found via FFMPEG_PATH: ${envPath}`);
return _ffmpegPath;
}
debugWarn(`[platform] FFMPEG_PATH set but not working: ${envPath}`);
}
const cmd = isWindows() ? "ffmpeg.exe" : "ffmpeg";
const ok = await testExecutable(cmd, ["-version"]);
if (ok) {
_ffmpegPath = cmd;
debugLog(`[platform] ffmpeg detected in PATH`);
return _ffmpegPath;
}
const commonPaths = isWindows()
? [
"C:\\ffmpeg\\bin\\ffmpeg.exe",
path.join(process.env.LOCALAPPDATA || "", "Programs", "ffmpeg", "bin", "ffmpeg.exe"),
path.join(process.env.ProgramFiles || "", "ffmpeg", "bin", "ffmpeg.exe"),
]
: [
"/usr/local/bin/ffmpeg",
"/opt/homebrew/bin/ffmpeg",
"/usr/bin/ffmpeg",
"/snap/bin/ffmpeg",
];
for (const p of commonPaths) {
if (p && fs.existsSync(p)) {
const works = await testExecutable(p, ["-version"]);
if (works) {
_ffmpegPath = p;
debugLog(`[platform] ffmpeg found at: ${p}`);
return _ffmpegPath;
}
}
}
_ffmpegPath = null;
return null;
})().finally(() => {
_ffmpegCheckPromise = null;
});
return _ffmpegCheckPromise;
}
/** Return true when an executable responds successfully to the given args. */
function testExecutable(cmd: string, args: string[]): Promise<boolean> {
return new Promise((resolve) => {
execFile(cmd, args, { timeout: 5000 }, (err) => {
resolve(!err);
});
});
}
// ---- silk-wasm detection ----
let _silkWasmAvailable: boolean | null = null;