import axios from 'axios'; import * as fs from 'fs'; import * as path from 'path'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import logger from '../log/main'; import { getFFmpegPath, getFFprobePath } from '../shell'; const DASHSCOPE_API_URL = 'https://dashscope.aliyuncs.com/api/v1'; const MIN_ENROLLMENT_SAMPLE_RATE = 16000; const execFileAsync = promisify(execFile); async function ensureEnrollmentAudio(filePath: string): Promise { if (!fs.existsSync(filePath)) { throw new Error(`文件不存在: ${filePath}`); } const ffprobePath = await getFFprobePath(); const ffmpegPath = await getFFmpegPath(); let sampleRate = 0; let channels = 0; try { const { stdout } = await execFileAsync(ffprobePath, [ '-v', 'error', '-select_streams', 'a:0', '-show_entries', 'stream=sample_rate,channels', '-of', 'json', filePath, ]); const probe = JSON.parse(stdout || '{}'); const stream = probe?.streams?.[0] || {}; sampleRate = parseInt(stream.sample_rate || '0', 10) || 0; channels = parseInt(stream.channels || '0', 10) || 0; } catch (error: any) { logger.warn('[Aliyun:Enrollment] ffprobe 检测失败,直接转码兜底', { filePath, error: error.message, }); } const needsConvert = !sampleRate || sampleRate < MIN_ENROLLMENT_SAMPLE_RATE || channels !== 1 || path.extname(filePath).toLowerCase() !== '.wav'; if (!needsConvert) { logger.info('[Aliyun:Enrollment] 音频格式符合要求,直接使用', { filePath, sampleRate, channels }); return filePath; } const parsed = path.parse(filePath); const normalizedPath = (p: string) => process.platform === 'win32' ? p.replace(/\\/g, '/') : p; const outputPath = path.join(parsed.dir, `${parsed.name}_enroll_16k.wav`); logger.info('[Aliyun:Enrollment] 开始转码复刻音频', { input: filePath, output: outputPath, sourceSampleRate: sampleRate, sourceChannels: channels, targetSampleRate: MIN_ENROLLMENT_SAMPLE_RATE, targetChannels: 1, }); await execFileAsync(ffmpegPath, [ '-y', '-i', normalizedPath(filePath), '-vn', '-acodec', 'pcm_s16le', '-ar', String(MIN_ENROLLMENT_SAMPLE_RATE), '-ac', '1', normalizedPath(outputPath), ]); logger.info('[Aliyun:Enrollment] 音频转码完成', { outputPath }); return outputPath; } /** * 阿里云 DashScope Voice Enrollment (声音复刻) 服务 */ /** * 将本地音频文件转换为 Base64 Data URI * DashScope create_voice 接口支持 data:{mime_type};base64,{base64_str} 格式 * 这完全绕过了需要公网URL的问题! */ export async function uploadFileToLease(apiKey: string, filePath: string): Promise { if (!fs.existsSync(filePath)) { throw new Error(`文件不存在: ${filePath}`); } const enrollmentAudioPath = await ensureEnrollmentAudio(filePath); const fileName = path.basename(enrollmentAudioPath); const ext = path.extname(enrollmentAudioPath).toLowerCase(); // 根据扩展名确定 MIME 类型 const mimeTypes: Record = { '.wav': 'audio/wav', '.mp3': 'audio/mp3', '.m4a': 'audio/m4a', '.flac': 'audio/flac', '.ogg': 'audio/ogg', '.aac': 'audio/aac', '.wma': 'audio/wma', '.amr': 'audio/amr', '.pcm': 'audio/pcm' }; const mimeType = mimeTypes[ext] || 'audio/wav'; try { logger.info(`[Aliyun:Enrollment] 将音频文件转换为 Base64 Data URI: ${fileName}`); // 读取文件并转换为 Base64 const fileBuffer = fs.readFileSync(enrollmentAudioPath); const base64Str = fileBuffer.toString('base64'); // 构造 Data URI const dataUri = `data:${mimeType};base64,${base64Str}`; logger.info(`[Aliyun:Enrollment] 文件转换成功, 大小: ${Math.round(fileBuffer.length / 1024)} KB, MIME: ${mimeType}`); return dataUri; } catch (error: any) { logger.error('[Aliyun:Enrollment] 文件转换失败', error.message); throw error; } } // 废弃旧的 applyLease 和 uploadToOss 函数 // async function applyLease ... // async function uploadToOss ... /* interface LeaseResponse { url: string; // 用于后续API调用的文件URL param: { url: string; // 上传地址 method: string; headers: any; }; upload_method: string; } async function applyLease(apiKey: string, extension: string): Promise { const url = `${DASHSCOPE_API_URL}/uploads`; const payload = { action: 'getPolicy', // 注意:DashScope不同服务的上传接口可能不同,这里使用通用的uploads/getPolicy ? // 修正:DashScope有一个通用的 `uploads` 接口用于申请 lease // 文档: POST https://dashscope.aliyuncs.com/api/v1/uploads model: 'voice-enrollment' // 指定模型 }; try { const response = await axios.get(url, { params: payload, headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' } }); // 响应结构: // { // "data": { // "url": "https://...", // "param": { ... }, // "upload_method": "PUT" / "POST" // } // } if (response.data && response.data.data) { const data = response.data.data; logger.info('[Aliyun:Enrollment] Apply Lease 响应:', JSON.stringify(data)); // 适配 OSS PostObject 格式 // https://help.aliyun.com/zh/oss/developer-reference/postobject if (data.policy && data.signature && data.upload_host) { // 构造文件名(使用随机UUID或保留原扩展名) // 上传后的 URL = upload_host + / + upload_dir + / + filename // 但是 DashScope 可能要求特定的 key 格式? // data.upload_dir 是前缀。 // 我们需要自己生成 key。 return { url: '', // 这里的 URL 需要在上传后拼接,或者 DashScope 不需要? // 文档说 create_voice 需要 public url. // OSS PostObject 不会自动返回 URL。 // 通常是 upload_host + '/' + key param: { url: data.upload_host, method: 'POST', headers: {}, // PostObject 不需要 Authorization header,签名在 body 里 formData: { 'key': data.upload_dir + '/${filename}', // 这是一个模板,实际上传时替换 'policy': data.policy, 'Signature': data.signature, 'OSSAccessKeyId': data.oss_access_key_id, 'x-oss-object-acl': data.x_oss_object_acl, 'x-oss-forbid-overwrite': data.x_oss_forbid_overwrite, // 'file': ... (在 uploadToOss 中添加) }, // 用于拼接最终 URL uploadDir: data.upload_dir, uploadHost: data.upload_host }, upload_method: 'POST' } as LeaseResponse; // 临时通过 any 绕过类型检查,稍后修正 Interface } // 兼容性处理:如果 param 不存在,可能是扁平结构,或者结构有所不同 if (!data.param) { // 如果没有 param,看看有没有 url if (data.url) { // 构造一个伪 param return { url: data.url, upload_method: data.upload_method || 'POST', // 默认 POST? param: { url: data.url, method: data.upload_method || 'PUT', // 大多数预签名URL是PUT headers: data.headers || {} // 扁平结构可能有 headers } } as LeaseResponse; } } return data as LeaseResponse; } else { logger.error('[Aliyun:Enrollment] Apply Lease 响应异常:', JSON.stringify(response.data)); throw new Error('申请上传凭证失败: ' + JSON.stringify(response.data)); } } catch (error: any) { logger.error('[Aliyun:Enrollment] Apply Lease 失败', error.response?.data || error.message); throw error; } } async function uploadToOss(filePath: string, param: any): Promise { const fileBuffer = fs.existsSync(filePath) ? fs.readFileSync(filePath) : null; if (!fileBuffer) throw new Error(`文件读取失败: ${filePath}`); const fileName = path.basename(filePath); try { const method = param.method || 'PUT'; logger.info(`[Aliyun:Enrollment] 上传文件到临时存储 URL: ${param.url}`); if (method.toUpperCase() === 'POST' && param.formData) { logger.info('[Aliyun:Enrollment] 使用 FormData (OSS PostObject) 上传'); // 使用 Node.js 全局 FormData (Node 18+) // 如果环境不支持,这里会报错。Electron 29 (Node 20) 应该支持。 const FormDataClass = (global as any).FormData; if (!FormDataClass) { throw new Error('当前环境不支持全局 FormData,无法执行 POST 上传'); } const form = new FormDataClass(); // 构造 Key (FilePath in OSS) // 上面的 param.formData['key'] 是个模板: .../${filename} // 我们生成一个唯一文件名,或者直接用 fileName const finalKey = param.formData['key'].replace('${filename}', fileName); // 必须按照顺序添加吗?OSS 对顺序通常不敏感,但 file 最好在最后 form.append('key', finalKey); form.append('policy', param.formData['policy']); form.append('Signature', param.formData['Signature']); form.append('OSSAccessKeyId', param.formData['OSSAccessKeyId']); if (param.formData['x-oss-object-acl']) form.append('x-oss-object-acl', param.formData['x-oss-object-acl']); if (param.formData['x-oss-forbid-overwrite']) form.append('x-oss-forbid-overwrite', param.formData['x-oss-forbid-overwrite']); // 添加文件 // Node.js 的 FormData 需要 Blob 或 File。 // 我们可以用 Blob (Node 18+) const BlobClass = (global as any).Blob; if (!BlobClass) { throw new Error('当前环境不支持全局 Blob'); } const blob = new BlobClass([fileBuffer]); form.append('file', blob, fileName); // 发送请求 // axios 在 Node 中支持传入 FormData 实例,并自动设置 headers (Content-Type: multipart/form-data; boundary=...) await axios.post(param.url, form, { headers: { // 让 axios/FormData 自动计算 Content-Type // 'Content-Type': 'multipart/form-data' // 不要手动设置,否则丢失 boundary } }); // 更新 leaseData.url,因为 PostObject 不会返回,我们需要自己算 // param 是引用传递吗?不是,这里是 uploadToOss。 // 我们需要一种方式把 URL 传回去。 // 这里我们无法修改外部的 leaseData。 // 所以 uploadToOss 应该返回 url。 const objectUrl = `${param.uploadHost}/${finalKey}`; logger.info(`[Aliyun:Enrollment] OSS 上传成功, URL: ${objectUrl}`); return objectUrl; } // PUT 逻辑保持不变 else if (method.toUpperCase() === 'PUT') { logger.info(`[Aliyun:Enrollment] 上传 Method: ${method}, Headers:`, param.headers); await axios.put(param.url, fileBuffer, { headers: param.headers || {} }); logger.info('[Aliyun:Enrollment] 上传 OSS 成功'); return param.url.split('?')[0]; // 去除签名参数得到纯URL? 不一定,视情况而定 } else { logger.warn(`[Aliyun:Enrollment] 未知上传方法: ${method}, 尝试 Generic Request`); await axios({ method: method, url: param.url, headers: param.headers, data: fileBuffer }); logger.info('[Aliyun:Enrollment] 上传 OSS 成功'); return param.url.split('?')[0]; // 假设通用请求也返回带签名的URL } } catch (error: any) { // 详细错误日志 const errInfo = { status: error.response?.status, statusText: error.response?.statusText, data: error.response?.data, headers: error.response?.headers, message: error.message }; logger.error('[Aliyun:Enrollment] 上传 OSS 失败', JSON.stringify(errInfo)); throw error; } } */ /** * 创建复刻音色 */ export async function createVoice(apiKey: string, audioUrl: string, prefix: string = 'custom_voice'): Promise { const url = `${DASHSCOPE_API_URL}/services/audio/tts/customization`; const payload = { model: 'qwen-voice-enrollment', input: { action: 'create', target_model: 'qwen3-tts-vc-2026-01-22', preferred_name: prefix, audio: { data: audioUrl } } }; logger.info('[Aliyun:Enrollment] 开始复刻音色', payload); try { const response = await axios.post(url, payload, { headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' } }); const data = response.data; const voiceId = data.output?.voice || data.output?.voice_id; if (voiceId) { logger.info('[Aliyun:Enrollment] 复刻成功', data.output); return voiceId; } else { throw new Error('复刻响应格式错误: ' + JSON.stringify(data)); } } catch (error: any) { logger.error('[Aliyun:Enrollment] 复刻失败', error.response?.data || error.message); throw error; } }