Qwen3-TTS-12Hz-1.7B-VoiceDesign与Vue3前端集成指南

你是不是也想过,在自己的网页应用里加入一个能“开口说话”的AI助手?不是那种机械的电子音,而是可以根据你的描述,创造出各种生动声音的智能语音。比如让一个虚拟主播用“温柔知性的女声”介绍产品,或者让游戏角色用“粗犷豪迈的男声”念出台词。

以前要实现这种效果,要么得用昂贵的商业API,要么就得自己搭建复杂的后端服务。但现在不一样了,有了Qwen3-TTS-12Hz-1.7B-VoiceDesign这个开源模型,我们完全可以在前端项目里直接集成语音生成功能。

今天我就带你一步步实现这个功能。我会用Vue3作为前端框架,从零开始搭建一个完整的语音生成应用。你不需要是AI专家,只要会一点JavaScript和Vue3的基础知识,就能跟着做出来。

1. 准备工作:理解我们要做什么

在开始写代码之前,我们先搞清楚几个关键点。

Qwen3-TTS-12Hz-1.7B-VoiceDesign这个模型有个很酷的特点:它不需要你提供任何录音样本,只需要用文字描述你想要的声音,它就能“创造”出来。比如你说“年轻活泼的女声,语速快,音调上扬”,它就能生成符合这个描述的声音。

但是这里有个实际问题:这个模型本身是用Python写的,而且需要GPU才能跑得快。我们不可能让用户的浏览器直接运行这个模型。所以我们需要一个中间层——一个后端服务来负责实际的语音生成,前端只负责发送请求和播放结果。

我们的架构是这样的:

  • 前端(Vue3应用):用户输入文字和声音描述,发送请求,播放生成的语音
  • 后端(Python服务):接收请求,调用Qwen3-TTS模型生成语音,返回音频文件
  • 两者通过HTTP API通信

听起来有点复杂?别担心,我会把每一步都拆解得清清楚楚。我们先从前端开始,因为这是用户直接接触的部分。

2. 搭建Vue3项目基础框架

首先,我们创建一个新的Vue3项目。如果你已经熟悉Vue3的创建过程,可以跳过这部分,直接看核心代码。

# 使用Vite创建Vue3项目
npm create vue@latest qwen-tts-frontend

# 按照提示选择配置
# ✔ Project name: qwen-tts-frontend
# ✔ Add TypeScript? No
# ✔ Add JSX Support? No  
# ✔ Add Vue Router for Single Page Application development? No
# ✔ Add Pinia for state management? Yes
# ✔ Add Vitest for Unit Testing? No
# ✔ Add an End-to-End Testing Solution? No
# ✔ Add ESLint for code quality? Yes

# 进入项目目录并安装依赖
cd qwen-tts-frontend
npm install

# 安装我们需要的额外依赖
npm install axios element-plus

项目创建好后,我们先来设计一下界面。我们的应用需要几个核心功能:

  1. 输入要转换成语音的文字
  2. 输入声音描述(比如“温柔的女声,语速适中”)
  3. 选择语言
  4. 生成按钮和播放控制

打开 src/App.vue,我们先写一个简单的界面:

<template>
  <div class="app-container">
    <header class="app-header">
      <h1>Qwen3-TTS 语音生成器</h1>
      <p class="subtitle">用文字描述创造你想要的声音</p>
    </header>

    <main class="main-content">
      <div class="input-section">
        <!-- 这里我们会逐步添加输入组件 -->
      </div>
      
      <div class="control-section">
        <!-- 控制按钮区域 -->
      </div>
      
      <div class="output-section">
        <!-- 音频播放区域 -->
      </div>
    </main>
  </div>
</template>

<script setup>
// 这里我们稍后会添加逻辑
</script>

<style scoped>
.app-container {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}

.app-header {
  text-align: center;
  margin-bottom: 40px;
  padding-bottom: 20px;
  border-bottom: 1px solid #eaeaea;
}

.app-header h1 {
  margin: 0;
  color: #333;
  font-size: 2.5rem;
}

.subtitle {
  margin-top: 10px;
  color: #666;
  font-size: 1.1rem;
}

.main-content {
  background: white;
  border-radius: 12px;
  padding: 30px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
}

.input-section, .control-section, .output-section {
  margin-bottom: 30px;
}
</style>

基础框架搭好了,现在我们来添加具体的功能组件。

3. 创建语音生成的核心组件

我们需要两个主要的输入区域:一个用于输入要转换的文字,另一个用于描述想要的声音。我们先创建一个 TextInput 组件:

<!-- src/components/TextInput.vue -->
<template>
  <div class="text-input-container">
    <label class="input-label">
      {{ label }}
      <span v-if="required" class="required-mark">*</span>
    </label>
    
    <textarea
      v-model="localValue"
      :placeholder="placeholder"
      :rows="rows"
      class="text-input"
      @input="handleInput"
    ></textarea>
    
    <div v-if="showCounter" class="char-counter">
      已输入 {{ localValue.length }} / {{ maxLength }} 字符
    </div>
  </div>
</template>

<script setup>
import { ref, watch } from 'vue'

const props = defineProps({
  modelValue: {
    type: String,
    default: ''
  },
  label: {
    type: String,
    default: '输入内容'
  },
  placeholder: {
    type: String,
    default: '请输入...'
  },
  rows: {
    type: Number,
    default: 4
  },
  maxLength: {
    type: Number,
    default: 1000
  },
  required: {
    type: Boolean,
    default: false
  },
  showCounter: {
    type: Boolean,
    default: true
  }
})

const emit = defineEmits(['update:modelValue'])

const localValue = ref(props.modelValue)

const handleInput = () => {
  if (localValue.value.length > props.maxLength) {
    localValue.value = localValue.value.substring(0, props.maxLength)
  }
  emit('update:modelValue', localValue.value)
}

watch(() => props.modelValue, (newVal) => {
  if (newVal !== localValue.value) {
    localValue.value = newVal
  }
})
</script>

<style scoped>
.text-input-container {
  margin-bottom: 20px;
}

.input-label {
  display: block;
  margin-bottom: 8px;
  font-weight: 600;
  color: #333;
  font-size: 0.95rem;
}

.required-mark {
  color: #f56c6c;
  margin-left: 4px;
}

.text-input {
  width: 100%;
  padding: 12px;
  border: 1px solid #dcdfe6;
  border-radius: 6px;
  font-size: 1rem;
  font-family: inherit;
  resize: vertical;
  transition: border-color 0.3s;
  box-sizing: border-box;
}

.text-input:focus {
  outline: none;
  border-color: #409eff;
  box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.1);
}

.text-input::placeholder {
  color: #c0c4cc;
}

.char-counter {
  margin-top: 6px;
  font-size: 0.85rem;
  color: #909399;
  text-align: right;
}
</style>

现在我们在主页面中使用这个组件。更新 src/App.vue

<template>
  <div class="app-container">
    <header class="app-header">
      <h1>Qwen3-TTS 语音生成器</h1>
      <p class="subtitle">用文字描述创造你想要的声音</p>
    </header>

    <main class="main-content">
      <div class="input-section">
        <TextInput
          v-model="text"
          label="要转换的文字"
          placeholder="请输入要转换成语音的文字内容..."
          :rows="5"
          :max-length="500"
          required
        />
        
        <TextInput
          v-model="voiceDescription"
          label="声音描述"
          placeholder="例如:温柔的女声,语速适中,音调柔和..."
          :rows="3"
          :max-length="200"
        />
        
        <div class="language-selector">
          <label class="input-label">选择语言</label>
          <select v-model="language" class="language-select">
            <option value="Chinese">中文</option>
            <option value="English">English</option>
            <option value="Japanese">日本語</option>
            <option value="Korean">한국어</option>
            <option value="German">Deutsch</option>
            <option value="French">Français</option>
            <option value="Spanish">Español</option>
            <option value="Italian">Italiano</option>
            <option value="Russian">Русский</option>
            <option value="Portuguese">Português</option>
          </select>
        </div>
      </div>
      
      <div class="control-section">
        <button 
          @click="generateSpeech" 
          :disabled="isGenerating || !text"
          class="generate-button"
        >
          <span v-if="isGenerating">生成中...</span>
          <span v-else>生成语音</span>
        </button>
      </div>
      
      <div v-if="audioUrl" class="output-section">
        <h3>生成的语音</h3>
        <audio :src="audioUrl" controls class="audio-player"></audio>
        <div class="audio-actions">
          <button @click="downloadAudio" class="action-button">
            下载音频
          </button>
          <button @click="copyAudioUrl" class="action-button">
            复制链接
          </button>
        </div>
      </div>
      
      <div v-if="errorMessage" class="error-message">
        {{ errorMessage }}
      </div>
    </main>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import TextInput from './components/TextInput.vue'

// 响应式数据
const text = ref('')
const voiceDescription = ref('温柔的女声,语速适中,音调柔和')
const language = ref('Chinese')
const audioUrl = ref('')
const isGenerating = ref(false)
const errorMessage = ref('')

// 生成语音的函数
const generateSpeech = async () => {
  // 这里我们先留空,下一节实现
  console.log('开始生成语音...')
}

// 下载音频
const downloadAudio = () => {
  if (!audioUrl.value) return
  
  const link = document.createElement('a')
  link.href = audioUrl.value
  link.download = 'generated-speech.wav'
  document.body.appendChild(link)
  link.click()
  document.body.removeChild(link)
}

// 复制音频链接
const copyAudioUrl = async () => {
  if (!audioUrl.value) return
  
  try {
    await navigator.clipboard.writeText(audioUrl.value)
    alert('链接已复制到剪贴板')
  } catch (err) {
    console.error('复制失败:', err)
  }
}
</script>

<style scoped>
/* 添加新样式 */
.language-selector {
  margin-top: 20px;
}

.language-select {
  width: 100%;
  padding: 10px 12px;
  border: 1px solid #dcdfe6;
  border-radius: 6px;
  font-size: 1rem;
  background: white;
  cursor: pointer;
  transition: border-color 0.3s;
}

.language-select:focus {
  outline: none;
  border-color: #409eff;
}

.generate-button {
  width: 100%;
  padding: 14px;
  background: linear-gradient(135deg, #409eff, #337ecc);
  color: white;
  border: none;
  border-radius: 8px;
  font-size: 1.1rem;
  font-weight: 600;
  cursor: pointer;
  transition: all 0.3s;
}

.generate-button:hover:not(:disabled) {
  background: linear-gradient(135deg, #66b1ff, #409eff);
  transform: translateY(-1px);
  box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
}

.generate-button:disabled {
  background: #c0c4cc;
  cursor: not-allowed;
  transform: none;
  box-shadow: none;
}

.output-section {
  padding: 20px;
  background: #f8f9fa;
  border-radius: 8px;
  border: 1px solid #eaeaea;
}

.output-section h3 {
  margin-top: 0;
  margin-bottom: 15px;
  color: #333;
}

.audio-player {
  width: 100%;
  margin-bottom: 15px;
}

.audio-actions {
  display: flex;
  gap: 10px;
}

.action-button {
  flex: 1;
  padding: 10px;
  background: white;
  border: 1px solid #dcdfe6;
  border-radius: 6px;
  color: #606266;
  cursor: pointer;
  transition: all 0.2s;
}

.action-button:hover {
  background: #f5f7fa;
  border-color: #c0c4cc;
}

.error-message {
  padding: 15px;
  background: #fef0f0;
  border: 1px solid #fde2e2;
  border-radius: 8px;
  color: #f56c6c;
  margin-top: 20px;
}
</style>

现在界面已经基本完成了,但还缺少最重要的部分:和后端API的通信。接下来我们就来实现这个功能。

4. 实现API通信层

我们需要创建一个服务来管理所有和后端的通信。在 src 目录下创建 services 文件夹,然后创建 api.js

// src/services/api.js
import axios from 'axios'

// 创建axios实例
const api = axios.create({
  baseURL: 'http://localhost:8000', // 后端服务地址
  timeout: 300000, // 5分钟超时,因为语音生成可能需要较长时间
  headers: {
    'Content-Type': 'application/json'
  }
})

// 请求拦截器
api.interceptors.request.use(
  (config) => {
    // 可以在请求前添加token等
    console.log('发送请求:', config.url)
    return config
  },
  (error) => {
    console.error('请求错误:', error)
    return Promise.reject(error)
  }
)

// 响应拦截器
api.interceptors.response.use(
  (response) => {
    console.log('收到响应:', response.config.url)
    return response
  },
  (error) => {
    console.error('响应错误:', error)
    
    if (error.response) {
      // 服务器返回了错误状态码
      switch (error.response.status) {
        case 400:
          error.message = '请求参数错误'
          break
        case 401:
          error.message = '未授权,请检查API密钥'
          break
        case 404:
          error.message = '请求的资源不存在'
          break
        case 500:
          error.message = '服务器内部错误'
          break
        case 503:
          error.message = '服务暂时不可用'
          break
        default:
          error.message = `请求失败: ${error.response.status}`
      }
    } else if (error.request) {
      // 请求发送了但没有收到响应
      error.message = '网络错误,请检查网络连接'
    } else {
      // 请求配置出错
      error.message = '请求配置错误'
    }
    
    return Promise.reject(error)
  }
)

// API接口定义
export const ttsApi = {
  // 生成语音
  async generateSpeech(data) {
    const response = await api.post('/api/generate', data)
    return response.data
  },
  
  // 获取生成状态
  async getStatus(taskId) {
    const response = await api.get(`/api/status/${taskId}`)
    return response.data
  },
  
  // 测试连接
  async testConnection() {
    const response = await api.get('/api/health')
    return response.data
  }
}

export default api

现在我们需要一个状态管理来管理应用的状态。我们使用Pinia,这是Vue3推荐的状态管理库。创建 src/stores/ttsStore.js

// src/stores/ttsStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { ttsApi } from '@/services/api'

export const useTtsStore = defineStore('tts', () => {
  // 状态
  const text = ref('')
  const voiceDescription = ref('温柔的女声,语速适中,音调柔和')
  const language = ref('Chinese')
  const audioUrl = ref('')
  const isGenerating = ref(false)
  const errorMessage = ref('')
  const generationHistory = ref([])
  const currentTaskId = ref('')

  // 计算属性
  const canGenerate = computed(() => {
    return text.value.trim().length > 0 && !isGenerating.value
  })

  const historyCount = computed(() => {
    return generationHistory.value.length
  })

  // 动作
  const generateSpeech = async () => {
    if (!canGenerate.value) return

    isGenerating.value = true
    errorMessage.value = ''
    audioUrl.value = ''

    try {
      const requestData = {
        text: text.value,
        language: language.value,
        instruct: voiceDescription.value
      }

      console.log('发送生成请求:', requestData)
      
      const response = await ttsApi.generateSpeech(requestData)
      
      if (response.success) {
        if (response.audio_url) {
          // 直接返回音频URL
          audioUrl.value = response.audio_url
          addToHistory({
            text: text.value,
            voiceDescription: voiceDescription.value,
            language: language.value,
            audioUrl: response.audio_url,
            timestamp: new Date().toISOString()
          })
        } else if (response.task_id) {
          // 需要轮询获取结果
          currentTaskId.value = response.task_id
          await pollGenerationStatus(response.task_id)
        }
      } else {
        throw new Error(response.message || '生成失败')
      }
    } catch (error) {
      console.error('生成语音失败:', error)
      errorMessage.value = error.message || '生成语音时发生错误'
    } finally {
      isGenerating.value = false
    }
  }

  const pollGenerationStatus = async (taskId) => {
    const maxAttempts = 60 // 最多尝试60次
    const interval = 2000 // 每2秒检查一次
    
    for (let i = 0; i < maxAttempts; i++) {
      try {
        const status = await ttsApi.getStatus(taskId)
        
        if (status.status === 'completed') {
          audioUrl.value = status.audio_url
          addToHistory({
            text: text.value,
            voiceDescription: voiceDescription.value,
            language: language.value,
            audioUrl: status.audio_url,
            timestamp: new Date().toISOString()
          })
          return
        } else if (status.status === 'failed') {
          throw new Error(status.message || '生成失败')
        }
        
        // 如果还在处理中,等待后继续检查
        await new Promise(resolve => setTimeout(resolve, interval))
      } catch (error) {
        errorMessage.value = error.message || '获取生成状态失败'
        break
      }
    }
    
    errorMessage.value = '生成超时,请稍后重试'
  }

  const addToHistory = (item) => {
    generationHistory.value.unshift(item)
    // 只保留最近10条记录
    if (generationHistory.value.length > 10) {
      generationHistory.value = generationHistory.value.slice(0, 10)
    }
    
    // 保存到localStorage
    try {
      localStorage.setItem('ttsHistory', JSON.stringify(generationHistory.value))
    } catch (e) {
      console.warn('无法保存历史记录到localStorage:', e)
    }
  }

  const loadHistory = () => {
    try {
      const saved = localStorage.getItem('ttsHistory')
      if (saved) {
        generationHistory.value = JSON.parse(saved)
      }
    } catch (e) {
      console.warn('无法从localStorage加载历史记录:', e)
    }
  }

  const clearHistory = () => {
    generationHistory.value = []
    try {
      localStorage.removeItem('ttsHistory')
    } catch (e) {
      console.warn('无法清除localStorage:', e)
    }
  }

  const clearError = () => {
    errorMessage.value = ''
  }

  const setText = (value) => {
    text.value = value
  }

  const setVoiceDescription = (value) => {
    voiceDescription.value = value
  }

  const setLanguage = (value) => {
    language.value = value
  }

  // 初始化时加载历史记录
  loadHistory()

  return {
    // 状态
    text,
    voiceDescription,
    language,
    audioUrl,
    isGenerating,
    errorMessage,
    generationHistory,
    currentTaskId,
    
    // 计算属性
    canGenerate,
    historyCount,
    
    // 动作
    generateSpeech,
    pollGenerationStatus,
    addToHistory,
    loadHistory,
    clearHistory,
    clearError,
    setText,
    setVoiceDescription,
    setLanguage
  }
})

现在更新 src/App.vue 来使用我们的store:

<template>
  <div class="app-container">
    <header class="app-header">
      <h1>Qwen3-TTS 语音生成器</h1>
      <p class="subtitle">用文字描述创造你想要的声音</p>
    </header>

    <main class="main-content">
      <div class="input-section">
        <TextInput
          v-model="store.text"
          label="要转换的文字"
          placeholder="请输入要转换成语音的文字内容..."
          :rows="5"
          :max-length="500"
          required
        />
        
        <TextInput
          v-model="store.voiceDescription"
          label="声音描述"
          placeholder="例如:温柔的女声,语速适中,音调柔和..."
          :rows="3"
          :max-length="200"
        />
        
        <div class="language-selector">
          <label class="input-label">选择语言</label>
          <select v-model="store.language" class="language-select">
            <option value="Chinese">中文</option>
            <option value="English">English</option>
            <option value="Japanese">日本語</option>
            <option value="Korean">한국어</option>
            <option value="German">Deutsch</option>
            <option value="French">Français</option>
            <option value="Spanish">Español</option>
            <option value="Italian">Italiano</option>
            <option value="Russian">Русский</option>
            <option value="Portuguese">Português</option>
          </select>
        </div>
        
        <div class="description-tips">
          <h4>声音描述小贴士:</h4>
          <ul>
            <li>尽量具体:用"低沉"、"清脆"、"语速快"等明确描述</li>
            <li>多维度结合:性别 + 年龄 + 情感 + 使用场景</li>
            <li>示例:"年轻活泼的女声,语速快,音调上扬,适合产品介绍"</li>
          </ul>
        </div>
      </div>
      
      <div class="control-section">
        <button 
          @click="store.generateSpeech" 
          :disabled="!store.canGenerate"
          class="generate-button"
        >
          <span v-if="store.isGenerating">
            <span class="loading-spinner"></span>
            生成中...
          </span>
          <span v-else>生成语音</span>
        </button>
      </div>
      
      <div v-if="store.audioUrl" class="output-section">
        <h3>生成的语音</h3>
        <audio :src="store.audioUrl" controls class="audio-player"></audio>
        <div class="audio-actions">
          <button @click="downloadAudio" class="action-button">
            下载音频
          </button>
          <button @click="copyAudioUrl" class="action-button">
            复制链接
          </button>
          <button @click="playInNewTab" class="action-button">
            新标签页播放
          </button>
        </div>
      </div>
      
      <div v-if="store.errorMessage" class="error-message">
        <div class="error-header">
          <span class="error-icon"></span>
          <span>出错了</span>
          <button @click="store.clearError" class="close-error">×</button>
        </div>
        <p>{{ store.errorMessage }}</p>
      </div>
      
      <div v-if="store.historyCount > 0" class="history-section">
        <div class="history-header">
          <h3>生成历史 ({{ store.historyCount }})</h3>
          <button @click="store.clearHistory" class="clear-history-btn">
            清空历史
          </button>
        </div>
        <div class="history-list">
          <div 
            v-for="(item, index) in store.generationHistory" 
            :key="index"
            class="history-item"
            @click="playHistoryItem(item)"
          >
            <div class="history-text">
              <strong>{{ item.text.substring(0, 50) }}{{ item.text.length > 50 ? '...' : '' }}</strong>
              <small>{{ formatDate(item.timestamp) }}</small>
            </div>
            <div class="history-desc">
              声音:{{ item.voiceDescription }}
            </div>
          </div>
        </div>
      </div>
    </main>
  </div>
</template>

<script setup>
import { useTtsStore } from '@/stores/ttsStore'
import TextInput from './components/TextInput.vue'

const store = useTtsStore()

const downloadAudio = () => {
  if (!store.audioUrl) return
  
  const link = document.createElement('a')
  link.href = store.audioUrl
  link.download = `tts-${Date.now()}.wav`
  document.body.appendChild(link)
  link.click()
  document.body.removeChild(link)
}

const copyAudioUrl = async () => {
  if (!store.audioUrl) return
  
  try {
    await navigator.clipboard.writeText(store.audioUrl)
    alert('链接已复制到剪贴板')
  } catch (err) {
    console.error('复制失败:', err)
  }
}

const playInNewTab = () => {
  if (!store.audioUrl) return
  window.open(store.audioUrl, '_blank')
}

const playHistoryItem = (item) => {
  store.audioUrl = item.audioUrl
  // 自动播放
  setTimeout(() => {
    const audioElement = document.querySelector('.audio-player')
    if (audioElement) {
      audioElement.play().catch(e => console.log('自动播放被阻止:', e))
    }
  }, 100)
}

const formatDate = (timestamp) => {
  const date = new Date(timestamp)
  return date.toLocaleString('zh-CN', {
    month: 'short',
    day: 'numeric',
    hour: '2-digit',
    minute: '2-digit'
  })
}
</script>

<style scoped>
/* 添加新样式 */
.description-tips {
  margin-top: 20px;
  padding: 15px;
  background: #f0f9ff;
  border-radius: 6px;
  border-left: 4px solid #409eff;
}

.description-tips h4 {
  margin-top: 0;
  margin-bottom: 10px;
  color: #333;
}

.description-tips ul {
  margin: 0;
  padding-left: 20px;
  color: #666;
}

.description-tips li {
  margin-bottom: 5px;
  font-size: 0.9rem;
}

.loading-spinner {
  display: inline-block;
  width: 16px;
  height: 16px;
  margin-right: 8px;
  border: 2px solid rgba(255, 255, 255, 0.3);
  border-radius: 50%;
  border-top-color: white;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

.error-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 10px;
}

.error-icon {
  margin-right: 8px;
}

.close-error {
  background: none;
  border: none;
  font-size: 1.5rem;
  color: #f56c6c;
  cursor: pointer;
  padding: 0;
  width: 24px;
  height: 24px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
}

.close-error:hover {
  background: rgba(245, 108, 108, 0.1);
}

.history-section {
  margin-top: 30px;
  padding-top: 20px;
  border-top: 1px solid #eaeaea;
}

.history-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 15px;
}

.clear-history-btn {
  padding: 6px 12px;
  background: #fef0f0;
  border: 1px solid #fde2e2;
  border-radius: 4px;
  color: #f56c6c;
  font-size: 0.9rem;
  cursor: pointer;
  transition: all 0.2s;
}

.clear-history-btn:hover {
  background: #fde2e2;
}

.history-list {
  max-height: 300px;
  overflow-y: auto;
}

.history-item {
  padding: 12px;
  margin-bottom: 8px;
  background: white;
  border: 1px solid #eaeaea;
  border-radius: 6px;
  cursor: pointer;
  transition: all 0.2s;
}

.history-item:hover {
  background: #f8f9fa;
  border-color: #409eff;
  transform: translateX(4px);
}

.history-text {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  margin-bottom: 8px;
}

.history-text strong {
  flex: 1;
  margin-right: 10px;
  color: #333;
  font-size: 0.95rem;
}

.history-text small {
  color: #909399;
  font-size: 0.8rem;
  white-space: nowrap;
}

.history-desc {
  color: #666;
  font-size: 0.85rem;
  line-height: 1.4;
}
</style>

5. 后端服务搭建指南

前端部分已经完成了,现在我们需要一个后端服务来处理实际的语音生成。这里我提供一个简单的FastAPI后端示例:

# backend/main.py
from fastapi import FastAPI, HTTPException, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Optional
import uuid
import asyncio
from datetime import datetime
import os

app = FastAPI(title="Qwen3-TTS API", version="1.0.0")

# 配置CORS
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5173"],  # Vue开发服务器地址
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# 数据模型
class TTSRequest(BaseModel):
    text: str
    language: str = "Chinese"
    instruct: Optional[str] = None

class TTSResponse(BaseModel):
    success: bool
    message: str
    task_id: Optional[str] = None
    audio_url: Optional[str] = None

class StatusResponse(BaseModel):
    task_id: str
    status: str  # pending, processing, completed, failed
    message: Optional[str] = None
    audio_url: Optional[str] = None
    created_at: datetime
    updated_at: datetime

# 模拟的任务存储
tasks = {}

@app.get("/api/health")
async def health_check():
    return {"status": "healthy", "timestamp": datetime.now()}

@app.post("/api/generate", response_model=TTSResponse)
async def generate_tts(request: TTSRequest, background_tasks: BackgroundTasks):
    # 这里应该是实际的Qwen3-TTS调用
    # 由于模型需要GPU,这里我们先模拟一下
    
    task_id = str(uuid.uuid4())
    
    # 创建任务
    tasks[task_id] = {
        "status": "pending",
        "request": request.dict(),
        "created_at": datetime.now(),
        "updated_at": datetime.now()
    }
    
    # 在后台处理任务
    background_tasks.add_task(process_tts_task, task_id)
    
    return TTSResponse(
        success=True,
        message="任务已开始处理",
        task_id=task_id
    )

@app.get("/api/status/{task_id}", response_model=StatusResponse)
async def get_status(task_id: str):
    if task_id not in tasks:
        raise HTTPException(status_code=404, detail="任务不存在")
    
    task = tasks[task_id]
    
    return StatusResponse(
        task_id=task_id,
        status=task["status"],
        message=task.get("message"),
        audio_url=task.get("audio_url"),
        created_at=task["created_at"],
        updated_at=task["updated_at"]
    )

async def process_tts_task(task_id: str):
    """模拟语音生成过程"""
    try:
        task = tasks[task_id]
        task["status"] = "processing"
        task["updated_at"] = datetime.now()
        
        # 模拟处理时间
        await asyncio.sleep(5)
        
        # 这里应该是实际的Qwen3-TTS调用
        # 示例代码:
        # from qwen_tts import Qwen3TTSModel
        # import torch
        # import soundfile as sf
        # 
        # model = Qwen3TTSModel.from_pretrained(
        #     "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign",
        #     device_map="cuda:0",
        #     dtype=torch.bfloat16
        # )
        # 
        # wavs, sr = model.generate_voice_design(
        #     text=task["request"]["text"],
        #     language=task["request"]["language"],
        #     instruct=task["request"].get("instruct", "")
        # )
        # 
        # # 保存音频文件
        # filename = f"generated_{task_id}.wav"
        # sf.write(f"static/{filename}", wavs[0], sr)
        
        # 模拟生成成功
        task["status"] = "completed"
        task["audio_url"] = f"http://localhost:8000/static/sample_audio.wav"  # 示例音频
        task["message"] = "语音生成成功"
        task["updated_at"] = datetime.now()
        
    except Exception as e:
        task["status"] = "failed"
        task["message"] = str(e)
        task["updated_at"] = datetime.now()

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

要运行这个后端,你需要安装依赖:

pip install fastapi uvicorn python-multipart

然后运行:

python backend/main.py

6. 性能优化和错误处理

在实际使用中,我们还需要考虑一些优化和错误处理。让我们在前端添加一些改进:

// src/utils/audioUtils.js
export class AudioManager {
  constructor() {
    this.audioContext = null
    this.audioCache = new Map()
    this.maxCacheSize = 10
  }

  // 初始化AudioContext
  initAudioContext() {
    if (!this.audioContext && typeof window !== 'undefined') {
      const AudioContext = window.AudioContext || window.webkitAudioContext
      this.audioContext = new AudioContext()
    }
    return this.audioContext
  }

  // 预加载音频
  async preloadAudio(url) {
    if (this.audioCache.has(url)) {
      return this.audioCache.get(url)
    }

    try {
      const response = await fetch(url)
      const arrayBuffer = await response.arrayBuffer()
      
      const audioContext = this.initAudioContext()
      const audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
      
      // 添加到缓存
      this.audioCache.set(url, audioBuffer)
      
      // 清理旧缓存
      if (this.audioCache.size > this.maxCacheSize) {
        const firstKey = this.audioCache.keys().next().value
        this.audioCache.delete(firstKey)
      }
      
      return audioBuffer
    } catch (error) {
      console.error('预加载音频失败:', error)
      throw error
    }
  }

  // 播放音频
  async playAudio(url) {
    try {
      const audioBuffer = await this.preloadAudio(url)
      const audioContext = this.initAudioContext()
      
      const source = audioContext.createBufferSource()
      source.buffer = audioBuffer
      source.connect(audioContext.destination)
      source.start(0)
      
      return {
        stop: () => source.stop(),
        duration: audioBuffer.duration
      }
    } catch (error) {
      console.error('播放音频失败:', error)
      throw error
    }
  }

  // 下载音频
  async downloadAudio(url, filename = 'audio.wav') {
    try {
      const response = await fetch(url)
      const blob = await response.blob()
      
      const link = document.createElement('a')
      link.href = URL.createObjectURL(blob)
      link.download = filename
      document.body.appendChild(link)
      link.click()
      document.body.removeChild(link)
      
      // 清理URL对象
      setTimeout(() => URL.revokeObjectURL(link.href), 100)
      
      return true
    } catch (error) {
      console.error('下载音频失败:', error)
      throw error
    }
  }

  // 获取音频时长
  async getAudioDuration(url) {
    try {
      const audioBuffer = await this.preloadAudio(url)
      return audioBuffer.duration
    } catch (error) {
      console.error('获取音频时长失败:', error)
      return 0
    }
  }
}

// 创建单例实例
export const audioManager = new AudioManager()

现在更新我们的store来使用这个音频管理器:

// 在ttsStore.js中添加
import { audioManager } from '@/utils/audioUtils'

// 在store中添加新的action
const playAudio = async () => {
  if (!audioUrl.value) return
  
  try {
    await audioManager.playAudio(audioUrl.value)
  } catch (error) {
    errorMessage.value = '播放音频失败: ' + error.message
  }
}

const preloadAudio = async (url) => {
  try {
    await audioManager.preloadAudio(url)
  } catch (error) {
    console.warn('预加载音频失败:', error)
  }
}

// 在generateSpeech成功生成后预加载音频
if (response.audio_url) {
  audioUrl.value = response.audio_url
  // 预加载音频以便快速播放
  preloadAudio(response.audio_url)
  // ... 其他逻辑
}

7. 部署和配置建议

当你的应用开发完成后,需要考虑部署问题。这里有一些建议:

前端部署

  1. 构建生产版本
npm run build

这会生成 dist 文件夹,里面是优化过的静态文件。

  1. 部署到静态托管服务
    • Vercel: vercel --prod
    • Netlify: 直接拖拽 dist 文件夹
    • GitHub Pages: 配置自动化部署

后端部署

  1. 使用Docker容器化
# Dockerfile
FROM python:3.9-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

# 安装Qwen3-TTS
RUN pip install qwen-tts

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
  1. 云服务部署
    • 需要GPU的云服务器(AWS EC2 G4实例、Google Cloud GPU实例等)
    • 配置环境变量和API密钥
    • 设置反向代理(Nginx)

环境配置

创建 .env 文件:

VITE_API_BASE_URL=http://localhost:8000
VITE_MAX_TEXT_LENGTH=1000
VITE_DEFAULT_VOICE_DESCRIPTION=温柔的女声,语速适中

更新 api.js 使用环境变量:

const api = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000',
  // ... 其他配置
})

8. 实际应用场景

这个集成方案可以应用在很多实际场景中:

  1. 在线教育平台:为课程内容生成不同风格的讲解语音
  2. 游戏开发:快速生成游戏角色的对话语音
  3. 内容创作:为视频、播客生成配音
  4. 无障碍服务:为视障用户提供文本转语音
  5. 智能客服:创建个性化的客服语音

你可以根据具体需求扩展功能,比如:

  • 添加语音克隆功能(需要上传参考音频)
  • 实现批量生成
  • 添加语音效果调节(语速、音调等)
  • 集成到现有的CMS或电商平台

9. 总结

通过这个教程,我们完成了一个完整的Qwen3-TTS与Vue3前端集成方案。从界面设计到状态管理,从API通信到音频处理,我们覆盖了前端集成的所有关键环节。

实际用下来,这种前后端分离的架构确实比较灵活。前端负责用户体验和界面交互,后端专注处理复杂的AI模型推理。即使你不是AI专家,也能通过这个方案在自己的应用中添加高质量的语音生成功能。

当然,实际部署时可能会遇到一些挑战,比如后端服务的GPU资源、网络延迟、音频文件存储等。但这些问题都有成熟的解决方案,可以根据具体需求来选择。

如果你刚开始接触AI语音生成,建议先从简单的例子开始,熟悉了整个流程后再尝试更复杂的场景。这个方案提供了一个很好的起点,你可以基于它构建更强大的语音应用。


获取更多AI镜像

想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。

更多推荐