import crypto from 'node:crypto'; import { spawn } from 'node:child_process'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; export const DEFAULT_WECHAT_VOICE_RECO_API_BASE = 'https://api.weixin.qq.com'; export const DEFAULT_WECHAT_VOICE_RECO_LANG = 'zh_CN'; export const WECHAT_VOICE_RECO_MAX_MP3_BYTES = 1024 * 1024; export const WECHAT_VOICE_RECO_DEFAULT_POLL_INTERVAL_MS = 300; export const WECHAT_VOICE_RECO_DEFAULT_POLL_TIMEOUT_MS = 8000; export function buildWechatVoiceRecoVoiceId({ msgId = '', mediaId = '' } = {}) { const raw = String(msgId || mediaId || '').trim(); if (raw) return raw.slice(0, 64); return crypto.randomUUID().replace(/-/g, ''); } function resolveFfmpegPath(explicitPath = '') { const configured = String(explicitPath ?? process.env.H5_FFMPEG_PATH ?? '').trim(); return configured || 'ffmpeg'; } function runFfmpeg(ffmpegPath, args) { return new Promise((resolve, reject) => { const proc = spawn(ffmpegPath, args, { stdio: ['ignore', 'ignore', 'pipe'] }); let stderr = ''; proc.stderr.on('data', (chunk) => { stderr += chunk; }); proc.on('error', reject); proc.on('close', (code) => { if (code === 0) { resolve(undefined); return; } reject(new Error(stderr.trim() || `ffmpeg exit ${code}`)); }); }); } export async function convertWechatVoiceToMp3( buffer, { format = 'amr', ffmpegPath = resolveFfmpegPath(), } = {}, ) { if (!Buffer.isBuffer(buffer) || buffer.length === 0) return null; const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-wechat-voice-')); const extension = String(format ?? '').trim().toLowerCase() || 'amr'; const inputPath = path.join(tmpDir, `input.${extension}`); const outputPath = path.join(tmpDir, 'output.mp3'); try { await fs.writeFile(inputPath, buffer); await runFfmpeg(ffmpegPath, [ '-y', '-i', inputPath, '-ar', '16000', '-ac', '1', '-f', 'mp3', outputPath, ]); const mp3Buffer = await fs.readFile(outputPath); if (!mp3Buffer.length || mp3Buffer.length > WECHAT_VOICE_RECO_MAX_MP3_BYTES) { return null; } return mp3Buffer; } catch { return null; } finally { await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); } } async function readWechatApiPayload(response) { const text = await response.text(); if (!text) return { payload: null, text: '' }; try { return { payload: JSON.parse(text), text }; } catch { return { payload: null, text }; } } function assertWechatApiOk(payload, fallbackText, httpStatus) { const errcode = Number(payload?.errcode ?? 0); if (errcode !== 0) { throw new Error(String(payload?.errmsg ?? fallbackText ?? `WeChat API error ${errcode}`)); } if (!httpStatus || httpStatus < 200 || httpStatus >= 300) { throw new Error(fallbackText || `WeChat HTTP ${httpStatus}`); } } export function isRetryableWechatVoiceRecoQueryError(payload) { const errcode = Number(payload?.errcode ?? 0); return errcode === -1 || errcode === 87009; } async function queryWechatVoiceRecoResultOnce({ apiBase = DEFAULT_WECHAT_VOICE_RECO_API_BASE, accessToken, voiceId, lang = DEFAULT_WECHAT_VOICE_RECO_LANG, wechatFetch, }) { if (!accessToken) throw new Error('缺少微信 access_token'); if (!voiceId) throw new Error('缺少 voice_id'); const url = new URL('/cgi-bin/media/voice/queryrecoresultfortext', apiBase); url.searchParams.set('access_token', accessToken); url.searchParams.set('voice_id', voiceId); url.searchParams.set('lang', lang); const response = await wechatFetch(url.toString(), { method: 'POST' }); const { payload, text } = await readWechatApiPayload(response); if (isRetryableWechatVoiceRecoQueryError(payload)) { return ''; } assertWechatApiOk(payload, text, response.status); return String(payload?.result ?? '').trim(); } export async function uploadWechatVoiceForReco({ apiBase = DEFAULT_WECHAT_VOICE_RECO_API_BASE, accessToken, voiceId, mp3Buffer, lang = DEFAULT_WECHAT_VOICE_RECO_LANG, wechatFetch, }) { if (!accessToken) throw new Error('缺少微信 access_token'); if (!voiceId) throw new Error('缺少 voice_id'); if (!Buffer.isBuffer(mp3Buffer) || !mp3Buffer.length) { throw new Error('语音内容为空'); } const url = new URL('/cgi-bin/media/voice/addvoicetorecofortext', apiBase); url.searchParams.set('access_token', accessToken); url.searchParams.set('format', 'mp3'); url.searchParams.set('voice_id', voiceId); url.searchParams.set('lang', lang); const form = new FormData(); form.append('media', new Blob([mp3Buffer], { type: 'audio/mpeg' }), 'voice.mp3'); const response = await wechatFetch(url.toString(), { method: 'POST', body: form }); const { payload, text } = await readWechatApiPayload(response); assertWechatApiOk(payload, text, response.status); return payload; } export async function queryWechatVoiceRecoResult(options) { return queryWechatVoiceRecoResultOnce(options); } function sleep(ms) { return new Promise((resolve) => { setTimeout(resolve, ms); }); } export async function transcribeWechatVoiceViaRecoApi({ accessToken, voiceBuffer, format = 'amr', voiceId, lang = DEFAULT_WECHAT_VOICE_RECO_LANG, apiBase = DEFAULT_WECHAT_VOICE_RECO_API_BASE, wechatFetch, pollIntervalMs = WECHAT_VOICE_RECO_DEFAULT_POLL_INTERVAL_MS, pollTimeoutMs = WECHAT_VOICE_RECO_DEFAULT_POLL_TIMEOUT_MS, convertToMp3 = convertWechatVoiceToMp3, now = Date.now, }) { const mp3Buffer = await convertToMp3(voiceBuffer, { format }); if (!mp3Buffer?.length) return ''; await uploadWechatVoiceForReco({ apiBase, accessToken, voiceId, mp3Buffer, lang, wechatFetch, }); const deadline = now() + Math.max(0, pollTimeoutMs); while (now() < deadline) { const result = await queryWechatVoiceRecoResultOnce({ apiBase, accessToken, voiceId, lang, wechatFetch, }); if (result) return result; await sleep(Math.max(1, pollIntervalMs)); } return ''; }