uniapp-text-to-speech
Advanced tools
Comparing version 1.0.3 to 1.0.4
@@ -145,3 +145,3 @@ /** | ||
/** | ||
* 结束计时并返回耗时(毫���) | ||
* 结束计时并返回时(毫秒) | ||
* @private | ||
@@ -189,3 +189,4 @@ */ | ||
private handleError; | ||
private calculateProgress; | ||
} | ||
export default SpeechSynthesisUtil; |
@@ -242,7 +242,7 @@ /** | ||
return; | ||
// 确保每次播放前都创建新的音频上下文 | ||
if (!this.audioContext) { | ||
this.audioContext = uni.createInnerAudioContext(); | ||
this.bindAudioEvents(); | ||
} | ||
// 销毁旧的音频上下文(如果存在) | ||
this.destroyAudioContext(); | ||
// 创建新的音频上下文 | ||
this.audioContext = uni.createInnerAudioContext(); | ||
this.bindAudioEvents(); | ||
this.setState({ | ||
@@ -258,8 +258,2 @@ isStartPlayQueue: true, | ||
yield this.safePlay(); | ||
// 移除这里的 emit,因为 handleAudioPlay 会处理 | ||
// this.emit(EventType.AUDIO_PLAY, { | ||
// currentAudio, | ||
// currentText: currentItem?.text || '', | ||
// remainingCount: this.audioQueue.length - 1 | ||
// }); | ||
} | ||
@@ -275,3 +269,2 @@ catch (error) { | ||
this.pendingAudioUrl = currentAudio; | ||
// 播放失败时销毁音频上下文 | ||
this.destroyAudioContext(); | ||
@@ -348,9 +341,13 @@ } | ||
const currentItem = this.audioTextQueue[0]; | ||
this.setState({ isPlaying: true }); | ||
this.emit(EventType.AUDIO_PLAY, { | ||
currentAudio, | ||
currentText: (currentItem === null || currentItem === void 0 ? void 0 : currentItem.text) || '', | ||
remainingCount: this.audioQueue.length - 1, // 减1是因为不包括当前正在播放的 | ||
totalCount: this.totalAudioCount | ||
}); | ||
// 确保只有在真正开始播放时才更新状态和触发事件 | ||
if (currentAudio && currentItem) { | ||
this.setState({ isPlaying: true, isPaused: false }); | ||
this.emit(EventType.AUDIO_PLAY, { | ||
currentAudio, | ||
currentText: currentItem.text, | ||
remainingCount: this.audioQueue.length - 1, | ||
totalCount: this.totalAudioCount, | ||
progress: this.calculateProgress() | ||
}); | ||
} | ||
} | ||
@@ -364,34 +361,42 @@ /** | ||
const finishedItem = this.audioTextQueue[0]; | ||
if (!finishedAudio || !finishedItem) | ||
return; | ||
// 先设置播放状态为 false | ||
this.setState({ isPlaying: false }); | ||
// 移除已播放的音频 | ||
this.audioQueue.shift(); | ||
this.audioTextQueue.shift(); | ||
// 计算当前进度 | ||
const playedCount = this.totalAudioCount - this.audioQueue.length; | ||
const progress = this.totalAudioCount > 0 ? (playedCount / this.totalAudioCount) * 100 : 0; | ||
// 发送进度事件 | ||
this.emit(EventType.PROGRESS, { | ||
progress: Math.round(progress), // 四舍五入到整数 | ||
playedCount, | ||
totalCount: this.totalAudioCount, | ||
currentText: (finishedItem === null || finishedItem === void 0 ? void 0 : finishedItem.text) || '', | ||
remainingCount: this.audioQueue.length // 剩余数量就是队列的长度 | ||
}); | ||
// 检查是否还有待播放的音频 | ||
if (this.audioQueue.length > 0) { | ||
if (this.audioQueue.length > 1) { | ||
// 移除当前音频 | ||
this.audioQueue.shift(); | ||
this.audioTextQueue.shift(); | ||
// 发送进度事件 | ||
this.emit(EventType.PROGRESS, { | ||
progress: this.calculateProgress(), | ||
playedCount: this.totalAudioCount - this.audioQueue.length, | ||
totalCount: this.totalAudioCount, | ||
currentText: finishedItem.text, | ||
remainingCount: this.audioQueue.length - 1 | ||
}); | ||
// 确保在开始下一个播放前销毁当前上下文 | ||
this.destroyAudioContext(); | ||
this.playAudioQueue(); | ||
// 使用 setTimeout 确保异步执行下一个播放 | ||
setTimeout(() => { | ||
this.playAudioQueue(); | ||
}, 0); | ||
} | ||
else { | ||
// 最后一个音频播放完成 | ||
this.audioQueue.shift(); | ||
this.audioTextQueue.shift(); | ||
this.setState({ | ||
isEnded: true, | ||
isStartPlayQueue: false, | ||
isPlaying: false | ||
isPlaying: false, | ||
isPaused: false | ||
}); | ||
this.emit(EventType.AUDIO_END, { | ||
finishedAudio, | ||
finishedText: (finishedItem === null || finishedItem === void 0 ? void 0 : finishedItem.text) || '', | ||
finishedText: finishedItem.text, | ||
remainingCount: 0, | ||
isComplete: true, | ||
progress: 100, // 确保最后进度为100% | ||
progress: 100, | ||
totalCount: this.totalAudioCount, | ||
@@ -401,2 +406,3 @@ playedCount: this.totalAudioCount | ||
this.destroyAudioContext(); | ||
this.resetTextProcessor(); | ||
} | ||
@@ -506,3 +512,3 @@ } | ||
/** | ||
* 结束计时并返回耗时(毫���) | ||
* 结束计时并返回时(毫秒) | ||
* @private | ||
@@ -654,3 +660,10 @@ */ | ||
} | ||
// 添加进度计算辅助方法 | ||
calculateProgress() { | ||
if (this.totalAudioCount === 0) | ||
return 0; | ||
const playedCount = this.totalAudioCount - this.audioQueue.length; | ||
return Math.round((playedCount / this.totalAudioCount) * 100); | ||
} | ||
} | ||
export default SpeechSynthesisUtil; |
{ | ||
"name": "uniapp-text-to-speech", | ||
"version": "1.0.3", | ||
"version": "1.0.4", | ||
"description": "uniapp 文本转语音", | ||
@@ -5,0 +5,0 @@ "main": "dist/index.js", |
608
README.md
# uniapp 文本转语音 | ||
- 基于 Minimax API 的 UniApp 文本转语音工具,支持文本分段、队列播放、暂停恢复等功能。 | ||
- 基于 Minimax API 的 UniApp 文本转语音工具,支持文本分段、队列播放、暂停恢复等功能。 | ||
- 目前只内置了 [Minimax](https://platform.minimaxi.com/document/T2A%20V2?key=66719005a427f0c8a5701643)文本转语音 | ||
- Minimax的语音生成技术以其自然、情感丰富和实时性强而著称 | ||
- Minimax 的语音生成技术以其自然、情感丰富和实时性强而著称 | ||
#### API_KEY、GroupId获取方法 | ||
#### API_KEY、GroupId 获取方法 | ||
[https://platform.minimaxi.com/user-center/basic-information/interface-key](https://platform.minimaxi.com/user-center/basic-information/interface-key) | ||
[NPM地址](https://www.npmjs.com/package/uniapp-text-to-speech) | ||
[NPM 地址](https://www.npmjs.com/package/uniapp-text-to-speech) | ||
@@ -22,3 +23,4 @@ ## 特性 | ||
## 安装 (推荐npm安装,而不是导入) | ||
## 安装 (推荐 npm 安装,而不是导入) | ||
```typescript | ||
@@ -58,25 +60,22 @@ npm install uniapp-text-to-speech | ||
## 分段使用 | ||
- 模拟AI大模型流式返回数据 | ||
- 模拟 AI 大模型流式返回数据,自动会处理合成分段:["你好","我是一个ai机器人","我的名字叫做阿强"] | ||
```typescript | ||
import SpeechSynthesisUtil from 'uniapp-text-to-speech'; | ||
import SpeechSynthesisUtil from "uniapp-text-to-speech"; | ||
// 初始化 | ||
const tts = new SpeechSynthesisUtil({ | ||
API_KEY: 'your_minimax_api_key', // Minimax API密钥 | ||
GroupId: 'your_group_id', // Minimax 组ID | ||
API_KEY: "your_minimax_api_key", // Minimax API密钥 | ||
GroupId: "your_group_id", // Minimax 组ID | ||
}); | ||
const mockArray = ['你好,', '我是', '一个ai','机器人', ',我的名', '字', '叫做', '阿强'] | ||
const mockTexts = ['你好,', '我是', '人工智能助手,', '很高兴认识你!']; | ||
// 自动会处理合成分段:["你好","我是一个ai机器人","我的名字叫做阿强"] | ||
// 基础播放 | ||
try { | ||
mockArray.map(item => { | ||
await tts.textToSpeech(item); | ||
}) | ||
await tts.flushRemainingText() | ||
for (const text of mockTexts) { | ||
await tts.processText(text); | ||
} | ||
await tts.flushRemainingText(); | ||
} catch (error) { | ||
console.error('语音合成失败:', error); | ||
addLog(`分段播放失败: ${error.message}`); | ||
} | ||
@@ -90,21 +89,42 @@ ``` | ||
```typescript | ||
import { EventType } from 'uniapp-text-to-speech'; | ||
import { EventType } from "uniapp-text-to-speech"; | ||
// 监听合成开始 | ||
tts.on(EventType.SYNTHESIS_START, ({ text }) => { | ||
console.log('开始合成文本:', text); | ||
console.log(`开始合成文本: ${text}`); | ||
}); | ||
// 监听播放开始 | ||
tts.on(EventType.AUDIO_PLAY, ({ currentText, remainingCount }) => { | ||
console.log('正在播放:', currentText); | ||
console.log('剩余数量:', remainingCount); | ||
tts.on(EventType.AUDIO_PLAY, ({ currentText }) => { | ||
console.log(`正在播放: ${currentText}`); | ||
status.value = "播放中"; | ||
}); | ||
// 监听播放结束 | ||
tts.on(EventType.AUDIO_END, ({ finishedText }) => { | ||
console.log('播放完成:', finishedText); | ||
console.log(`播放完成: ${finishedText}`); | ||
status.value = "就绪"; | ||
progress.value = 100; | ||
}); | ||
// 监听错误 | ||
tts.on(EventType.ERROR, ({ error }) => { | ||
console.error('发生错误:', error); | ||
console.log(`错误: ${error.message}`); | ||
status.value = "错误"; | ||
}); | ||
// 监听暂停 | ||
tts.on(EventType.PAUSE, () => { | ||
console.log("播放已暂停"); | ||
status.value = "已暂停"; | ||
isPaused.value = true; | ||
}); | ||
// 监听恢复 | ||
tts.on(EventType.RESUME, () => { | ||
console.log("播放已恢复"); | ||
status.value = "播放中"; | ||
isPaused.value = false; | ||
}); | ||
``` | ||
### 2. 暂停和恢复 | ||
@@ -120,8 +140,8 @@ | ||
``` | ||
### 3. 长文本分段处理 | ||
```typescript | ||
// 自动按标点符号分段处理长文本 | ||
await tts.processText('这是第一句话。这是第二句话!这是第三句话?'); | ||
await tts.processText("这是第一句话。这是第二句话!这是第三句话?"); | ||
// 强制处理剩余未播放的文本 | ||
@@ -132,2 +152,3 @@ await tts.flushRemainingText(); | ||
``` | ||
### 4. 状态管理 | ||
@@ -138,4 +159,4 @@ | ||
const state = tts.getState(); | ||
console.log('是否正在播放:', state.isPlaying); | ||
console.log('是否已暂停:', state.isPaused); | ||
console.log("是否正在播放:", state.isPlaying); | ||
console.log("是否已暂停:", state.isPaused); | ||
// 重置所有状态 | ||
@@ -145,3 +166,2 @@ tts.reset(); | ||
## API 文档 | ||
@@ -151,34 +171,60 @@ | ||
| 参数 | 类型 | 必填 | 说明 | | ||
|------|------|------|------| | ||
| API_KEY | string | 是 | Minimax API密钥 | | ||
| GroupId | string | 是 | Minimax 组ID | | ||
| MAX_QUEUE_LENGTH | number | 否 | 音频队列最大长度,默认为3 | | ||
|modelConfig|object|否|合成语音配置,参考[minimaxi](https://platform.minimaxi.com/document/T2A%20V2?key=66719005a427f0c8a5701643#YqSh1KAoyms1WH4XJrdeIrrb) | ||
| 参数 | 类型 | 必填 | 说明 | | ||
| ---------------- | ------ | ---- | ----------------------------------------------------------------------------------------------------------------------------------- | | ||
| API_KEY | string | 是 | Minimax API 密钥 | | ||
| GroupId | string | 是 | Minimax 组 ID | | ||
| MAX_QUEUE_LENGTH | number | 否 | 音频队列最大长度,默认为 3 | | ||
| modelConfig | object | 否 | 合成语音配置,参考[minimaxi](https://platform.minimaxi.com/document/T2A%20V2?key=66719005a427f0c8a5701643#YqSh1KAoyms1WH4XJrdeIrrb) | | ||
### 事件类型 | ||
| 事件名 | 说明 | 回调参数 | | ||
|--------|------|----------| | ||
| SYNTHESIS_START | 开始合成 | { text: string } | | ||
| SYNTHESIS_END | 合成结束 | { text: string } | | ||
| AUDIO_PLAY | 开始播放 | { currentText: string, remainingCount: number } | | ||
| AUDIO_END | 播放结束 | { finishedText: string } | | ||
| PAUSE | 暂停播放 | - | | ||
| RESUME | 恢复播放 | - | | ||
| ERROR | 发生错误 | { error: Error } | | ||
| 事件名 | 说明 | 回调参数 | | ||
| --------------- | -------------------- | ---------------- | | ||
| SYNTHESIS_START | 开始合成 | { text: string } | | ||
| SYNTHESIS_END | 合成结束 | { text: string } | | ||
| AUDIO_PLAY | 开始播放单个音频片段 | { text: string } | | ||
| AUDIO_END | 所有音频播放完成 | { text: string } | | ||
| PAUSE | 暂停播放 | - | | ||
| RESUME | 恢复播放 | - | | ||
| ERROR | 发生错误 | { error: Error } | | ||
### 主要方法 | ||
### 事件说明 | ||
| 方法名 | 说明 | 参数 | 返回值 | | ||
|--------|------|------|--------| | ||
| textToSpeech | 文本转语音 | text: string | Promise<void> | | ||
| processText | 处理长文本 | text: string | Promise<void> | | ||
| pause | 暂停播放 | - | void | | ||
| resume | 恢复播放 | - | void | | ||
| togglePlay | 切换播放状态 | - | void | | ||
| reset | 重置所有状态 | - | void | | ||
| on | 添加事件监听 | event: EventType, callback: Function | void | | ||
| off | 移除事件监听 | event: EventType, callback: Function | void | | ||
- `AUDIO_PLAY`: 每个音频片段开始播放时触发 | ||
- `AUDIO_END`: 仅在所有音频片段都播放完成后触发一次 | ||
## 使用示例 | ||
```typescript | ||
import SpeechSynthesisUtil, { EventType } from "uniapp-text-to-speech"; | ||
const tts = new SpeechSynthesisUtil({ | ||
API_KEY: "your_minimax_api_key", | ||
GroupId: "your_group_id", | ||
modelConfig: { | ||
model: "speech-01-240228", | ||
voice_setting: { | ||
voice_id: "female-yujie", // 默认使用悦姐声音 | ||
speed: 1.2, | ||
vol: 1, | ||
}, | ||
}, | ||
}); | ||
// 监听播放完成事件 | ||
tts.on(EventType.AUDIO_END, ({ text }) => { | ||
console.log("所有音频播放完成,最后播放的文本:", text); | ||
}); | ||
// 分段播放示例 | ||
async function playMultipleTexts() { | ||
await tts.processText("第一段文本"); | ||
await tts.processText("第二段文本"); | ||
await tts.flushRemainingText(); // 确保所有文本都被处理 | ||
} | ||
// 重置播放状态 | ||
tts.reset(); | ||
``` | ||
## 注意事项 | ||
@@ -191,5 +237,19 @@ | ||
- 低优先级:,、 | ||
3. 音频队列最大长度默认为3,可以通过构造函数参数修改 | ||
4. 播放失败时会自动重试,如需手动触发可调用 manualPlay 方法 | ||
3. 音频队列最大长度默认为 3,可以通过构造函数参数修改 | ||
4. `AUDIO_END` 事件只会在所有音频片段播放完成后触发一次 | ||
5. 使用 `reset()` 方法可以重置所有播放状态和计数器 | ||
## 主要的方 | ||
| 方法名 | 说明 | 参数 | 返回值 | | ||
| ------------ | ------------ | ------------------------------------ | ------------- | | ||
| textToSpeech | 文本转语音 | text: string | Promise<void> | | ||
| processText | 处理长文本 | text: string | Promise<void> | | ||
| pause | 暂停播放 | - | void | | ||
| resume | 恢复播放 | - | void | | ||
| togglePlay | 切换播放状态 | - | void | | ||
| reset | 重置所有状态 | - | void | | ||
| on | 添加事件监听 | event: EventType, callback: Function | void | | ||
| off | 移除事件监听 | event: EventType, callback: Function | void | | ||
## 完整的示例代码 | ||
@@ -199,249 +259,245 @@ | ||
<template> | ||
<div class="speech-container"> | ||
<!-- 文本输入区域 --> | ||
<textarea | ||
v-model="inputText" | ||
placeholder="请输入要转换的文本" | ||
:disabled="isProcessing" | ||
></textarea> | ||
<div class="speech-demo"> | ||
<!-- 基础演示区域 --> | ||
<section class="demo-section"> | ||
<h3>基础演示</h3> | ||
<textarea v-model="basicText" placeholder="请输入要转换的文本"></textarea> | ||
<button @click="handleBasicSpeech">开始播放</button> | ||
</section> | ||
<!-- 控制按钮区域 --> | ||
<div class="control-panel"> | ||
<button | ||
@click="startPlayback" | ||
:disabled="isProcessing || !inputText" | ||
> | ||
{{ isProcessing ? '处理中...' : '开始播放' }} | ||
</button> | ||
<!-- 分段演示区域 --> | ||
<section class="demo-section"> | ||
<h3>分段播放演示</h3> | ||
<div class="segment-container"> | ||
<div v-for="(text, index) in mockTexts" :key="index" class="segment"> | ||
<span>{{ text }}</span> | ||
</div> | ||
</div> | ||
<button @click="handleSegmentSpeech">分段播放</button> | ||
</section> | ||
<button | ||
@click="handlePlayPause" | ||
:disabled="!canTogglePlay" | ||
> | ||
{{ isPaused ? '继续' : '暂停' }} | ||
</button> | ||
<!-- 高级功能演示区域 --> | ||
<section class="demo-section"> | ||
<h3>高级功能演示</h3> | ||
<div class="controls"> | ||
<button @click="handleTogglePlay">{{ isPaused ? '继续' : '暂停' }}</button> | ||
<button @click="handleReset">重置</button> | ||
</div> | ||
<div class="status"> | ||
<p>当前状态: {{ status }}</p> | ||
<p>播放进度: {{ progress }}%</p> | ||
</div> | ||
</section> | ||
<button | ||
@click="handleStop" | ||
:disabled="!isPlaying && !isPaused" | ||
> | ||
停止 | ||
</button> | ||
</div> | ||
<!-- 事件日志区域 --> | ||
<section class="demo-section"> | ||
<h3>事件日志</h3> | ||
<div class="log-container"> | ||
<div v-for="(log, index) in eventLogs" :key="index" class="log-item"> | ||
{{ log }} | ||
</div> | ||
</div> | ||
</section> | ||
</div> | ||
</template> | ||
<!-- 状态显示区域 --> | ||
<div class="status-panel"> | ||
<div class="status-item"> | ||
<span>状态:</span> | ||
<span :class="statusClass">{{ status }}</span> | ||
</div> | ||
<div class="status-item"> | ||
<span>进度:</span> | ||
<div class="progress-bar"> | ||
<div | ||
class="progress-fill" | ||
:style="{ width: `${progress}%` }" | ||
></div> | ||
<span>{{ progress }}%</span> | ||
</div> | ||
</div> | ||
</div> | ||
<script setup lang="ts"> | ||
import { ref, onMounted, onBeforeUnmount } from 'vue'; | ||
import SpeechSynthesisUtil, { EventType } from 'uniapp-text-to-speech'; | ||
<!-- 错误信息显示 --> | ||
<div v-if="errorMessage" class="error-message"> | ||
{{ errorMessage }} | ||
</div> | ||
</div> | ||
</template> | ||
// 响应式状态 | ||
const basicText = ref('你好,这是一个基础示例。'); | ||
const mockTexts = ref(['你好,', '我是', '人工智能助手,', '很高兴认识你!']); | ||
const status = ref('就绪'); | ||
const progress = ref(0); | ||
const isPaused = ref(false); | ||
const eventLogs = ref<string[]>([]); | ||
<script setup> | ||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'; | ||
import SpeechSynthesisUtil, { EventType } from 'uniapp-text-to-speech'; | ||
// 初始化语音工具 | ||
const tts = new SpeechSynthesisUtil({ | ||
API_KEY: 'your_minimax_api_key', // Minimax API密钥 | ||
GroupId: 'your_group_id', // Minimax 组ID | ||
modelConfig: { | ||
model: 'speech-01-240228', | ||
voice_setting: { | ||
voice_id: "female-yujie", | ||
speed: 1, | ||
vol: 1 | ||
} | ||
} | ||
}); | ||
// 响应式状态 | ||
const inputText = ref(''); | ||
const isProcessing = ref(false); | ||
const isPaused = ref(false); | ||
const isPlaying = ref(false); | ||
const status = ref('就绪'); | ||
const progress = ref(0); | ||
const errorMessage = ref(''); | ||
// 添加日志 | ||
const addLog = (message : string) => { | ||
eventLogs.value.unshift(`${new Date().toLocaleTimeString()}: ${message}`); | ||
if (eventLogs.value.length > 10) { | ||
eventLogs.value.pop(); | ||
} | ||
}; | ||
// 计算属性 | ||
const canTogglePlay = computed(() => { | ||
return isPlaying.value || isPaused.value; | ||
}); | ||
// 设置事件监听 | ||
const setupEventListeners = () => { | ||
// 监听合成开始 | ||
tts.on(EventType.SYNTHESIS_START, ({ text }) => { | ||
addLog(`开始合成文本: ${text}`); | ||
}); | ||
const statusClass = computed(() => { | ||
return { | ||
'status-ready': status.value === '就绪', | ||
'status-playing': status.value === '播放中', | ||
'status-paused': status.value === '已暂停', | ||
'status-error': status.value.includes('错误') | ||
}; | ||
}); | ||
// 监听播放开始 | ||
tts.on(EventType.AUDIO_PLAY, ({ currentText }) => { | ||
addLog(`正在播放: ${currentText}`); | ||
status.value = '播放中'; | ||
}); | ||
// 初始化语音工具 | ||
const speechUtil = new SpeechSynthesisUtil({ | ||
API_KEY: 'your_api_key', | ||
GroupId: 'your_group_id' | ||
}); | ||
// 监听播放结束 | ||
tts.on(EventType.AUDIO_END, ({ finishedText }) => { | ||
addLog(`播放完成: ${finishedText}`); | ||
status.value = '就绪'; | ||
progress.value = 100; | ||
}); | ||
// 设置事件监听 | ||
const setupEventListeners = () => { | ||
// 状态变化监听 | ||
speechUtil.on(EventType.STATE_CHANGE, ({ newState }) => { | ||
isPaused.value = newState.isPaused; | ||
isPlaying.value = newState.isPlaying; | ||
status.value = newState.isPlaying ? '播放中' : | ||
newState.isPaused ? '已暂停' : | ||
newState.isError ? '错误' : '就绪'; | ||
}); | ||
// 监听错误 | ||
tts.on(EventType.ERROR, ({ error }) => { | ||
addLog(`错误: ${error.message}`); | ||
status.value = '错误'; | ||
}); | ||
// 进度监听 | ||
speechUtil.on(EventType.PROGRESS, ({ currentIndex, totalChunks }) => { | ||
progress.value = Math.round(((currentIndex + 1) / totalChunks) * 100); | ||
}); | ||
// 监听暂停 | ||
tts.on(EventType.PAUSE, () => { | ||
addLog('播放已暂停'); | ||
status.value = '已暂停'; | ||
isPaused.value = true; | ||
}); | ||
// 错误监听 | ||
speechUtil.on(EventType.ERROR, ({ error }) => { | ||
errorMessage.value = error.message; | ||
status.value = '错误'; | ||
}); | ||
// 监听恢复 | ||
tts.on(EventType.RESUME, () => { | ||
addLog('播放已恢复'); | ||
status.value = '播放中'; | ||
isPaused.value = false; | ||
}); | ||
}; | ||
// 播放结束监听 | ||
speechUtil.on(EventType.AUDIO_END, () => { | ||
if (progress.value === 100) { | ||
resetState(); | ||
} | ||
}); | ||
}; | ||
// 基础播放示例 | ||
const handleBasicSpeech = async () => { | ||
try { | ||
await tts.textToSpeech(basicText.value); | ||
} catch (error) { | ||
addLog(`播放失败: ${error.message}`); | ||
} | ||
}; | ||
// 开始播放 | ||
const startPlayback = async () => { | ||
if (!inputText.value) return; | ||
isProcessing.value = true; | ||
errorMessage.value = ''; | ||
try { | ||
await speechUtil.processText(inputText.value); | ||
await speechUtil.flushRemainingText(); | ||
} catch (error) { | ||
errorMessage.value = `处理失败: ${error.message}`; | ||
} finally { | ||
isProcessing.value = false; | ||
} | ||
}; | ||
// 分段播放示例 | ||
const handleSegmentSpeech = async () => { | ||
try { | ||
for (const text of mockTexts.value) { | ||
await tts.processText(text); | ||
} | ||
await tts.flushRemainingText(); | ||
} catch (error) { | ||
addLog(`分段播放失败: ${error.message}`); | ||
} | ||
}; | ||
// 处理播放/暂停 | ||
const handlePlayPause = () => { | ||
speechUtil.togglePlay(); | ||
}; | ||
// 切换播放/暂停 | ||
const handleTogglePlay = () => { | ||
tts.togglePlay(); | ||
}; | ||
// 处理停止 | ||
const handleStop = () => { | ||
speechUtil.reset(); | ||
resetState(); | ||
}; | ||
// 重置播放 | ||
const handleReset = () => { | ||
tts.reset(); | ||
status.value = '就绪'; | ||
progress.value = 0; | ||
isPaused.value = false; | ||
addLog('已重置所有状态'); | ||
}; | ||
// 重置状态 | ||
const resetState = () => { | ||
isProcessing.value = false; | ||
isPaused.value = false; | ||
isPlaying.value = false; | ||
status.value = '就绪'; | ||
progress.value = 0; | ||
errorMessage.value = ''; | ||
}; | ||
// 生命周期钩子 | ||
onMounted(() => { | ||
setupEventListeners(); | ||
}); | ||
// 生命周期钩子 | ||
onMounted(() => { | ||
setupEventListeners(); | ||
}); | ||
onBeforeUnmount(() => { | ||
speechUtil.reset(); | ||
}); | ||
onBeforeUnmount(() => { | ||
tts.reset(); | ||
}); | ||
</script> | ||
<style scoped> | ||
.speech-container { | ||
padding: 20px; | ||
max-width: 600px; | ||
margin: 0 auto; | ||
} | ||
.speech-demo { | ||
padding: 20px; | ||
max-width: 800px; | ||
margin: 0 auto; | ||
} | ||
textarea { | ||
width: 100%; | ||
height: 150px; | ||
padding: 10px; | ||
margin-bottom: 20px; | ||
border: 1px solid #ddd; | ||
border-radius: 4px; | ||
resize: vertical; | ||
} | ||
.demo-section { | ||
margin-bottom: 30px; | ||
padding: 20px; | ||
border: 1px solid #eee; | ||
border-radius: 8px; | ||
} | ||
.control-panel { | ||
display: flex; | ||
gap: 10px; | ||
margin-bottom: 20px; | ||
} | ||
h3 { | ||
margin-top: 0; | ||
margin-bottom: 15px; | ||
color: #333; | ||
} | ||
button { | ||
padding: 8px 16px; | ||
border: none; | ||
border-radius: 4px; | ||
background-color: #4CAF50; | ||
color: white; | ||
cursor: pointer; | ||
} | ||
textarea { | ||
width: 100%; | ||
height: 100px; | ||
padding: 10px; | ||
margin-bottom: 10px; | ||
border: 1px solid #ddd; | ||
border-radius: 4px; | ||
resize: vertical; | ||
} | ||
button:disabled { | ||
background-color: #cccccc; | ||
cursor: not-allowed; | ||
} | ||
button { | ||
padding: 8px 16px; | ||
margin-right: 10px; | ||
border: none; | ||
border-radius: 4px; | ||
background-color: #4CAF50; | ||
color: white; | ||
cursor: pointer; | ||
} | ||
.status-panel { | ||
background-color: #f5f5f5; | ||
padding: 15px; | ||
border-radius: 4px; | ||
} | ||
button:disabled { | ||
background-color: #cccccc; | ||
} | ||
.status-item { | ||
display: flex; | ||
align-items: center; | ||
margin-bottom: 10px; | ||
} | ||
.segment-container { | ||
margin-bottom: 15px; | ||
} | ||
.progress-bar { | ||
flex: 1; | ||
height: 20px; | ||
background-color: #ddd; | ||
border-radius: 10px; | ||
overflow: hidden; | ||
margin-left: 10px; | ||
position: relative; | ||
} | ||
.segment { | ||
display: inline-block; | ||
padding: 5px 10px; | ||
margin: 5px; | ||
background-color: #f5f5f5; | ||
border-radius: 4px; | ||
} | ||
.progress-fill { | ||
height: 100%; | ||
background-color: #4CAF50; | ||
transition: width 0.3s ease; | ||
} | ||
.controls { | ||
margin-bottom: 15px; | ||
} | ||
.error-message { | ||
margin-top: 20px; | ||
padding: 10px; | ||
background-color: #ffebee; | ||
color: #c62828; | ||
border-radius: 4px; | ||
} | ||
.status { | ||
padding: 10px; | ||
background-color: #f9f9f9; | ||
border-radius: 4px; | ||
} | ||
.status-ready { color: #2196F3; } | ||
.status-playing { color: #4CAF50; } | ||
.status-paused { color: #FF9800; } | ||
.status-error { color: #F44336; } | ||
.log-container { | ||
height: 200px; | ||
overflow-y: auto; | ||
padding: 10px; | ||
background-color: #f5f5f5; | ||
border-radius: 4px; | ||
} | ||
.log-item { | ||
padding: 5px; | ||
border-bottom: 1px solid #eee; | ||
font-family: monospace; | ||
} | ||
</style> | ||
@@ -456,2 +512,2 @@ ``` | ||
乔振 <qiaozhenleve@gmail.com> | ||
乔振 <qiaozhenleve@gmail.com> |
@@ -295,7 +295,8 @@ /** | ||
// 确保每次播放前都创建新的音频上下文 | ||
if (!this.audioContext) { | ||
this.audioContext = uni.createInnerAudioContext(); | ||
this.bindAudioEvents(); | ||
} | ||
// 销毁旧的音频上下文(如果存在) | ||
this.destroyAudioContext(); | ||
// 创建新的音频上下文 | ||
this.audioContext = uni.createInnerAudioContext(); | ||
this.bindAudioEvents(); | ||
@@ -315,8 +316,2 @@ this.setState({ | ||
await this.safePlay(); | ||
// 移除这里的 emit,因为 handleAudioPlay 会处理 | ||
// this.emit(EventType.AUDIO_PLAY, { | ||
// currentAudio, | ||
// currentText: currentItem?.text || '', | ||
// remainingCount: this.audioQueue.length - 1 | ||
// }); | ||
} catch (error) { | ||
@@ -331,3 +326,2 @@ console.warn('播放失败:', error); | ||
this.pendingAudioUrl = currentAudio; | ||
// 播放失败时销毁音频上下文 | ||
this.destroyAudioContext(); | ||
@@ -402,9 +396,13 @@ } | ||
this.setState({ isPlaying: true }); | ||
this.emit(EventType.AUDIO_PLAY, { | ||
currentAudio, | ||
currentText: currentItem?.text || '', | ||
remainingCount: this.audioQueue.length - 1, // 减1是因为不包括当前正在播放的 | ||
totalCount: this.totalAudioCount | ||
}); | ||
// 确保只有在真正开始播放时才更新状态和触发事件 | ||
if (currentAudio && currentItem) { | ||
this.setState({ isPlaying: true, isPaused: false }); | ||
this.emit(EventType.AUDIO_PLAY, { | ||
currentAudio, | ||
currentText: currentItem.text, | ||
remainingCount: this.audioQueue.length - 1, | ||
totalCount: this.totalAudioCount, | ||
progress: this.calculateProgress() | ||
}); | ||
} | ||
} | ||
@@ -420,30 +418,39 @@ | ||
if (!finishedAudio || !finishedItem) return; | ||
// 先设置播放状态为 false | ||
this.setState({ isPlaying: false }); | ||
// 移除已播放的音频 | ||
this.audioQueue.shift(); | ||
this.audioTextQueue.shift(); | ||
// 计算当前进度 | ||
const playedCount = this.totalAudioCount - this.audioQueue.length; | ||
const progress = this.totalAudioCount > 0 ? (playedCount / this.totalAudioCount) * 100 : 0; | ||
// 发送进度事件 | ||
this.emit(EventType.PROGRESS, { | ||
progress: Math.round(progress), // 四舍五入到整数 | ||
playedCount, | ||
totalCount: this.totalAudioCount, | ||
currentText: finishedItem?.text || '', | ||
remainingCount: this.audioQueue.length // 剩余数量就是队列的长度 | ||
}); | ||
// 检查是否还有待播放的音频 | ||
if (this.audioQueue.length > 0) { | ||
if (this.audioQueue.length > 1) { | ||
// 移除当前音频 | ||
this.audioQueue.shift(); | ||
this.audioTextQueue.shift(); | ||
// 发送进度事件 | ||
this.emit(EventType.PROGRESS, { | ||
progress: this.calculateProgress(), | ||
playedCount: this.totalAudioCount - this.audioQueue.length, | ||
totalCount: this.totalAudioCount, | ||
currentText: finishedItem.text, | ||
remainingCount: this.audioQueue.length - 1 | ||
}); | ||
// 确保在开始下一个播放前销毁当前上下文 | ||
this.destroyAudioContext(); | ||
this.playAudioQueue(); | ||
// 使用 setTimeout 确保异步执行下一个播放 | ||
setTimeout(() => { | ||
this.playAudioQueue(); | ||
}, 0); | ||
} else { | ||
// 最后一个音频播放完成 | ||
this.audioQueue.shift(); | ||
this.audioTextQueue.shift(); | ||
this.setState({ | ||
isEnded: true, | ||
isStartPlayQueue: false, | ||
isPlaying: false | ||
isPlaying: false, | ||
isPaused: false | ||
}); | ||
@@ -453,6 +460,6 @@ | ||
finishedAudio, | ||
finishedText: finishedItem?.text || '', | ||
finishedText: finishedItem.text, | ||
remainingCount: 0, | ||
isComplete: true, | ||
progress: 100, // 确保最后进度为100% | ||
progress: 100, | ||
totalCount: this.totalAudioCount, | ||
@@ -463,2 +470,3 @@ playedCount: this.totalAudioCount | ||
this.destroyAudioContext(); | ||
this.resetTextProcessor(); | ||
} | ||
@@ -579,3 +587,3 @@ } | ||
/** | ||
* 结束计时并返回耗时(毫���) | ||
* 结束计时并返回时(毫秒) | ||
* @private | ||
@@ -735,4 +743,11 @@ */ | ||
} | ||
// 添加进度计算辅助方法 | ||
private calculateProgress(): number { | ||
if (this.totalAudioCount === 0) return 0; | ||
const playedCount = this.totalAudioCount - this.audioQueue.length; | ||
return Math.round((playedCount / this.totalAudioCount) * 100); | ||
} | ||
} | ||
export default SpeechSynthesisUtil; |
declare namespace UniApp { | ||
interface SpeakOptions { | ||
text?: string; | ||
lang?: string; | ||
pitch?: number; | ||
volume?: number; | ||
rate?: number; | ||
} | ||
interface CallbackOptions { | ||
success?(res: any): void; | ||
fail?(err: any): void; | ||
complete?(res: any): void; | ||
} | ||
interface InnerAudioContext { | ||
@@ -22,15 +8,15 @@ src: string; | ||
destroy(): void; | ||
onPlay(callback: () => void): void; | ||
offPlay(callback: () => void): void; | ||
onEnded(callback: () => void): void; | ||
onError(callback: (res: any) => void): void; | ||
onPlay(callback: Function): void; | ||
onEnded(callback: Function): void; | ||
onError(callback: Function): void; | ||
offPlay(callback: Function): void; | ||
} | ||
interface FileSystemManager { | ||
writeFile(options: { | ||
writeFile(option: { | ||
filePath: string; | ||
data: string | ArrayBuffer; | ||
encoding?: string; | ||
success?(res: any): void; | ||
fail?(error: any): void; | ||
encoding: string; | ||
success?: Function; | ||
fail?: Function; | ||
}): void; | ||
@@ -44,17 +30,6 @@ unlinkSync(filePath: string): void; | ||
getFileSystemManager(): UniApp.FileSystemManager; | ||
createSpeechSynthesizer(): { | ||
speak(options: UniApp.SpeakOptions & UniApp.CallbackOptions): void; | ||
stop(options: UniApp.CallbackOptions): void; | ||
}; | ||
request(options: any): void; | ||
env: { | ||
USER_DATA_PATH: string; | ||
}; | ||
request(options: { | ||
url: string; | ||
method: string; | ||
header: Record<string, string>; | ||
data: any; | ||
success(res: any): void; | ||
fail(error: any): void; | ||
}): void; | ||
}; |
70468
8
1555
503