|
|
@@ -0,0 +1,180 @@
|
|
|
+// 讯飞录音文件转写大模型 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, string>): 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<string> {
|
|
|
+ const dateTime = formatDateTime(new Date());
|
|
|
+ const signatureRandom = randomString(16);
|
|
|
+ const fileSize = params.audio.length.toString();
|
|
|
+ const language = params.language || 'autodialect';
|
|
|
+
|
|
|
+ // 构建所有参数
|
|
|
+ const queryParams: Record<string, string> = {
|
|
|
+ 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<TaskResult> {
|
|
|
+ const dateTime = formatDateTime(new Date());
|
|
|
+ const signatureRandom = randomString(16);
|
|
|
+
|
|
|
+ const queryParams: Record<string, string> = {
|
|
|
+ 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 || '' };
|
|
|
+ }
|
|
|
+}
|