// 讯飞录音文件转写大模型 WebAPI 客户端 // API文档: https://www.xfyun.cn/doc/spark/asr_llm/Ifasr_llm.html import crypto from 'crypto'; interface AsrConfig { appId: string; accessKeyId: string; apiSecret: string; host: string; } interface CreateTaskParams { audio: Buffer; fileName: string; language?: string; } interface TaskResult { orderId: string; status: 'waiting' | 'processing' | 'completed' | 'failed'; text?: string; error?: string; } function randomString(len: number): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; return Array.from({ length: len }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); } function formatDateTime(d: Date): string { const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}+0800`; } export class IflytekAsr { private config: AsrConfig; private baseUrl: string; constructor(config: AsrConfig) { this.config = config; this.baseUrl = config.host.replace(/\/$/, ''); } // 标准签名: 对所有URL参数(除signature)排序 → URL编码 → &拼接 → HMAC-SHA1 → Base64 private sign(params: Record): string { // 1. 排除 signature,按 key 排序 const sorted = Object.keys(params) .filter(k => k !== 'signature' && params[k] != null && params[k] !== '') .sort() .map(k => { const encodedKey = encodeURIComponent(k); const encodedVal = encodeURIComponent(params[k]); return `${encodedKey}=${encodedVal}`; }) .join('&'); console.log('[ASR] sign raw:', sorted); // 2. HMAC-SHA1 → Base64 const hmac = crypto.createHmac('sha1', this.config.apiSecret); return hmac.update(sorted).digest('base64'); } // 创建转写任务 async createTask(params: CreateTaskParams): Promise { const dateTime = formatDateTime(new Date()); const signatureRandom = randomString(16); const fileSize = params.audio.length.toString(); const language = params.language || 'autodialect'; // 构建所有参数 const queryParams: Record = { appId: this.config.appId, accessKeyId: this.config.accessKeyId, dateTime, signatureRandom, fileSize, fileName: params.fileName, language, durationCheckDisable: 'true', // 关闭时长校验 }; // 生成签名 (不含 signature 字段) const signature = this.sign(queryParams); // 构建 URL — 使用与签名相同的编码方式 const sortedKeys = Object.keys(queryParams).filter(k => queryParams[k] != null && queryParams[k] !== '').sort(); const qs = sortedKeys.map(k => `${encodeURIComponent(k)}=${encodeURIComponent(queryParams[k])}`).join('&'); const url = `${this.baseUrl}/v2/upload?${qs}`; console.log('[ASR] upload URL:', url); const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/octet-stream', 'signature': signature, }, body: new Uint8Array(params.audio), }); const text = await res.text(); console.log('[ASR] upload response:', text.slice(0, 500)); let json: any; try { json = JSON.parse(text); } catch { throw new Error(`响应解析失败: ${text.slice(0, 200)}`); } if (json.code !== '000000') { throw new Error(`讯飞ASR上传失败: [${json.code}] ${json.descInfo || ''}`); } const orderId = json.content?.orderId; if (!orderId) throw new Error('未获取到 orderId'); return orderId; } // 查询转写结果 async getResult(orderId: string): Promise { const dateTime = formatDateTime(new Date()); const signatureRandom = randomString(16); const queryParams: Record = { appId: this.config.appId, accessKeyId: this.config.accessKeyId, dateTime, signatureRandom, orderId, resultType: 'transfer', }; const signature = this.sign(queryParams); const sortedKeys = Object.keys(queryParams).filter(k => queryParams[k] != null && queryParams[k] !== '').sort(); const qs = sortedKeys.map(k => `${encodeURIComponent(k)}=${encodeURIComponent(queryParams[k])}`).join('&'); const url = `${this.baseUrl}/v2/getResult?${qs}`; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'signature': signature, }, }); const text = await res.text(); let json: any; try { json = JSON.parse(text); } catch { throw new Error(`结果解析失败: ${text.slice(0, 200)}`); } if (json.code !== '000000') { if (json.code === '000001') return { orderId, status: 'processing' }; return { orderId, status: 'failed', error: `[${json.code}] ${json.descInfo || ''}` }; } const content = json.content || {}; let textResult = ''; if (content.orderResult) { try { const resultObj = typeof content.orderResult === 'string' ? JSON.parse(content.orderResult) : content.orderResult; if (resultObj.lattice && Array.isArray(resultObj.lattice)) { textResult = resultObj.lattice .map((seg: any) => { const jb = seg.json_1best || ''; try { const b = JSON.parse(jb); return b.st?.rt?.[0]?.ws?.map((w: any) => w.cw?.[0]?.w || '').join('') || ''; } catch { return seg.onebest || ''; } }) .join(''); } } catch { textResult = typeof content.orderResult === 'string' ? content.orderResult : ''; } } return { orderId, status: 'completed', text: textResult || content.descInfo || '' }; } }