Files
memind/wechat-voice-reco.mjs
T
john 26846a0274
Memind CI / Test, build, and release guards (push) Successful in 3m27s
feat(wechat): add WeChat voice reco API fallback for service account
When passive Recognition is empty, convert AMR to mp3 and call WeChat
addvoicetorecofortext before the existing asr.tkmind.cn fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 18:01:50 +08:00

210 lines
6.0 KiB
JavaScript

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 '';
}