feat: 语音交互网页初始版本 - 前端+后端

This commit is contained in:
2026-04-21 18:19:04 +08:00
commit b3cfae14a9
6 changed files with 850 additions and 0 deletions

110
README.md Normal file
View File

@@ -0,0 +1,110 @@
# 语音交互网页
基于 Qwen2-Audio 的语音对话网页点击按钮录音AI 实时回复。
## 端口
- **服务端口**: 19019
- **模型服务**: 19018需要单独部署
## 功能
- 点击按钮录音(再次点击停止)
- 录音状态可视化(波形动画)
- 多轮对话支持
- 服务状态检测
- 清除对话功能
## 架构
```
┌─────────────┐ ┌─────────────────┐ ┌───────────────────┐
│ 浏览器前端 │ ──── │ 本机后端(19019) │ ──── │ 模型服务(19018) │
│ (录音/播放) │ HTTP │ (转发代理) │ HTTP │ (Qwen2-Audio) │
└─────────────┘ └─────────────────┘ └───────────────────┘
```
## 部署
### 1. 安装依赖
```bash
pip install -r requirements.txt
```
### 2. 启动模型服务(另一台电脑)
参考 `qwen-audio-server` 项目,在 GPU 机器上部署。
### 3. 启动网页服务
```bash
# 默认端口 19019
python main.py
# 或指定模型服务地址
MODEL_SERVICE_URL=http://192.168.2.5:19018 python main.py
```
### 4. 访问网页
```
http://localhost:19019
```
## 环境变量
| 变量 | 说明 | 默认值 |
|------|------|--------|
| PORT | 服务端口 | 19019 |
| MODEL_SERVICE_URL | 模型服务地址 | http://localhost:19018 |
## API 接口
### 语音对话
```bash
POST /api/voice/chat
Content-Type: multipart/form-data
参数:
- audio: 音频文件
- conversation_id: 对话ID可选
返回:
{
"reply": "AI回复文本",
"conversation_id": "xxx",
"timestamp": "..."
}
```
### 服务状态
```bash
GET /api/status
返回:
{
"status": "ok",
"model_service": "http://...",
"model_online": true
}
```
## 前端功能
| 功能 | 说明 |
|------|------|
| 录音按钮 | 点击开始/停止录音 |
| 波形动画 | 录音时显示动态波形 |
| 消息列表 | 显示对话历史 |
| 状态指示 | 模型服务连接状态 |
| 清除对话 | 重置对话历史 |
## 注意事项
1. 需要浏览器支持 MediaRecorder APIChrome/Firefox/Edge
2. 需要用户授权麦克风权限
3. 模型推理较慢约10-30秒请耐心等待
4. 录音格式为 WebM模型服务会自动转换

42
main.py Normal file
View File

@@ -0,0 +1,42 @@
"""
语音交互网页 - 主入口
"""
import os
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
# 导入后端服务
from server import app as api_app
# 主应用
app = FastAPI(title="Voice Chat Web")
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 挂载 API
app.mount("/api", api_app)
# 静态文件
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/")
async def index():
"""主页"""
return FileResponse("static/index.html")
if __name__ == "__main__":
PORT = int(os.getenv("PORT", "19019"))
uvicorn.run(app, host="0.0.0.0", port=PORT)

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
fastapi==0.110.0
uvicorn==0.27.1
python-multipart==0.0.9
aiohttp==3.9.3

155
server.py Normal file
View File

@@ -0,0 +1,155 @@
"""
语音交互网页后端
代理转发到 Qwen2-Audio 模型服务
"""
import os
import logging
from typing import Optional
from datetime import datetime
import aiohttp
from fastapi import FastAPI, UploadFile, File, HTTPException, Form
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
# 配置
MODEL_SERVICE_URL = os.getenv("MODEL_SERVICE_URL", "http://localhost:19018")
PORT = int(os.getenv("PORT", "19019"))
# 日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(
title="Voice Chat Web",
description="语音交互网页后端",
version="1.0.0"
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class VoiceResponse(BaseModel):
"""语音响应"""
reply: str
conversation_id: str
timestamp: str
class StatusResponse(BaseModel):
"""状态响应"""
status: str
model_service: str
model_online: bool
@app.get("/")
async def root():
"""根路径返回状态"""
return {"status": "ok", "service": "voice-chat-web"}
@app.get("/api/status", response_model=StatusResponse)
async def get_status():
"""检查服务状态"""
try:
async with aiohttp.ClientSession() as session:
async with session.get(f"{MODEL_SERVICE_URL}/", timeout=aiohttp.ClientTimeout(total=5)) as resp:
if resp.status == 200:
data = await resp.json()
return StatusResponse(
status="ok",
model_service=MODEL_SERVICE_URL,
model_online=True
)
except Exception as e:
logger.warning(f"Model service check failed: {e}")
return StatusResponse(
status="partial",
model_service=MODEL_SERVICE_URL,
model_online=False
)
@app.post("/api/voice/chat", response_model=VoiceResponse)
async def voice_chat(
audio: UploadFile = File(..., description="音频文件"),
conversation_id: Optional[str] = Form(None, description="对话ID")
):
"""
语音聊天接口
转发到模型服务
"""
try:
# 读取音频数据
audio_bytes = await audio.read()
# 转发到模型服务
async with aiohttp.ClientSession() as session:
form = aiohttp.FormData()
form.add_field(
'audio',
audio_bytes,
filename=audio.filename or 'audio.wav',
content_type=audio.content_type or 'audio/wav'
)
if conversation_id:
form.add_field('conversation_id', conversation_id)
async with session.post(
f"{MODEL_SERVICE_URL}/api/voice/inference",
data=form,
timeout=aiohttp.ClientTimeout(total=120) # 模型推理可能较慢
) as resp:
if resp.status != 200:
error_text = await resp.text()
logger.error(f"Model service error: {error_text}")
raise HTTPException(status_code=resp.status, detail=error_text)
data = await resp.json()
return VoiceResponse(
reply=data["reply"],
conversation_id=data["conversation_id"],
timestamp=data.get("timestamp", datetime.now().isoformat())
)
except aiohttp.ClientError as e:
logger.error(f"Connection error: {e}")
raise HTTPException(status_code=503, detail="模型服务连接失败")
except Exception as e:
logger.error(f"Voice chat error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@app.delete("/api/conversation/{conversation_id}")
async def delete_conversation(conversation_id: str):
"""删除对话"""
try:
async with aiohttp.ClientSession() as session:
async with session.delete(
f"{MODEL_SERVICE_URL}/api/voice/conversation/{conversation_id}",
timeout=aiohttp.ClientTimeout(total=10)
) as resp:
return await resp.json()
except Exception as e:
logger.error(f"Delete conversation error: {e}")
raise HTTPException(status_code=500, detail=str(e))
# 静态文件(前端页面)
app.mount("/static", StaticFiles(directory="static"), name="static")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=PORT)

14
start.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/bash
# 语音交互网页启动脚本
cd "$(dirname "$0")"
PORT=${PORT:-19019}
MODEL_URL=${MODEL_SERVICE_URL:-http://localhost:19018}
echo "Starting Voice Chat Web on port $PORT..."
echo "Model service: $MODEL_URL"
export MODEL_SERVICE_URL=$MODEL_URL
python main.py

525
static/index.html Normal file
View File

@@ -0,0 +1,525 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>语音对话</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.container {
max-width: 600px;
width: 100%;
background: white;
border-radius: 20px;
padding: 30px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.header {
text-align: center;
margin-bottom: 30px;
}
.header h1 {
color: #333;
font-size: 28px;
margin-bottom: 10px;
}
.status-indicator {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #666;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #ccc;
}
.status-dot.online {
background: #4CAF50;
animation: pulse 2s infinite;
}
.status-dot.offline {
background: #f44336;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.record-section {
text-align: center;
margin-bottom: 30px;
}
.record-btn {
width: 120px;
height: 120px;
border-radius: 50%;
border: none;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-size: 24px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
margin: 0 auto;
}
.record-btn:hover {
transform: scale(1.05);
box-shadow: 0 10px 30px rgba(102,126,234,0.4);
}
.record-btn.recording {
background: linear-gradient(135deg, #f44336 0%, #e53935 100%);
animation: recording-pulse 1s infinite;
}
@keyframes recording-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
.record-btn .icon {
font-size: 36px;
}
.record-btn .text {
font-size: 14px;
}
.record-status {
margin-top: 15px;
font-size: 14px;
color: #666;
}
.record-status.recording {
color: #f44336;
font-weight: bold;
}
.waveform {
display: flex;
justify-content: center;
gap: 3px;
margin-top: 15px;
height: 30px;
}
.wave-bar {
width: 4px;
height: 10px;
background: #667eea;
border-radius: 2px;
animation: wave 0.5s infinite ease-in-out;
}
.wave-bar:nth-child(odd) {
animation-delay: 0.1s;
}
.wave-bar:nth-child(3n) {
animation-delay: 0.2s;
}
@keyframes wave {
0%, 100% { height: 10px; }
50% { height: 25px; }
}
.chat-section {
max-height: 400px;
overflow-y: auto;
border: 1px solid #eee;
border-radius: 10px;
padding: 15px;
background: #f9f9f9;
}
.message {
margin-bottom: 15px;
padding: 12px 15px;
border-radius: 10px;
max-width: 80%;
}
.message.user {
background: #667eea;
color: white;
margin-left: auto;
}
.message.assistant {
background: white;
color: #333;
border: 1px solid #eee;
}
.message .role {
font-size: 12px;
font-weight: bold;
margin-bottom: 5px;
opacity: 0.8;
}
.message .content {
font-size: 15px;
line-height: 1.5;
}
.loading {
display: flex;
justify-content: center;
gap: 8px;
padding: 20px;
}
.loading-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #667eea;
animation: loading-bounce 0.6s infinite ease-in-out;
}
.loading-dot:nth-child(1) { animation-delay: 0s; }
.loading-dot:nth-child(2) { animation-delay: 0.1s; }
.loading-dot:nth-child(3) { animation-delay: 0.2s; }
@keyframes loading-bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
.actions {
margin-top: 20px;
text-align: center;
}
.clear-btn {
padding: 10px 20px;
border: 1px solid #ddd;
border-radius: 8px;
background: white;
color: #666;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.clear-btn:hover {
background: #f5f5f5;
}
.error-message {
background: #ffebee;
color: #c62828;
padding: 15px;
border-radius: 10px;
margin-bottom: 15px;
text-align: center;
}
.hint {
margin-top: 15px;
font-size: 13px;
color: #999;
text-align: center;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎤 语音对话</h1>
<div class="status-indicator">
<span class="status-dot" id="statusDot"></span>
<span id="statusText">检测连接...</span>
</div>
</div>
<div class="record-section">
<button class="record-btn" id="recordBtn">
<span class="icon">🎤</span>
<span class="text">点击录音</span>
</button>
<div class="record-status" id="recordStatus">点击按钮开始录音</div>
<div class="waveform" id="waveform" style="display: none;">
<div class="wave-bar"></div>
<div class="wave-bar"></div>
<div class="wave-bar"></div>
<div class="wave-bar"></div>
<div class="wave-bar"></div>
<div class="wave-bar"></div>
<div class="wave-bar"></div>
<div class="wave-bar"></div>
</div>
</div>
<div class="chat-section" id="chatSection">
<div class="hint">开始你的第一次语音对话吧!</div>
</div>
<div class="actions">
<button class="clear-btn" id="clearBtn">清除对话</button>
</div>
</div>
<script>
// 配置
const API_URL = '/api';
// 状态
let isRecording = false;
let mediaRecorder = null;
let audioChunks = [];
let conversationId = null;
let audioContext = null;
// 元素
const recordBtn = document.getElementById('recordBtn');
const recordStatus = document.getElementById('recordStatus');
const waveform = document.getElementById('waveform');
const chatSection = document.getElementById('chatSection');
const clearBtn = document.getElementById('clearBtn');
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
// 检查服务状态
async function checkStatus() {
try {
const resp = await fetch(`${API_URL}/status`);
const data = await resp.json();
if (data.model_online) {
statusDot.className = 'status-dot online';
statusText.textContent = '模型服务已连接';
} else {
statusDot.className = 'status-dot offline';
statusText.textContent = '模型服务离线';
}
} catch (e) {
statusDot.className = 'status-dot offline';
statusText.textContent = '服务连接失败';
}
}
// 初始化录音
async function initAudio() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 16000
}
});
audioContext = new (window.AudioContext || window.webkitAudioContext)();
// 创建 MediaRecorder
mediaRecorder = new MediaRecorder(stream, {
mimeType: 'audio/webm'
});
mediaRecorder.ondataavailable = (e) => {
audioChunks.push(e.data);
};
mediaRecorder.onstop = async () => {
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
audioChunks = [];
await sendAudio(audioBlob);
};
return true;
} catch (e) {
console.error('获取麦克风失败:', e);
showError('无法访问麦克风,请检查权限设置');
return false;
}
}
// 开始录音
async function startRecording() {
if (!mediaRecorder) {
const success = await initAudio();
if (!success) return;
}
audioChunks = [];
mediaRecorder.start();
isRecording = true;
recordBtn.classList.add('recording');
recordBtn.querySelector('.icon').textContent = '⏹️';
recordBtn.querySelector('.text').textContent = '停止录音';
recordStatus.textContent = '正在录音...';
recordStatus.classList.add('recording');
waveform.style.display = 'flex';
}
// 停止录音
function stopRecording() {
if (mediaRecorder && isRecording) {
mediaRecorder.stop();
isRecording = false;
recordBtn.classList.remove('recording');
recordBtn.querySelector('.icon').textContent = '🎤';
recordBtn.querySelector('.text').textContent = '点击录音';
recordStatus.textContent = '处理中...';
recordStatus.classList.remove('recording');
waveform.style.display = 'none';
}
}
// 发送音频
async function sendAudio(audioBlob) {
try {
showLoading();
const formData = new FormData();
formData.append('audio', audioBlob, 'recording.webm');
if (conversationId) {
formData.append('conversation_id', conversationId);
}
const resp = await fetch(`${API_URL}/voice/chat`, {
method: 'POST',
body: formData
});
if (!resp.ok) {
const error = await resp.text();
throw new Error(error);
}
const data = await resp.json();
conversationId = data.conversation_id;
// 显示消息
addMessage('user', '🎵 语音消息');
addMessage('assistant', data.reply);
recordStatus.textContent = '点击按钮开始录音';
} catch (e) {
console.error('发送失败:', e);
showError('发送失败: ' + e.message);
recordStatus.textContent = '发送失败,点击重试';
}
}
// 添加消息
function addMessage(role, content) {
// 移除提示
const hint = chatSection.querySelector('.hint');
if (hint) hint.remove();
// 移除加载
const loading = chatSection.querySelector('.loading');
if (loading) loading.remove();
const msg = document.createElement('div');
msg.className = `message ${role}`;
msg.innerHTML = `
<div class="role">${role === 'user' ? '我' : 'AI'}</div>
<div class="content">${content}</div>
`;
chatSection.appendChild(msg);
// 滚动到底部
chatSection.scrollTop = chatSection.scrollHeight;
}
// 显示加载
function showLoading() {
const hint = chatSection.querySelector('.hint');
if (hint) hint.remove();
const loading = document.createElement('div');
loading.className = 'loading';
loading.innerHTML = `
<div class="loading-dot"></div>
<div class="loading-dot"></div>
<div class="loading-dot"></div>
`;
chatSection.appendChild(loading);
chatSection.scrollTop = chatSection.scrollHeight;
}
// 显示错误
function showError(message) {
const loading = chatSection.querySelector('.loading');
if (loading) loading.remove();
const error = document.createElement('div');
error.className = 'error-message';
error.textContent = message;
chatSection.appendChild(error);
// 3秒后移除
setTimeout(() => error.remove(), 3000);
}
// 清除对话
async function clearChat() {
if (conversationId) {
try {
await fetch(`${API_URL}/conversation/${conversationId}`, {
method: 'DELETE'
});
} catch (e) {}
}
conversationId = null;
chatSection.innerHTML = '<div class="hint">开始你的第一次语音对话吧!</div>';
}
// 事件绑定
recordBtn.addEventListener('click', () => {
if (isRecording) {
stopRecording();
} else {
startRecording();
}
});
clearBtn.addEventListener('click', clearChat);
// 初始化
checkStatus();
setInterval(checkStatus, 10000); // 每10秒检查状态
</script>
</body>
</html>