| @ -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> | |||