大模型应用:完整语音交互闭环:TTS+ASR融合系统可视化场景实践.22

一、引言

在文章开始之前,先简单释义说明一下,TTS即文本转语音,ASR即语音转文本,前面的章节我们仔细通俗的讲解了TTS和ASR的原理和各自应用场景,今天我们将两者结合在一起进行深度分析,首先我们需要考虑, 是什么样的场景需要TTS与ASR 的融合,回想我们打开手机的语音助手,说一句"查询明天的天气",它能立刻识别我们的语音,此时手机的语音助手内部处理将我们的语音转文本,解析指令后得到答案,再通过文本转语音,然后用自然语音给我们反馈结果, 这看似简单的交互,背后正是TTS与ASR两大技术的融合魔力。

在日常开发中,很多人对语音技术的认知还停留在单向转换,即用 ASR 做语音转文字,用 TTS 做文本转语音,却忽略了两者融合后的闭环价值。实际上,从智能客服、实时翻译到无障碍工具、车载语音,所有成熟的语音交互场景,核心都是 "ASR 接收输入→文本处理→TTS 反馈输出" 的完整链路。今天我们整合零散的理论基础,从基础概念入手,先搭建一套 "前端可视化 + 后端轻量 API" 的 TTS+ASR 融合交互系统,不仅实现 "语音→文本→语音" 的平滑切换,还对每一个环节的底层逻辑,做一些重点分析。

二、基础回顾

1. 强化理解ASR

ASR,全称Automatic Speech Recognition,语音交互的耳朵,即自动语音识别,核心作用是 "把语音转换成文本",相当于给机器装上耳朵,让它能听懂人类的语言。

  • 核心指标:字错率(WER),即 "转错的字数 / 总字数",数值越低识别越精准(比如 WER=5%,代表 100 个字里仅错 5 个);
  • 常见场景:微信语音转文字、会议纪要自动生成、语音输入搜索;
  • 技术优势:我们选用 OpenAI Whisper,支持离线运行、多语言识别,即使是带口音或轻微噪声的语音,识别精度也远超传统工具。

2. 强化理解TTS

TTS,全称Text-to-Speech,语音交互的嘴巴,即文本转语音,核心作用是 "把文本转换成自然语音",相当于给机器装上嘴巴,让它能主动回复人类。

  • 核心指标:语音自然度(是否接近真人发音)、语速 / 音调可调性;
  • 常见场景:导航语音播报、有声书生成、智能助手回复;
  • 技术选型:和前面的文章模型保持一致,我们选用 pyttsx3,本地离线,响应快。

3. 两者融合

  • 融合的核心价值:从单向到闭环,单独使用 ASR 或 TTS,只能完成输入或输出的单一动作;两者融合后,才能实现双向交互:
  • 单向局限:用 ASR 转写会议纪要后,需要手动整理回复;用 TTS 播报文本时,无法接收语音反馈;
  • 闭环优势:用户语音输入→ASR 转文本→程序解析指令→TTS 生成语音输出,全程无需手动干预,符合人类"说话 - 听话"的自然交互习惯。

三、技术结构

系统的技术选型围绕 "理解简单、轻量实用、适配场景" 的核心原则展开,各核心模块的选型及细节思考:

1. 前端层面

前端采用 HTML+CSS+JavaScript 原生技术搭配 Web Audio API 构建。

  • 原生的前端结构无需依赖任何前端框架,整体轻量易部署;
  • 同时 Web Audio API 可原生支持浏览器端录音功能,无需额外安装插件,大幅降低用户操作门槛。

2. 后端部分

后端选用 Python 结合 Flask 框架开发。

  • Flask 具备快速搭建 RESTful API 的优势,代码风格简洁易维护;能够
  • 完美适配 Python 生态下的 ASR、TTS 相关工具,契合本次系统轻量开发的需求。

3. ASR 核心模块

该模块采用 OpenAI Whisper 模型,其支持离线运行模式,且具备优秀的多语言识别能力,识别精度处于行业前列;其中 base 尺寸模型仅 139M 大小,即便在 CPU 环境下也能流畅运行,足以满足日常语音转文本场景的需求。

4. TTS 核心模块

该模块采用本地端使用 pyttsx3,可保证语音生成的响应速度(延迟<1 秒),无需依赖网络;

5. 音频处理环节

音频处理整合 wave、pyaudio 与 ffmpeg 工具:

  • wave 和 pyaudio 用于统一音频格式为 WAV,确保前后端音频数据兼容;
  • ffmpeg 则解决了多格式音频的处理难题,保障不同来源音频都能被系统识别处理。

6. 跨域处理方面

  • 引入 flask-cors 组件,专门解决前端页面访问后端 API 时的跨域问题
  • 避免因浏览器同源策略限制导致界面操作失败,保障前后端交互的顺畅性。

四、系统架构

整个融合系统的核心是数据流转的平滑性,前端负责"用户交互与可视化",后端负责"核心逻辑处理",两者通过 API 接口协同工作;

1. 核心流程

流程说明:

    1. 前端操作:用户通过浏览器点击 "开始录音",Web Audio API 获取麦克风权限,录制语音并生成 WAV 格式音频;
    1. 音频上传:前端将录音文件通过 FormData 格式上传到后端 ASR 接口;
    1. ASR 处理:后端 Whisper 模型接收音频,转写为文本,返回给前端展示;
    1. 文本处理:用户可直接使用 ASR 转写的文本,或手动输入新文本,点击 "生成语音";
    1. TTS 处理:后端接收文本,通过 pyttsx3 生成语音文件,返回音频播放链接;
    1. 语音输出:前端通过audioUrl标签加载链接,自动播放语音,完成 "语音→文本→语音" 的闭环。

2. 设计细节

  • 格式统一:前端录音强制生成 WAV 格式(16000Hz 采样率、单声道),与 Whisper 的输入要求完全匹配,避免格式转换耗时;
  • 异步交互:前端通过async/await调用后端 API,避免界面卡顿,同时添加 "状态提示"(如 "正在转写文本""生成语音中"),提升用户体验;
  • 自动衔接:ASR 转写完成后,文本自动填充到 TTS 输入框,用户无需手动复制,一键即可生成语音,减少操作步骤;
  • 异常处理:前后端均添加异常捕获(如录音失败、转写无结果、语音播放失败),并给出明确提示,避免用户不知所措。

五、核心模块解析

1. 前端核心模块

前端的核心是让操作更直观,主要实现 3 个功能:录音、ASR 结果展示、TTS 语音播放,关键代码逻辑:

1.1 录音功能

主要处理浏览器录音格式兼容,基于Web Audio API 录制的是 Blob 格式,需转为 Whisper 支持的 WAV 格式。

关键逻辑:

  • 调用navigator.mediaDevices.getUserMedia获取麦克风权限;
  • 用MediaRecorder监听录音数据,存储到audioChunks数组;
  • 录音停止后,将audioChunks转为 Blob 对象,类型设为audio/wav,确保后端能直接处理。

1.2 ASR/TTS 交互

javascript 复制代码
// 2. 调用后端ASR接口
const response = await fetch(`${BACKEND_URL}/asr`, {
    method: "POST",
    body: formData
});
const result = await response.json();


// 2. 调用后端TTS接口
const response = await fetch(`${BACKEND_URL}/tts`, {
    method: "POST",
    headers: {
        "Content-Type": "application/json"
    },
    body: JSON.stringify({ text })
});
const result = await response.json();
  • 前端通过fetch函数调用后端 API,ASR 用POST方法上传 FormData 格式的音频文件,TTS 用POST方法传递 JSON 格式的文本;
  • 添加 "按钮禁用状态"(如录音时禁用 "开始录音" 按钮,转写时禁用 "转写文本" 按钮),避免重复操作;
  • 状态反馈:用不同颜色的文本提示操作结果(成功为绿色,失败为红色),让用户清晰了解当前进度。

1.3 语音播放

TTS 生成语音后,后端返回音频播放链接,前端将链接赋值给html标签的src属性,调用play()方法自动播放,同时支持用户手动暂停、调整音量。

1.4 辅助功能

清理音频文件/清除历史记录:清理本地已经生成好的音频文件,避免占用不需要的空间,清理前先进行操作提示,许可后才进行下一步操作。

2. 后端核心模块

后端的核心是提供稳定的 API 接口,实现 ASR 转写、TTS 生成、音频管理三大功能,关键代码逻辑:

2.1 ASR 接口(/api/asr)

python 复制代码
@app.route("/api/asr", methods=["POST"])
def asr_voice_to_text():
    """
    接收前端上传的WAV音频文件,返回ASR转写文本
    """
    try:
        # 1. 接收音频文件
        if "audio" not in request.files:
            return jsonify({"code": 400, "msg": "未上传音频文件"}), 400
        audio_file = request.files["audio"]
        # 生成唯一文件名(避免覆盖)
        audio_filename = f"{uuid.uuid4()}.wav"
        audio_path = os.path.join(AUDIO_UPLOAD_PATH, audio_filename)
        audio_file.save(audio_path)

        # 2. Whisper语音转文本
        result = WHISPER_MODEL.transcribe(
            audio_path,
            language="zh",
            temperature=0.0
        )
        text = result["text"].strip()

        # 3. 返回结果
        return jsonify({
            "code": 200,
            "msg": "ASR转写成功",
            "data": {"text": text, "audio_path": audio_path}
        })
    except Exception as e:
        return jsonify({"code": 500, "msg": f"ASR失败:{str(e)}"}), 500

核心流程: 接收前端上传的音频文件→Whisper 转写为文本→返回结果。
关键细节:

  • 用uuid生成唯一文件名(如a1b2c3d4-1234-5678-90ef.wav),避免多个用户上传的音频文件相互覆盖;
  • 调用 Whisper 时显式指定language="zh",提升中文识别精度(默认自动检测可能误判为日语 / 韩语);
  • 捕获异常(如音频文件损坏、模型加载失败),返回清晰的错误信息,方便前端调试。

2.2 TTS 接口(/api/tts)

python 复制代码
@app.route("/api/tts", methods=["POST"])
def tts_text_to_voice():
    """
    接收前端传入的文本,返回TTS语音文件播放链接
    """
    try:
        # 1. 接收文本参数
        data = request.get_json()
        if not data or "text" not in data:
            return jsonify({"code": 400, "msg": "未传入文本"}), 400
        text = data["text"].strip()
        if not text:
            return jsonify({"code": 400, "msg": "文本不能为空"}), 400

        # 2. 生成TTS语音(基础版:pyttsx3本地生成)
        tts_filename = f"{uuid.uuid4()}.wav"
        tts_path = os.path.join(TTS_OUTPUT_PATH, tts_filename)
        
        # 初始化TTS引擎
        engine = pyttsx3.init()
        engine.setProperty('rate', 150)  # 语速
        engine.setProperty('volume', 1.0)  # 音量
        # 保存语音文件(而非直接播放)
        engine.save_to_file(text, tts_path)
        engine.runAndWait()

        # 3. 返回语音文件访问链接
        tts_url = f"http://127.0.0.1:5000/api/tts/play/{tts_filename}"
        return jsonify({
            "code": 200,
            "msg": "TTS生成成功",
            "data": {"tts_url": tts_url, "tts_path": tts_path}
        })
    except Exception as e:
        return jsonify({"code": 500, "msg": f"TTS失败:{str(e)}"}), 500

核心流程: 接收前端传入的文本→生成语音文件→返回播放链接。
实现方案:

  • 本地版(pyttsx3):直接将文本转为 WAV 文件,保存到tts_output目录,响应速度快,无需联网;

2.3 清理接口(/api/clean)

python 复制代码
@app.route("/api/clean", methods=["GET"])
def clean_audio_files():
    """清理所有生成的音频文件"""
    try:
        # 清理上传的ASR音频
        for file in os.listdir(AUDIO_UPLOAD_PATH):
            os.remove(os.path.join(AUDIO_UPLOAD_PATH, file))
        # 清理TTS生成的音频
        for file in os.listdir(TTS_OUTPUT_PATH):
            os.remove(os.path.join(TTS_OUTPUT_PATH, file))
        return jsonify({"code": 200, "msg": "音频文件清理成功"}), 200
    except Exception as e:
        return jsonify({"code": 500, "msg": f"清理失败:{str(e)}"}), 500
  • 核心功能:清理后端存储的所有录音和 TTS 音频文件,避免占用过多磁盘空间。
  • 适用场景:调试时频繁生成音频文件,一键清理可节省手动删除的时间。

2.4 后端服务启动

python 复制代码
if __name__ == "__main__":
    # 启动Flask服务(允许外部访问,调试模式)
    app.run(host="0.0.0.0", port=5000, debug=True)

启动Flask服务,通过5000端口提供外部访问

每一步操作执行的交互日志:

3. 核心操作流程

  • ① 点击 "开始录音",允许浏览器麦克风权限,说话(建议 5 秒内);
  • ② 点击 "停止录音",界面提示 "录音完成,可点击「转写文本」";
  • ③ 点击 "转写文本",等待 2-3 秒,ASR 结果显示在文本框中(自动填充到 TTS 输入框);
  • ④ 点击 "生成并播放语音",等待 1 秒左右,语音自动播放;
  • ⑤ 点击 "清理音频文件",删除后端生成的所有音频。
  • ⑥ 点击 "清除历史记录",产出所有操作的记录列表

六、总结

今天我们聚焦易用性与交互流畅性"的 TTS 与 ASR 融合交互闭环系统,采用 "前端可视化操作 + 后端轻量 API" 的架构设计,可实现 "语音输入→文本转写→语音输出" 的全流程自动化衔接,为解决传统语音技术单向转换的局限提供了一个初步的解决方案。

前端基于原生 HTML/CSS/JavaScript 开发,搭配 Web Audio API 实现浏览器端无插件录音,界面包含录音控制、ASR 结果展示、TTS 文本输入及语音播放等核心功能,同时添加操作状态提示(如 "正在转写""生成成功")和自动文本填充机制,无需依赖任何前端框架,轻量易部署。

后端以 Python+Flask 为核心搭建 RESTful API,集成 OpenAI Whisper 模型实现高精度离线语音转文本,支持多语言识别,base 尺寸模型仅需 CPU 即可流畅运行;TTS 模块提供双方案适配不同场景 ------pyttsx3 本地版保障<1 秒低延迟响应。系统通过 wave、pyaudio 统一音频为 WAV 格式,结合 ffmpeg 处理多格式兼容问题,借助 flask-cors 解决前后端跨域障碍。可以在此基础上按实际需求进行扩展调试。

附录一:后端完整代码

python 复制代码
from flask import Flask, request, jsonify, send_file
from flask_cors import CORS
import whisper
import pyttsx3
import wave
import os
import uuid

# ---------------------- 配置项 ----------------------
app = Flask(__name__)
CORS(app)  # 解决跨域问题(前端访问后端接口)

# 音频存储路径(自动创建)
AUDIO_UPLOAD_PATH = "upload_audio"
TTS_OUTPUT_PATH = "tts_output"
os.makedirs(AUDIO_UPLOAD_PATH, exist_ok=True)
os.makedirs(TTS_OUTPUT_PATH, exist_ok=True)

# Whisper模型配置(base模型平衡速度和精度)
WHISPER_MODEL = whisper.load_model("base")

# ---------------------- ASR接口(语音转文本) ----------------------
@app.route("/api/asr", methods=["POST"])
def asr_voice_to_text():
    """
    接收前端上传的WAV音频文件,返回ASR转写文本
    """
    try:
        # 1. 接收音频文件
        if "audio" not in request.files:
            return jsonify({"code": 400, "msg": "未上传音频文件"}), 400
        audio_file = request.files["audio"]
        # 生成唯一文件名(避免覆盖)
        audio_filename = f"{uuid.uuid4()}.wav"
        audio_path = os.path.join(AUDIO_UPLOAD_PATH, audio_filename)
        audio_file.save(audio_path)

        # 2. Whisper语音转文本
        result = WHISPER_MODEL.transcribe(
            audio_path,
            language="zh",
            temperature=0.0
        )
        text = result["text"].strip()

        # 3. 返回结果
        return jsonify({
            "code": 200,
            "msg": "ASR转写成功",
            "data": {"text": text, "audio_path": audio_path}
        })
    except Exception as e:
        return jsonify({"code": 500, "msg": f"ASR失败:{str(e)}"}), 500

# ---------------------- TTS接口(文本转语音) ----------------------
@app.route("/api/tts", methods=["POST"])
def tts_text_to_voice():
    """
    接收前端传入的文本,返回TTS语音文件播放链接
    """
    try:
        # 1. 接收文本参数
        data = request.get_json()
        if not data or "text" not in data:
            return jsonify({"code": 400, "msg": "未传入文本"}), 400
        text = data["text"].strip()
        if not text:
            return jsonify({"code": 400, "msg": "文本不能为空"}), 400

        # 2. 生成TTS语音(基础版:pyttsx3本地生成)
        tts_filename = f"{uuid.uuid4()}.wav"
        tts_path = os.path.join(TTS_OUTPUT_PATH, tts_filename)
        
        # 初始化TTS引擎
        engine = pyttsx3.init()
        engine.setProperty('rate', 150)  # 语速
        engine.setProperty('volume', 1.0)  # 音量
        # 保存语音文件(而非直接播放)
        engine.save_to_file(text, tts_path)
        engine.runAndWait()

        # 3. 返回语音文件访问链接
        tts_url = f"http://127.0.0.1:5000/api/tts/play/{tts_filename}"
        return jsonify({
            "code": 200,
            "msg": "TTS生成成功",
            "data": {"tts_url": tts_url, "tts_path": tts_path}
        })
    except Exception as e:
        return jsonify({"code": 500, "msg": f"TTS失败:{str(e)}"}), 500

# ---------------------- TTS语音播放接口 ----------------------
@app.route("/api/tts/play/<filename>", methods=["GET"])
def play_tts_audio(filename):
    """提供TTS语音文件的播放/下载"""
    tts_path = os.path.join(TTS_OUTPUT_PATH, filename)
    if not os.path.exists(tts_path):
        return jsonify({"code": 404, "msg": "语音文件不存在"}), 404
    # 发送音频文件(支持浏览器播放)
    return send_file(
        tts_path,
        mimetype="audio/wav",
        as_attachment=False  # False为播放,True为下载
    )

# ---------------------- 清理音频文件接口(可选) ----------------------
@app.route("/api/clean", methods=["GET"])
def clean_audio_files():
    """清理所有生成的音频文件"""
    try:
        # 清理上传的ASR音频
        for file in os.listdir(AUDIO_UPLOAD_PATH):
            os.remove(os.path.join(AUDIO_UPLOAD_PATH, file))
        # 清理TTS生成的音频
        for file in os.listdir(TTS_OUTPUT_PATH):
            os.remove(os.path.join(TTS_OUTPUT_PATH, file))
        return jsonify({"code": 200, "msg": "音频文件清理成功"}), 200
    except Exception as e:
        return jsonify({"code": 500, "msg": f"清理失败:{str(e)}"}), 500

# ---------------------- 启动后端 ----------------------
if __name__ == "__main__":
    # 启动Flask服务(允许外部访问,调试模式)
    app.run(host="0.0.0.0", port=5000, debug=True)

附录二:前端完整代码

python 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>TTS+ASR融合交互系统</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
            font-family: "Microsoft Yahei", sans-serif;
        }
        body {
            max-width: 1400px;
            margin: 60px auto;
            padding: 0 20px;
            background-color: #f5f7fa;
            overflow: hidden;
        }
        .container {
            background: white;
            padding: 30px;
            border-radius: 10px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
            max-height: calc(100vh - 0px);
            overflow: hidden;
            display: flex;
            flex-direction: column;
        }
        .content-wrapper {
            display: flex;
            gap: 15px;
            flex: 1;
            overflow: hidden;
            margin-top: 20px;
        }
        .main-content {
            flex: 1;
            overflow-y: auto;
            scrollbar-width: none;
            -ms-overflow-style: none;
        }
        .main-content::-webkit-scrollbar {
            display: none;
        }
        .side-panels {
            display: flex;
            gap: 15px;
            flex-shrink: 0;
        }
        .history-panel {
            width: 360px;
            background: #f9f9f9;
            border-radius: 8px;
            padding: 20px;
            overflow-y: auto;
            scrollbar-width: none;
            -ms-overflow-style: none;
        }
        .history-panel::-webkit-scrollbar {
            display: none;
        }
        .audio-panel {
            width: 300px;
            background: #f9f9f9;
            border-radius: 8px;
            padding: 20px;
            overflow-y: auto;
            scrollbar-width: none;
            -ms-overflow-style: none;
        }
        .audio-panel::-webkit-scrollbar {
            display: none;
        }
        .audio-panel-title {
            font-size: 18px;
            font-weight: bold;
            margin-bottom: 15px;
            color: #333;
            border-bottom: 2px solid #409eff;
            padding-bottom: 8px;
        }
        .history-title {
            font-size: 18px;
            font-weight: bold;
            margin-bottom: 15px;
            color: #333;
            border-bottom: 2px solid #409eff;
            padding-bottom: 8px;
        }
        .history-list {
            list-style: none;
            max-height: 500px;
        }
        .history-item {
            background: white;
            padding: 12px;
            margin-bottom: 10px;
            border-radius: 6px;
            box-shadow: 0 1px 3px rgba(0,0,0,0.1);
            cursor: pointer;
            transition: all 0.3s ease;
        }
        .history-item:hover {
            box-shadow: 0 2px 8px rgba(0,0,0,0.15);
            transform: translateY(-1px);
        }
        .history-item-time {
            font-size: 12px;
            color: #999;
            margin-bottom: 5px;
        }
        .history-item-text {
            font-size: 14px;
            color: #666;
            line-height: 1.4;
            max-height: 60px;
            overflow: hidden;
            text-overflow: ellipsis;
            display: -webkit-box;
            -webkit-line-clamp: 3;
            -webkit-box-orient: vertical;
        }
        .history-item-type {
            font-size: 12px;
            color: #409eff;
            margin-top: 5px;
            font-weight: bold;
        }
        .clear-history-btn {
            width: 100%;
            background-color: #f56c6c;
            color: white;
            border: none;
            padding: 8px;
            border-radius: 5px;
            cursor: pointer;
            font-size: 14px;
            margin-top: 15px;
        }
        .clear-history-btn:hover {
            background-color: #f78989;
        }
        h1 {
            text-align: center;
            color: #333;
            margin-bottom: 5px;
        }
        .module {
            margin-bottom: 10px;
            padding: 20px 20px 10px;
            border: 1px solid #eee;
            border-radius: 8px;
        }
        .module h2 {
            color: #409eff;
            margin-bottom: 15px;
            font-size: 18px;
        }
        button {
            background-color: #409eff;
            color: white;
            border: none;
            padding: 10px 20px;
            border-radius: 5px;
            cursor: pointer;
            margin-right: 10px;
            margin-bottom: 10px;
            font-size: 14px;
        }
        button:disabled {
            background-color: #ccc;
            cursor: not-allowed;
        }
        button:hover:not(:disabled) {
            background-color: #66b1ff;
        }
        textarea, input {
            width: 100%;
            padding: 10px;
            border: 1px solid #ddd;
            border-radius: 5px;
            margin-bottom: 10px;
            font-size: 14px;
            resize: none;
        }
        .two-columns {
            display: flex;
            gap: 20px;
        }
        .column {
            flex: 1;
        }
        .audio-list {
            list-style: none;
            max-height: 500px;
            overflow-y: auto;
            scrollbar-width: none;
            -ms-overflow-style: none;
        }
        .audio-list::-webkit-scrollbar {
            display: none;
        }
        .audio-item {
            background: white;
            padding: 13px;
            margin-bottom: 8px;
            border-radius: 5px;
            box-shadow: 0 1px 3px rgba(0,0,0,0.1);
            cursor: pointer;
            transition: all 0.3s ease;
        }
        .audio-item:hover {
            box-shadow: 0 2px 8px rgba(0,0,0,0.15);
        }
        .audio-item-time {
            font-size: 12px;
            color: #999;
            margin-bottom: 5px;
        }
        .audio-item-text {
            font-size: 14px;
            color: #666;
            line-height: 1.4;
            max-height: 40px;
            overflow: hidden;
            text-overflow: ellipsis;
            display: -webkit-box;
            -webkit-line-clamp: 2;
            -webkit-box-orient: vertical;
        }
        .audio-item-controls {
            margin-top: 8px;
            display: flex;
            gap: 5px;
        }
        .audio-item-controls button {
            padding: 5px 10px;
            font-size: 12px;
            margin: 0;
        }
        textarea {
            height: 65px;
        }
        .status {
            color: #666;
            font-size: 14px;
            margin-top: 1px;
        }
        .success {
            color: #67c23a;
        }
        .error {
            color: #f56c6c;
        }
        audio {
            width: 100%;
            margin-top: 0px;
            height: 40px;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>TTS+ASR融合交互系统</h1>
        
        <div class="content-wrapper">
            <div class="main-content">
                <!-- ASR模块:语音转文本 -->
                <div class="module">
                    <h2>🔊 语音转文本(ASR)</h2>
                    <button id="recordBtn">开始录音</button>
                    <button id="stopRecordBtn" disabled>停止录音</button>
                    <button id="asrBtn" disabled>转写文本</button>
                    <div class="status" id="asrStatus"></div>
                    <textarea id="asrResult" placeholder="ASR转写结果将显示在这里..."></textarea>
                </div>

                <!-- TTS模块:文本转语音 -->
                <div class="module">
                    <h2>📢 文本转语音(TTS)</h2>
                    <textarea id="ttsText" placeholder="请输入要转换的文本...">你好!这是TTS与ASR融合交互系统的演示语音</textarea>
                    <button id="ttsBtn">生成并播放语音</button>
                    <div class="status" id="ttsStatus"></div>
                    <audio id="ttsAudio" controls></audio>
                </div>

                <!-- 辅助功能 -->
                <div class="module" style="margin-bottom: 0;">
                    <h2>🧹 辅助功能</h2>
                    <button id="cleanBtn">清理音频文件</button>
                    <button id="clearHistoryInMainBtn" style="margin-left: 10px;">清除历史记录</button>
                    <div class="status" id="cleanStatus"></div>
                </div>
            </div>
            
            <!-- 侧边面板:历史记录和音频列表 -->
            <div class="side-panels">
                <!-- 历史记录面板 -->
                <div class="history-panel">
                    <div class="history-title">📝 历史记录</div>
                    <ul id="historyList" class="history-list"></ul>
                    <button id="clearHistoryBtn" class="clear-history-btn">清空历史记录</button>
                </div>
                
                <!-- 音频列表面板 -->
                <div class="audio-panel">
                    <div class="audio-panel-title">🎵 已生成音频列表</div>
                    <ul id="audioList" class="audio-list"></ul>
                    <button id="clearAudioBtn" class="clear-history-btn">清空音频列表</button>
                </div>
            </div>
        </div>
    </div>

    <script>
        // ---------------------- 全局变量 ----------------------
        let mediaRecorder;  // 录音对象
        let audioChunks = [];  // 录音数据
        const BACKEND_URL = "http://127.0.0.1:5000/api";  // 后端接口地址
        let historyRecords = JSON.parse(localStorage.getItem('ttsAsrHistory') || '[]');  // 历史记录,存储在localStorage
        let audioRecords = JSON.parse(localStorage.getItem('ttsAudioRecords') || '[]');  // 音频记录,存储在localStorage

        // ---------------------- DOM元素 ----------------------
        const recordBtn = document.getElementById("recordBtn");
        const stopRecordBtn = document.getElementById("stopRecordBtn");
        const asrBtn = document.getElementById("asrBtn");
        const asrStatus = document.getElementById("asrStatus");
        const asrResult = document.getElementById("asrResult");
        const ttsText = document.getElementById("ttsText");
        const ttsBtn = document.getElementById("ttsBtn");
        const ttsStatus = document.getElementById("ttsStatus");
        const ttsAudio = document.getElementById("ttsAudio");
        const cleanBtn = document.getElementById("cleanBtn");
        const cleanStatus = document.getElementById("cleanStatus");
        const historyList = document.getElementById("historyList");
        const clearHistoryBtn = document.getElementById("clearHistoryBtn");
        const clearHistoryInMainBtn = document.getElementById("clearHistoryInMainBtn");
        const audioList = document.getElementById("audioList");
        const clearAudioBtn = document.getElementById("clearAudioBtn");

        // ---------------------- ASR:录音功能 ----------------------
        // 开始录音
        recordBtn.addEventListener("click", async () => {
            try {
                // 获取麦克风权限
                const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
                mediaRecorder = new MediaRecorder(stream);
                audioChunks = [];

                // 监听录音数据
                mediaRecorder.ondataavailable = (e) => {
                    audioChunks.push(e.data);
                };

                // 录音结束后启用转写按钮
                mediaRecorder.onstop = () => {
                    recordBtn.disabled = false;
                    stopRecordBtn.disabled = true;
                    asrBtn.disabled = false;
                    asrStatus.textContent = "录音完成,可点击「转写文本」";
                    asrStatus.className = "status success";
                };

                // 启动录音
                mediaRecorder.start();
                recordBtn.disabled = true;
                stopRecordBtn.disabled = false;
                asrBtn.disabled = true;
                asrStatus.textContent = "正在录音...请说话(建议5秒内)";
                asrStatus.className = "status";
            } catch (e) {
                asrStatus.textContent = `录音失败:${e.message}(请允许麦克风权限)`;
                asrStatus.className = "status error";
            }
        });

        // 停止录音
        stopRecordBtn.addEventListener("click", () => {
            if (mediaRecorder && mediaRecorder.state !== "inactive") {
                mediaRecorder.stop();
                // 停止所有音频轨道
                mediaRecorder.stream.getTracks().forEach(track => track.stop());
            }
        });

        // ---------------------- ASR:语音转文本 ----------------------
        asrBtn.addEventListener("click", async () => {
            try {
                asrBtn.disabled = true;
                asrStatus.textContent = "正在转写文本...";
                asrStatus.className = "status";

                // 1. 将录音数据转为WAV文件(适配Whisper)
                const audioBlob = new Blob(audioChunks, { type: "audio/wav" });
                const formData = new FormData();
                formData.append("audio", audioBlob, "record.wav");

                // 2. 调用后端ASR接口
                const response = await fetch(`${BACKEND_URL}/asr`, {
                    method: "POST",
                    body: formData
                });
                const result = await response.json();

                // 3. 处理结果
                if (result.code === 200) {
                    asrResult.value = result.data.text;
                    asrStatus.textContent = "转写成功!";
                    asrStatus.className = "status success";
                    // 自动填充到TTS文本框(方便后续TTS)
                    ttsText.value = result.data.text || ttsText.value;
                    // 添加到历史记录
                    addToHistory('asr', result.data.text);
                } else {
                    asrStatus.textContent = `转写失败:${result.msg}`;
                    asrStatus.className = "status error";
                }
            } catch (e) {
                asrStatus.textContent = `转写异常:${e.message}`;
                asrStatus.className = "status error";
            } finally {
                asrBtn.disabled = false;
            }
        });

        // ---------------------- TTS:文本转语音 ----------------------
        ttsBtn.addEventListener("click", async () => {
            try {
                ttsBtn.disabled = true;
                ttsStatus.textContent = "正在生成语音...";
                ttsStatus.className = "status";

                // 1. 获取文本
                const text = ttsText.value.trim();
                if (!text) {
                    ttsStatus.textContent = "错误:文本不能为空";
                    ttsStatus.className = "status error";
                    ttsBtn.disabled = false;
                    return;
                }

                // 2. 调用后端TTS接口
                const response = await fetch(`${BACKEND_URL}/tts`, {
                    method: "POST",
                    headers: {
                        "Content-Type": "application/json"
                    },
                    body: JSON.stringify({ text })
                });
                const result = await response.json();

                // 3. 处理结果
                if (result.code === 200) {
                    // 设置音频播放地址
                    ttsAudio.src = result.data.tts_url;
                    ttsAudio.play();  // 自动播放
                    ttsStatus.textContent = "语音生成并播放成功!";
                    ttsStatus.className = "status success";
                    // 添加到历史记录
                    addToHistory('tts', text);
                    // 添加到音频列表
                    addToAudioList(text, result.data.tts_url);
                } else {
                    ttsStatus.textContent = `生成失败:${result.msg}`;
                    ttsStatus.className = "status error";
                }
            } catch (e) {
                ttsStatus.textContent = `生成异常:${e.message}`;
                ttsStatus.className = "status error";
            } finally {
                ttsBtn.disabled = false;
            }
        });

        // ---------------------- 辅助功能:清理音频文件 ----------------------
        cleanBtn.addEventListener("click", async () => {
            try {
                cleanBtn.disabled = true;
                cleanStatus.textContent = "正在清理音频文件...";
                cleanStatus.className = "status";

                // 1. 清理前端localStorage中的音频记录
                localStorage.removeItem('ttsAudioRecords');
                audioRecords = [];
                renderAudioList();

                // 2. 尝试调用后端清理接口
                try {
                    const response = await fetch(`${BACKEND_URL}/clean`, {
                        method: "GET"
                    });
                    const result = await response.json();

                    if (result.code === 200) {
                        cleanStatus.textContent = "音频文件清理成功!(前后端均已清理)";
                        cleanStatus.className = "status success";
                    } else {
                        cleanStatus.textContent = `后端清理失败:${result.msg},但前端音频记录已清理`;
                        cleanStatus.className = "status warning";
                    }
                } catch (e) {
                    cleanStatus.textContent = "后端服务不可用,但前端音频记录已清理";
                    cleanStatus.className = "status warning";
                }
            } catch (e) {
                cleanStatus.textContent = `清理异常:${e.message}`;
                cleanStatus.className = "status error";
            } finally {
                cleanBtn.disabled = false;
            }
        });

        // ---------------------- 历史记录管理 ----------------------
        
        // 添加记录到历史
        function addToHistory(type, text) {
            const record = {
                id: Date.now(),
                type: type,
                text: text,
                time: new Date().toLocaleString('zh-CN')
            };
            
            // 添加到历史记录数组的开头
            historyRecords.unshift(record);
            
            // 限制历史记录数量(最多20条)
            if (historyRecords.length > 20) {
                historyRecords = historyRecords.slice(0, 20);
            }
            
            // 保存到localStorage
            localStorage.setItem('ttsAsrHistory', JSON.stringify(historyRecords));
            
            // 更新历史记录显示
            renderHistory();
        }
        
        // 渲染历史记录
        function renderHistory() {
            historyList.innerHTML = '';
            
            if (historyRecords.length === 0) {
                const emptyItem = document.createElement('li');
                emptyItem.className = 'history-item';
                emptyItem.innerHTML = '<div class="history-item-text">暂无历史记录</div>';
                emptyItem.style.textAlign = 'center';
                emptyItem.style.color = '#999';
                emptyItem.style.cursor = 'default';
                emptyItem.style.boxShadow = 'none';
                historyList.appendChild(emptyItem);
                return;
            }
            
            historyRecords.forEach(record => {
                const li = document.createElement('li');
                li.className = 'history-item';
                li.dataset.id = record.id;
                
                const typeText = record.type === 'asr' ? '语音转文本' : '文本转语音';
                const typeIcon = record.type === 'asr' ? '🔊' : '📢';
                
                li.innerHTML = `
                    <div class="history-item-time">${record.time}</div>
                    <div class="history-item-text">${record.text}</div>
                    <div class="history-item-type">${typeIcon} ${typeText}</div>
                `;
                
                // 添加点击事件
                li.addEventListener('click', () => {
                    // 将历史记录的文本填充到对应输入框
                    if (record.type === 'asr') {
                        asrResult.value = record.text;
                        ttsText.value = record.text;
                    } else {
                        ttsText.value = record.text;
                    }
                });
                
                historyList.appendChild(li);
            });
        }
        
        // 清空历史记录函数
        function clearHistory() {
            if (confirm('确定要清空所有历史记录吗?')) {
                historyRecords = [];
                localStorage.removeItem('ttsAsrHistory');
                renderHistory();
            }
        }
        
        // 清空历史记录按钮事件
        clearHistoryBtn.addEventListener('click', clearHistory);
        clearHistoryInMainBtn.addEventListener('click', clearHistory);
        
        // 清空音频列表按钮事件
        clearAudioBtn.addEventListener('click', () => {
            if (confirm('确定要清空所有已生成的音频吗?')) {
                localStorage.removeItem('ttsAudioRecords');
                audioRecords = [];
                renderAudioList();
                cleanStatus.textContent = "音频列表已清空";
                cleanStatus.className = "status success";
                setTimeout(() => {
                    cleanStatus.textContent = "";
                    cleanStatus.className = "status";
                }, 3000);
            }
        });
        
        // ---------------------- 音频列表管理 ----------------------
        
        // 添加音频到列表
        function addToAudioList(text, audioUrl) {
            const audioRecord = {
                id: Date.now(),
                text: text,
                audioUrl: audioUrl,
                time: new Date().toLocaleString('zh-CN')
            };
            
            // 添加到音频记录数组的开头
            audioRecords.unshift(audioRecord);
            
            // 限制最多保存10条音频记录
            if (audioRecords.length > 10) {
                audioRecords = audioRecords.slice(0, 10);
            }
            
            // 保存到localStorage
            localStorage.setItem('ttsAudioRecords', JSON.stringify(audioRecords));
            
            // 更新音频列表显示
            renderAudioList();
        }
        
        // 渲染音频列表
        function renderAudioList() {
            audioList.innerHTML = '';
            
            if (audioRecords.length === 0) {
                const emptyItem = document.createElement('li');
                emptyItem.className = 'audio-item';
                emptyItem.innerHTML = '<div class="audio-item-text">暂无生成的音频</div>';
                emptyItem.style.textAlign = 'center';
                emptyItem.style.color = '#999';
                emptyItem.style.cursor = 'default';
                emptyItem.style.boxShadow = 'none';
                audioList.appendChild(emptyItem);
                return;
            }
            
            audioRecords.forEach(record => {
                const li = document.createElement('li');
                li.className = 'audio-item';
                li.dataset.id = record.id;
                
                li.innerHTML = `
                    <div class="audio-item-time">${record.time}</div>
                    <div class="audio-item-text">${record.text}</div>
                    <div class="audio-item-controls">
                        <button onclick="playAudio('${record.audioUrl}')" title="播放">播放</button>
                        <button onclick="useAudioText('${encodeURIComponent(record.text)}')" title="使用此文本">使用</button>
                        <button onclick="removeAudioRecord(${record.id})" title="删除">删除</button>
                    </div>
                `;
                
                audioList.appendChild(li);
            });
        }
        
        // 播放音频
        function playAudio(audioUrl) {
            ttsAudio.src = audioUrl;
            ttsAudio.play();
        }
        
        // 使用音频对应的文本
        function useAudioText(text) {
            ttsText.value = decodeURIComponent(text);
        }
        
        // 删除音频记录
        function removeAudioRecord(id) {
            audioRecords = audioRecords.filter(record => record.id !== id);
            localStorage.setItem('ttsAudioRecords', JSON.stringify(audioRecords));
            renderAudioList();
        }
        
        // 页面加载时渲染历史记录和音频列表
        renderHistory();
        renderAudioList();
    </script>
</body>
</html>
相关推荐
神算大模型APi--天枢6464 小时前
从异构调度到边缘部署:国产大模型算力平台的后端开发能力拆解
大数据·人工智能·科技·架构·硬件架构
song5014 小时前
鸿蒙 Flutter 日志系统:分级日志与鸿蒙 Hilog 集成
图像处理·人工智能·分布式·flutter·华为
Mintopia4 小时前
🚀 AIGC 如何重塑 Web 内容生产的价值链 —— 一场“硅基文艺复兴”的技术变革
人工智能·aigc·全栈
拉姆哥的小屋4 小时前
从原子到性能:机器学习如何重塑双金属催化剂的设计范式
人工智能·python·算法·机器学习
sponge'4 小时前
opencv学习笔记13:U-Net
人工智能·深度学习·机器学习
Evand J4 小时前
【MATLAB例程】自适应阈值的小波变换去噪,信号噪声:拉普拉斯噪声、脉冲噪声与高斯噪声|混合非高斯。附下载链接
开发语言·人工智能·matlab
skywalk81634 小时前
openi启智社区提供大模型在线体验功能
人工智能
秋刀鱼 ..4 小时前
第五届遥感与测绘国际学术会议(RSSM 2026)
大数据·运维·人工智能·机器人·自动化
全栈陈序员4 小时前
【Python】基础语法入门(十八)——函数式编程初探:用 `map`、`filter`、`reduce` 和 `lambda` 写出更简洁的代码
开发语言·人工智能·python·学习