Files
qiming/qiming-mobile/servers/audioUploader.uts

557 lines
15 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 音频文件上传器
* 处理音频文件上传和语音转文字功能
*/
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`;
}
}