news 2025/12/24 11:27:39

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
大模型应用:完整语音交互闭环: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 格式音频;
  • 2. 音频上传:前端将录音文件通过 FormData 格式上传到后端 ASR 接口;
  • 3. ASR 处理:后端 Whisper 模型接收音频,转写为文本,返回给前端展示;
  • 4. 文本处理:用户可直接使用 ASR 转写的文本,或手动输入新文本,点击 “生成语音”;
  • 5. TTS 处理:后端接收文本,通过 pyttsx3 生成语音文件,返回音频播放链接;
  • 6. 语音输出:前端通过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 交互

// 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)

@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)

@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)

@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 后端服务启动

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 解决前后端跨域障碍。可以在此基础上按实际需求进行扩展调试。

附录一:后端完整代码

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)

附录二:前端完整代码

<!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>
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2025/12/12 21:33:09

《UNIX高级环境编程》 第七章 进程环境 读书笔记

一、main函数 C程序总是从main函数开始执行&#xff0c;main函数的原型是&#xff1a; int main(int argc,char *argv[]); 正如前面提到的&#xff0c;argc是命令行参数的数目&#xff0c;argv是指向个参数的指针组成的数组。 当内核执行C程序时&#xff0c;在调用main前先调用…

作者头像 李华
网站建设 2025/12/12 21:32:50

锂电池保护板mos越多越好吗

锂电池保护板中的MOS管并非越多越好&#xff0c;这是一个典型的"边际效益递减"工程问题。MOS管数量需根据电池容量、放电电流、成本预算和空间限制科学配置&#xff0c;盲目堆砌反而可能降低可靠性。一、MOS管在保护板中的核心作用MOS管是保护IC指令的 "肌肉执行…

作者头像 李华
网站建设 2025/12/12 21:32:10

Windows系统文件sxs.dll缺失损坏问题 下载修复

在使用电脑系统时经常会出现丢失找不到某些文件的情况&#xff0c;由于很多常用软件都是采用 Microsoft Visual Studio 编写的&#xff0c;所以这类软件的运行需要依赖微软Visual C运行库&#xff0c;比如像 QQ、迅雷、Adobe 软件等等&#xff0c;如果没有安装VC运行库或者安装…

作者头像 李华
网站建设 2025/12/12 21:31:59

Windows系统文件tdh.dll缺失找不到情况 下载修复

在使用电脑系统时经常会出现丢失找不到某些文件的情况&#xff0c;由于很多常用软件都是采用 Microsoft Visual Studio 编写的&#xff0c;所以这类软件的运行需要依赖微软Visual C运行库&#xff0c;比如像 QQ、迅雷、Adobe 软件等等&#xff0c;如果没有安装VC运行库或者安装…

作者头像 李华
网站建设 2025/12/20 9:19:38

10分钟从零搭建Art Design Pro:Vue 3后台管理系统的完整配置手册

10分钟从零搭建Art Design Pro&#xff1a;Vue 3后台管理系统的完整配置手册 【免费下载链接】art-design-pro 这是一个基于 Vue3、TypeScript、Vite 和 Element-Plus 精心打造的后台管理系统模板&#xff0c;专注于用户体验和视觉设计。 项目地址: https://gitcode.com/GitH…

作者头像 李华