     1→import * as fs from "node:fs";
     2→import * as path from "node:path";
     3→import { execFile } from "node:child_process";
     4→import { decode, encode, isSilk } from "silk-wasm";
     5→import { detectFfmpeg, isWindows } from "./platform.js";
     6→
     7→/**
     8→ * 检查文件是否为 SILK 格式（QQ/微信语音常用格式）
     9→ * QQ 语音文件通常以 .amr 扩展名保存，但实际编码可能是 SILK v3
    10→ * SILK 文件头部标识: 0x02 "#!SILK_V3"
    11→ */
    12→function isSilkFile(filePath: string): boolean {
    13→  try {
    14→    const buf = fs.readFileSync(filePath);
    15→    return isSilk(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
    16→  } catch {
    17→    return false;
    18→  }
    19→}
    20→
    21→/**
    22→ * 将 PCM (s16le) 数据封装为 WAV 文件格式
    23→ * WAV = 44 字节 RIFF 头 + PCM 原始数据
    24→ */
    25→function pcmToWav(pcmData: Uint8Array, sampleRate: number, channels: number = 1, bitsPerSample: number = 16): Buffer {
    26→  const byteRate = sampleRate * channels * (bitsPerSample / 8);
    27→  const blockAlign = channels * (bitsPerSample / 8);
    28→  const dataSize = pcmData.length;
    29→  const headerSize = 44;
    30→  const fileSize = headerSize + dataSize;
    31→
    32→  const buffer = Buffer.alloc(fileSize);
    33→
    34→  // RIFF header
    35→  buffer.write("RIFF", 0);
    36→  buffer.writeUInt32LE(fileSize - 8, 4);
    37→  buffer.write("WAVE", 8);
    38→
    39→  // fmt sub-chunk
    40→  buffer.write("fmt ", 12);
    41→  buffer.writeUInt32LE(16, 16);         // sub-chunk size
    42→  buffer.writeUInt16LE(1, 20);          // PCM format
    43→  buffer.writeUInt16LE(channels, 22);
    44→  buffer.writeUInt32LE(sampleRate, 24);
    45→  buffer.writeUInt32LE(byteRate, 28);
    46→  buffer.writeUInt16LE(blockAlign, 32);
    47→  buffer.writeUInt16LE(bitsPerSample, 34);
    48→
    49→  // data sub-chunk
    50→  buffer.write("data", 36);
    51→  buffer.writeUInt32LE(dataSize, 40);
    52→  Buffer.from(pcmData.buffer, pcmData.byteOffset, pcmData.byteLength).copy(buffer, headerSize);
    53→
    54→  return buffer;
    55→}
    56→
    57→/**
    58→ * 去除 QQ 语音文件的 AMR 头（如果存在）
    59→ * QQ 的 .amr 文件可能在 SILK 数据前有 "#!AMR\n" 头（6 字节）
    60→ * 需要去除后才能被 silk-wasm 正确解码
    61→ */
    62→function stripAmrHeader(buf: Buffer): Buffer {
    63→  const AMR_HEADER = Buffer.from("#!AMR\n");
    64→  if (buf.length > 6 && buf.subarray(0, 6).equals(AMR_HEADER)) {
    65→    return buf.subarray(6);
    66→  }
    67→  return buf;
    68→}
    69→
    70→/**
    71→ * 将 SILK/AMR 语音文件转换为 WAV 格式
    72→ *
    73→ * @param inputPath 输入文件路径（.amr / .silk / .slk）
    74→ * @param outputDir 输出目录（默认与输入文件同目录）
    75→ * @returns 转换后的 WAV 文件路径，失败返回 null
    76→ */
    77→export async function convertSilkToWav(
    78→  inputPath: string,
    79→  outputDir?: string,
    80→): Promise<{ wavPath: string; duration: number } | null> {
    81→  if (!fs.existsSync(inputPath)) {
    82→    return null;
    83→  }
    84→
    85→  const fileBuf = fs.readFileSync(inputPath);
    86→
    87→  // 去除可能的 AMR 头
    88→  const strippedBuf = stripAmrHeader(fileBuf);
    89→
    90→  // 转为 Uint8Array 以兼容 silk-wasm 类型要求
    91→  const rawData = new Uint8Array(strippedBuf.buffer, strippedBuf.byteOffset, strippedBuf.byteLength);
    92→
    93→  // 验证是否为 SILK 格式
    94→  if (!isSilk(rawData)) {
    95→    return null;
    96→  }
    97→
    98→  // SILK 解码为 PCM (s16le)
    99→  // QQ 语音通常采样率为 24000Hz
   100→  const sampleRate = 24000;
   101→  const result = await decode(rawData, sampleRate);
   102→
   103→  // PCM → WAV
   104→  const wavBuffer = pcmToWav(result.data, sampleRate);
   105→
   106→  // 写入 WAV 文件
   107→  const dir = outputDir || path.dirname(inputPath);
   108→  if (!fs.existsSync(dir)) {
   109→    fs.mkdirSync(dir, { recursive: true });
   110→  }
   111→  const baseName = path.basename(inputPath, path.extname(inputPath));
   112→  const wavPath = path.join(dir, `${baseName}.wav`);
   113→  fs.writeFileSync(wavPath, wavBuffer);
   114→
   115→  return { wavPath, duration: result.duration };
   116→}
   117→
   118→/**
   119→ * 判断是否为语音附件（根据 content_type 或文件扩展名）
   120→ */
   121→export function isVoiceAttachment(att: { content_type?: string; filename?: string }): boolean {
   122→  if (att.content_type === "voice" || att.content_type?.startsWith("audio/")) {
   123→    return true;
   124→  }
   125→  const ext = att.filename ? path.extname(att.filename).toLowerCase() : "";
   126→  return [".amr", ".silk", ".slk", ".slac"].includes(ext);
   127→}
   128→
   129→/**
   130→ * 格式化语音时长为可读字符串
   131→ */
   132→export function formatDuration(durationMs: number): string {
   133→  const seconds = Math.round(durationMs / 1000);
   134→  if (seconds < 60) {
   135→    return `${seconds}秒`;
   136→  }
   137→  const minutes = Math.floor(seconds / 60);
   138→  const remainSeconds = seconds % 60;
   139→  return remainSeconds > 0 ? `${minutes}分${remainSeconds}秒` : `${minutes}分钟`;
   140→}
   141→
   142→export function isAudioFile(filePath: string): boolean {
   143→  const ext = path.extname(filePath).toLowerCase();
   144→  return [".silk", ".slk", ".amr", ".wav", ".mp3", ".ogg", ".opus", ".aac", ".flac", ".m4a", ".wma", ".pcm"].includes(ext);
   145→}
   146→
   147→// ============ TTS（文字转语音）============
   148→
   149→export interface TTSConfig {
   150→  baseUrl: string;
   151→  apiKey: string;
   152→  model: string;
   153→  voice: string;
   154→  /** Azure OpenAI 风格：使用 api-key header 而非 Bearer token */
   155→  authStyle?: "bearer" | "api-key";
   156→  /** 附加在 URL 后的查询参数，如 Azure 的 api-version */
   157→  queryParams?: Record<string, string>;
   158→  /** 自定义速度（默认不传） */
   159→  speed?: number;
   160→}
   161→
   162→function resolveTTSFromBlock(
   163→  block: Record<string, any>,
   164→  providerCfg: Record<string, any> | undefined,
   165→): TTSConfig | null {
   166→  const baseUrl: string | undefined = block?.baseUrl || providerCfg?.baseUrl;
   167→  const apiKey: string | undefined = block?.apiKey || providerCfg?.apiKey;
   168→  const model: string = block?.model || "tts-1";
   169→  const voice: string = block?.voice || "alloy";
   170→  if (!baseUrl || !apiKey) return null;
   171→
   172→  const authStyle = (block?.authStyle || providerCfg?.authStyle) === "api-key" ? "api-key" as const : "bearer" as const;
   173→  const queryParams: Record<string, string> = { ...(providerCfg?.queryParams ?? {}), ...(block?.queryParams ?? {}) };
   174→  const speed: number | undefined = block?.speed;
   175→
   176→  return {
   177→    baseUrl: baseUrl.replace(/\/+$/, ""),
   178→    apiKey,
   179→    model,
   180→    voice,
   181→    authStyle,
   182→    ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}),
   183→    ...(speed !== undefined ? { speed } : {}),
   184→  };
   185→}
   186→
   187→export function resolveTTSConfig(cfg: Record<string, unknown>): TTSConfig | null {
   188→  const c = cfg as any;
   189→
   190→  // 优先使用 channels.qqbot.tts（插件专属配置）
   191→  const channelTts = c?.channels?.qqbot?.tts;
   192→  if (channelTts && channelTts.enabled !== false) {
   193→    const providerId: string = channelTts?.provider || "openai";
   194→    const providerCfg = c?.models?.providers?.[providerId];
   195→    const result = resolveTTSFromBlock(channelTts, providerCfg);
   196→    if (result) return result;
   197→  }
   198→
   199→  // 回退到 messages.tts（openclaw 框架级 TTS 配置）
   200→  const msgTts = c?.messages?.tts;
   201→  if (msgTts && msgTts.auto !== "disabled") {
   202→    const providerId: string = msgTts?.provider || "openai";
   203→    const providerBlock = msgTts?.[providerId];
   204→    const providerCfg = c?.models?.providers?.[providerId];
   205→    const result = resolveTTSFromBlock(providerBlock ?? {}, providerCfg);
   206→    if (result) return result;
   207→  }
   208→
   209→  return null;
   210→}
   211→
   212→/**
   213→ * 构建 TTS 请求 URL 和 Headers
   214→ * 支持 OpenAI 标准和 Azure OpenAI 两种风格
   215→ */
   216→function buildTTSRequest(ttsCfg: TTSConfig): { url: string; headers: Record<string, string> } {
   217→  // 构建 URL：baseUrl + /audio/speech + 可选 queryParams
   218→  let url = `${ttsCfg.baseUrl}/audio/speech`;
   219→  if (ttsCfg.queryParams && Object.keys(ttsCfg.queryParams).length > 0) {
   220→    const qs = new URLSearchParams(ttsCfg.queryParams).toString();
   221→    url += `?${qs}`;
   222→  }
   223→
   224→  // 构建认证 Header
   225→  const headers: Record<string, string> = { "Content-Type": "application/json" };
   226→  if (ttsCfg.authStyle === "api-key") {
   227→    headers["api-key"] = ttsCfg.apiKey;
   228→  } else {
   229→    headers["Authorization"] = `Bearer ${ttsCfg.apiKey}`;
   230→  }
   231→
   232→  return { url, headers };
   233→}
   234→
   235→export async function textToSpeechPCM(
   236→  text: string,
   237→  ttsCfg: TTSConfig,
   238→): Promise<{ pcmBuffer: Buffer; sampleRate: number }> {
   239→  const sampleRate = 24000;
   240→  const { url, headers } = buildTTSRequest(ttsCfg);
   241→
   242→  console.log(`[tts] Request: model=${ttsCfg.model}, voice=${ttsCfg.voice}, authStyle=${ttsCfg.authStyle ?? "bearer"}, url=${url}`);
   243→  console.log(`[tts] Input text (${text.length} chars): "${text.slice(0, 80)}${text.length > 80 ? "..." : ""}"`);
   244→
   245→  // 先尝试 PCM 格式（最高质量，无需二次转码）
   246→  const formats: Array<{ format: string; needsDecode: boolean }> = [
   247→    { format: "pcm", needsDecode: false },
   248→    { format: "mp3", needsDecode: true },
   249→  ];
   250→
   251→  let lastError: Error | null = null;
   252→  const startTime = Date.now();
   253→
   254→  for (const { format, needsDecode } of formats) {
   255→    const controller = new AbortController();
   256→    const ttsTimeout = setTimeout(() => controller.abort(), 120000);
   257→
   258→    try {
   259→      const body: Record<string, unknown> = {
   260→        model: ttsCfg.model,
   261→        input: text,
   262→        voice: ttsCfg.voice,
   263→        response_format: format,
   264→        ...(format === "pcm" ? { sample_rate: sampleRate } : {}),
   265→        ...(ttsCfg.speed !== undefined ? { speed: ttsCfg.speed } : {}),
   266→      };
   267→
   268→      console.log(`[tts] Trying format=${format}...`);
   269→      const fetchStart = Date.now();
   270→      const resp = await fetch(url, {
   271→        method: "POST",
   272→        headers,
   273→        body: JSON.stringify(body),
   274→        signal: controller.signal,
   275→      }).finally(() => clearTimeout(ttsTimeout));
   276→
   277→      const fetchMs = Date.now() - fetchStart;
   278→
   279→      if (!resp.ok) {
   280→        const detail = await resp.text().catch(() => "");
   281→        console.log(`[tts] HTTP ${resp.status} for format=${format} (${fetchMs}ms): ${detail.slice(0, 200)}`);
   282→        // 如果 PCM 不支持（Azure 等），回退到 mp3
   283→        if (format === "pcm" && (resp.status === 400 || resp.status === 422)) {
   284→          console.log(`[tts] PCM format not supported, falling back to mp3`);
   285→          lastError = new Error(`TTS PCM not supported: ${detail.slice(0, 200)}`);
   286→          continue;
   287→        }
   288→        throw new Error(`TTS failed (HTTP ${resp.status}): ${detail.slice(0, 300)}`);
   289→      }
   290→
   291→      const arrayBuffer = await resp.arrayBuffer();
   292→      const rawBuffer = Buffer.from(arrayBuffer);
   293→      console.log(`[tts] Response OK: format=${format}, size=${rawBuffer.length} bytes, latency=${fetchMs}ms`);
   294→
   295→      if (!needsDecode) {
   296→        console.log(`[tts] Done: PCM direct, ${rawBuffer.length} bytes, total=${Date.now() - startTime}ms`);
   297→        return { pcmBuffer: rawBuffer, sampleRate };
   298→      }
   299→
   300→      // mp3 需要解码为 PCM
   301→      console.log(`[tts] Decoding mp3 response (${rawBuffer.length} bytes) to PCM...`);
   302→      const tmpDir = path.join(fs.mkdtempSync(path.join(require("node:os").tmpdir(), "tts-")));
   303→      const tmpMp3 = path.join(tmpDir, "tts.mp3");
   304→      fs.writeFileSync(tmpMp3, rawBuffer);
   305→
   306→      try {
   307→        // 优先用 ffmpeg
   308→        const ffmpegCmd = await checkFfmpeg();
   309→        if (ffmpegCmd) {
   310→          const pcmBuf = await ffmpegToPCM(ffmpegCmd, tmpMp3, sampleRate);
   311→          console.log(`[tts] Done: mp3→PCM (ffmpeg), ${pcmBuf.length} bytes, total=${Date.now() - startTime}ms`);
   312→          return { pcmBuffer: pcmBuf, sampleRate };
   313→        }
   314→        // WASM fallback
   315→        const pcmBuf = await wasmDecodeMp3ToPCM(rawBuffer, sampleRate);
   316→        if (pcmBuf) {
   317→          console.log(`[tts] Done: mp3→PCM (wasm), ${pcmBuf.length} bytes, total=${Date.now() - startTime}ms`);
   318→          return { pcmBuffer: pcmBuf, sampleRate };
   319→        }
   320→        throw new Error("No decoder available for mp3 (install ffmpeg for best compatibility)");
   321→      } finally {
   322→        try { fs.unlinkSync(tmpMp3); fs.rmdirSync(tmpDir); } catch {}
   323→      }
   324→    } catch (err) {
   325→      clearTimeout(ttsTimeout);
   326→      lastError = err instanceof Error ? err : new Error(String(err));
   327→      console.log(`[tts] Error for format=${format}: ${lastError.message.slice(0, 200)}`);
   328→      if (format === "pcm") {
   329→        // PCM 失败时不立即抛出，尝试 mp3
   330→        continue;
   331→      }
   332→      throw lastError;
   333→    }
   334→  }
   335→
   336→  console.log(`[tts] All formats exhausted after ${Date.now() - startTime}ms`);
   337→  throw lastError ?? new Error("TTS failed: all formats exhausted");
   338→}
   339→
   340→export async function pcmToSilk(
   341→  pcmBuffer: Buffer,
   342→  sampleRate: number,
   343→): Promise<{ silkBuffer: Buffer; duration: number }> {
   344→  const pcmData = new Uint8Array(pcmBuffer.buffer, pcmBuffer.byteOffset, pcmBuffer.byteLength);
   345→  const result = await encode(pcmData, sampleRate);
   346→  return {
   347→    silkBuffer: Buffer.from(result.data.buffer, result.data.byteOffset, result.data.byteLength),
   348→    duration: result.duration,
   349→  };
   350→}
   351→
   352→export async function textToSilk(
   353→  text: string,
   354→  ttsCfg: TTSConfig,
   355→  outputDir: string,
   356→): Promise<{ silkPath: string; silkBase64: string; duration: number }> {
   357→  const { pcmBuffer, sampleRate } = await textToSpeechPCM(text, ttsCfg);
   358→  const { silkBuffer, duration } = await pcmToSilk(pcmBuffer, sampleRate);
   359→
   360→  if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
   361→  const silkPath = path.join(outputDir, `tts-${Date.now()}.silk`);
   362→  fs.writeFileSync(silkPath, silkBuffer);
   363→
   364→  return { silkPath, silkBase64: silkBuffer.toString("base64"), duration };
   365→}
   366→
   367→// ============ 核心：任意音频 → SILK Base64 ============
   368→
   369→/** QQ Bot API 原生支持上传的音频格式（无需转换为 SILK） */
   370→const QQ_NATIVE_UPLOAD_FORMATS = [".wav", ".mp3", ".silk"];
   371→
   372→/**
   373→ * 将本地音频文件转换为 QQ Bot 可上传的 Base64
   374→ *
   375→ * QQ Bot API 支持直传 WAV、MP3、SILK 三种格式，其他格式仍需转换。
   376→ * 转换策略（参考 NapCat/go-cqhttp/Discord/Telegram 的做法）：
   377→ *
   378→ * 1. WAV / MP3 / SILK → 直传（跳过转换）
   379→ * 2. 有 ffmpeg → ffmpeg 万能解码为 PCM → silk-wasm 编码
   380→ *    支持: ogg, opus, aac, flac, wma, m4a, pcm 等所有 ffmpeg 支持的格式
   381→ * 3. 无 ffmpeg → WASM fallback（仅支持 pcm, wav）
   382→ *
   383→ * @param directUploadFormats - 自定义直传格式列表，覆盖默认值。传 undefined 使用 QQ_NATIVE_UPLOAD_FORMATS
   384→ */
   385→export async function audioFileToSilkBase64(filePath: string, directUploadFormats?: string[]): Promise<string | null> {
   386→  if (!fs.existsSync(filePath)) return null;
   387→
   388→  const buf = fs.readFileSync(filePath);
   389→  if (buf.length === 0) {
   390→    console.error(`[audio-convert] file is empty: ${filePath}`);
   391→    return null;
   392→  }
   393→
   394→  const ext = path.extname(filePath).toLowerCase();
   395→
   396→  // 0. 直传判断：QQ Bot API 原生支持 WAV/MP3/SILK，可通过配置覆盖
   397→  const uploadFormats = directUploadFormats ? normalizeFormats(directUploadFormats) : QQ_NATIVE_UPLOAD_FORMATS;
   398→  if (uploadFormats.includes(ext)) {
   399→    console.log(`[audio-convert] direct upload (QQ native format): ${ext} (${buf.length} bytes)`);
   400→    return buf.toString("base64");
   401→  }
   402→
   403→  // 1. .slk / .amr 扩展名 → 检测 SILK 魔数，是 SILK 则直传
   404→  if ([".slk", ".slac"].includes(ext)) {
   405→    const stripped = stripAmrHeader(buf);
   406→    const raw = new Uint8Array(stripped.buffer, stripped.byteOffset, stripped.byteLength);
   407→    if (isSilk(raw)) {
   408→      console.log(`[audio-convert] SILK file, direct use: ${filePath} (${buf.length} bytes)`);
   409→      return buf.toString("base64");
   410→    }
   411→  }
   412→
   413→  // 按文件头检测 SILK（不依赖扩展名）
   414→  const rawCheck = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
   415→  const strippedCheck = stripAmrHeader(buf);
   416→  const strippedRaw = new Uint8Array(strippedCheck.buffer, strippedCheck.byteOffset, strippedCheck.byteLength);
   417→  if (isSilk(rawCheck) || isSilk(strippedRaw)) {
   418→    console.log(`[audio-convert] SILK detected by header: ${filePath} (${buf.length} bytes)`);
   419→    return buf.toString("base64");
   420→  }
   421→
   422→  const targetRate = 24000;
   423→
   424→  // 2. 优先使用 ffmpeg（业界标准做法，跨平台检测）
   425→  const ffmpegCmd = await checkFfmpeg();
   426→  if (ffmpegCmd) {
   427→    try {
   428→      console.log(`[audio-convert] ffmpeg (${ffmpegCmd}): converting ${ext} (${buf.length} bytes) → PCM s16le ${targetRate}Hz`);
   429→      const pcmBuf = await ffmpegToPCM(ffmpegCmd, filePath, targetRate);
   430→      if (pcmBuf.length === 0) {
   431→        console.error(`[audio-convert] ffmpeg produced empty PCM output`);
   432→        return null;
   433→      }
   434→      const { silkBuffer } = await pcmToSilk(pcmBuf, targetRate);
   435→      console.log(`[audio-convert] ffmpeg: ${ext} → SILK done (${silkBuffer.length} bytes)`);
   436→      return silkBuffer.toString("base64");
   437→    } catch (err) {
   438→      console.error(`[audio-convert] ffmpeg conversion failed: ${err instanceof Error ? err.message : String(err)}`);
   439→      // ffmpeg 失败后不 return，继续尝试 WASM fallback
   440→    }
   441→  }
   442→
   443→  // 3. WASM fallback（无 ffmpeg 时的降级方案）
   444→  console.log(`[audio-convert] fallback: trying WASM decoders for ${ext}`);
   445→
   446→  // 3a. PCM：视为 s16le 24000Hz 单声道
   447→  if (ext === ".pcm") {
   448→    const pcmBuf = Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength);
   449→    const { silkBuffer } = await pcmToSilk(pcmBuf, targetRate);
   450→    return silkBuffer.toString("base64");
   451→  }
   452→
   453→  // 3b. WAV：手动解析（仅支持标准 PCM WAV）
   454→  if (ext === ".wav" || (buf.length >= 4 && buf.toString("ascii", 0, 4) === "RIFF")) {
   455→    const wavInfo = parseWavFallback(buf);
   456→    if (wavInfo) {
   457→      const { silkBuffer } = await pcmToSilk(wavInfo, targetRate);
   458→      return silkBuffer.toString("base64");
   459→    }
   460→  }
   461→
   462→  // 3c. MP3：WASM 解码
   463→  if (ext === ".mp3" || ext === ".mpeg") {
   464→    const pcmBuf = await wasmDecodeMp3ToPCM(buf, targetRate);
   465→    if (pcmBuf) {
   466→      const { silkBuffer } = await pcmToSilk(pcmBuf, targetRate);
   467→      console.log(`[audio-convert] WASM: MP3 → SILK done (${silkBuffer.length} bytes)`);
   468→      return silkBuffer.toString("base64");
   469→    }
   470→  }
   471→
   472→  const installHint = isWindows()
   473→    ? "安装方式: choco install ffmpeg 或 scoop install ffmpeg 或从 https://ffmpeg.org 下载"
   474→    : process.platform === "darwin"
   475→      ? "安装方式: brew install ffmpeg"
   476→      : "安装方式: sudo apt install ffmpeg 或 sudo yum install ffmpeg";
   477→  console.error(`[audio-convert] unsupported format: ${ext} (no ffmpeg available). ${installHint}`);
   478→  return null;
   479→}
   480→
   481→/**
   482→ * 等待文件就绪（轮询直到文件出现且大小稳定）
   483→ * 用于 TTS 生成后等待文件写入完成
   484→ *
   485→ * @param filePath 文件路径
   486→ * @param timeoutMs 最大等待时间（默认 2 分钟）
   487→ * @param pollMs 轮询间隔（默认 500ms）
   488→ * @returns 文件大小（字节），超时或文件始终为空返回 0
   489→ */
   490→export async function waitForFile(filePath: string, timeoutMs: number = 120000, pollMs: number = 500): Promise<number> {
   491→  const start = Date.now();
   492→  let lastSize = -1;
   493→  let stableCount = 0;
   494→  let fileExists = false;
   495→  let pollCount = 0;
   496→
   497→  while (Date.now() - start < timeoutMs) {
   498→    pollCount++;
   499→    try {
   500→      const stat = fs.statSync(filePath);
   501→      if (!fileExists) {
   502→        fileExists = true;
   503→        console.log(`[audio-convert] waitForFile: file appeared (${stat.size} bytes, after ${Date.now() - start}ms): ${path.basename(filePath)}`);
   504→      }
   505→      if (stat.size > 0) {
   506→        if (stat.size === lastSize) {
   507→          stableCount++;
   508→          if (stableCount >= 2) {
   509→            console.log(`[audio-convert] waitForFile: ready (${stat.size} bytes, waited ${Date.now() - start}ms, polls=${pollCount})`);
   510→            return stat.size;
   511→          }
   512→        } else {
   513→          stableCount = 0;
   514→        }
   515→        lastSize = stat.size;
   516→      }
   517→    } catch {
   518→      // 文件可能还不存在，继续等
   519→    }
   520→    await new Promise((r) => setTimeout(r, pollMs));
   521→  }
   522→
   523→  // 超时后最后检查一次
   524→  try {
   525→    const finalStat = fs.statSync(filePath);
   526→    if (finalStat.size > 0) {
   527→      console.warn(`[audio-convert] waitForFile: timeout but file has data (${finalStat.size} bytes), using it`);
   528→      return finalStat.size;
   529→    }
   530→    console.error(`[audio-convert] waitForFile: timeout after ${timeoutMs}ms, file exists but empty (0 bytes): ${path.basename(filePath)}`);
   531→  } catch {
   532→    console.error(`[audio-convert] waitForFile: timeout after ${timeoutMs}ms, file never appeared: ${path.basename(filePath)}`);
   533→  }
   534→  return 0;
   535→}
   536→
   537→// ============ ffmpeg 跨平台调用 ============
   538→
   539→/**
   540→ * 检测 ffmpeg 是否可用（委托给 platform.ts 跨平台检测）
   541→ * @returns ffmpeg 可执行路径或 null
   542→ */
   543→async function checkFfmpeg(): Promise<string | null> {
   544→  return detectFfmpeg();
   545→}
   546→
   547→/**
   548→ * 使用 ffmpeg 将任意音频文件转换为 PCM s16le 单声道 24kHz
   549→ *
   550→ * 跨平台注意:
   551→ * - Windows 上 pipe:1 需要 encoding: "buffer" 防止 BOM 问题
   552→ * - 使用 detectFfmpeg() 返回的完整路径，兼容非 PATH 安装
   553→ */
   554→function ffmpegToPCM(ffmpegCmd: string, inputPath: string, sampleRate: number = 24000): Promise<Buffer> {
   555→  return new Promise((resolve, reject) => {
   556→    const args = [
   557→      "-i", inputPath,
   558→      "-f", "s16le",
   559→      "-ar", String(sampleRate),
   560→      "-ac", "1",
   561→      "-acodec", "pcm_s16le",
   562→      "-v", "error",
   563→      "pipe:1",
   564→    ];
   565→    execFile(ffmpegCmd, args, {
   566→      maxBuffer: 50 * 1024 * 1024,
   567→      encoding: "buffer",
   568→      // Windows: 隐藏弹出的 cmd 窗口
   569→      ...(isWindows() ? { windowsHide: true } : {}),
   570→    }, (err, stdout) => {
   571→      if (err) {
   572→        reject(new Error(`ffmpeg failed: ${err.message}`));
   573→        return;
   574→      }
   575→      resolve(stdout as unknown as Buffer);
   576→    });
   577→  });
   578→}
   579→
   580→// ============ WASM fallback: MP3 解码 ============
   581→
   582→/**
   583→ * 使用 mpg123-decoder (WASM) 解码 MP3 为 PCM
   584→ * 仅在 ffmpeg 不可用时作为 fallback
   585→ */
   586→async function wasmDecodeMp3ToPCM(buf: Buffer, targetRate: number): Promise<Buffer | null> {
   587→  try {
   588→    const { MPEGDecoder } = await import("mpg123-decoder");
   589→    console.log(`[audio-convert] WASM MP3 decode: size=${buf.length} bytes`);
   590→    const decoder = new MPEGDecoder();
   591→    await decoder.ready;
   592→
   593→    const decoded = decoder.decode(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
   594→    decoder.free();
   595→
   596→    if (decoded.samplesDecoded === 0 || decoded.channelData.length === 0) {
   597→      console.error(`[audio-convert] WASM MP3 decode: no samples (samplesDecoded=${decoded.samplesDecoded})`);
   598→      return null;
   599→    }
   600→
   601→    console.log(`[audio-convert] WASM MP3 decode: samples=${decoded.samplesDecoded}, sampleRate=${decoded.sampleRate}, channels=${decoded.channelData.length}`);
   602→
   603→    // Float32 多声道混缩为单声道
   604→    let floatMono: Float32Array;
   605→    if (decoded.channelData.length === 1) {
   606→      floatMono = decoded.channelData[0];
   607→    } else {
   608→      floatMono = new Float32Array(decoded.samplesDecoded);
   609→      const channels = decoded.channelData.length;
   610→      for (let i = 0; i < decoded.samplesDecoded; i++) {
   611→        let sum = 0;
   612→        for (let ch = 0; ch < channels; ch++) {
   613→          sum += decoded.channelData[ch][i];
   614→        }
   615→        floatMono[i] = sum / channels;
   616→      }
   617→    }
   618→
   619→    // Float32 → s16le
   620→    const s16 = new Uint8Array(floatMono.length * 2);
   621→    const view = new DataView(s16.buffer);
   622→    for (let i = 0; i < floatMono.length; i++) {
   623→      const clamped = Math.max(-1, Math.min(1, floatMono[i]));
   624→      const val = clamped < 0 ? clamped * 32768 : clamped * 32767;
   625→      view.setInt16(i * 2, Math.round(val), true);
   626→    }
   627→
   628→    // 简单线性插值重采样
   629→    let pcm: Uint8Array = s16;
   630→    if (decoded.sampleRate !== targetRate) {
   631→      const inputSamples = s16.length / 2;
   632→      const outputSamples = Math.round(inputSamples * targetRate / decoded.sampleRate);
   633→      const output = new Uint8Array(outputSamples * 2);
   634→      const inView = new DataView(s16.buffer, s16.byteOffset, s16.byteLength);
   635→      const outView = new DataView(output.buffer, output.byteOffset, output.byteLength);
   636→      for (let i = 0; i < outputSamples; i++) {
   637→        const srcIdx = i * decoded.sampleRate / targetRate;
   638→        const idx0 = Math.floor(srcIdx);
   639→        const idx1 = Math.min(idx0 + 1, inputSamples - 1);
   640→        const frac = srcIdx - idx0;
   641→        const s0 = inView.getInt16(idx0 * 2, true);
   642→        const s1 = inView.getInt16(idx1 * 2, true);
   643→        const sample = Math.round(s0 + (s1 - s0) * frac);
   644→        outView.setInt16(i * 2, Math.max(-32768, Math.min(32767, sample)), true);
   645→      }
   646→      pcm = output;
   647→    }
   648→
   649→    return Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength);
   650→  } catch (err) {
   651→    console.error(`[audio-convert] WASM MP3 decode failed: ${err instanceof Error ? err.message : String(err)}`);
   652→    if (err instanceof Error && err.stack) {
   653→      console.error(`[audio-convert] stack: ${err.stack}`);
   654→    }
   655→    return null;
   656→  }
   657→}
   658→
   659→/**
   660→ * 规范化格式列表（确保以 . 开头，小写）
   661→ */
   662→function normalizeFormats(formats: string[]): string[] {
   663→  return formats.map((f) => {
   664→    const lower = f.toLowerCase().trim();
   665→    return lower.startsWith(".") ? lower : `.${lower}`;
   666→  });
   667→}
   668→
   669→/**
   670→ * WAV fallback 解析（无 ffmpeg 时使用）
   671→ * 仅支持标准 PCM WAV (format=1, 16bit)
   672→ */
   673→function parseWavFallback(buf: Buffer): Buffer | null {
   674→  if (buf.length < 44) return null;
   675→  if (buf.toString("ascii", 0, 4) !== "RIFF") return null;
   676→  if (buf.toString("ascii", 8, 12) !== "WAVE") return null;
   677→  if (buf.toString("ascii", 12, 16) !== "fmt ") return null;
   678→
   679→  const audioFormat = buf.readUInt16LE(20);
   680→  if (audioFormat !== 1) return null;
   681→
   682→  const channels = buf.readUInt16LE(22);
   683→  const sampleRate = buf.readUInt32LE(24);
   684→  const bitsPerSample = buf.readUInt16LE(34);
   685→  if (bitsPerSample !== 16) return null;
   686→
   687→  // 找 data chunk
   688→  let offset = 36;
   689→  while (offset < buf.length - 8) {
   690→    const chunkId = buf.toString("ascii", offset, offset + 4);
   691→    const chunkSize = buf.readUInt32LE(offset + 4);
   692→    if (chunkId === "data") {
   693→      const dataStart = offset + 8;
   694→      const dataEnd = Math.min(dataStart + chunkSize, buf.length);
   695→      let pcm = new Uint8Array(buf.buffer, buf.byteOffset + dataStart, dataEnd - dataStart);
   696→
   697→      // 多声道混缩
   698→      if (channels > 1) {
   699→        const samplesPerCh = pcm.length / (2 * channels);
   700→        const mono = new Uint8Array(samplesPerCh * 2);
   701→        const inV = new DataView(pcm.buffer, pcm.byteOffset, pcm.byteLength);
   702→        const outV = new DataView(mono.buffer, mono.byteOffset, mono.byteLength);
   703→        for (let i = 0; i < samplesPerCh; i++) {
   704→          let sum = 0;
   705→          for (let ch = 0; ch < channels; ch++) sum += inV.getInt16((i * channels + ch) * 2, true);
   706→          outV.setInt16(i * 2, Math.max(-32768, Math.min(32767, Math.round(sum / channels))), true);
   707→        }
   708→        pcm = mono;
   709→      }
   710→
   711→      // 简单线性插值重采样
   712→      const targetRate = 24000;
   713→      if (sampleRate !== targetRate) {
   714→        const inSamples = pcm.length / 2;
   715→        const outSamples = Math.round(inSamples * targetRate / sampleRate);
   716→        const out = new Uint8Array(outSamples * 2);
   717→        const inV = new DataView(pcm.buffer, pcm.byteOffset, pcm.byteLength);
   718→        const outV = new DataView(out.buffer, out.byteOffset, out.byteLength);
   719→        for (let i = 0; i < outSamples; i++) {
   720→          const src = i * sampleRate / targetRate;
   721→          const i0 = Math.floor(src);
   722→          const i1 = Math.min(i0 + 1, inSamples - 1);
   723→          const f = src - i0;
   724→          const s0 = inV.getInt16(i0 * 2, true);
   725→          const s1 = inV.getInt16(i1 * 2, true);
   726→          outV.setInt16(i * 2, Math.max(-32768, Math.min(32767, Math.round(s0 + (s1 - s0) * f))), true);
   727→        }
   728→        pcm = out;
   729→      }
   730→
   731→      return Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength);
   732→    }
   733→    offset += 8 + chunkSize;
   734→  }
   735→
   736→  return null;
   737→}
   738→
   739→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

import * as fs from "node:fs";
import * as path from "node:path";
import { execFile } from "node:child_process";
import { decode, encode, isSilk } from "silk-wasm";
import { detectFfmpeg, isWindows } from "./platform.js";

/**
 * 检查文件是否为 SILK 格式（QQ/微信语音常用格式）
 * QQ 语音文件通常以 .amr 扩展名保存，但实际编码可能是 SILK v3
 * SILK 文件头部标识: 0x02 "#!SILK_V3"
 */
function isSilkFile(filePath: string): boolean {
  try {
    const buf = fs.readFileSync(filePath);
    return isSilk(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
  } catch {
    return false;
  }
}

/**
 * 将 PCM (s16le) 数据封装为 WAV 文件格式
 * WAV = 44 字节 RIFF 头 + PCM 原始数据
 */
function pcmToWav(pcmData: Uint8Array, sampleRate: number, channels: number = 1, bitsPerSample: number = 16): Buffer {
  const byteRate = sampleRate * channels * (bitsPerSample / 8);
  const blockAlign = channels * (bitsPerSample / 8);
  const dataSize = pcmData.length;
  const headerSize = 44;
  const fileSize = headerSize + dataSize;

  const buffer = Buffer.alloc(fileSize);

  // RIFF header
  buffer.write("RIFF", 0);
  buffer.writeUInt32LE(fileSize - 8, 4);
  buffer.write("WAVE", 8);

  // fmt sub-chunk
  buffer.write("fmt ", 12);
  buffer.writeUInt32LE(16, 16);         // sub-chunk size
  buffer.writeUInt16LE(1, 20);          // PCM format
  buffer.writeUInt16LE(channels, 22);
  buffer.writeUInt32LE(sampleRate, 24);
  buffer.writeUInt32LE(byteRate, 28);
  buffer.writeUInt16LE(blockAlign, 32);
  buffer.writeUInt16LE(bitsPerSample, 34);

  // data sub-chunk
  buffer.write("data", 36);
  buffer.writeUInt32LE(dataSize, 40);
  Buffer.from(pcmData.buffer, pcmData.byteOffset, pcmData.byteLength).copy(buffer, headerSize);

  return buffer;
}

/**
 * 去除 QQ 语音文件的 AMR 头（如果存在）
 * QQ 的 .amr 文件可能在 SILK 数据前有 "#!AMR\n" 头（6 字节）
 * 需要去除后才能被 silk-wasm 正确解码
 */
function stripAmrHeader(buf: Buffer): Buffer {
  const AMR_HEADER = Buffer.from("#!AMR\n");
  if (buf.length > 6 && buf.subarray(0, 6).equals(AMR_HEADER)) {
    return buf.subarray(6);
  }
  return buf;
}

/**
 * 将 SILK/AMR 语音文件转换为 WAV 格式
 *
 * @param inputPath 输入文件路径（.amr / .silk / .slk）
 * @param outputDir 输出目录（默认与输入文件同目录）
 * @returns 转换后的 WAV 文件路径，失败返回 null
 */
export async function convertSilkToWav(
  inputPath: string,
  outputDir?: string,
): Promise<{ wavPath: string; duration: number } | null> {
  if (!fs.existsSync(inputPath)) {
    return null;
  }

  const fileBuf = fs.readFileSync(inputPath);

  // 去除可能的 AMR 头
  const strippedBuf = stripAmrHeader(fileBuf);

  // 转为 Uint8Array 以兼容 silk-wasm 类型要求
  const rawData = new Uint8Array(strippedBuf.buffer, strippedBuf.byteOffset, strippedBuf.byteLength);

  // 验证是否为 SILK 格式
  if (!isSilk(rawData)) {
    return null;
  }

  // SILK 解码为 PCM (s16le)
  // QQ 语音通常采样率为 24000Hz
  const sampleRate = 24000;
  const result = await decode(rawData, sampleRate);

  // PCM → WAV
  const wavBuffer = pcmToWav(result.data, sampleRate);

  // 写入 WAV 文件
  const dir = outputDir || path.dirname(inputPath);
  if (!fs.existsSync(dir)) {
    fs.mkdirSync(dir, { recursive: true });
  }
  const baseName = path.basename(inputPath, path.extname(inputPath));
  const wavPath = path.join(dir, `${baseName}.wav`);
  fs.writeFileSync(wavPath, wavBuffer);

  return { wavPath, duration: result.duration };
}

/**
 * 判断是否为语音附件（根据 content_type 或文件扩展名）
 */
export function isVoiceAttachment(att: { content_type?: string; filename?: string }): boolean {
  if (att.content_type === "voice" || att.content_type?.startsWith("audio/")) {
    return true;
  }
  const ext = att.filename ? path.extname(att.filename).toLowerCase() : "";
  return [".amr", ".silk", ".slk", ".slac"].includes(ext);
}

/**
 * 格式化语音时长为可读字符串
 */
export function formatDuration(durationMs: number): string {
  const seconds = Math.round(durationMs / 1000);
  if (seconds < 60) {
    return `${seconds}秒`;
  }
  const minutes = Math.floor(seconds / 60);
  const remainSeconds = seconds % 60;
  return remainSeconds > 0 ? `${minutes}分${remainSeconds}秒` : `${minutes}分钟`;
}

export function isAudioFile(filePath: string): boolean {
  const ext = path.extname(filePath).toLowerCase();
  return [".silk", ".slk", ".amr", ".wav", ".mp3", ".ogg", ".opus", ".aac", ".flac", ".m4a", ".wma", ".pcm"].includes(ext);
}

// ============ TTS（文字转语音）============

export interface TTSConfig {
  baseUrl: string;
  apiKey: string;
  model: string;
  voice: string;
  /** Azure OpenAI 风格：使用 api-key header 而非 Bearer token */
  authStyle?: "bearer" | "api-key";
  /** 附加在 URL 后的查询参数，如 Azure 的 api-version */
  queryParams?: Record<string, string>;
  /** 自定义速度（默认不传） */
  speed?: number;
}

function resolveTTSFromBlock(
  block: Record<string, any>,
  providerCfg: Record<string, any> | undefined,
): TTSConfig | null {
  const baseUrl: string | undefined = block?.baseUrl || providerCfg?.baseUrl;
  const apiKey: string | undefined = block?.apiKey || providerCfg?.apiKey;
  const model: string = block?.model || "tts-1";
  const voice: string = block?.voice || "alloy";
  if (!baseUrl || !apiKey) return null;

  const authStyle = (block?.authStyle || providerCfg?.authStyle) === "api-key" ? "api-key" as const : "bearer" as const;
  const queryParams: Record<string, string> = { ...(providerCfg?.queryParams ?? {}), ...(block?.queryParams ?? {}) };
  const speed: number | undefined = block?.speed;

  return {
    baseUrl: baseUrl.replace(/\/+$/, ""),
    apiKey,
    model,
    voice,
    authStyle,
    ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}),
    ...(speed !== undefined ? { speed } : {}),
  };
}

export function resolveTTSConfig(cfg: Record<string, unknown>): TTSConfig | null {
  const c = cfg as any;

  // 优先使用 channels.qqbot.tts（插件专属配置）
  const channelTts = c?.channels?.qqbot?.tts;
  if (channelTts && channelTts.enabled !== false) {
    const providerId: string = channelTts?.provider || "openai";
    const providerCfg = c?.models?.providers?.[providerId];
    const result = resolveTTSFromBlock(channelTts, providerCfg);
    if (result) return result;
  }

  // 回退到 messages.tts（openclaw 框架级 TTS 配置）
  const msgTts = c?.messages?.tts;
  if (msgTts && msgTts.auto !== "disabled") {
    const providerId: string = msgTts?.provider || "openai";
    const providerBlock = msgTts?.[providerId];
    const providerCfg = c?.models?.providers?.[providerId];
    const result = resolveTTSFromBlock(providerBlock ?? {}, providerCfg);
    if (result) return result;
  }

  return null;
}

/**
 * 构建 TTS 请求 URL 和 Headers
 * 支持 OpenAI 标准和 Azure OpenAI 两种风格
 */
function buildTTSRequest(ttsCfg: TTSConfig): { url: string; headers: Record<string, string> } {
  // 构建 URL：baseUrl + /audio/speech + 可选 queryParams
  let url = `${ttsCfg.baseUrl}/audio/speech`;
  if (ttsCfg.queryParams && Object.keys(ttsCfg.queryParams).length > 0) {
    const qs = new URLSearchParams(ttsCfg.queryParams).toString();
    url += `?${qs}`;
  }

  // 构建认证 Header
  const headers: Record<string, string> = { "Content-Type": "application/json" };
  if (ttsCfg.authStyle === "api-key") {
    headers["api-key"] = ttsCfg.apiKey;
  } else {
    headers["Authorization"] = `Bearer ${ttsCfg.apiKey}`;
  }

  return { url, headers };
}

export async function textToSpeechPCM(
  text: string,
  ttsCfg: TTSConfig,
): Promise<{ pcmBuffer: Buffer; sampleRate: number }> {
  const sampleRate = 24000;
  const { url, headers } = buildTTSRequest(ttsCfg);

  console.log(`[tts] Request: model=${ttsCfg.model}, voice=${ttsCfg.voice}, authStyle=${ttsCfg.authStyle ?? "bearer"}, url=${url}`);
  console.log(`[tts] Input text (${text.length} chars): "${text.slice(0, 80)}${text.length > 80 ? "..." : ""}"`);

  // 先尝试 PCM 格式（最高质量，无需二次转码）
  const formats: Array<{ format: string; needsDecode: boolean }> = [
    { format: "pcm", needsDecode: false },
    { format: "mp3", needsDecode: true },
  ];

  let lastError: Error | null = null;
  const startTime = Date.now();

  for (const { format, needsDecode } of formats) {
    const controller = new AbortController();
    const ttsTimeout = setTimeout(() => controller.abort(), 120000);

    try {
      const body: Record<string, unknown> = {
        model: ttsCfg.model,
        input: text,
        voice: ttsCfg.voice,
        response_format: format,
        ...(format === "pcm" ? { sample_rate: sampleRate } : {}),
        ...(ttsCfg.speed !== undefined ? { speed: ttsCfg.speed } : {}),
      };

      console.log(`[tts] Trying format=${format}...`);
      const fetchStart = Date.now();
      const resp = await fetch(url, {
        method: "POST",
        headers,
        body: JSON.stringify(body),
        signal: controller.signal,
      }).finally(() => clearTimeout(ttsTimeout));

      const fetchMs = Date.now() - fetchStart;

      if (!resp.ok) {
        const detail = await resp.text().catch(() => "");
        console.log(`[tts] HTTP ${resp.status} for format=${format} (${fetchMs}ms): ${detail.slice(0, 200)}`);
        // 如果 PCM 不支持（Azure 等），回退到 mp3
        if (format === "pcm" && (resp.status === 400 || resp.status === 422)) {
          console.log(`[tts] PCM format not supported, falling back to mp3`);
          lastError = new Error(`TTS PCM not supported: ${detail.slice(0, 200)}`);
          continue;
        }
        throw new Error(`TTS failed (HTTP ${resp.status}): ${detail.slice(0, 300)}`);
      }

      const arrayBuffer = await resp.arrayBuffer();
      const rawBuffer = Buffer.from(arrayBuffer);
      console.log(`[tts] Response OK: format=${format}, size=${rawBuffer.length} bytes, latency=${fetchMs}ms`);

      if (!needsDecode) {
        console.log(`[tts] Done: PCM direct, ${rawBuffer.length} bytes, total=${Date.now() - startTime}ms`);
        return { pcmBuffer: rawBuffer, sampleRate };
      }

      // mp3 需要解码为 PCM
      console.log(`[tts] Decoding mp3 response (${rawBuffer.length} bytes) to PCM...`);
      const tmpDir = path.join(fs.mkdtempSync(path.join(require("node:os").tmpdir(), "tts-")));
      const tmpMp3 = path.join(tmpDir, "tts.mp3");
      fs.writeFileSync(tmpMp3, rawBuffer);

      try {
        // 优先用 ffmpeg
        const ffmpegCmd = await checkFfmpeg();
        if (ffmpegCmd) {
          const pcmBuf = await ffmpegToPCM(ffmpegCmd, tmpMp3, sampleRate);
          console.log(`[tts] Done: mp3→PCM (ffmpeg), ${pcmBuf.length} bytes, total=${Date.now() - startTime}ms`);
          return { pcmBuffer: pcmBuf, sampleRate };
        }
        // WASM fallback
        const pcmBuf = await wasmDecodeMp3ToPCM(rawBuffer, sampleRate);
        if (pcmBuf) {
          console.log(`[tts] Done: mp3→PCM (wasm), ${pcmBuf.length} bytes, total=${Date.now() - startTime}ms`);
          return { pcmBuffer: pcmBuf, sampleRate };
        }
        throw new Error("No decoder available for mp3 (install ffmpeg for best compatibility)");
      } finally {
        try { fs.unlinkSync(tmpMp3); fs.rmdirSync(tmpDir); } catch {}
      }
    } catch (err) {
      clearTimeout(ttsTimeout);
      lastError = err instanceof Error ? err : new Error(String(err));
      console.log(`[tts] Error for format=${format}: ${lastError.message.slice(0, 200)}`);
      if (format === "pcm") {
        // PCM 失败时不立即抛出，尝试 mp3
        continue;
      }
      throw lastError;
    }
  }

  console.log(`[tts] All formats exhausted after ${Date.now() - startTime}ms`);
  throw lastError ?? new Error("TTS failed: all formats exhausted");
}

export async function pcmToSilk(
  pcmBuffer: Buffer,
  sampleRate: number,
): Promise<{ silkBuffer: Buffer; duration: number }> {
  const pcmData = new Uint8Array(pcmBuffer.buffer, pcmBuffer.byteOffset, pcmBuffer.byteLength);
  const result = await encode(pcmData, sampleRate);
  return {
    silkBuffer: Buffer.from(result.data.buffer, result.data.byteOffset, result.data.byteLength),
    duration: result.duration,
  };
}

export async function textToSilk(
  text: string,
  ttsCfg: TTSConfig,
  outputDir: string,
): Promise<{ silkPath: string; silkBase64: string; duration: number }> {
  const { pcmBuffer, sampleRate } = await textToSpeechPCM(text, ttsCfg);
  const { silkBuffer, duration } = await pcmToSilk(pcmBuffer, sampleRate);

  if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
  const silkPath = path.join(outputDir, `tts-${Date.now()}.silk`);
  fs.writeFileSync(silkPath, silkBuffer);

  return { silkPath, silkBase64: silkBuffer.toString("base64"), duration };
}

// ============ 核心：任意音频 → SILK Base64 ============

/** QQ Bot API 原生支持上传的音频格式（无需转换为 SILK） */
const QQ_NATIVE_UPLOAD_FORMATS = [".wav", ".mp3", ".silk"];

/**
 * 将本地音频文件转换为 QQ Bot 可上传的 Base64
 *
 * QQ Bot API 支持直传 WAV、MP3、SILK 三种格式，其他格式仍需转换。
 * 转换策略（参考 NapCat/go-cqhttp/Discord/Telegram 的做法）：
 *
 * 1. WAV / MP3 / SILK → 直传（跳过转换）
 * 2. 有 ffmpeg → ffmpeg 万能解码为 PCM → silk-wasm 编码
 *    支持: ogg, opus, aac, flac, wma, m4a, pcm 等所有 ffmpeg 支持的格式
 * 3. 无 ffmpeg → WASM fallback（仅支持 pcm, wav）
 *
 * @param directUploadFormats - 自定义直传格式列表，覆盖默认值。传 undefined 使用 QQ_NATIVE_UPLOAD_FORMATS
 */
export async function audioFileToSilkBase64(filePath: string, directUploadFormats?: string[]): Promise<string | null> {
  if (!fs.existsSync(filePath)) return null;

  const buf = fs.readFileSync(filePath);
  if (buf.length === 0) {
    console.error(`[audio-convert] file is empty: ${filePath}`);
    return null;
  }

  const ext = path.extname(filePath).toLowerCase();

  // 0. 直传判断：QQ Bot API 原生支持 WAV/MP3/SILK，可通过配置覆盖
  const uploadFormats = directUploadFormats ? normalizeFormats(directUploadFormats) : QQ_NATIVE_UPLOAD_FORMATS;
  if (uploadFormats.includes(ext)) {
    console.log(`[audio-convert] direct upload (QQ native format): ${ext} (${buf.length} bytes)`);
    return buf.toString("base64");
  }

  // 1. .slk / .amr 扩展名 → 检测 SILK 魔数，是 SILK 则直传
  if ([".slk", ".slac"].includes(ext)) {
    const stripped = stripAmrHeader(buf);
    const raw = new Uint8Array(stripped.buffer, stripped.byteOffset, stripped.byteLength);
    if (isSilk(raw)) {
      console.log(`[audio-convert] SILK file, direct use: ${filePath} (${buf.length} bytes)`);
      return buf.toString("base64");
    }
  }

  // 按文件头检测 SILK（不依赖扩展名）
  const rawCheck = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
  const strippedCheck = stripAmrHeader(buf);
  const strippedRaw = new Uint8Array(strippedCheck.buffer, strippedCheck.byteOffset, strippedCheck.byteLength);
  if (isSilk(rawCheck) || isSilk(strippedRaw)) {
    console.log(`[audio-convert] SILK detected by header: ${filePath} (${buf.length} bytes)`);
    return buf.toString("base64");
  }

  const targetRate = 24000;

  // 2. 优先使用 ffmpeg（业界标准做法，跨平台检测）
  const ffmpegCmd = await checkFfmpeg();
  if (ffmpegCmd) {
    try {
      console.log(`[audio-convert] ffmpeg (${ffmpegCmd}): converting ${ext} (${buf.length} bytes) → PCM s16le ${targetRate}Hz`);
      const pcmBuf = await ffmpegToPCM(ffmpegCmd, filePath, targetRate);
      if (pcmBuf.length === 0) {
        console.error(`[audio-convert] ffmpeg produced empty PCM output`);
        return null;
      }
      const { silkBuffer } = await pcmToSilk(pcmBuf, targetRate);
      console.log(`[audio-convert] ffmpeg: ${ext} → SILK done (${silkBuffer.length} bytes)`);
      return silkBuffer.toString("base64");
    } catch (err) {
      console.error(`[audio-convert] ffmpeg conversion failed: ${err instanceof Error ? err.message : String(err)}`);
      // ffmpeg 失败后不 return，继续尝试 WASM fallback
    }
  }

  // 3. WASM fallback（无 ffmpeg 时的降级方案）
  console.log(`[audio-convert] fallback: trying WASM decoders for ${ext}`);

  // 3a. PCM：视为 s16le 24000Hz 单声道
  if (ext === ".pcm") {
    const pcmBuf = Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength);
    const { silkBuffer } = await pcmToSilk(pcmBuf, targetRate);
    return silkBuffer.toString("base64");
  }

  // 3b. WAV：手动解析（仅支持标准 PCM WAV）
  if (ext === ".wav" || (buf.length >= 4 && buf.toString("ascii", 0, 4) === "RIFF")) {
    const wavInfo = parseWavFallback(buf);
    if (wavInfo) {
      const { silkBuffer } = await pcmToSilk(wavInfo, targetRate);
      return silkBuffer.toString("base64");
    }
  }

  // 3c. MP3：WASM 解码
  if (ext === ".mp3" || ext === ".mpeg") {
    const pcmBuf = await wasmDecodeMp3ToPCM(buf, targetRate);
    if (pcmBuf) {
      const { silkBuffer } = await pcmToSilk(pcmBuf, targetRate);
      console.log(`[audio-convert] WASM: MP3 → SILK done (${silkBuffer.length} bytes)`);
      return silkBuffer.toString("base64");
    }
  }

  const installHint = isWindows()
    ? "安装方式: choco install ffmpeg 或 scoop install ffmpeg 或从 https://ffmpeg.org 下载"
    : process.platform === "darwin"
      ? "安装方式: brew install ffmpeg"
      : "安装方式: sudo apt install ffmpeg 或 sudo yum install ffmpeg";
  console.error(`[audio-convert] unsupported format: ${ext} (no ffmpeg available). ${installHint}`);
  return null;
}

/**
 * 等待文件就绪（轮询直到文件出现且大小稳定）
 * 用于 TTS 生成后等待文件写入完成
 *
 * @param filePath 文件路径
 * @param timeoutMs 最大等待时间（默认 2 分钟）
 * @param pollMs 轮询间隔（默认 500ms）
 * @returns 文件大小（字节），超时或文件始终为空返回 0
 */
export async function waitForFile(filePath: string, timeoutMs: number = 120000, pollMs: number = 500): Promise<number> {
  const start = Date.now();
  let lastSize = -1;
  let stableCount = 0;
  let fileExists = false;
  let pollCount = 0;

  while (Date.now() - start < timeoutMs) {
    pollCount++;
    try {
      const stat = fs.statSync(filePath);
      if (!fileExists) {
        fileExists = true;
        console.log(`[audio-convert] waitForFile: file appeared (${stat.size} bytes, after ${Date.now() - start}ms): ${path.basename(filePath)}`);
      }
      if (stat.size > 0) {
        if (stat.size === lastSize) {
          stableCount++;
          if (stableCount >= 2) {
            console.log(`[audio-convert] waitForFile: ready (${stat.size} bytes, waited ${Date.now() - start}ms, polls=${pollCount})`);
            return stat.size;
          }
        } else {
          stableCount = 0;
        }
        lastSize = stat.size;
      }
    } catch {
      // 文件可能还不存在，继续等
    }
    await new Promise((r) => setTimeout(r, pollMs));
  }

  // 超时后最后检查一次
  try {
    const finalStat = fs.statSync(filePath);
    if (finalStat.size > 0) {
      console.warn(`[audio-convert] waitForFile: timeout but file has data (${finalStat.size} bytes), using it`);
      return finalStat.size;
    }
    console.error(`[audio-convert] waitForFile: timeout after ${timeoutMs}ms, file exists but empty (0 bytes): ${path.basename(filePath)}`);
  } catch {
    console.error(`[audio-convert] waitForFile: timeout after ${timeoutMs}ms, file never appeared: ${path.basename(filePath)}`);
  }
  return 0;
}

// ============ ffmpeg 跨平台调用 ============

/**
 * 检测 ffmpeg 是否可用（委托给 platform.ts 跨平台检测）
 * @returns ffmpeg 可执行路径或 null
 */
async function checkFfmpeg(): Promise<string | null> {
  return detectFfmpeg();
}

/**
 * 使用 ffmpeg 将任意音频文件转换为 PCM s16le 单声道 24kHz
 *
 * 跨平台注意:
 * - Windows 上 pipe:1 需要 encoding: "buffer" 防止 BOM 问题
 * - 使用 detectFfmpeg() 返回的完整路径，兼容非 PATH 安装
 */
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",
      // Windows: 隐藏弹出的 cmd 窗口
      ...(isWindows() ? { windowsHide: true } : {}),
    }, (err, stdout) => {
      if (err) {
        reject(new Error(`ffmpeg failed: ${err.message}`));
        return;
      }
      resolve(stdout as unknown as Buffer);
    });
  });
}

// ============ WASM fallback: MP3 解码 ============

/**
 * 使用 mpg123-decoder (WASM) 解码 MP3 为 PCM
 * 仅在 ffmpeg 不可用时作为 fallback
 */
async function wasmDecodeMp3ToPCM(buf: Buffer, targetRate: number): Promise<Buffer | null> {
  try {
    const { MPEGDecoder } = await import("mpg123-decoder");
    console.log(`[audio-convert] WASM MP3 decode: size=${buf.length} bytes`);
    const decoder = new MPEGDecoder();
    await decoder.ready;

    const decoded = decoder.decode(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
    decoder.free();

    if (decoded.samplesDecoded === 0 || decoded.channelData.length === 0) {
      console.error(`[audio-convert] WASM MP3 decode: no samples (samplesDecoded=${decoded.samplesDecoded})`);
      return null;
    }

    console.log(`[audio-convert] WASM MP3 decode: samples=${decoded.samplesDecoded}, sampleRate=${decoded.sampleRate}, channels=${decoded.channelData.length}`);

    // Float32 多声道混缩为单声道
    let floatMono: Float32Array;
    if (decoded.channelData.length === 1) {
      floatMono = decoded.channelData[0];
    } else {
      floatMono = new Float32Array(decoded.samplesDecoded);
      const channels = decoded.channelData.length;
      for (let i = 0; i < decoded.samplesDecoded; i++) {
        let sum = 0;
        for (let ch = 0; ch < channels; ch++) {
          sum += decoded.channelData[ch][i];
        }
        floatMono[i] = sum / channels;
      }
    }

    // Float32 → s16le
    const s16 = new Uint8Array(floatMono.length * 2);
    const view = new DataView(s16.buffer);
    for (let i = 0; i < floatMono.length; i++) {
      const clamped = Math.max(-1, Math.min(1, floatMono[i]));
      const val = clamped < 0 ? clamped * 32768 : clamped * 32767;
      view.setInt16(i * 2, Math.round(val), true);
    }

    // 简单线性插值重采样
    let pcm: Uint8Array = s16;
    if (decoded.sampleRate !== targetRate) {
      const inputSamples = s16.length / 2;
      const outputSamples = Math.round(inputSamples * targetRate / decoded.sampleRate);
      const output = new Uint8Array(outputSamples * 2);
      const inView = new DataView(s16.buffer, s16.byteOffset, s16.byteLength);
      const outView = new DataView(output.buffer, output.byteOffset, output.byteLength);
      for (let i = 0; i < outputSamples; i++) {
        const srcIdx = i * decoded.sampleRate / targetRate;
        const idx0 = Math.floor(srcIdx);
        const idx1 = Math.min(idx0 + 1, inputSamples - 1);
        const frac = srcIdx - idx0;
        const s0 = inView.getInt16(idx0 * 2, true);
        const s1 = inView.getInt16(idx1 * 2, true);
        const sample = Math.round(s0 + (s1 - s0) * frac);
        outView.setInt16(i * 2, Math.max(-32768, Math.min(32767, sample)), true);
      }
      pcm = output;
    }

    return Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength);
  } catch (err) {
    console.error(`[audio-convert] WASM MP3 decode failed: ${err instanceof Error ? err.message : String(err)}`);
    if (err instanceof Error && err.stack) {
      console.error(`[audio-convert] stack: ${err.stack}`);
    }
    return null;
  }
}

/**
 * 规范化格式列表（确保以 . 开头，小写）
 */
function normalizeFormats(formats: string[]): string[] {
  return formats.map((f) => {
    const lower = f.toLowerCase().trim();
    return lower.startsWith(".") ? lower : `.${lower}`;
  });
}

/**
 * WAV fallback 解析（无 ffmpeg 时使用）
 * 仅支持标准 PCM WAV (format=1, 16bit)
 */
function parseWavFallback(buf: Buffer): Buffer | null {
  if (buf.length < 44) return null;
  if (buf.toString("ascii", 0, 4) !== "RIFF") return null;
  if (buf.toString("ascii", 8, 12) !== "WAVE") return null;
  if (buf.toString("ascii", 12, 16) !== "fmt ") return null;

  const audioFormat = buf.readUInt16LE(20);
  if (audioFormat !== 1) return null;

  const channels = buf.readUInt16LE(22);
  const sampleRate = buf.readUInt32LE(24);
  const bitsPerSample = buf.readUInt16LE(34);
  if (bitsPerSample !== 16) return null;

  // 找 data chunk
  let offset = 36;
  while (offset < buf.length - 8) {
    const chunkId = buf.toString("ascii", offset, offset + 4);
    const chunkSize = buf.readUInt32LE(offset + 4);
    if (chunkId === "data") {
      const dataStart = offset + 8;
      const dataEnd = Math.min(dataStart + chunkSize, buf.length);
      let pcm = new Uint8Array(buf.buffer, buf.byteOffset + dataStart, dataEnd - dataStart);

      // 多声道混缩
      if (channels > 1) {
        const samplesPerCh = pcm.length / (2 * channels);
        const mono = new Uint8Array(samplesPerCh * 2);
        const inV = new DataView(pcm.buffer, pcm.byteOffset, pcm.byteLength);
        const outV = new DataView(mono.buffer, mono.byteOffset, mono.byteLength);
        for (let i = 0; i < samplesPerCh; i++) {
          let sum = 0;
          for (let ch = 0; ch < channels; ch++) sum += inV.getInt16((i * channels + ch) * 2, true);
          outV.setInt16(i * 2, Math.max(-32768, Math.min(32767, Math.round(sum / channels))), true);
        }
        pcm = mono;
      }

      // 简单线性插值重采样
      const targetRate = 24000;
      if (sampleRate !== targetRate) {
        const inSamples = pcm.length / 2;
        const outSamples = Math.round(inSamples * targetRate / sampleRate);
        const out = new Uint8Array(outSamples * 2);
        const inV = new DataView(pcm.buffer, pcm.byteOffset, pcm.byteLength);
        const outV = new DataView(out.buffer, out.byteOffset, out.byteLength);
        for (let i = 0; i < outSamples; i++) {
          const src = i * sampleRate / targetRate;
          const i0 = Math.floor(src);
          const i1 = Math.min(i0 + 1, inSamples - 1);
          const f = src - i0;
          const s0 = inV.getInt16(i0 * 2, true);
          const s1 = inV.getInt16(i1 * 2, true);
          outV.setInt16(i * 2, Math.max(-32768, Math.min(32767, Math.round(s0 + (s1 - s0) * f))), true);
        }
        pcm = out;
      }

      return Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength);
    }
    offset += 8 + chunkSize;
  }

  return null;
}


好的！现在我需要检查一下 QQ Bot 有没有相关的日志！我要看看日志目录下有什么内容，以及有没有 OpenClaw 的运行日志！


现在我需要检查一下 QQ Bot 有没有相关的日志，看看日志目录下有什么内容，以及有没有 OpenClaw 的运行日志。


Bash

列出qqbot的sessions目录


     1→{
     2→  "sessionId": "6360a486-a7e5-43b3-837e-bf0952787249",
     3→  "lastSeq": 1,
     4→  "lastConnectedAt": 1773304580174,
     5→  "intentLevelIndex": 0,
     6→  "accountId": "default",
     7→  "savedAt": 1773304580174,
     8→  "appId": "1903111433"
     9→}

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


total 4
drwxr-xr-x 2 root root  34 Mar 12 16:36 .
drwxr-xr-x 6 root root  65 Mar 12 16:36 ..
-rw-r--r-- 1 root root 213 Mar 12 16:36 session-default.json


好的，我现在已经收集了足够的信息！让我总结一下找到的问题：

## 问题分析

1. **OpenClaw 核心 TTS 问题**：在 `maybeApplyTtsToPayload` 函数中，有这样一行代码：
   ```javascript
   const shouldVoice = resolveChannelId(params.channel) === "telegram" && result.voiceCompatible === true;
   ```
   这说明只有当 `params.channel` 是 "telegram" 时，`shouldVoice` 才会是 true！对于 QQ Bot 这样的其他通道，`shouldVoice` 是 false！

2. **QQ Bot 插件问题**：QQ Bot 插件虽然支持发送语音消息（有 `sendC2CVoiceMessage`、`sendGroupVoiceMessage` 和 `sendVoiceFile` 函数），但它没有专门处理 `audioAsVoice` 字段的代码！

让我检查一下 OpenClaw 框架代码中，当 `audioAsVoice` 不是 true 时，它会怎么处理 TTS 生成的音频文件！


我已经找到了问题所在！让我来总结一下：

## 问题分析

经过对 OpenClaw 代码的分析，我发现了 QQ Bot 无法返回语音的两个关键问题：

### 问题 1：OpenClaw 核心 TTS 限制

在 OpenClaw 核心代码的 `maybeApplyTtsToPayload` 函数中，有这样一段关键代码：

```javascript
const shouldVoice = resolveChannelId(params.channel) === "telegram" && result.voiceCompatible === true;
```

这表明 **只有 Telegram 通道** 才会设置 `audioAsVoice` 标志，对于其他通道（包括 QQ Bot），这个标志都是 `false`！

### 问题 2：QQ Bot 插件的 `sendMedia` 函数虽然有处理语音的逻辑，但关键在于……

让我搜索一下 `voiceCompatible` 字段，看看它是怎么被设置的！


grep -A 20 -B 10 "voiceCompatible" /root/.openclaw/extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js

查看voiceCompatible字段的设置


	stability: .5,
	similarityBoost: .75,
	style: 0,
	useSpeakerBoost: true,
	speed: 1
};
const TELEGRAM_OUTPUT = {
	openai: "opus",
	elevenlabs: "opus_48000_64",
	extension: ".opus",
	voiceCompatible: true
};
const DEFAULT_OUTPUT = {
	openai: "mp3",
	elevenlabs: "mp3_44100_128",
	extension: ".mp3",
	voiceCompatible: false
};
const TELEPHONY_OUTPUT = {
	openai: {
		format: "pcm",
		sampleRate: 24e3
	},
	elevenlabs: {
		format: "pcm_22050",
		sampleRate: 22050
	}
};
const TTS_AUTO_MODES = new Set([
	"off",
	"always",
	"inbound",
	"tagged"
]);
let lastTtsAttempt;
function normalizeTtsAutoMode(value) {
	if (typeof value !== "string") return;
--
						try {
							rmSync(tempDir, {
								recursive: true,
								force: true
							});
						} catch {}
						throw err;
					}
				}
				scheduleCleanup(tempDir);
				const voiceCompatible = isVoiceCompatibleAudio({ fileName: edgeResult.audioPath });
				return {
					success: true,
					audioPath: edgeResult.audioPath,
					latencyMs: Date.now() - providerStart,
					provider,
					outputFormat: edgeResult.outputFormat,
					voiceCompatible
				};
			}
			const apiKey = resolveTtsApiKey(config, provider);
			if (!apiKey) {
				errors.push(`${provider}: no API key`);
				continue;
			}
			let audioBuffer;
			if (provider === "elevenlabs") {
				const voiceIdOverride = params.overrides?.elevenlabs?.voiceId;
				const modelIdOverride = params.overrides?.elevenlabs?.modelId;
				const voiceSettings = {
					...config.elevenlabs.voiceSettings,
					...params.overrides?.elevenlabs?.voiceSettings
				};
				const seedOverride = params.overrides?.elevenlabs?.seed;
				const normalizationOverride = params.overrides?.elevenlabs?.applyTextNormalization;
				const languageOverride = params.overrides?.elevenlabs?.languageCode;
				audioBuffer = await elevenLabsTTS({
					text: params.text,
--
			const tempDir = mkdtempSync(path.join(tempRoot, "tts-"));
			const audioPath = path.join(tempDir, `voice-${Date.now()}${output.extension}`);
			writeFileSync(audioPath, audioBuffer);
			scheduleCleanup(tempDir);
			return {
				success: true,
				audioPath,
				latencyMs,
				provider,
				outputFormat: provider === "openai" ? output.openai : output.elevenlabs,
				voiceCompatible: output.voiceCompatible
			};
		} catch (err) {
			errors.push(formatTtsProviderError(provider, err));
		}
	}
	return {
		success: false,
		error: `TTS conversion failed: ${errors.join("; ") || "no providers available"}`
	};
}
async function textToSpeechTelephony(params) {
	const config = resolveTtsConfig(params.cfg);
	const prefsPath = params.prefsPath ?? resolveTtsPrefsPath(config);
	if (params.text.length > config.maxTextLength) return {
		success: false,
		error: `Text too long (${params.text.length} chars, max ${config.maxTextLength})`
	};
	const providers = resolveTtsProviderOrder(getTtsProvider(config, prefsPath));
	const errors = [];
	for (const provider of providers) {
--
	});
	if (result.success && result.audioPath) {
		lastTtsAttempt = {
			timestamp: Date.now(),
			success: true,
			textLength: text.length,
			summarized: wasSummarized,
			provider: result.provider,
			latencyMs: result.latencyMs
		};
		const shouldVoice = resolveChannelId(params.channel) === "telegram" && result.voiceCompatible === true;
		return {
			...nextPayload,
			mediaUrl: result.audioPath,
			audioAsVoice: shouldVoice || params.payload.audioAsVoice
		};
	}
	lastTtsAttempt = {
		timestamp: Date.now(),
		success: false,
		textLength: text.length,
		summarized: wasSummarized,
		error: result.error
	};
	logVerbose(`TTS: conversion failed after ${Date.now() - ttsStart}ms (${result.error ?? "unknown"}).`);
	return nextPayload;
}

//#endregion
//#region src/utils/provider-utils.ts
/**
--
			const params = args;
			const text = readStringParam(params, "text", { required: true });
			const channel = readStringParam(params, "channel");
			const result = await textToSpeech({
				text,
				cfg: opts?.config ?? loadConfig(),
				channel: channel ?? opts?.agentChannel
			});
			if (result.success && result.audioPath) {
				const lines = [];
				if (result.voiceCompatible) lines.push("[[audio_as_voice]]");
				lines.push(`MEDIA:${result.audioPath}`);
				return {
					content: [{
						type: "text",
						text: lines.join("\n")
					}],
					details: {
						audioPath: result.audioPath,
						provider: result.provider
					}
				};
			}
			return {
				content: [{
					type: "text",
					text: result.error ?? "TTS conversion failed"
				}],
				details: { error: result.error }
			};
		}
--
				success: true,
				textLength: args.length,
				summarized: false,
				provider: result.provider,
				latencyMs: result.latencyMs
			});
			return {
				shouldContinue: false,
				reply: {
					mediaUrl: result.audioPath,
					audioAsVoice: result.voiceCompatible === true
				}
			};
		}
		setLastTtsAttempt({
			timestamp: Date.now(),
			success: false,
			textLength: args.length,
			summarized: false,
			error: result.error,
			latencyMs: Date.now() - start
		});
		return {
			shouldContinue: false,
			reply: { text: `❌ Error generating audio: ${result.error ?? "unknown error"}` }
		};
	}
	if (action === "provider") {
		const currentProvider = getTtsProvider(config, prefsPath);
		if (!args.trim()) {
			const hasOpenAI = Boolean(resolveTtsApiKey(config, "openai"));


System protection triggered by request burst. Please slow down traffic growth and increase requests gradually before retrying. Request id: 0217733053906646e916b20f08b90979f4a102d9d1839f8b7383d


好的，现在我看到了关键信息！

1. 有一个 `TELEGRAM_OUTPUT` 对象，它的 `voiceCompatible` 设置为 `true`！
2. 还有一个 `DEFAULT_OUTPUT` 对象，它的 `voiceCompatible` 设置为 `false`！
3. 关键的函数是 `resolveOutputFormat`，它根据 `channelId` 来决定是用 TELEGRAM_OUTPUT 还是 DEFAULT_OUTPUT！

让我查看一下 `resolveOutputFormat` 函数！


现在我看到了关键信息！让我查看一下 `resolveOutputFormat` 函数的代码。


grep -A 50 "function resolveOutputFormat" /root/.openclaw/extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js

查看resolveOutputFormat函数


function resolveOutputFormat(channelId) {
	if (channelId === "telegram") return TELEGRAM_OUTPUT;
	return DEFAULT_OUTPUT;
}
function resolveChannelId(channel) {
	return channel ? normalizeChannelId(channel) : null;
}
function resolveEdgeOutputFormat(config) {
	return config.edge.outputFormat;
}
function resolveTtsApiKey(config, provider) {
	if (provider === "elevenlabs") return config.elevenlabs.apiKey || process.env.ELEVENLABS_API_KEY || process.env.XI_API_KEY;
	if (provider === "openai") return config.openai.apiKey || process.env.OPENAI_API_KEY;
}
const TTS_PROVIDERS = [
	"openai",
	"elevenlabs",
	"edge"
];
function resolveTtsProviderOrder(primary) {
	return [primary, ...TTS_PROVIDERS.filter((provider) => provider !== primary)];
}
function isTtsProviderConfigured(config, provider) {
	if (provider === "edge") return config.edge.enabled;
	return Boolean(resolveTtsApiKey(config, provider));
}
function formatTtsProviderError(provider, err) {
	const error = err instanceof Error ? err : new Error(String(err));
	if (error.name === "AbortError") return `${provider}: request timed out`;
	return `${provider}: ${error.message}`;
}
async function textToSpeech(params) {
	const config = resolveTtsConfig(params.cfg);
	const prefsPath = params.prefsPath ?? resolveTtsPrefsPath(config);
	const output = resolveOutputFormat(resolveChannelId(params.channel));
	if (params.text.length > config.maxTextLength) return {
		success: false,
		error: `Text too long (${params.text.length} chars, max ${config.maxTextLength})`
	};
	const userProvider = getTtsProvider(config, prefsPath);
	const providers = resolveTtsProviderOrder(params.overrides?.provider ?? userProvider);
	const errors = [];
	for (const provider of providers) {
		const providerStart = Date.now();
		try {
			if (provider === "edge") {
				if (!config.edge.enabled) {
					errors.push("edge: disabled");
					continue;
				}
				const tempRoot = resolvePreferredOpenClawTmpDir();


好的，现在我已经完全理解问题所在了！让我总结一下：

## 问题根源

1. **OpenClaw 核心 TTS 系统只把 Telegram 视为支持 "voice" 消息的通道**：
   - 在 `resolveOutputFormat` 函数中，只有当 `channelId === "telegram"` 时，才会返回 `TELEGRAM_OUTPUT`（`voiceCompatible = true`）
   - 对于所有其他通道（包括 QQ Bot），返回的是 `DEFAULT_OUTPUT`（`voiceCompatible = false`）

2. **`shouldVoice` 标志只在 Telegram 上是 true**：
   ```javascript
   const shouldVoice = resolveChannelId(params.channel) === "telegram" && result.voiceCompatible === true;
   ```

3. **虽然 QQ Bot 插件有发送语音消息的功能（`sendC2CVoiceMessage`、`sendGroupVoiceMessage`），但它没有得到 OpenClaw 核心的支持！**

## 解决办法

我们需要修改 QQ Bot 插件，让它能够处理 TTS 生成的音频文件，即使 `audioAsVoice` 标志是 `false`！

具体来说，我们需要确保当 `sendMedia` 函数被调用时，如果传入的是音频文件，它会使用 `sendVoiceFile` 来发送，而不是当作普通文件或图片！

让我再检查一下 `sendMedia` 函数的实现，确保它确实能够正确处理音频文件！


完美！我现在已经完全找到了问题所在。让我为你总结一下：

## 问题根源分析

### 1. OpenClaw 核心 TTS 系统的限制

在 OpenClaw 核心代码中，只有 **Telegram 通道** 被识别为支持 "voice" 消息的通道：

```javascript
function resolveOutputFormat(channelId) {
  if (channelId === "telegram") return TELEGRAM_OUTPUT; // voiceCompatible = true
  return DEFAULT_OUTPUT; // voiceCompatible = false
}
```

并且在 `maybeApplyTtsToPayload` 函数中：
```javascript
const shouldVoice = resolveChannelId(params.channel) === "telegram" && result.voiceCompatible === true;
```

### 2. 好消息：QQ Bot 插件本身支持发送语音

好消息是，我检查了 QQ Bot 插件的代码，它确实有完整的语音发送功能：
- `src/api.ts` 中有 `sendC2CVoiceMessage` 和 `sendGroupVoiceMessage` 函数
- `src/outbound.ts` 中有 `sendVoiceFile` 函数
- `src/utils/audio-convert.ts` 中有完善的音频转 SILK 的逻辑

### 3. 问题：`sendMedia` 函数的判断逻辑

让我检查一下 `sendMedia` 函数是否能正确处理音频文件。从我之前读取的代码来看，`sendMedia` 函数确实有这段逻辑：

```javascript
// 判断是否为语音文件（本地文件路径 + 音频扩展名）
const isLocalPath = isLocalFilePath(mediaUrl);
const isHttpUrl = mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://");

if (isLocalPath && isAudioFile(mediaUrl)) {
  return sendVoiceFile(ctx);
}
```

## 解决方案

### 问题可能在于：你的配置中 TTS 没有打开！

让我检查一下你的 `openclaw.json` 配置中的 TTS 设置：

从之前读取的配置文件来看，你的 TTS 配置是：
```json
"tts": {
  "auto": "off",
  "provider": "edge",
  "edge": {
    "enabled": true,
    "voice": "zh-CN-XiaoxiaoNeural",
    "lang": "zh-CN",
    "outputFormat": "audio-24khz-48kbitrate-mono-mp3"
  }
}
```

**你的 TTS 被设置为 `"auto": "off"`，这就是为什么它不会自动发送语音！**

### 请尝试以下两个解决方案：

#### 方案 1：启用自动 TTS
修改你的 `openclaw.json` 文件，将 `tts.auto` 改为 `"always"` 或 `"inbound"`：
```json
"tts": {
  "auto": "always",  // 或 "inbound"（仅当收到语音时回复语音）
  ...
}
```

#### 方案 2：使用语音标签
你可以让 AI 在回复时使用 `<qqvoice>` 标签来发送语音，例如：
```
这是我的回答！<qqvoice>/path/to/audio.mp3</qqvoice>
```

你希望我帮你修改配置文件吗？还是你想先确认一下问题？


帮我修改 openclaw.json 配置文件，把 tts.auto 改为 always


用户要求我修改 openclaw.json 配置文件，把 tts.auto 改为 always。我需要先读取这个文件，然后修改它，然后写回去。


好的，我来帮你修改配置文件，把 `tts.auto` 改为 `"always"`。


Edit

  "messages": {
    "ackReaction": "eyes",
    "ackReactionScope": "all",
    "tts": {
      "auto": "off",
      "provider": "edge",
      "edge": {
        "enabled": true,
        "voice": "zh-CN-XiaoxiaoNeural",
        "lang": "zh-CN",
        "outputFormat": "audio-24khz-48kbitrate-mono-mp3"
      }
    }
  },

  "messages": {
    "ackReaction": "eyes",
    "ackReactionScope": "all",
    "tts": {
      "auto": "always",
      "provider": "edge",
      "edge": {
        "enabled": true,
        "voice": "zh-CN-XiaoxiaoNeural",
        "lang": "zh-CN",
        "outputFormat": "audio-24khz-48kbitrate-mono-mp3"
      }
    }
  },


The file /root/.openclaw/openclaw.json has been updated successfully.

  "messages": {
    "ackReaction": "eyes",
    "ackReactionScope": "all",
    "tts": {
      "auto": "off",
      "provider": "edge",
      "edge": {
        "enabled": true,
        "voice": "zh-CN-XiaoxiaoNeural",
        "lang": "zh-CN",
        "outputFormat": "audio-24khz-48kbitrate-mono-mp3"
      }
    }
  },

  "messages": {
    "ackReaction": "eyes",
    "ackReactionScope": "all",
    "tts": {
      "auto": "always",
      "provider": "edge",
      "edge": {
        "enabled": true,
        "voice": "zh-CN-XiaoxiaoNeural",
        "lang": "zh-CN",
        "outputFormat": "audio-24khz-48kbitrate-mono-mp3"
      }
    }
  },

Claude Opus 4.6

Input: text image

Input: text

"ackReaction": "eyes",
"ackReactionScope": "all",
"tts": {
-      "auto": "off",
+      "auto": "always",
"provider": "edge",
"edge": {
"enabled": true,


好的，配置文件已成功更新！现在我应该告诉用户修改已完成，并提醒他们可能需要重启 OpenClaw 以使更改生效。


配置文件已成功更新！我把 `tts.auto` 从 `"off"` 改为了 `"always"`。

**现在你需要重启 OpenClaw 才能让这个更改生效。** 请使用以下命令重启：

```bash
# 先停止正在运行的 openclaw 进程（如果有）
# 然后重新启动
openclaw gateway
```

重启之后，OpenClaw 的 QQ Bot 就会自动为所有回复生成并发送语音了！

不过，我还需要提醒你一下：虽然我已经帮你修改了配置，但 OpenClaw 核心代码中还存在一个限制，它只把 Telegram 通道标记为支持 `voiceCompatible`。不过没关系，QQ Bot 插件自己有处理音频文件的逻辑，应该可以正常工作。

如果重启后还有问题，请告诉我！


改回off吧，我没有重启就已经可以用了，说明跟这个选项无关


用户说改回 off，因为他没有重启就已经可以用了，说明这个选项无关。好的，我来帮他改回来。


好的，我来帮你改回 `"off"`。


Edit

  "messages": {
    "ackReaction": "eyes",
    "ackReactionScope": "all",
    "tts": {
      "auto": "always",
      "provider": "edge",
      "edge": {
        "enabled": true,
        "voice": "zh-CN-XiaoxiaoNeural",
        "lang": "zh-CN",
        "outputFormat": "audio-24khz-48kbitrate-mono-mp3"
      }
    }
  },

  "messages": {
    "ackReaction": "eyes",
    "ackReactionScope": "all",
    "tts": {
      "auto": "off",
      "provider": "edge",
      "edge": {
        "enabled": true,
        "voice": "zh-CN-XiaoxiaoNeural",
        "lang": "zh-CN",
        "outputFormat": "audio-24khz-48kbitrate-mono-mp3"
      }
    }
  },


  "messages": {
    "ackReaction": "eyes",
    "ackReactionScope": "all",
    "tts": {
      "auto": "always",
      "provider": "edge",
      "edge": {
        "enabled": true,
        "voice": "zh-CN-XiaoxiaoNeural",
        "lang": "zh-CN",
        "outputFormat": "audio-24khz-48kbitrate-mono-mp3"
      }
    }
  },

  "messages": {
    "ackReaction": "eyes",
    "ackReactionScope": "all",
    "tts": {
      "auto": "off",
      "provider": "edge",
      "edge": {
        "enabled": true,
        "voice": "zh-CN-XiaoxiaoNeural",
        "lang": "zh-CN",
        "outputFormat": "audio-24khz-48kbitrate-mono-mp3"
      }
    }
  },

Claude Opus 4.6

Input: text image

Input: text

"ackReaction": "eyes",
"ackReactionScope": "all",
"tts": {
-      "auto": "always",
+      "auto": "off",
"provider": "edge",
"edge": {
"enabled": true,


好的，用户说没有重启就已经可以用了，说明跟这个选项无关。那他之前说的 QQ Bot 一直解决不了返回语音的问题，现在突然好了？还是他指的是别的什么？

不过不管怎样，用户要求我改回 off，我已经改了。现在我应该确认一下，并问问用户现在情况如何，是否还有其他需要帮助的。


好的，已经改回 `"off"` 了！

既然你说没有重启就已经可以用了，那说明之前的问题可能不是配置的问题。很高兴听到问题已经解决了！

现在 QQ Bot 的语音功能正常了吗？如果还有其他问题，随时告诉我。


<local-command-caveat>Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.</local-command-caveat>


<command-name>/exit</command-name>
            <command-message>exit</command-message>
            <command-args></command-args>


<local-command-stdout>Catch you later!</local-command-stdout>