/** * 音频文件上传器 * 处理音频文件上传和语音转文字功能 */ 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 = {}) { this.config = { ...this.config, ...config }; } /** * 上传音频文件并转换为文字 */ async uploadAudio( audioFile: AudioFile, onProgress?: (progress: UploadProgress) => void, ): Promise { 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 { 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 { 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 { // // 模拟网络延迟 // 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 { // return new Promise(resolve => setTimeout(resolve, ms)) // } /** * 更新配置 */ updateConfig(config: Partial): void { this.config = { ...this.config, ...config }; } /** * 获取当前配置 */ getConfig(): UploadConfig { return { ...this.config }; } } /** * 创建音频上传器实例 */ export function createAudioUploader( config?: Partial, ): AudioUploader { return new AudioUploader(config); } /** * 内存管理器 * 用于清理临时音频文件和管理内存使用 */ export class MemoryManager { private static audioFiles: Set = 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 { 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`; } }