Files
test-repo/docs/.vitepress/theme/components/appendix/audio-intro/AudioQuickStartDemo.vue
T
sanbuphy e5b1c6cc88 docs: update content and components across multiple files
- Refine chapter introductions in zh-cn docs for clarity and conciseness
- Update navigation links to include '/easy-vibe' prefix
- Simplify UI components (ChapterIntroduction, ContextWindowVisualizer)
- Add new agent-related demo components (AgentMemoryDemo, AgentToolUseDemo)
- Improve context compression demo with better visuals and metrics
- Adjust styling and layout across various components
2026-02-03 01:46:03 +08:00

439 lines
10 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
AudioQuickStartDemo.vue
AI 音频快速体验组件
用途
让用户在文章开头就能体验 AI 音频的魅力通过交互式演示理解文本转语音的基本概念
交互功能
- 文本输入和语音合成
- 声音选择不同音色
- 语速和音调调节
- 实时波形可视化
-->
<template>
<div class="audio-quickstart">
<el-card shadow="never">
<template #header>
<div class="header-title">
<el-icon><Microphone /></el-icon>
<span>🎙 AI 语音合成体验室</span>
</div>
</template>
<div class="demo-layout">
<!-- 左侧控制面板 -->
<div class="control-panel">
<div class="input-section">
<label>输入文本</label>
<el-input
v-model="inputText"
type="textarea"
:rows="4"
placeholder="输入你想让 AI 朗读的文本..."
/>
</div>
<div class="voice-section">
<label>选择声音</label>
<div class="voice-options">
<div
v-for="voice in voices"
:key="voice.id"
class="voice-card"
:class="{ active: selectedVoice === voice.id }"
@click="selectedVoice = voice.id"
>
<div class="voice-icon">{{ voice.icon }}</div>
<div class="voice-name">{{ voice.name }}</div>
<div class="voice-desc">{{ voice.description }}</div>
</div>
</div>
</div>
<div class="params-section">
<div class="param-row">
<label>语速</label>
<el-slider v-model="speed" :min="0.5" :max="2" :step="0.1" />
</div>
<div class="param-row">
<label>音调</label>
<el-slider v-model="pitch" :min="-10" :max="10" :step="1" />
</div>
</div>
<el-button
type="primary"
:loading="isSynthesizing"
@click="synthesize"
class="synthesize-btn"
>
<el-icon><VideoPlay /></el-icon>
{{ isSynthesizing ? '合成中...' : '开始合成' }}
</el-button>
</div>
<!-- 右侧可视化展示 -->
<div class="display-panel">
<div class="waveform-container">
<canvas
ref="waveformCanvas"
width="400"
height="200"
class="waveform-canvas"
/>
<div v-if="!hasAudio" class="placeholder">
<el-icon :size="48"><Microphone /></el-icon>
<p>点击"开始合成"生成语音</p>
</div>
</div>
<div class="audio-controls" v-if="hasAudio">
<el-button circle @click="togglePlay">
<el-icon v-if="isPlaying"><VideoPause /></el-icon>
<el-icon v-else><VideoPlay /></el-icon>
</el-button>
<el-slider v-model="playbackProgress" :max="100" class="progress-slider" />
<span class="time-display">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
</div>
<div class="audio-info" v-if="hasAudio">
<el-descriptions :column="2" size="small" border>
<el-descriptions-item label="采样率">22.05 kHz</el-descriptions-item>
<el-descriptions-item label="声道">单声道</el-descriptions-item>
<el-descriptions-item label="语速">{{ speed }}x</el-descriptions-item>
<el-descriptions-item label="音调">{{ pitch > 0 ? '+' : '' }}{{ pitch }}</el-descriptions-item>
</el-descriptions>
</div>
</div>
</div>
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>小提示</strong>
现代 TTS 模型可以生成非常自然的语音尝试不同的声音和参数找到最适合你场景的音色
</p>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { Microphone, VideoPlay, VideoPause } from '@element-plus/icons-vue'
const inputText = ref('你好,我是 AI 语音助手。我可以将文字转换成自然流畅的语音。')
const selectedVoice = ref('female1')
const speed = ref(1.0)
const pitch = ref(0)
const isSynthesizing = ref(false)
const hasAudio = ref(false)
const isPlaying = ref(false)
const playbackProgress = ref(0)
const currentTime = ref(0)
const duration = ref(0)
const waveformCanvas = ref(null)
let animationId = null
const voices = [
{ id: 'female1', name: '女声 1', icon: '👩', description: '温柔甜美' },
{ id: 'female2', name: '女声 2', icon: '👧', description: '活泼可爱' },
{ id: 'male1', name: '男声 1', icon: '👨', description: '沉稳磁性' },
{ id: 'male2', name: '男声 2', icon: '👦', description: '年轻活力' }
]
// 模拟波形动画
const drawWaveform = (isActive = false) => {
const canvas = waveformCanvas.value
if (!canvas) return
const ctx = canvas.getContext('2d')
const width = canvas.width
const height = canvas.height
ctx.clearRect(0, 0, width, height)
// 绘制背景网格
ctx.strokeStyle = '#e0e0e0'
ctx.lineWidth = 1
for (let i = 0; i < width; i += 40) {
ctx.beginPath()
ctx.moveTo(i, 0)
ctx.lineTo(i, height)
ctx.stroke()
}
for (let i = 0; i < height; i += 40) {
ctx.beginPath()
ctx.moveTo(0, i)
ctx.lineTo(width, i)
ctx.stroke()
}
// 绘制波形
const centerY = height / 2
ctx.strokeStyle = isActive ? '#409eff' : '#909399'
ctx.lineWidth = 2
ctx.beginPath()
for (let x = 0; x < width; x++) {
let amplitude = 0
if (isActive) {
// 模拟语音波形
const t = Date.now() / 1000
amplitude = Math.sin(x * 0.05 + t * 5) * 30 +
Math.sin(x * 0.1 + t * 3) * 20 +
Math.random() * 10
} else {
// 静态低振幅波形
amplitude = Math.sin(x * 0.02) * 5
}
if (x === 0) {
ctx.moveTo(x, centerY + amplitude)
} else {
ctx.lineTo(x, centerY + amplitude)
}
}
ctx.stroke()
// 填充波形下方
if (isActive) {
ctx.lineTo(width, centerY)
ctx.lineTo(0, centerY)
ctx.closePath()
ctx.fillStyle = 'rgba(64, 158, 255, 0.1)'
ctx.fill()
}
}
const synthesize = async () => {
isSynthesizing.value = true
// 模拟合成过程
await new Promise(resolve => setTimeout(resolve, 1500))
hasAudio.value = true
isSynthesizing.value = false
duration.value = inputText.value.length * 0.15 / speed.value
// 开始波形动画
startWaveformAnimation()
}
const startWaveformAnimation = () => {
const animate = () => {
if (!isPlaying.value) {
drawWaveform(false)
return
}
drawWaveform(true)
animationId = requestAnimationFrame(animate)
}
animate()
}
const togglePlay = () => {
isPlaying.value = !isPlaying.value
if (isPlaying.value) {
startWaveformAnimation()
simulatePlayback()
} else {
cancelAnimationFrame(animationId)
drawWaveform(false)
}
}
const simulatePlayback = () => {
if (!isPlaying.value) return
const interval = setInterval(() => {
if (!isPlaying.value || currentTime.value >= duration.value) {
clearInterval(interval)
isPlaying.value = false
currentTime.value = 0
playbackProgress.value = 0
return
}
currentTime.value += 0.1
playbackProgress.value = (currentTime.value / duration.value) * 100
}, 100)
}
const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
onMounted(() => {
drawWaveform(false)
})
onUnmounted(() => {
cancelAnimationFrame(animationId)
})
</script>
<style scoped>
.audio-quickstart {
margin: 1rem 0;
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.demo-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 768px) {
.demo-layout {
grid-template-columns: 1fr;
}
}
.control-panel {
display: flex;
flex-direction: column;
gap: 16px;
}
.input-section label,
.voice-section label,
.params-section label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 8px;
color: var(--vp-c-text-2);
}
.voice-options {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.voice-card {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 12px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
}
.voice-card:hover {
border-color: var(--vp-c-brand);
}
.voice-card.active {
border-color: var(--vp-c-brand);
background: var(--vp-c-bg-mute);
}
.voice-icon {
font-size: 1.5rem;
margin-bottom: 4px;
}
.voice-name {
font-weight: 500;
font-size: 0.875rem;
}
.voice-desc {
font-size: 0.75rem;
color: var(--vp-c-text-3);
}
.param-row {
margin-bottom: 12px;
}
.param-row label {
font-size: 0.8rem;
margin-bottom: 4px;
}
.synthesize-btn {
width: 100%;
margin-top: auto;
}
.display-panel {
display: flex;
flex-direction: column;
gap: 16px;
}
.waveform-container {
position: relative;
width: 100%;
aspect-ratio: 2;
background: var(--vp-c-bg-mute);
border-radius: 8px;
overflow: hidden;
}
.waveform-canvas {
width: 100%;
height: 100%;
}
.placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--vp-c-text-3);
gap: 8px;
}
.audio-controls {
display: flex;
align-items: center;
gap: 12px;
}
.progress-slider {
flex: 1;
}
.time-display {
font-size: 0.875rem;
color: var(--vp-c-text-2);
min-width: 100px;
text-align: right;
}
.audio-info {
font-size: 0.8rem;
}
.info-box {
margin-top: 16px;
padding: 12px;
background: var(--vp-c-bg-mute);
border-radius: 6px;
font-size: 0.9rem;
line-height: 1.6;
}
.icon {
font-size: 1.2em;
}
</style>