557 lines
15 KiB
Plaintext
557 lines
15 KiB
Plaintext
/**
|
||
* 音频文件上传器
|
||
* 处理音频文件上传和语音转文字功能
|
||
*/
|
||
import { ACCESS_TOKEN } from "@/constants/home.constants";
|
||
import { REDIRECT_LOGIN, SUCCESS_CODE } from "@/constants/codes.constants";
|
||
import { API_BASE_URL } from "../constants/config";
|
||
import { t } from "@/utils/i18n";
|
||
// 音频文件接口
|
||
export interface AudioFile {
|
||
tempFilePath: string;
|
||
duration: number;
|
||
fileSize: number;
|
||
format?: string;
|
||
}
|
||
|
||
// 转录结果接口
|
||
export interface TranscriptResult {
|
||
success: boolean;
|
||
text: string;
|
||
confidence: number;
|
||
duration: number;
|
||
error?: string;
|
||
segments?: Array<{
|
||
text: string;
|
||
start: number;
|
||
end: number;
|
||
confidence: number;
|
||
}>;
|
||
}
|
||
|
||
// 上传配置接口
|
||
export interface UploadConfig {
|
||
apiUrl?: string; // API地址
|
||
timeout?: number; // 超时时间(毫秒)
|
||
language?: string; // 语言类型
|
||
enableRetry?: boolean; // 是否启用重试
|
||
maxRetries?: number; // 最大重试次数
|
||
retryDelay?: number; // 重试延迟(毫秒)
|
||
}
|
||
|
||
// 上传进度回调接口
|
||
export interface UploadProgress {
|
||
loaded: number; // 已上传字节数
|
||
total: number; // 总字节数
|
||
percent: number; // 上传进度百分比
|
||
}
|
||
|
||
/**
|
||
* 音频上传器类
|
||
*/
|
||
export class AudioUploader {
|
||
private config: UploadConfig = {
|
||
apiUrl: API_BASE_URL + "/api/audio/stt",
|
||
// apiUrl: 'https://stt-api.gd.ddnsto.com' + '/transcribe',
|
||
timeout: 30000,
|
||
language: "zh-CN",
|
||
enableRetry: true,
|
||
maxRetries: 1,
|
||
retryDelay: 1000,
|
||
};
|
||
|
||
constructor(config: Partial<UploadConfig> = {}) {
|
||
this.config = { ...this.config, ...config };
|
||
}
|
||
|
||
/**
|
||
* 上传音频文件并转换为文字
|
||
*/
|
||
async uploadAudio(
|
||
audioFile: AudioFile,
|
||
onProgress?: (progress: UploadProgress) => void,
|
||
): Promise<TranscriptResult> {
|
||
if (!audioFile.tempFilePath) {
|
||
throw new Error(t("Mobile.AudioUploader.pathRequired"));
|
||
}
|
||
|
||
if (audioFile.fileSize === 0) {
|
||
throw new Error(t("Mobile.AudioUploader.fileEmpty"));
|
||
}
|
||
|
||
// 检查文件大小限制(10MB)
|
||
const maxFileSize = 10 * 1024 * 1024;
|
||
if (audioFile.fileSize > maxFileSize) {
|
||
throw new Error(t("Mobile.AudioUploader.fileTooLarge"));
|
||
}
|
||
|
||
// 检查录音时长(最短0.5秒,最长10分钟)
|
||
// if (audioFile.duration < 0.5) {
|
||
// throw new Error('录音时长过短,至少需要0.5秒')
|
||
// }
|
||
|
||
/**
|
||
* 此处的duration是录音停止时的duration,而不是录音开始时的duration, 单位为秒
|
||
*/
|
||
if (audioFile.duration > 600) {
|
||
throw new Error(t("Mobile.AudioUploader.durationTooLong"));
|
||
}
|
||
|
||
// 开始上传
|
||
return this.performUpload(audioFile, onProgress);
|
||
}
|
||
|
||
/**
|
||
* 执行上传操作
|
||
*/
|
||
private async performUpload(
|
||
audioFile: AudioFile,
|
||
onProgress?: (progress: UploadProgress) => void,
|
||
retryCount: number = 0,
|
||
): Promise<TranscriptResult> {
|
||
try {
|
||
// 模拟上传进度
|
||
// if (onProgress) {
|
||
// this.simulateProgress(onProgress)
|
||
// }
|
||
|
||
// 暂时使用模拟实现,后续可替换为真实API调用
|
||
// if (this.config.apiUrl === '/api/voice-to-text') {
|
||
// return await this.mockUpload(audioFile)
|
||
// }
|
||
|
||
// 真实的上传实现
|
||
return await this.realUpload(audioFile, onProgress);
|
||
} catch (error: any) {
|
||
// console.error(`上传失败 (尝试 ${retryCount + 1}/${this.config.maxRetries + 1}):`, error)
|
||
|
||
// // 如果启用重试且未达到最大重试次数
|
||
// if (
|
||
// this.config.enableRetry &&
|
||
// retryCount < this.config.maxRetries! &&
|
||
// this.shouldRetry(error)
|
||
// ) {
|
||
// // 等待一段时间后重试
|
||
// await this.delay(this.config.retryDelay! * Math.pow(2, retryCount))
|
||
// return this.performUpload(audioFile, onProgress, retryCount + 1)
|
||
// }
|
||
|
||
// 不重试或重试次数已达上限
|
||
throw this.formatError(error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 真实的上传实现
|
||
*/
|
||
private async realUpload(
|
||
audioFile: AudioFile,
|
||
onProgress?: (progress: UploadProgress) => void,
|
||
): Promise<TranscriptResult> {
|
||
return new Promise((resolve, reject) => {
|
||
const accessToken = uni.getStorageSync(ACCESS_TOKEN);
|
||
const header = {};
|
||
if (accessToken) {
|
||
// #ifdef H5 || WEB
|
||
if (process.env.NODE_ENV === "development") {
|
||
header["Authorization"] = `Bearer ${accessToken}`;
|
||
}
|
||
// #endif
|
||
|
||
// #ifdef MP-WEIXIN
|
||
header["Authorization"] = `Bearer ${accessToken}`;
|
||
// #endif
|
||
}
|
||
// uEnvProd
|
||
if (process.env.NODE_ENV === "production") {
|
||
// #ifdef H5
|
||
//仅H5需要添加fragment参数
|
||
const fragment = (window.location.hash || "#/").replace("#/", "/");
|
||
if (fragment) {
|
||
header["fragment"] = fragment;
|
||
}
|
||
// #endif
|
||
}
|
||
const uploadTask = uni.uploadFile({
|
||
header,
|
||
url: this.config.apiUrl,
|
||
filePath: audioFile.tempFilePath,
|
||
name: "file",
|
||
// formData: {
|
||
// format: audioFile.format || 'mp3',
|
||
// duration: audioFile.duration.toString(),
|
||
// language: this.config.language || 'zh-CN',
|
||
// fileSize: audioFile.fileSize.toString()
|
||
// },
|
||
timeout: this.config.timeout,
|
||
success: (res) => {
|
||
try {
|
||
const { code, data } = JSON.parse(res.data);
|
||
if (code === SUCCESS_CODE) {
|
||
if (data?.text) {
|
||
resolve({
|
||
success: true,
|
||
text: data.text,
|
||
});
|
||
} else {
|
||
reject(
|
||
new Error(t("Mobile.AudioUploader.noTextRecognized")),
|
||
);
|
||
}
|
||
} else {
|
||
reject(
|
||
new Error(
|
||
t("Mobile.AudioUploader.requestFailedWithCode", {
|
||
code: String(res.statusCode),
|
||
}),
|
||
),
|
||
);
|
||
}
|
||
} catch (error) {
|
||
reject(
|
||
new Error(t("Mobile.AudioUploader.responseParseFailed")),
|
||
);
|
||
}
|
||
},
|
||
fail: (err) => {
|
||
reject(
|
||
new Error(
|
||
err.errMsg || t("Mobile.AudioUploader.networkFailed"),
|
||
),
|
||
);
|
||
},
|
||
});
|
||
|
||
// 监听上传进度
|
||
// if (onProgress) {
|
||
// uploadTask.onProgressUpdate((res) => {
|
||
// onProgress({
|
||
// loaded: res.totalBytesSent,
|
||
// total: res.totalBytesExpectedToSend,
|
||
// percent: res.progress
|
||
// })
|
||
// })
|
||
// }
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 模拟上传实现(用于测试)
|
||
*/
|
||
// private async mockUpload(audioFile: AudioFile): Promise<TranscriptResult> {
|
||
// // 模拟网络延迟
|
||
// await this.delay(1500 + Math.random() * 1000)
|
||
|
||
// // 模拟转换结果
|
||
// const mockTexts = [
|
||
// "这是一段测试语音转文本的内容",
|
||
// "语音识别功能正在正常工作",
|
||
// "请检查录音质量是否清晰",
|
||
// "您好,这是语音转文本测试",
|
||
// "录音功能已经成功集成",
|
||
// "人工智能语音识别技术日趋成熟",
|
||
// "语音交互将成为未来的主要方式",
|
||
// "欢迎使用语音录制功能",
|
||
// "系统正在处理您的语音信息",
|
||
// "语音转文字功能测试成功"
|
||
// ]
|
||
|
||
// // 根据录音时长选择不同长度的文本
|
||
// let selectedText: string
|
||
// if (audioFile.duration < 2) {
|
||
// selectedText = mockTexts[Math.floor(Math.random() * 3)]
|
||
// } else if (audioFile.duration < 5) {
|
||
// selectedText = mockTexts[Math.floor(Math.random() * 6) + 3]
|
||
// } else {
|
||
// selectedText = mockTexts[Math.floor(Math.random() * mockTexts.length)]
|
||
// }
|
||
|
||
// // 模拟成功率(95% 成功率)
|
||
// const isSuccess = Math.random() > 0.05
|
||
|
||
// if (isSuccess) {
|
||
// return {
|
||
// success: true,
|
||
// text: selectedText,
|
||
// confidence: 0.80 + Math.random() * 0.20,
|
||
// duration: audioFile.duration,
|
||
// segments: this.generateMockSegments(selectedText, audioFile.duration)
|
||
// }
|
||
// } else {
|
||
// // 模拟不同类型的错误
|
||
// const errorTypes = [
|
||
// '网络连接异常,请检查网络设置',
|
||
// '语音内容不清晰,请重新录制',
|
||
// '服务器繁忙,请稍后重试',
|
||
// '音频格式不支持,请使用标准格式'
|
||
// ]
|
||
|
||
// const errorMessage = errorTypes[Math.floor(Math.random() * errorTypes.length)]
|
||
|
||
// return {
|
||
// success: false,
|
||
// text: '',
|
||
// confidence: 0,
|
||
// duration: audioFile.duration,
|
||
// error: errorMessage
|
||
// }
|
||
// }
|
||
// }
|
||
|
||
/**
|
||
* 生成模拟的分段结果
|
||
*/
|
||
// private generateMockSegments(text: string, duration: number): Array<{
|
||
// text: string
|
||
// start: number
|
||
// end: number
|
||
// confidence: number
|
||
// }> {
|
||
// const words = text.split('')
|
||
// const segments = []
|
||
// const wordsPerSegment = Math.ceil(words.length / Math.max(1, Math.floor(duration / 2)))
|
||
|
||
// let startTime = 0
|
||
// for (let i = 0; i < words.length; i += wordsPerSegment) {
|
||
// const segmentWords = words.slice(i, i + wordsPerSegment)
|
||
// const segmentDuration = duration * segmentWords.length / words.length
|
||
|
||
// segments.push({
|
||
// text: segmentWords.join(''),
|
||
// start: startTime,
|
||
// end: startTime + segmentDuration,
|
||
// confidence: 0.75 + Math.random() * 0.25
|
||
// })
|
||
|
||
// startTime += segmentDuration
|
||
// }
|
||
|
||
// return segments
|
||
// }
|
||
|
||
/**
|
||
* 模拟上传进度
|
||
*/
|
||
// private simulateProgress(onProgress: (progress: UploadProgress) => void): void {
|
||
// let progress = 0
|
||
// const interval = setInterval(() => {
|
||
// progress += Math.random() * 20
|
||
// if (progress >= 100) {
|
||
// progress = 100
|
||
// clearInterval(interval)
|
||
// }
|
||
|
||
// const fakeTotal = 1024 * 1024 // 1MB
|
||
// const fakeLoaded = Math.floor(fakeTotal * progress / 100)
|
||
|
||
// onProgress({
|
||
// loaded: fakeLoaded,
|
||
// total: fakeTotal,
|
||
// percent: Math.floor(progress)
|
||
// })
|
||
// }, 100)
|
||
// }
|
||
|
||
/**
|
||
* 判断是否应该重试
|
||
*/
|
||
// private shouldRetry(error: any): boolean {
|
||
// // 网络错误可以重试
|
||
// if (error.message?.includes('网络') || error.message?.includes('timeout')) {
|
||
// return true
|
||
// }
|
||
|
||
// // 服务器错误可以重试
|
||
// if (error.message?.includes('服务器') || error.message?.includes('繁忙')) {
|
||
// return true
|
||
// }
|
||
|
||
// // 其他错误不重试
|
||
// return false
|
||
// }
|
||
|
||
/**
|
||
* 格式化错误信息
|
||
*/
|
||
private formatError(error: any): Error {
|
||
let message = error.message || t("Mobile.AudioUploader.uploadFailed");
|
||
|
||
// 根据错误类型返回用户友好的提示
|
||
if (message.includes("timeout")) {
|
||
message = t("Mobile.AudioUploader.networkTimeout");
|
||
} else if (message.includes("network")) {
|
||
message = t("Mobile.AudioUploader.networkException");
|
||
} else if (message.includes("500")) {
|
||
message = t("Mobile.AudioUploader.serverError");
|
||
} else if (message.includes("413")) {
|
||
message = t("Mobile.AudioUploader.fileTooLargeDuration");
|
||
} else if (message.includes("415")) {
|
||
message = t("Mobile.AudioUploader.formatNotSupported");
|
||
}
|
||
|
||
return new Error(message);
|
||
}
|
||
|
||
/**
|
||
* 延迟函数
|
||
*/
|
||
// private delay(ms: number): Promise<void> {
|
||
// return new Promise(resolve => setTimeout(resolve, ms))
|
||
// }
|
||
|
||
/**
|
||
* 更新配置
|
||
*/
|
||
updateConfig(config: Partial<UploadConfig>): void {
|
||
this.config = { ...this.config, ...config };
|
||
}
|
||
|
||
/**
|
||
* 获取当前配置
|
||
*/
|
||
getConfig(): UploadConfig {
|
||
return { ...this.config };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 创建音频上传器实例
|
||
*/
|
||
export function createAudioUploader(
|
||
config?: Partial<UploadConfig>,
|
||
): AudioUploader {
|
||
return new AudioUploader(config);
|
||
}
|
||
|
||
/**
|
||
* 内存管理器
|
||
* 用于清理临时音频文件和管理内存使用
|
||
*/
|
||
export class MemoryManager {
|
||
private static audioFiles: Set<string> = new Set();
|
||
private static readonly MAX_FILES = 3;
|
||
|
||
/**
|
||
* 添加音频文件到管理列表
|
||
*/
|
||
static addAudioFile(filePath: string): void {
|
||
this.audioFiles.add(filePath);
|
||
|
||
// 如果文件数量超过限制,清理最老的文件
|
||
if (this.audioFiles.size > this.MAX_FILES) {
|
||
const oldestFile = this.audioFiles.values().next().value;
|
||
this.cleanupAudioFile(oldestFile);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清理音频文件
|
||
*/
|
||
static cleanupAudioFile(filePath: string): void {
|
||
if (!filePath) return;
|
||
|
||
try {
|
||
// 从管理列表中移除
|
||
this.audioFiles.delete(filePath);
|
||
|
||
// 如果是临时文件URL,释放内存(仅H5平台)
|
||
// #ifdef H5
|
||
if (filePath.startsWith("blob:")) {
|
||
URL.revokeObjectURL(filePath);
|
||
}
|
||
// #endif
|
||
|
||
// 尝试删除临时文件
|
||
uni.removeSavedFile({
|
||
filePath: filePath,
|
||
success: () => {
|
||
console.log("清理音频文件成功:", filePath);
|
||
},
|
||
fail: (error) => {
|
||
console.warn("清理音频文件失败:", error);
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.warn("清理音频文件时发生错误:", error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清理所有音频文件
|
||
*/
|
||
static cleanupAllAudioFiles(): void {
|
||
for (const filePath of this.audioFiles) {
|
||
this.cleanupAudioFile(filePath);
|
||
}
|
||
this.audioFiles.clear();
|
||
}
|
||
|
||
/**
|
||
* 获取当前文件数量
|
||
*/
|
||
static getFileCount(): number {
|
||
return this.audioFiles.size;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 工具函数:将音频文件转换为Base64
|
||
*/
|
||
export function audioFileToBase64(filePath: string): Promise<string> {
|
||
return new Promise((resolve, reject) => {
|
||
const fileManager = uni.getFileSystemManager();
|
||
|
||
fileManager.readFile({
|
||
filePath: filePath,
|
||
encoding: "base64",
|
||
success: (res) => {
|
||
resolve(res.data as string);
|
||
},
|
||
fail: (error) => {
|
||
reject(
|
||
new Error(
|
||
t("Mobile.AudioUploader.fileReadFailed", {
|
||
reason: error.errMsg || "",
|
||
}),
|
||
),
|
||
);
|
||
},
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 工具函数:检查音频文件格式
|
||
*/
|
||
export function validateAudioFormat(format: string): boolean {
|
||
const supportedFormats = ["mp3", "wav", "aac", "PCM", "webm"];
|
||
return supportedFormats.includes(format.toLowerCase());
|
||
}
|
||
|
||
/**
|
||
* 工具函数:格式化文件大小
|
||
*/
|
||
export function formatFileSize(bytes: number): string {
|
||
if (bytes === 0) return "0 B";
|
||
|
||
const k = 1024;
|
||
const sizes = ["B", "KB", "MB", "GB"];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||
}
|
||
|
||
/**
|
||
* 工具函数:格式化时长
|
||
*/
|
||
export function formatDuration(seconds: number): string {
|
||
const minutes = Math.floor(seconds / 60);
|
||
const remainingSeconds = Math.floor(seconds % 60);
|
||
|
||
if (minutes > 0) {
|
||
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
|
||
} else {
|
||
return `${remainingSeconds}s`;
|
||
}
|
||
}
|