| @ -1,364 +0,0 @@ | |||||
| # UniApp微信小程序TTS接口调用说明 | |||||
| 本文档详细介绍如何在UniApp微信小程序中调用后端的文字转语音(TTS)接口。 | |||||
| ## 📁 文件说明 | |||||
| ### 1. uniapp-tts-example.vue | |||||
| 完整的TTS功能页面组件,包含: | |||||
| - 文本输入界面 | |||||
| - 音色选择功能 | |||||
| - 语速和音量调节 | |||||
| - 音频格式选择 | |||||
| - 音频播放功能 | |||||
| - 完整的错误处理 | |||||
| ### 2. uniapp-tts-config.js | |||||
| TTS功能的配置文件,包含: | |||||
| - API接口配置 | |||||
| - 参数验证工具 | |||||
| - 错误处理配置 | |||||
| - 通用工具函数 | |||||
| ## 🚀 快速开始 | |||||
| ### 1. 文件集成 | |||||
| 将以下文件复制到你的UniApp项目中: | |||||
| ``` | |||||
| your-project/ | |||||
| ├── pages/ | |||||
| │ └── tts/ | |||||
| │ └── index.vue # 复制 uniapp-tts-example.vue 内容 | |||||
| ├── utils/ | |||||
| │ └── tts-config.js # 复制 uniapp-tts-config.js 内容 | |||||
| └── pages.json # 添加页面路由配置 | |||||
| ``` | |||||
| ### 2. 页面路由配置 | |||||
| 在 `pages.json` 中添加TTS页面路由: | |||||
| ```json | |||||
| { | |||||
| "pages": [ | |||||
| { | |||||
| "path": "pages/tts/index", | |||||
| "style": { | |||||
| "navigationBarTitleText": "文字转语音", | |||||
| "navigationBarBackgroundColor": "#007aff", | |||||
| "navigationBarTextStyle": "white" | |||||
| } | |||||
| } | |||||
| ] | |||||
| } | |||||
| ``` | |||||
| ### 3. 配置API地址 | |||||
| 修改 `uniapp-tts-config.js` 中的API地址: | |||||
| ```javascript | |||||
| export const API_CONFIG = { | |||||
| // 开发环境API地址 | |||||
| DEV_BASE_URL: 'http://localhost:8080', // 修改为你的开发环境地址 | |||||
| // 生产环境API地址 | |||||
| PROD_BASE_URL: 'https://your-domain.com', // 修改为你的生产环境地址 | |||||
| }; | |||||
| ``` | |||||
| ## 🔧 功能特性 | |||||
| ### 1. 音色管理 | |||||
| - 自动加载后端音色列表 | |||||
| - 支持音色选择和切换 | |||||
| - 音色信息缓存 | |||||
| ### 2. 参数控制 | |||||
| - **语速调节**:支持-2到6的语速范围 | |||||
| - **音量控制**:支持-10到10的音量范围 | |||||
| - **格式选择**:支持WAV、MP3、PCM格式 | |||||
| ### 3. 音频处理 | |||||
| - 二进制音频数据处理 | |||||
| - 本地音频文件创建 | |||||
| - 音频播放控制 | |||||
| - 文件大小显示 | |||||
| ### 4. 用户体验 | |||||
| - 实时状态反馈 | |||||
| - 转换进度提示 | |||||
| - 错误信息展示 | |||||
| - 响应式界面设计 | |||||
| ## 📋 接口说明 | |||||
| ### 1. 获取音色列表 | |||||
| **接口地址:** `GET /appletApi/tts/list` | |||||
| **响应格式:** | |||||
| ```json | |||||
| { | |||||
| "success": true, | |||||
| "result": [ | |||||
| { | |||||
| "id": 0, | |||||
| "name": "云小宁", | |||||
| "description": "甜美女声" | |||||
| } | |||||
| ] | |||||
| } | |||||
| ``` | |||||
| ### 2. 文字转语音 | |||||
| **接口地址:** `GET /appletApi/tts/textToVoice` | |||||
| **请求参数:** | |||||
| | 参数名 | 类型 | 必填 | 说明 | | |||||
| |--------|------|------|------| | |||||
| | text | String | 是 | 要转换的文本内容 | | |||||
| | speed | Float | 否 | 语速,范围[-2,6],默认0 | | |||||
| | voiceType | Integer | 否 | 音色ID,默认0 | | |||||
| | volume | Float | 否 | 音量,范围[-10,10],默认0 | | |||||
| | codec | String | 否 | 音频格式,默认wav | | |||||
| | userId | String | 否 | 用户ID,用于日志记录 | | |||||
| **响应格式:** 二进制音频数据 | |||||
| ## 💡 使用示例 | |||||
| ### 1. 基础调用 | |||||
| ```javascript | |||||
| import { API_CONFIG, UTILS } from '@/utils/tts-config.js'; | |||||
| // 调用TTS接口 | |||||
| async function callTTS() { | |||||
| try { | |||||
| const response = await uni.request({ | |||||
| url: UTILS.buildApiUrl(API_CONFIG.ENDPOINTS.TEXT_TO_VOICE), | |||||
| method: 'GET', | |||||
| data: { | |||||
| text: '你好,世界!', | |||||
| speed: 0, | |||||
| voiceType: 0, | |||||
| volume: 0, | |||||
| codec: 'wav', | |||||
| userId: 'user123' | |||||
| }, | |||||
| responseType: 'arraybuffer' | |||||
| }); | |||||
| if (response.statusCode === 200) { | |||||
| // 处理音频数据 | |||||
| console.log('TTS调用成功'); | |||||
| } | |||||
| } catch (error) { | |||||
| console.error('TTS调用失败:', error); | |||||
| } | |||||
| } | |||||
| ``` | |||||
| ### 2. 参数验证 | |||||
| ```javascript | |||||
| import { UTILS } from '@/utils/tts-config.js'; | |||||
| // 验证文本 | |||||
| const textValidation = UTILS.validateText('要转换的文本'); | |||||
| if (!textValidation.valid) { | |||||
| console.error(textValidation.message); | |||||
| return; | |||||
| } | |||||
| // 验证语速 | |||||
| const speedValidation = UTILS.validateSpeed(1); | |||||
| if (!speedValidation.valid) { | |||||
| console.error(speedValidation.message); | |||||
| return; | |||||
| } | |||||
| ``` | |||||
| ### 3. 音频播放 | |||||
| ```javascript | |||||
| // 创建音频上下文 | |||||
| const audioContext = wx.createInnerAudioContext(); | |||||
| audioContext.src = audioFilePath; | |||||
| // 监听播放事件 | |||||
| audioContext.onPlay(() => { | |||||
| console.log('开始播放'); | |||||
| }); | |||||
| audioContext.onEnded(() => { | |||||
| console.log('播放结束'); | |||||
| }); | |||||
| audioContext.onError((error) => { | |||||
| console.error('播放失败:', error); | |||||
| }); | |||||
| // 开始播放 | |||||
| audioContext.play(); | |||||
| ``` | |||||
| ## ⚠️ 注意事项 | |||||
| ### 1. 权限配置 | |||||
| 在 `manifest.json` 中配置必要的权限: | |||||
| ```json | |||||
| { | |||||
| "mp-weixin": { | |||||
| "permission": { | |||||
| "scope.writePhotosAlbum": { | |||||
| "desc": "保存音频文件到相册" | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| ``` | |||||
| ### 2. 网络配置 | |||||
| 在微信小程序后台配置服务器域名,将你的API域名添加到request合法域名中。 | |||||
| ### 3. 文件大小限制 | |||||
| - 微信小程序本地文件存储有限制 | |||||
| - 建议音频文件不超过10MB | |||||
| - 长文本建议分段处理 | |||||
| ### 4. 错误处理 | |||||
| ```javascript | |||||
| // 统一错误处理 | |||||
| function handleError(error, context = '') { | |||||
| console.error(`${context}错误:`, error); | |||||
| let message = '操作失败,请重试'; | |||||
| if (error.errMsg) { | |||||
| if (error.errMsg.includes('network')) { | |||||
| message = '网络连接失败,请检查网络'; | |||||
| } else if (error.errMsg.includes('timeout')) { | |||||
| message = '请求超时,请重试'; | |||||
| } | |||||
| } | |||||
| uni.showToast({ | |||||
| title: message, | |||||
| icon: 'error' | |||||
| }); | |||||
| } | |||||
| ``` | |||||
| ## 🔍 调试技巧 | |||||
| ### 1. 开启调试日志 | |||||
| ```javascript | |||||
| // 在开发环境开启详细日志 | |||||
| if (process.env.NODE_ENV === 'development') { | |||||
| console.log('TTS请求参数:', params); | |||||
| console.log('TTS响应数据:', response); | |||||
| } | |||||
| ``` | |||||
| ### 2. 网络请求监控 | |||||
| ```javascript | |||||
| // 监控请求状态 | |||||
| uni.onNetworkStatusChange((res) => { | |||||
| console.log('网络状态:', res.isConnected ? '已连接' : '已断开'); | |||||
| console.log('网络类型:', res.networkType); | |||||
| }); | |||||
| ``` | |||||
| ### 3. 性能监控 | |||||
| ```javascript | |||||
| // 监控转换耗时 | |||||
| const startTime = Date.now(); | |||||
| // ... TTS调用 ... | |||||
| const endTime = Date.now(); | |||||
| console.log(`TTS转换耗时: ${(endTime - startTime) / 1000}秒`); | |||||
| ``` | |||||
| ## 📈 性能优化 | |||||
| ### 1. 音色列表缓存 | |||||
| ```javascript | |||||
| // 缓存音色列表,避免重复请求 | |||||
| const VOICE_CACHE_KEY = 'tts_voice_list'; | |||||
| const CACHE_EXPIRE_TIME = 24 * 60 * 60 * 1000; // 24小时 | |||||
| function getCachedVoiceList() { | |||||
| const cached = uni.getStorageSync(VOICE_CACHE_KEY); | |||||
| if (cached && (Date.now() - cached.timestamp) < CACHE_EXPIRE_TIME) { | |||||
| return cached.data; | |||||
| } | |||||
| return null; | |||||
| } | |||||
| ``` | |||||
| ### 2. 音频文件管理 | |||||
| ```javascript | |||||
| // 清理过期的音频文件 | |||||
| function cleanupAudioFiles() { | |||||
| const fileManager = wx.getFileSystemManager(); | |||||
| // 实现文件清理逻辑 | |||||
| } | |||||
| ``` | |||||
| ### 3. 请求防抖 | |||||
| ```javascript | |||||
| // 防止重复请求 | |||||
| let isConverting = false; | |||||
| async function convertToVoice() { | |||||
| if (isConverting) { | |||||
| console.log('正在转换中,请稍候...'); | |||||
| return; | |||||
| } | |||||
| isConverting = true; | |||||
| try { | |||||
| // TTS转换逻辑 | |||||
| } finally { | |||||
| isConverting = false; | |||||
| } | |||||
| } | |||||
| ``` | |||||
| ## 🤝 技术支持 | |||||
| 如果在使用过程中遇到问题,请检查: | |||||
| 1. **网络连接**:确保设备网络正常 | |||||
| 2. **API地址**:确认API地址配置正确 | |||||
| 3. **参数格式**:检查请求参数是否符合要求 | |||||
| 4. **权限设置**:确认小程序权限配置正确 | |||||
| 5. **后端服务**:确认后端TTS服务正常运行 | |||||
| ## 📝 更新日志 | |||||
| ### v1.0.0 (2025-01-XX) | |||||
| - 初始版本发布 | |||||
| - 支持基础TTS功能 | |||||
| - 完整的参数控制 | |||||
| - 音频播放功能 | |||||
| - 错误处理机制 | |||||
| --- | |||||
| **注意:** 本示例基于JeecgBoot 3.8.1框架开发,使用腾讯云TTS服务。在实际使用时,请根据你的具体环境进行相应调整。 | |||||
| @ -1,243 +0,0 @@ | |||||
| /** | |||||
| * UniApp TTS接口配置文件 | |||||
| * 用于配置文字转语音相关的API接口和参数 | |||||
| */ | |||||
| // API配置 | |||||
| export const API_CONFIG = { | |||||
| // 开发环境API地址 | |||||
| DEV_BASE_URL: 'http://localhost:8080', | |||||
| // 生产环境API地址(请根据实际情况修改) | |||||
| PROD_BASE_URL: 'https://your-domain.com', | |||||
| // 接口路径 | |||||
| ENDPOINTS: { | |||||
| // 获取音色列表 | |||||
| VOICE_LIST: '/appletApi/tts/list', | |||||
| // 文字转语音 | |||||
| TEXT_TO_VOICE: '/appletApi/tts/textToVoice' | |||||
| }, | |||||
| // 请求超时时间(毫秒) | |||||
| TIMEOUT: 30000 | |||||
| }; | |||||
| // TTS参数配置 | |||||
| export const TTS_CONFIG = { | |||||
| // 语速配置 | |||||
| SPEED: { | |||||
| MIN: -2, | |||||
| MAX: 6, | |||||
| DEFAULT: 0, | |||||
| OPTIONS: [ | |||||
| { value: -2, label: '0.6倍速', description: '很慢' }, | |||||
| { value: -1, label: '0.8倍速', description: '慢' }, | |||||
| { value: 0, label: '1.0倍速', description: '正常' }, | |||||
| { value: 1, label: '1.2倍速', description: '快' }, | |||||
| { value: 2, label: '1.5倍速', description: '很快' }, | |||||
| { value: 6, label: '2.5倍速', description: '极快' } | |||||
| ] | |||||
| }, | |||||
| // 音量配置 | |||||
| VOLUME: { | |||||
| MIN: -10, | |||||
| MAX: 10, | |||||
| DEFAULT: 0 | |||||
| }, | |||||
| // 音频格式配置 | |||||
| CODEC: { | |||||
| OPTIONS: [ | |||||
| { value: 'wav', label: 'WAV', description: '无损音质,文件较大' }, | |||||
| { value: 'mp3', label: 'MP3', description: '压缩音质,文件适中' }, | |||||
| { value: 'pcm', label: 'PCM', description: '原始音频,文件最大' } | |||||
| ], | |||||
| DEFAULT: 'wav' | |||||
| }, | |||||
| // 文本限制 | |||||
| TEXT: { | |||||
| MAX_LENGTH: 500, | |||||
| MIN_LENGTH: 1 | |||||
| } | |||||
| }; | |||||
| // 错误码配置 | |||||
| export const ERROR_CODES = { | |||||
| // 网络错误 | |||||
| NETWORK_ERROR: 'NETWORK_ERROR', | |||||
| // 参数错误 | |||||
| PARAM_ERROR: 'PARAM_ERROR', | |||||
| // 服务器错误 | |||||
| SERVER_ERROR: 'SERVER_ERROR', | |||||
| // 音频播放错误 | |||||
| AUDIO_ERROR: 'AUDIO_ERROR', | |||||
| // 文件操作错误 | |||||
| FILE_ERROR: 'FILE_ERROR' | |||||
| }; | |||||
| // 错误消息配置 | |||||
| export const ERROR_MESSAGES = { | |||||
| [ERROR_CODES.NETWORK_ERROR]: '网络连接失败,请检查网络设置', | |||||
| [ERROR_CODES.PARAM_ERROR]: '参数错误,请检查输入内容', | |||||
| [ERROR_CODES.SERVER_ERROR]: '服务器错误,请稍后重试', | |||||
| [ERROR_CODES.AUDIO_ERROR]: '音频播放失败,请重试', | |||||
| [ERROR_CODES.FILE_ERROR]: '文件操作失败,请重试' | |||||
| }; | |||||
| // 工具函数 | |||||
| export const UTILS = { | |||||
| /** | |||||
| * 获取当前环境的API基础地址 | |||||
| */ | |||||
| getBaseUrl() { | |||||
| // #ifdef MP-WEIXIN | |||||
| // 微信小程序环境 | |||||
| return process.env.NODE_ENV === 'production' ? API_CONFIG.PROD_BASE_URL : API_CONFIG.DEV_BASE_URL; | |||||
| // #endif | |||||
| // #ifdef H5 | |||||
| // H5环境 | |||||
| return process.env.NODE_ENV === 'production' ? API_CONFIG.PROD_BASE_URL : API_CONFIG.DEV_BASE_URL; | |||||
| // #endif | |||||
| // #ifdef APP-PLUS | |||||
| // App环境 | |||||
| return API_CONFIG.PROD_BASE_URL; | |||||
| // #endif | |||||
| return API_CONFIG.DEV_BASE_URL; | |||||
| }, | |||||
| /** | |||||
| * 构建完整的API地址 | |||||
| * @param {string} endpoint 接口路径 | |||||
| */ | |||||
| buildApiUrl(endpoint) { | |||||
| return this.getBaseUrl() + endpoint; | |||||
| }, | |||||
| /** | |||||
| * 格式化文件大小 | |||||
| * @param {number} bytes 字节数 | |||||
| */ | |||||
| formatFileSize(bytes) { | |||||
| 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]; | |||||
| }, | |||||
| /** | |||||
| * 验证文本长度 | |||||
| * @param {string} text 文本内容 | |||||
| */ | |||||
| validateText(text) { | |||||
| if (!text || typeof text !== 'string') { | |||||
| return { valid: false, message: '请输入文本内容' }; | |||||
| } | |||||
| const trimmedText = text.trim(); | |||||
| if (trimmedText.length < TTS_CONFIG.TEXT.MIN_LENGTH) { | |||||
| return { valid: false, message: '文本内容不能为空' }; | |||||
| } | |||||
| if (trimmedText.length > TTS_CONFIG.TEXT.MAX_LENGTH) { | |||||
| return { valid: false, message: `文本长度不能超过${TTS_CONFIG.TEXT.MAX_LENGTH}个字符` }; | |||||
| } | |||||
| return { valid: true, text: trimmedText }; | |||||
| }, | |||||
| /** | |||||
| * 验证语速参数 | |||||
| * @param {number} speed 语速值 | |||||
| */ | |||||
| validateSpeed(speed) { | |||||
| if (typeof speed !== 'number') { | |||||
| return { valid: false, message: '语速参数必须为数字' }; | |||||
| } | |||||
| if (speed < TTS_CONFIG.SPEED.MIN || speed > TTS_CONFIG.SPEED.MAX) { | |||||
| return { valid: false, message: `语速范围为${TTS_CONFIG.SPEED.MIN}到${TTS_CONFIG.SPEED.MAX}` }; | |||||
| } | |||||
| return { valid: true, speed }; | |||||
| }, | |||||
| /** | |||||
| * 验证音量参数 | |||||
| * @param {number} volume 音量值 | |||||
| */ | |||||
| validateVolume(volume) { | |||||
| if (typeof volume !== 'number') { | |||||
| return { valid: false, message: '音量参数必须为数字' }; | |||||
| } | |||||
| if (volume < TTS_CONFIG.VOLUME.MIN || volume > TTS_CONFIG.VOLUME.MAX) { | |||||
| return { valid: false, message: `音量范围为${TTS_CONFIG.VOLUME.MIN}到${TTS_CONFIG.VOLUME.MAX}` }; | |||||
| } | |||||
| return { valid: true, volume }; | |||||
| }, | |||||
| /** | |||||
| * 获取语速描述 | |||||
| * @param {number} speed 语速值 | |||||
| */ | |||||
| getSpeedDescription(speed) { | |||||
| const option = TTS_CONFIG.SPEED.OPTIONS.find(item => item.value === speed); | |||||
| return option ? option.description : '未知'; | |||||
| }, | |||||
| /** | |||||
| * 获取音频格式描述 | |||||
| * @param {string} codec 音频格式 | |||||
| */ | |||||
| getCodecDescription(codec) { | |||||
| const option = TTS_CONFIG.CODEC.OPTIONS.find(item => item.value === codec); | |||||
| return option ? option.description : '未知格式'; | |||||
| }, | |||||
| /** | |||||
| * 生成唯一ID | |||||
| */ | |||||
| generateId() { | |||||
| return 'id_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); | |||||
| }, | |||||
| /** | |||||
| * 获取当前时间戳 | |||||
| */ | |||||
| getCurrentTimestamp() { | |||||
| return Date.now(); | |||||
| }, | |||||
| /** | |||||
| * 格式化时间 | |||||
| * @param {number} timestamp 时间戳 | |||||
| */ | |||||
| formatTime(timestamp) { | |||||
| const date = new Date(timestamp); | |||||
| return date.toLocaleString('zh-CN', { | |||||
| year: 'numeric', | |||||
| month: '2-digit', | |||||
| day: '2-digit', | |||||
| hour: '2-digit', | |||||
| minute: '2-digit', | |||||
| second: '2-digit' | |||||
| }); | |||||
| } | |||||
| }; | |||||
| // 默认导出配置对象 | |||||
| export default { | |||||
| API_CONFIG, | |||||
| TTS_CONFIG, | |||||
| ERROR_CODES, | |||||
| ERROR_MESSAGES, | |||||
| UTILS | |||||
| }; | |||||
| @ -1,591 +0,0 @@ | |||||
| <template> | |||||
| <view class="container"> | |||||
| <view class="header"> | |||||
| <text class="title">文字转语音示例</text> | |||||
| </view> | |||||
| <view class="form-section"> | |||||
| <!-- 文本输入 --> | |||||
| <view class="form-item"> | |||||
| <text class="label">输入文本:</text> | |||||
| <textarea | |||||
| v-model="formData.text" | |||||
| placeholder="请输入要转换的文本内容" | |||||
| class="textarea" | |||||
| maxlength="500" | |||||
| /> | |||||
| </view> | |||||
| <!-- 音色选择 --> | |||||
| <view class="form-item"> | |||||
| <text class="label">音色:</text> | |||||
| <picker | |||||
| @change="onVoiceTypeChange" | |||||
| :value="voiceTypeIndex" | |||||
| :range="voiceTypeList" | |||||
| range-key="name" | |||||
| > | |||||
| <view class="picker"> | |||||
| {{ voiceTypeList[voiceTypeIndex]?.name || '请选择音色' }} | |||||
| </view> | |||||
| </picker> | |||||
| </view> | |||||
| <!-- 语速调节 --> | |||||
| <view class="form-item"> | |||||
| <text class="label">语速:{{ formData.speed }}</text> | |||||
| <slider | |||||
| v-model="formData.speed" | |||||
| :min="-2" | |||||
| :max="6" | |||||
| :step="1" | |||||
| show-value | |||||
| class="slider" | |||||
| /> | |||||
| </view> | |||||
| <!-- 音量调节 --> | |||||
| <view class="form-item"> | |||||
| <text class="label">音量:{{ formData.volume }}</text> | |||||
| <slider | |||||
| v-model="formData.volume" | |||||
| :min="-10" | |||||
| :max="10" | |||||
| :step="1" | |||||
| show-value | |||||
| class="slider" | |||||
| /> | |||||
| </view> | |||||
| <!-- 音频格式选择 --> | |||||
| <view class="form-item"> | |||||
| <text class="label">音频格式:</text> | |||||
| <radio-group @change="onCodecChange" class="radio-group"> | |||||
| <label class="radio-item" v-for="codec in codecList" :key="codec.value"> | |||||
| <radio :value="codec.value" :checked="formData.codec === codec.value" /> | |||||
| <text>{{ codec.name }}</text> | |||||
| </label> | |||||
| </radio-group> | |||||
| </view> | |||||
| </view> | |||||
| <!-- 操作按钮 --> | |||||
| <view class="button-section"> | |||||
| <button | |||||
| @click="loadVoiceTypes" | |||||
| :disabled="loading" | |||||
| class="btn btn-secondary" | |||||
| > | |||||
| {{ loading ? '加载中...' : '加载音色列表' }} | |||||
| </button> | |||||
| <button | |||||
| @click="convertToVoice" | |||||
| :disabled="!formData.text || converting" | |||||
| class="btn btn-primary" | |||||
| > | |||||
| {{ converting ? '转换中...' : '开始转换' }} | |||||
| </button> | |||||
| <button | |||||
| @click="playAudio" | |||||
| :disabled="!audioUrl || playing" | |||||
| class="btn btn-success" | |||||
| > | |||||
| {{ playing ? '播放中...' : '播放音频' }} | |||||
| </button> | |||||
| </view> | |||||
| <!-- 结果显示 --> | |||||
| <view class="result-section" v-if="audioUrl"> | |||||
| <text class="result-title">转换结果:</text> | |||||
| <view class="audio-info"> | |||||
| <text>音频大小:{{ audioSize }}</text> | |||||
| <text>转换耗时:{{ convertTime }}秒</text> | |||||
| </view> | |||||
| </view> | |||||
| </view> | |||||
| </template> | |||||
| <script> | |||||
| export default { | |||||
| data() { | |||||
| return { | |||||
| // 表单数据 | |||||
| formData: { | |||||
| text: '你好,这是一个文字转语音的测试。', | |||||
| speed: 0, | |||||
| volume: 0, | |||||
| codec: 'wav' | |||||
| }, | |||||
| // 音色相关 | |||||
| voiceTypeList: [], | |||||
| voiceTypeIndex: 0, | |||||
| // 音频格式选项 | |||||
| codecList: [ | |||||
| { name: 'WAV', value: 'wav' }, | |||||
| { name: 'MP3', value: 'mp3' }, | |||||
| { name: 'PCM', value: 'pcm' } | |||||
| ], | |||||
| // 状态控制 | |||||
| loading: false, | |||||
| converting: false, | |||||
| playing: false, | |||||
| // 结果数据 | |||||
| audioUrl: '', | |||||
| audioSize: '', | |||||
| convertTime: 0, | |||||
| // 音频上下文 | |||||
| audioContext: null | |||||
| } | |||||
| }, | |||||
| onLoad() { | |||||
| // 页面加载时自动获取音色列表 | |||||
| this.loadVoiceTypes(); | |||||
| // 获取用户信息(如果需要记录日志) | |||||
| this.getUserInfo(); | |||||
| }, | |||||
| methods: { | |||||
| /** | |||||
| * 获取用户信息 | |||||
| */ | |||||
| getUserInfo() { | |||||
| // 这里可以从缓存或登录状态获取用户ID | |||||
| // 示例:从本地存储获取 | |||||
| const userInfo = uni.getStorageSync('userInfo'); | |||||
| if (userInfo && userInfo.id) { | |||||
| this.userId = userInfo.id; | |||||
| } else { | |||||
| // 如果没有用户信息,可以生成一个临时ID | |||||
| this.userId = 'temp_' + Date.now(); | |||||
| } | |||||
| }, | |||||
| /** | |||||
| * 加载音色列表 | |||||
| */ | |||||
| async loadVoiceTypes() { | |||||
| this.loading = true; | |||||
| try { | |||||
| const response = await this.request({ | |||||
| url: '/appletApi/tts/list', | |||||
| method: 'GET' | |||||
| }); | |||||
| if (response.success && response.result) { | |||||
| this.voiceTypeList = response.result; | |||||
| if (this.voiceTypeList.length > 0) { | |||||
| this.voiceTypeIndex = 0; | |||||
| } | |||||
| uni.showToast({ | |||||
| title: '音色列表加载成功', | |||||
| icon: 'success' | |||||
| }); | |||||
| } else { | |||||
| throw new Error(response.message || '加载音色列表失败'); | |||||
| } | |||||
| } catch (error) { | |||||
| console.error('加载音色列表失败:', error); | |||||
| uni.showToast({ | |||||
| title: '加载音色列表失败', | |||||
| icon: 'error' | |||||
| }); | |||||
| } finally { | |||||
| this.loading = false; | |||||
| } | |||||
| }, | |||||
| /** | |||||
| * 音色选择变化 | |||||
| */ | |||||
| onVoiceTypeChange(e) { | |||||
| this.voiceTypeIndex = e.detail.value; | |||||
| }, | |||||
| /** | |||||
| * 音频格式选择变化 | |||||
| */ | |||||
| onCodecChange(e) { | |||||
| this.formData.codec = e.detail.value; | |||||
| }, | |||||
| /** | |||||
| * 文字转语音 | |||||
| */ | |||||
| async convertToVoice() { | |||||
| if (!this.formData.text.trim()) { | |||||
| uni.showToast({ | |||||
| title: '请输入要转换的文本', | |||||
| icon: 'error' | |||||
| }); | |||||
| return; | |||||
| } | |||||
| this.converting = true; | |||||
| const startTime = Date.now(); | |||||
| try { | |||||
| // 构建请求参数 | |||||
| const params = { | |||||
| text: this.formData.text, | |||||
| speed: this.formData.speed, | |||||
| voiceType: this.voiceTypeList[this.voiceTypeIndex]?.id || 0, | |||||
| volume: this.formData.volume, | |||||
| codec: this.formData.codec, | |||||
| }; | |||||
| // 发起请求 | |||||
| const response = await this.requestBinary({ | |||||
| url: '/appletApi/tts/textToVoice', | |||||
| method: 'GET', | |||||
| data: params, | |||||
| responseType: 'arraybuffer' | |||||
| }); | |||||
| if (response) { | |||||
| // 计算转换耗时 | |||||
| this.convertTime = ((Date.now() - startTime) / 1000).toFixed(2); | |||||
| // 创建音频文件 | |||||
| await this.createAudioFile(response, this.formData.codec); | |||||
| // 计算文件大小 | |||||
| this.audioSize = this.formatFileSize(response.byteLength); | |||||
| uni.showToast({ | |||||
| title: '转换成功', | |||||
| icon: 'success' | |||||
| }); | |||||
| } else { | |||||
| throw new Error('转换失败,未返回音频数据'); | |||||
| } | |||||
| } catch (error) { | |||||
| console.error('文字转语音失败:', error); | |||||
| uni.showToast({ | |||||
| title: '转换失败: ' + error.message, | |||||
| icon: 'error' | |||||
| }); | |||||
| } finally { | |||||
| this.converting = false; | |||||
| } | |||||
| }, | |||||
| /** | |||||
| * 创建音频文件 | |||||
| */ | |||||
| async createAudioFile(arrayBuffer, codec) { | |||||
| return new Promise((resolve, reject) => { | |||||
| // 将ArrayBuffer转换为Base64 | |||||
| const uint8Array = new Uint8Array(arrayBuffer); | |||||
| let binary = ''; | |||||
| for (let i = 0; i < uint8Array.length; i++) { | |||||
| binary += String.fromCharCode(uint8Array[i]); | |||||
| } | |||||
| const base64 = btoa(binary); | |||||
| // 创建临时文件 | |||||
| const fileName = `tts_${Date.now()}.${codec}`; | |||||
| const filePath = `${wx.env.USER_DATA_PATH}/${fileName}`; | |||||
| // 写入文件 | |||||
| wx.getFileSystemManager().writeFile({ | |||||
| filePath: filePath, | |||||
| data: arrayBuffer, | |||||
| success: () => { | |||||
| this.audioUrl = filePath; | |||||
| resolve(filePath); | |||||
| }, | |||||
| fail: (error) => { | |||||
| console.error('创建音频文件失败:', error); | |||||
| reject(error); | |||||
| } | |||||
| }); | |||||
| }); | |||||
| }, | |||||
| /** | |||||
| * 播放音频 | |||||
| */ | |||||
| playAudio() { | |||||
| if (!this.audioUrl) { | |||||
| uni.showToast({ | |||||
| title: '没有可播放的音频', | |||||
| icon: 'error' | |||||
| }); | |||||
| return; | |||||
| } | |||||
| this.playing = true; | |||||
| // 创建音频上下文 | |||||
| if (this.audioContext) { | |||||
| this.audioContext.destroy(); | |||||
| } | |||||
| this.audioContext = wx.createInnerAudioContext(); | |||||
| this.audioContext.src = this.audioUrl; | |||||
| // 监听播放事件 | |||||
| this.audioContext.onPlay(() => { | |||||
| console.log('开始播放'); | |||||
| }); | |||||
| this.audioContext.onEnded(() => { | |||||
| console.log('播放结束'); | |||||
| this.playing = false; | |||||
| }); | |||||
| this.audioContext.onError((error) => { | |||||
| console.error('播放失败:', error); | |||||
| this.playing = false; | |||||
| uni.showToast({ | |||||
| title: '播放失败', | |||||
| icon: 'error' | |||||
| }); | |||||
| }); | |||||
| // 开始播放 | |||||
| this.audioContext.play(); | |||||
| }, | |||||
| /** | |||||
| * 格式化文件大小 | |||||
| */ | |||||
| formatFileSize(bytes) { | |||||
| 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]; | |||||
| }, | |||||
| /** | |||||
| * 通用请求方法 | |||||
| */ | |||||
| request(options) { | |||||
| return new Promise((resolve, reject) => { | |||||
| uni.request({ | |||||
| url: this.getApiUrl(options.url), | |||||
| method: options.method || 'GET', | |||||
| data: options.data || {}, | |||||
| header: { | |||||
| 'Content-Type': 'application/json', | |||||
| // 如果需要token认证,在这里添加 | |||||
| // 'Authorization': 'Bearer ' + uni.getStorageSync('token') | |||||
| }, | |||||
| success: (res) => { | |||||
| if (res.statusCode === 200) { | |||||
| resolve(res.data); | |||||
| } else { | |||||
| reject(new Error(`HTTP ${res.statusCode}: ${res.data?.message || '请求失败'}`)); | |||||
| } | |||||
| }, | |||||
| fail: (error) => { | |||||
| reject(error); | |||||
| } | |||||
| }); | |||||
| }); | |||||
| }, | |||||
| /** | |||||
| * 二进制数据请求方法 | |||||
| */ | |||||
| requestBinary(options) { | |||||
| return new Promise((resolve, reject) => { | |||||
| uni.request({ | |||||
| url: this.getApiUrl(options.url), | |||||
| method: options.method || 'GET', | |||||
| data: options.data || {}, | |||||
| responseType: 'arraybuffer', | |||||
| header: { | |||||
| // 如果需要token认证,在这里添加 | |||||
| // 'Authorization': 'Bearer ' + uni.getStorageSync('token') | |||||
| }, | |||||
| success: (res) => { | |||||
| if (res.statusCode === 200) { | |||||
| resolve(res.data); | |||||
| } else { | |||||
| reject(new Error(`HTTP ${res.statusCode}: 请求失败`)); | |||||
| } | |||||
| }, | |||||
| fail: (error) => { | |||||
| reject(error); | |||||
| } | |||||
| }); | |||||
| }); | |||||
| }, | |||||
| /** | |||||
| * 获取完整的API地址 | |||||
| */ | |||||
| getApiUrl(path) { | |||||
| // 这里配置你的后端API地址 | |||||
| const baseUrl = 'http://localhost:8080'; // 开发环境 | |||||
| // const baseUrl = 'https://your-domain.com'; // 生产环境 | |||||
| return baseUrl + path; | |||||
| } | |||||
| }, | |||||
| onUnload() { | |||||
| // 页面卸载时销毁音频上下文 | |||||
| if (this.audioContext) { | |||||
| this.audioContext.destroy(); | |||||
| } | |||||
| } | |||||
| } | |||||
| </script> | |||||
| <style scoped> | |||||
| .container { | |||||
| padding: 20rpx; | |||||
| background-color: #f5f5f5; | |||||
| min-height: 100vh; | |||||
| } | |||||
| .header { | |||||
| text-align: center; | |||||
| margin-bottom: 40rpx; | |||||
| } | |||||
| .title { | |||||
| font-size: 36rpx; | |||||
| font-weight: bold; | |||||
| color: #333; | |||||
| } | |||||
| .form-section { | |||||
| background-color: #fff; | |||||
| border-radius: 16rpx; | |||||
| padding: 30rpx; | |||||
| margin-bottom: 30rpx; | |||||
| box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1); | |||||
| } | |||||
| .form-item { | |||||
| margin-bottom: 30rpx; | |||||
| } | |||||
| .form-item:last-child { | |||||
| margin-bottom: 0; | |||||
| } | |||||
| .label { | |||||
| display: block; | |||||
| font-size: 28rpx; | |||||
| color: #333; | |||||
| margin-bottom: 10rpx; | |||||
| font-weight: 500; | |||||
| } | |||||
| .textarea { | |||||
| width: 100%; | |||||
| min-height: 120rpx; | |||||
| padding: 20rpx; | |||||
| border: 2rpx solid #e0e0e0; | |||||
| border-radius: 8rpx; | |||||
| font-size: 28rpx; | |||||
| background-color: #fafafa; | |||||
| } | |||||
| .picker { | |||||
| padding: 20rpx; | |||||
| border: 2rpx solid #e0e0e0; | |||||
| border-radius: 8rpx; | |||||
| background-color: #fafafa; | |||||
| font-size: 28rpx; | |||||
| } | |||||
| .slider { | |||||
| margin-top: 20rpx; | |||||
| } | |||||
| .radio-group { | |||||
| display: flex; | |||||
| flex-wrap: wrap; | |||||
| gap: 20rpx; | |||||
| } | |||||
| .radio-item { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| gap: 10rpx; | |||||
| font-size: 28rpx; | |||||
| } | |||||
| .button-section { | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| gap: 20rpx; | |||||
| margin-bottom: 30rpx; | |||||
| } | |||||
| .btn { | |||||
| padding: 24rpx; | |||||
| border-radius: 12rpx; | |||||
| font-size: 30rpx; | |||||
| font-weight: 500; | |||||
| border: none; | |||||
| } | |||||
| .btn-primary { | |||||
| background-color: #007aff; | |||||
| color: #fff; | |||||
| } | |||||
| .btn-primary:disabled { | |||||
| background-color: #ccc; | |||||
| } | |||||
| .btn-secondary { | |||||
| background-color: #6c757d; | |||||
| color: #fff; | |||||
| } | |||||
| .btn-secondary:disabled { | |||||
| background-color: #ccc; | |||||
| } | |||||
| .btn-success { | |||||
| background-color: #28a745; | |||||
| color: #fff; | |||||
| } | |||||
| .btn-success:disabled { | |||||
| background-color: #ccc; | |||||
| } | |||||
| .result-section { | |||||
| background-color: #fff; | |||||
| border-radius: 16rpx; | |||||
| padding: 30rpx; | |||||
| box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1); | |||||
| } | |||||
| .result-title { | |||||
| font-size: 32rpx; | |||||
| font-weight: bold; | |||||
| color: #333; | |||||
| margin-bottom: 20rpx; | |||||
| } | |||||
| .audio-info { | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| gap: 10rpx; | |||||
| } | |||||
| .audio-info text { | |||||
| font-size: 28rpx; | |||||
| color: #666; | |||||
| } | |||||
| </style> | |||||
| @ -1,429 +0,0 @@ | |||||
| /** | |||||
| * UniApp TTS服务类 | |||||
| * 封装文字转语音功能,提供简单易用的API | |||||
| */ | |||||
| import { API_CONFIG, TTS_CONFIG, UTILS, ERROR_CODES, ERROR_MESSAGES } from './uniapp-tts-config.js'; | |||||
| class TTSService { | |||||
| constructor() { | |||||
| this.audioContext = null; | |||||
| this.isPlaying = false; | |||||
| this.isConverting = false; | |||||
| this.voiceList = []; | |||||
| this.currentAudioUrl = ''; | |||||
| } | |||||
| /** | |||||
| * 初始化TTS服务 | |||||
| * @param {Object} options 配置选项 | |||||
| */ | |||||
| async init(options = {}) { | |||||
| try { | |||||
| // 加载音色列表 | |||||
| await this.loadVoiceList(); | |||||
| // 初始化音频上下文 | |||||
| this.initAudioContext(); | |||||
| console.log('TTS服务初始化成功'); | |||||
| return { success: true }; | |||||
| } catch (error) { | |||||
| console.error('TTS服务初始化失败:', error); | |||||
| return { success: false, error: error.message }; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 加载音色列表 | |||||
| */ | |||||
| async loadVoiceList() { | |||||
| try { | |||||
| // 先尝试从缓存获取 | |||||
| const cached = this.getCachedVoiceList(); | |||||
| if (cached) { | |||||
| this.voiceList = cached; | |||||
| return cached; | |||||
| } | |||||
| // 从服务器获取 | |||||
| const response = await this.request({ | |||||
| url: API_CONFIG.ENDPOINTS.VOICE_LIST, | |||||
| method: 'GET' | |||||
| }); | |||||
| if (response.success && response.result) { | |||||
| this.voiceList = response.result; | |||||
| this.cacheVoiceList(this.voiceList); | |||||
| return this.voiceList; | |||||
| } else { | |||||
| throw new Error(response.message || '获取音色列表失败'); | |||||
| } | |||||
| } catch (error) { | |||||
| console.error('加载音色列表失败:', error); | |||||
| throw error; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 文字转语音 | |||||
| * @param {Object} params 转换参数 | |||||
| * @param {string} params.text 文本内容 | |||||
| * @param {number} params.speed 语速 | |||||
| * @param {number} params.voiceType 音色ID | |||||
| * @param {number} params.volume 音量 | |||||
| * @param {string} params.codec 音频格式 | |||||
| * @param {string} params.userId 用户ID | |||||
| */ | |||||
| async textToVoice(params) { | |||||
| if (this.isConverting) { | |||||
| throw new Error('正在转换中,请稍候...'); | |||||
| } | |||||
| // 参数验证 | |||||
| const validation = this.validateParams(params); | |||||
| if (!validation.valid) { | |||||
| throw new Error(validation.message); | |||||
| } | |||||
| this.isConverting = true; | |||||
| const startTime = Date.now(); | |||||
| try { | |||||
| // 构建请求参数 | |||||
| const requestParams = { | |||||
| text: params.text, | |||||
| speed: params.speed || TTS_CONFIG.SPEED.DEFAULT, | |||||
| voiceType: params.voiceType || 0, | |||||
| volume: params.volume || TTS_CONFIG.VOLUME.DEFAULT, | |||||
| codec: params.codec || TTS_CONFIG.CODEC.DEFAULT, | |||||
| userId: params.userId || this.generateUserId() | |||||
| }; | |||||
| // 发起请求 | |||||
| const audioData = await this.requestBinary({ | |||||
| url: API_CONFIG.ENDPOINTS.TEXT_TO_VOICE, | |||||
| method: 'GET', | |||||
| data: requestParams | |||||
| }); | |||||
| if (!audioData || audioData.byteLength === 0) { | |||||
| throw new Error('转换失败,未返回音频数据'); | |||||
| } | |||||
| // 创建音频文件 | |||||
| const audioUrl = await this.createAudioFile(audioData, requestParams.codec); | |||||
| // 计算转换耗时 | |||||
| const convertTime = ((Date.now() - startTime) / 1000).toFixed(2); | |||||
| // 更新当前音频URL | |||||
| this.currentAudioUrl = audioUrl; | |||||
| return { | |||||
| success: true, | |||||
| audioUrl: audioUrl, | |||||
| audioSize: UTILS.formatFileSize(audioData.byteLength), | |||||
| convertTime: convertTime, | |||||
| params: requestParams | |||||
| }; | |||||
| } catch (error) { | |||||
| console.error('文字转语音失败:', error); | |||||
| throw error; | |||||
| } finally { | |||||
| this.isConverting = false; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 播放音频 | |||||
| * @param {string} audioUrl 音频文件路径(可选,默认使用最后转换的音频) | |||||
| */ | |||||
| async playAudio(audioUrl) { | |||||
| const targetUrl = audioUrl || this.currentAudioUrl; | |||||
| if (!targetUrl) { | |||||
| throw new Error('没有可播放的音频文件'); | |||||
| } | |||||
| if (this.isPlaying) { | |||||
| this.stopAudio(); | |||||
| } | |||||
| return new Promise((resolve, reject) => { | |||||
| try { | |||||
| this.initAudioContext(); | |||||
| this.audioContext.src = targetUrl; | |||||
| this.isPlaying = true; | |||||
| this.audioContext.onPlay(() => { | |||||
| console.log('音频开始播放'); | |||||
| resolve({ success: true, action: 'play_started' }); | |||||
| }); | |||||
| this.audioContext.onEnded(() => { | |||||
| console.log('音频播放结束'); | |||||
| this.isPlaying = false; | |||||
| }); | |||||
| this.audioContext.onError((error) => { | |||||
| console.error('音频播放失败:', error); | |||||
| this.isPlaying = false; | |||||
| reject(new Error('音频播放失败')); | |||||
| }); | |||||
| this.audioContext.play(); | |||||
| } catch (error) { | |||||
| this.isPlaying = false; | |||||
| reject(error); | |||||
| } | |||||
| }); | |||||
| } | |||||
| /** | |||||
| * 停止音频播放 | |||||
| */ | |||||
| stopAudio() { | |||||
| if (this.audioContext && this.isPlaying) { | |||||
| this.audioContext.stop(); | |||||
| this.isPlaying = false; | |||||
| console.log('音频播放已停止'); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 暂停音频播放 | |||||
| */ | |||||
| pauseAudio() { | |||||
| if (this.audioContext && this.isPlaying) { | |||||
| this.audioContext.pause(); | |||||
| console.log('音频播放已暂停'); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 获取音色列表 | |||||
| */ | |||||
| getVoiceList() { | |||||
| return this.voiceList; | |||||
| } | |||||
| /** | |||||
| * 根据ID获取音色信息 | |||||
| * @param {number} voiceId 音色ID | |||||
| */ | |||||
| getVoiceById(voiceId) { | |||||
| return this.voiceList.find(voice => voice.id === voiceId); | |||||
| } | |||||
| /** | |||||
| * 获取当前播放状态 | |||||
| */ | |||||
| getPlayStatus() { | |||||
| return { | |||||
| isPlaying: this.isPlaying, | |||||
| isConverting: this.isConverting, | |||||
| currentAudioUrl: this.currentAudioUrl | |||||
| }; | |||||
| } | |||||
| /** | |||||
| * 清理资源 | |||||
| */ | |||||
| destroy() { | |||||
| if (this.audioContext) { | |||||
| this.audioContext.destroy(); | |||||
| this.audioContext = null; | |||||
| } | |||||
| this.isPlaying = false; | |||||
| this.isConverting = false; | |||||
| this.currentAudioUrl = ''; | |||||
| console.log('TTS服务已销毁'); | |||||
| } | |||||
| // ==================== 私有方法 ==================== | |||||
| /** | |||||
| * 初始化音频上下文 | |||||
| */ | |||||
| initAudioContext() { | |||||
| if (this.audioContext) { | |||||
| this.audioContext.destroy(); | |||||
| } | |||||
| // #ifdef MP-WEIXIN | |||||
| this.audioContext = wx.createInnerAudioContext(); | |||||
| // #endif | |||||
| // #ifdef H5 | |||||
| this.audioContext = uni.createInnerAudioContext(); | |||||
| // #endif | |||||
| } | |||||
| /** | |||||
| * 参数验证 | |||||
| */ | |||||
| validateParams(params) { | |||||
| if (!params || typeof params !== 'object') { | |||||
| return { valid: false, message: '参数格式错误' }; | |||||
| } | |||||
| // 验证文本 | |||||
| const textValidation = UTILS.validateText(params.text); | |||||
| if (!textValidation.valid) { | |||||
| return textValidation; | |||||
| } | |||||
| // 验证语速 | |||||
| if (params.speed !== undefined) { | |||||
| const speedValidation = UTILS.validateSpeed(params.speed); | |||||
| if (!speedValidation.valid) { | |||||
| return speedValidation; | |||||
| } | |||||
| } | |||||
| // 验证音量 | |||||
| if (params.volume !== undefined) { | |||||
| const volumeValidation = UTILS.validateVolume(params.volume); | |||||
| if (!volumeValidation.valid) { | |||||
| return volumeValidation; | |||||
| } | |||||
| } | |||||
| return { valid: true }; | |||||
| } | |||||
| /** | |||||
| * 创建音频文件 | |||||
| */ | |||||
| async createAudioFile(arrayBuffer, codec) { | |||||
| return new Promise((resolve, reject) => { | |||||
| const fileName = `tts_${Date.now()}.${codec}`; | |||||
| // #ifdef MP-WEIXIN | |||||
| const filePath = `${wx.env.USER_DATA_PATH}/${fileName}`; | |||||
| wx.getFileSystemManager().writeFile({ | |||||
| filePath: filePath, | |||||
| data: arrayBuffer, | |||||
| success: () => resolve(filePath), | |||||
| fail: (error) => reject(new Error('创建音频文件失败: ' + error.errMsg)) | |||||
| }); | |||||
| // #endif | |||||
| // #ifdef H5 | |||||
| // H5环境下创建Blob URL | |||||
| const blob = new Blob([arrayBuffer], { type: `audio/${codec}` }); | |||||
| const url = URL.createObjectURL(blob); | |||||
| resolve(url); | |||||
| // #endif | |||||
| }); | |||||
| } | |||||
| /** | |||||
| * 生成用户ID | |||||
| */ | |||||
| generateUserId() { | |||||
| // 尝试从存储获取用户ID | |||||
| let userId = uni.getStorageSync('tts_user_id'); | |||||
| if (!userId) { | |||||
| userId = 'user_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); | |||||
| uni.setStorageSync('tts_user_id', userId); | |||||
| } | |||||
| return userId; | |||||
| } | |||||
| /** | |||||
| * 缓存音色列表 | |||||
| */ | |||||
| cacheVoiceList(voiceList) { | |||||
| const cacheData = { | |||||
| data: voiceList, | |||||
| timestamp: Date.now() | |||||
| }; | |||||
| uni.setStorageSync('tts_voice_cache', cacheData); | |||||
| } | |||||
| /** | |||||
| * 获取缓存的音色列表 | |||||
| */ | |||||
| getCachedVoiceList() { | |||||
| try { | |||||
| const cached = uni.getStorageSync('tts_voice_cache'); | |||||
| if (cached && cached.data) { | |||||
| // 检查缓存是否过期(24小时) | |||||
| const expireTime = 24 * 60 * 60 * 1000; | |||||
| if (Date.now() - cached.timestamp < expireTime) { | |||||
| return cached.data; | |||||
| } | |||||
| } | |||||
| } catch (error) { | |||||
| console.error('获取缓存失败:', error); | |||||
| } | |||||
| return null; | |||||
| } | |||||
| /** | |||||
| * 通用请求方法 | |||||
| */ | |||||
| request(options) { | |||||
| return new Promise((resolve, reject) => { | |||||
| uni.request({ | |||||
| url: UTILS.buildApiUrl(options.url), | |||||
| method: options.method || 'GET', | |||||
| data: options.data || {}, | |||||
| timeout: API_CONFIG.TIMEOUT, | |||||
| header: { | |||||
| 'Content-Type': 'application/json', | |||||
| // 如果需要token认证,在这里添加 | |||||
| // 'Authorization': 'Bearer ' + uni.getStorageSync('token') | |||||
| }, | |||||
| success: (res) => { | |||||
| if (res.statusCode === 200) { | |||||
| resolve(res.data); | |||||
| } else { | |||||
| reject(new Error(`HTTP ${res.statusCode}: ${res.data?.message || '请求失败'}`)); | |||||
| } | |||||
| }, | |||||
| fail: (error) => { | |||||
| reject(new Error(ERROR_MESSAGES[ERROR_CODES.NETWORK_ERROR] || error.errMsg)); | |||||
| } | |||||
| }); | |||||
| }); | |||||
| } | |||||
| /** | |||||
| * 二进制数据请求方法 | |||||
| */ | |||||
| requestBinary(options) { | |||||
| return new Promise((resolve, reject) => { | |||||
| uni.request({ | |||||
| url: UTILS.buildApiUrl(options.url), | |||||
| method: options.method || 'GET', | |||||
| data: options.data || {}, | |||||
| responseType: 'arraybuffer', | |||||
| timeout: API_CONFIG.TIMEOUT, | |||||
| header: { | |||||
| // 如果需要token认证,在这里添加 | |||||
| // 'Authorization': 'Bearer ' + uni.getStorageSync('token') | |||||
| }, | |||||
| success: (res) => { | |||||
| if (res.statusCode === 200) { | |||||
| resolve(res.data); | |||||
| } else { | |||||
| reject(new Error(`HTTP ${res.statusCode}: 请求失败`)); | |||||
| } | |||||
| }, | |||||
| fail: (error) => { | |||||
| reject(new Error(ERROR_MESSAGES[ERROR_CODES.NETWORK_ERROR] || error.errMsg)); | |||||
| } | |||||
| }); | |||||
| }); | |||||
| } | |||||
| } | |||||
| // 创建单例实例 | |||||
| const ttsService = new TTSService(); | |||||
| // 导出服务实例和类 | |||||
| export { TTSService, ttsService }; | |||||
| export default ttsService; | |||||
| @ -1,522 +0,0 @@ | |||||
| <template> | |||||
| <view class="simple-tts-container"> | |||||
| <view class="header"> | |||||
| <text class="title">简单TTS示例</text> | |||||
| <text class="subtitle">快速集成文字转语音功能</text> | |||||
| </view> | |||||
| <!-- 快速转换区域 --> | |||||
| <view class="quick-section"> | |||||
| <view class="input-group"> | |||||
| <textarea | |||||
| v-model="quickText" | |||||
| placeholder="输入要转换的文本..." | |||||
| class="quick-input" | |||||
| maxlength="200" | |||||
| /> | |||||
| <view class="char-count">{{ quickText.length }}/200</view> | |||||
| </view> | |||||
| <view class="quick-buttons"> | |||||
| <button | |||||
| @click="quickConvert" | |||||
| :disabled="!quickText.trim() || loading" | |||||
| class="btn-convert" | |||||
| > | |||||
| {{ loading ? '转换中...' : '一键转换' }} | |||||
| </button> | |||||
| <button | |||||
| @click="quickPlay" | |||||
| :disabled="!hasAudio || playing" | |||||
| class="btn-play" | |||||
| > | |||||
| {{ playing ? '播放中...' : '播放' }} | |||||
| </button> | |||||
| </view> | |||||
| </view> | |||||
| <!-- 高级设置区域 --> | |||||
| <view class="advanced-section" v-if="showAdvanced"> | |||||
| <view class="section-title" @click="toggleAdvanced"> | |||||
| <text>高级设置</text> | |||||
| <text class="toggle-icon">{{ showAdvanced ? '▼' : '▶' }}</text> | |||||
| </view> | |||||
| <view class="advanced-content"> | |||||
| <!-- 音色选择 --> | |||||
| <view class="setting-item"> | |||||
| <text class="setting-label">音色:</text> | |||||
| <picker | |||||
| @change="onVoiceChange" | |||||
| :value="voiceIndex" | |||||
| :range="voiceOptions" | |||||
| range-key="name" | |||||
| class="setting-picker" | |||||
| > | |||||
| <view class="picker-display"> | |||||
| {{ voiceOptions[voiceIndex]?.name || '默认音色' }} | |||||
| </view> | |||||
| </picker> | |||||
| </view> | |||||
| <!-- 语速设置 --> | |||||
| <view class="setting-item"> | |||||
| <text class="setting-label">语速:{{ speedValue }}</text> | |||||
| <slider | |||||
| v-model="speedValue" | |||||
| :min="-2" | |||||
| :max="6" | |||||
| :step="1" | |||||
| class="setting-slider" | |||||
| /> | |||||
| </view> | |||||
| <!-- 音量设置 --> | |||||
| <view class="setting-item"> | |||||
| <text class="setting-label">音量:{{ volumeValue }}</text> | |||||
| <slider | |||||
| v-model="volumeValue" | |||||
| :min="-10" | |||||
| :max="10" | |||||
| :step="1" | |||||
| class="setting-slider" | |||||
| /> | |||||
| </view> | |||||
| </view> | |||||
| </view> | |||||
| <!-- 状态显示区域 --> | |||||
| <view class="status-section" v-if="statusInfo"> | |||||
| <view class="status-item"> | |||||
| <text class="status-label">状态:</text> | |||||
| <text class="status-value">{{ statusInfo.status }}</text> | |||||
| </view> | |||||
| <view class="status-item" v-if="statusInfo.size"> | |||||
| <text class="status-label">大小:</text> | |||||
| <text class="status-value">{{ statusInfo.size }}</text> | |||||
| </view> | |||||
| <view class="status-item" v-if="statusInfo.time"> | |||||
| <text class="status-label">耗时:</text> | |||||
| <text class="status-value">{{ statusInfo.time }}秒</text> | |||||
| </view> | |||||
| </view> | |||||
| <!-- 预设文本区域 --> | |||||
| <view class="preset-section"> | |||||
| <view class="section-title">预设文本</view> | |||||
| <view class="preset-buttons"> | |||||
| <button | |||||
| v-for="(preset, index) in presetTexts" | |||||
| :key="index" | |||||
| @click="usePreset(preset)" | |||||
| class="preset-btn" | |||||
| > | |||||
| {{ preset.name }} | |||||
| </button> | |||||
| </view> | |||||
| </view> | |||||
| </view> | |||||
| </template> | |||||
| <script> | |||||
| import ttsService from '@/utils/uniapp-tts-service.js'; | |||||
| export default { | |||||
| data() { | |||||
| return { | |||||
| // 基础数据 | |||||
| quickText: '', | |||||
| loading: false, | |||||
| playing: false, | |||||
| hasAudio: false, | |||||
| // 高级设置 | |||||
| showAdvanced: false, | |||||
| voiceOptions: [], | |||||
| voiceIndex: 0, | |||||
| speedValue: 0, | |||||
| volumeValue: 0, | |||||
| // 状态信息 | |||||
| statusInfo: null, | |||||
| // 预设文本 | |||||
| presetTexts: [ | |||||
| { name: '问候语', text: '你好,欢迎使用文字转语音功能!' }, | |||||
| { name: '感谢语', text: '谢谢您的使用,祝您生活愉快!' }, | |||||
| { name: '提醒语', text: '请注意,您有新的消息需要查看。' }, | |||||
| { name: '测试语', text: '这是一个语音测试,请检查音质是否清晰。' } | |||||
| ] | |||||
| } | |||||
| }, | |||||
| async onLoad() { | |||||
| await this.initTTS(); | |||||
| }, | |||||
| methods: { | |||||
| /** | |||||
| * 初始化TTS服务 | |||||
| */ | |||||
| async initTTS() { | |||||
| try { | |||||
| uni.showLoading({ title: '初始化中...' }); | |||||
| const result = await ttsService.init(); | |||||
| if (result.success) { | |||||
| this.voiceOptions = ttsService.getVoiceList(); | |||||
| console.log('TTS服务初始化成功'); | |||||
| } else { | |||||
| throw new Error(result.error); | |||||
| } | |||||
| } catch (error) { | |||||
| console.error('TTS初始化失败:', error); | |||||
| uni.showToast({ | |||||
| title: '初始化失败', | |||||
| icon: 'error' | |||||
| }); | |||||
| } finally { | |||||
| uni.hideLoading(); | |||||
| } | |||||
| }, | |||||
| /** | |||||
| * 快速转换 | |||||
| */ | |||||
| async quickConvert() { | |||||
| if (!this.quickText.trim()) { | |||||
| uni.showToast({ | |||||
| title: '请输入文本', | |||||
| icon: 'error' | |||||
| }); | |||||
| return; | |||||
| } | |||||
| this.loading = true; | |||||
| this.statusInfo = { status: '转换中...' }; | |||||
| try { | |||||
| const params = { | |||||
| text: this.quickText.trim(), | |||||
| speed: this.speedValue, | |||||
| voiceType: this.voiceOptions[this.voiceIndex]?.id || 0, | |||||
| volume: this.volumeValue, | |||||
| codec: 'wav' | |||||
| }; | |||||
| const result = await ttsService.textToVoice(params); | |||||
| if (result.success) { | |||||
| this.hasAudio = true; | |||||
| this.statusInfo = { | |||||
| status: '转换成功', | |||||
| size: result.audioSize, | |||||
| time: result.convertTime | |||||
| }; | |||||
| uni.showToast({ | |||||
| title: '转换成功', | |||||
| icon: 'success' | |||||
| }); | |||||
| } | |||||
| } catch (error) { | |||||
| console.error('转换失败:', error); | |||||
| this.statusInfo = { status: '转换失败' }; | |||||
| uni.showToast({ | |||||
| title: error.message || '转换失败', | |||||
| icon: 'error' | |||||
| }); | |||||
| } finally { | |||||
| this.loading = false; | |||||
| } | |||||
| }, | |||||
| /** | |||||
| * 快速播放 | |||||
| */ | |||||
| async quickPlay() { | |||||
| if (!this.hasAudio) { | |||||
| uni.showToast({ | |||||
| title: '请先转换文本', | |||||
| icon: 'error' | |||||
| }); | |||||
| return; | |||||
| } | |||||
| this.playing = true; | |||||
| try { | |||||
| await ttsService.playAudio(); | |||||
| // 监听播放结束 | |||||
| setTimeout(() => { | |||||
| this.playing = false; | |||||
| }, 100); | |||||
| } catch (error) { | |||||
| console.error('播放失败:', error); | |||||
| this.playing = false; | |||||
| uni.showToast({ | |||||
| title: '播放失败', | |||||
| icon: 'error' | |||||
| }); | |||||
| } | |||||
| }, | |||||
| /** | |||||
| * 切换高级设置显示 | |||||
| */ | |||||
| toggleAdvanced() { | |||||
| this.showAdvanced = !this.showAdvanced; | |||||
| }, | |||||
| /** | |||||
| * 音色选择变化 | |||||
| */ | |||||
| onVoiceChange(e) { | |||||
| this.voiceIndex = e.detail.value; | |||||
| }, | |||||
| /** | |||||
| * 使用预设文本 | |||||
| */ | |||||
| usePreset(preset) { | |||||
| this.quickText = preset.text; | |||||
| uni.showToast({ | |||||
| title: `已选择:${preset.name}`, | |||||
| icon: 'success' | |||||
| }); | |||||
| }, | |||||
| /** | |||||
| * 清空文本 | |||||
| */ | |||||
| clearText() { | |||||
| this.quickText = ''; | |||||
| this.hasAudio = false; | |||||
| this.statusInfo = null; | |||||
| }, | |||||
| /** | |||||
| * 停止播放 | |||||
| */ | |||||
| stopPlay() { | |||||
| ttsService.stopAudio(); | |||||
| this.playing = false; | |||||
| } | |||||
| }, | |||||
| onUnload() { | |||||
| // 页面卸载时清理资源 | |||||
| ttsService.destroy(); | |||||
| } | |||||
| } | |||||
| </script> | |||||
| <style scoped> | |||||
| .simple-tts-container { | |||||
| padding: 30rpx; | |||||
| background-color: #f8f9fa; | |||||
| min-height: 100vh; | |||||
| } | |||||
| .header { | |||||
| text-align: center; | |||||
| margin-bottom: 40rpx; | |||||
| } | |||||
| .title { | |||||
| display: block; | |||||
| font-size: 40rpx; | |||||
| font-weight: bold; | |||||
| color: #333; | |||||
| margin-bottom: 10rpx; | |||||
| } | |||||
| .subtitle { | |||||
| font-size: 28rpx; | |||||
| color: #666; | |||||
| } | |||||
| .quick-section { | |||||
| background-color: #fff; | |||||
| border-radius: 20rpx; | |||||
| padding: 30rpx; | |||||
| margin-bottom: 30rpx; | |||||
| box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08); | |||||
| } | |||||
| .input-group { | |||||
| position: relative; | |||||
| margin-bottom: 30rpx; | |||||
| } | |||||
| .quick-input { | |||||
| width: 100%; | |||||
| min-height: 160rpx; | |||||
| padding: 20rpx; | |||||
| border: 2rpx solid #e9ecef; | |||||
| border-radius: 12rpx; | |||||
| font-size: 30rpx; | |||||
| background-color: #fafbfc; | |||||
| resize: none; | |||||
| } | |||||
| .char-count { | |||||
| position: absolute; | |||||
| bottom: 10rpx; | |||||
| right: 15rpx; | |||||
| font-size: 24rpx; | |||||
| color: #999; | |||||
| } | |||||
| .quick-buttons { | |||||
| display: flex; | |||||
| gap: 20rpx; | |||||
| } | |||||
| .btn-convert, .btn-play { | |||||
| flex: 1; | |||||
| padding: 24rpx; | |||||
| border-radius: 12rpx; | |||||
| font-size: 32rpx; | |||||
| font-weight: 500; | |||||
| border: none; | |||||
| } | |||||
| .btn-convert { | |||||
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |||||
| color: #fff; | |||||
| } | |||||
| .btn-convert:disabled { | |||||
| background: #ccc; | |||||
| } | |||||
| .btn-play { | |||||
| background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); | |||||
| color: #fff; | |||||
| } | |||||
| .btn-play:disabled { | |||||
| background: #ccc; | |||||
| } | |||||
| .advanced-section { | |||||
| background-color: #fff; | |||||
| border-radius: 20rpx; | |||||
| margin-bottom: 30rpx; | |||||
| overflow: hidden; | |||||
| box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08); | |||||
| } | |||||
| .section-title { | |||||
| display: flex; | |||||
| justify-content: space-between; | |||||
| align-items: center; | |||||
| padding: 30rpx; | |||||
| font-size: 32rpx; | |||||
| font-weight: 500; | |||||
| color: #333; | |||||
| border-bottom: 1rpx solid #f0f0f0; | |||||
| cursor: pointer; | |||||
| } | |||||
| .toggle-icon { | |||||
| font-size: 24rpx; | |||||
| color: #999; | |||||
| } | |||||
| .advanced-content { | |||||
| padding: 30rpx; | |||||
| } | |||||
| .setting-item { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| margin-bottom: 30rpx; | |||||
| } | |||||
| .setting-item:last-child { | |||||
| margin-bottom: 0; | |||||
| } | |||||
| .setting-label { | |||||
| width: 120rpx; | |||||
| font-size: 28rpx; | |||||
| color: #333; | |||||
| } | |||||
| .setting-picker { | |||||
| flex: 1; | |||||
| } | |||||
| .picker-display { | |||||
| padding: 20rpx; | |||||
| border: 2rpx solid #e9ecef; | |||||
| border-radius: 8rpx; | |||||
| background-color: #fafbfc; | |||||
| font-size: 28rpx; | |||||
| } | |||||
| .setting-slider { | |||||
| flex: 1; | |||||
| margin-left: 20rpx; | |||||
| } | |||||
| .status-section { | |||||
| background-color: #fff; | |||||
| border-radius: 20rpx; | |||||
| padding: 30rpx; | |||||
| margin-bottom: 30rpx; | |||||
| box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08); | |||||
| } | |||||
| .status-item { | |||||
| display: flex; | |||||
| justify-content: space-between; | |||||
| margin-bottom: 15rpx; | |||||
| } | |||||
| .status-item:last-child { | |||||
| margin-bottom: 0; | |||||
| } | |||||
| .status-label { | |||||
| font-size: 28rpx; | |||||
| color: #666; | |||||
| } | |||||
| .status-value { | |||||
| font-size: 28rpx; | |||||
| color: #333; | |||||
| font-weight: 500; | |||||
| } | |||||
| .preset-section { | |||||
| background-color: #fff; | |||||
| border-radius: 20rpx; | |||||
| padding: 30rpx; | |||||
| box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08); | |||||
| } | |||||
| .preset-buttons { | |||||
| display: flex; | |||||
| flex-wrap: wrap; | |||||
| gap: 15rpx; | |||||
| } | |||||
| .preset-btn { | |||||
| padding: 15rpx 25rpx; | |||||
| border-radius: 25rpx; | |||||
| font-size: 26rpx; | |||||
| background-color: #f8f9fa; | |||||
| color: #495057; | |||||
| border: 2rpx solid #e9ecef; | |||||
| } | |||||
| .preset-btn:active { | |||||
| background-color: #e9ecef; | |||||
| } | |||||
| </style> | |||||