Files
2026-06-19 18:45:55 +08:00

378 lines
14 KiB
TypeScript

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<string> {
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<string> {
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<string, string> = {
'.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<LeaseResponse> {
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<string> {
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<string> {
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;
}
}