26846a0274
Memind CI / Test, build, and release guards (push) Successful in 3m27s
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>
210 lines
6.0 KiB
JavaScript
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 '';
|
|
}
|