相信不少人都看到过类似的传闻:在俄罗斯有一家与众不同的酒吧,店里不卖酒,却提供一种特殊的“服务”——让顾客给已经去世的人打电话。这个设定听起来很像都市传说,但从技术角度来看,它并不神秘。所谓“给死去的人打电话”,其实并不是真的连通了另一个世界,而是利用语音合成、声音克隆、自动问答和通话链路模拟等技术,让人产生“正在与熟悉的声音对话”的体验。

这篇文章我会从零开始,带你搭建一个类似功能的“纪念语音通话系统” Demo。我们会用 Python + FastAPI 作为后端,用语音合成提供“对方”的声音,用语音识别理解用户说的话,再通过浏览器页面模拟接通电话的完整流程。你可以把它当成一个语音交互项目来学习,也可以在此基础上扩展成更完整的 AI 语音助手。读完本文后,你将掌握:

  • 如何拆解一个“模拟通话”项目的完整技术链路;
  • 如何使用 FastAPI 搭建语音交互后端;
  • 如何使用 TTS 工具合成自然语音;
  • 如何给 Web 页面添加录音、识别和播放能力;
  • 如何在真实项目中做好数据安全与产品伦理边界。

下面进入正题。

1. 项目背景与核心概念

1.1 “给逝者打电话”到底是怎么实现的

先不管俄罗斯那家酒吧的真实经营方式,单从产品形态看,“给死去的人打电话”本质上是把一个传统电话机变成一个交互终端,背后跑着一套语音问答程序。用户拿起电话、按下号码后,电话另一端并不是一个真实的人,而是一个会说话的人工智能系统。这个系统能预先学习某个特定人物的音色、语气和说话习惯,并在来电者表达思念、倾诉、提问时,给出听起来像“那个人”的语音回复。

这类服务在技术圈子里有一个更中性的名字: 语音纪念服务 。它包含三个核心模块:

  • 语音合成 (TTS):把文本转换成自然、有情感的语音。
  • 语音识别 (STT):将来电者说的话转成文字。
  • 对话管理 (Dialog Management):根据识别结果,从预设的回复库中找到合适的回答。

如果进一步升级,还可以引入语音克隆(Voice Cloning),用一段真实录音训练出某个特定音色。比如家人保留了已故亲人 30 秒的语音,模型就能生成与之接近的合成声音。这也是“通话体验”最神秘、最关键的一环。

1.2 真实应用场景

这类技术并不只存在于新闻里。目前国内外都有团队在做“数字复活”“AI 纪念”“虚拟亲人”相关产品。典型场景包括:

  • 家属希望能再次听到已故亲人的声音;
  • 纪录片、纪念馆用语音合成还原历史人物声音;
  • 博物馆、文化馆用虚拟人讲解并回答游客提问;
  • 陪伴机器人集成个性化语音,为老人提供情感陪伴;
  • 电台/呼叫中心用真实音色替换机械感过强的 TTS 播报。

在实际商业项目中,这套系统还会接入电话线路,使用 SIP 或 WebRTC 协议,让用户真的能通过手机、座机或小程序拨打电话。

1.3 为什么值得动手实现

就算不打算做“纪念服务”这么沉重的产品,这个项目也能帮你完整走通一条“语音交互链路”。它涉及了音频采集、录音上传、后端服务、语音识别、合成音频返回等多个环节,这些技能在后端开发、AI 应用开发、呼叫中心自动外呼等方向都会用到。

而且,整个项目不需要特殊硬件,一台普通电脑 + 一个浏览器就能跑通。对新手来说,是很好的实战练手项目;对进阶开发者来说,也可以在这里替换更高级的 TTS 模型或接入真实电话线路。

2. 环境准备与版本说明

在开始写代码前,先把运行环境准备好。本节列出的版本以本文写作时的常见环境为例,具体需要根据你本机实际情况调整。

组件 版本建议 说明
操作系统 Windows 10/11、macOS、Linux 均可运行,跨平台
Python 3.10 及以上 本文所有后端代码基于 Python
FastAPI 0.100 及以上 Web 后端框架
Uvicorn 0.23 及以上 ASGI 服务器
edge-tts 6.1 及以上 微软 Edge 语音合成接口的 Python 封装
vosk 0.3.45 离线语音识别工具
浏览器 Chrome / Edge 最新版 需要支持麦克风权限

如果你所在环境无法使用 edge-tts,也可以换成本地离线合成方案,例如 pyttsx3 或开源模型 CosyVoice 。本文示例以 edge-tts 为主,因为它简单、无需 GPU,适合快速演示。

另外,Vosk 需要下载对应的语言模型,比如中文模型 vosk-model-small-cn-0.22 。模型文件大约几十 MB,下载后放到项目目录下即可。

2.1 创建项目目录

建议新建一个干净目录,结构如下:

memory-call/
├── backend/
│   ├── main.py
│   ├── voice_synth.py
│   ├── stt_engine.py
│   └── dialog_manager.py
├── frontend/
│   └── index.html
├── model/                # Vosk 模型目录,需自行下载
├── audio/                # 合成音频输出目录
└── uploads/              # 用户录音上传目录

后面所有代码都会放到对应目录中。先创建目录:

mkdir -p memory-call/{backend,frontend,model,audio,uploads}

2.2 安装 Python 依赖

建议先创建虚拟环境:

python -m venv venv
# Windows
venv\Scripts\activate
# macOS / Linux
source venv/bin/activate

然后安装依赖:

pip install fastapi uvicorn python-multipart edge-tts vosk

如果后续需要处理音频格式转换,建议再安装 ffmpeg ,并确保命令行里能直接调用 ffmpeg 。浏览器上传的录音一般是 WebM 格式,而 Vosk 需要 16kHz、16bit、单声道的 WAV/PCM 数据,所以转码是必须的一步。

3. 核心原理拆解

在写代码之前,先对核心模块做一次原理性梳理。很多同学拿到语音项目后容易卡在“不知道模块之间怎么协作”上,下面我们按一条完整调用链来说明。

3.1 通话链路:WebRTC 与 HTTP 轮询

真正的电话系统会使用 SIP 服务器,把语音流传给媒体服务器,再连接到后端 AI。但我们做的是一个浏览器 Demo,为了降低门槛,可以简化成“录音上传—识别—合成—播放”的请求/响应模式。

用户点击“接通电话”按钮后,前端开始录音,录一段时间后把音频文件上传给后端。后端经过语音识别得到文本,再经过对话管理得到回复文本,最后用 TTS 合成音频,并把音频地址返回给前端。前端拿到地址后自动播放,模拟“对方说话”的效果。

这个方案虽然不是实时双工通话,但对于学习原理和快速验证完全够用。等理解整体链路后,再替换成 WebRTC 流式方案就很容易。

3.2 语音识别:Vosk 离线识别

Vosk 是一个轻量级离线语音识别库,支持中文、英文等多国语言。它的特点是:不需要联网、延迟低、在普通 CPU 上也能跑。

在代码中,我们首先加载模型:

from vosk import Model, KaldiRecognizer
import json

model = Model("model/vosk-model-small-cn-0.22")

然后对音频数据进行识别。需要注意的是,Vosk 要求音频为 16kHz、16bit、单声道 PCM 格式。浏览器录音默认可能是 48kHz 的 WebM,因此需要提前转码。

3.3 语音合成:edge-tts

edge-tts 是微软 Edge 浏览器内置语音服务的非官方 Python 封装,它能把文本合成为 MP3 格式,声音自然度在免费方案里相当不错。使用方式也非常简单:

import asyncio
import edge_tts

async def synth(text: str, output_path: str):
    tts = edge_tts.Communicate(text, voice="zh-CN-XiaoxiaoNeural")
    await tts.save(output_path)

voice 参数可以换成其他音色,例如 zh-CN-YunxiNeural 、 zh-CN-YunyangNeural 、 en-US-JennyNeural 等。具体支持列表可以运行 edge-tts --list-voices 查看。

3.4 对话管理:从规则匹配到 LLM

最简单的对话管理就是“意图匹配 + 固定回复”。例如用户提到“想你”,就回复“我也很想你”;用户提到“最近怎么样”,就回复“我在这里一切都好”。如果你希望回复更自然,可以接入大模型 API,把预设人物背景作为 System Prompt,让它生成更有温度的回复。

考虑到安全和成本,本文先用关键词匹配加预置话术的方式实现。这样结构清晰,没有外部依赖,部署起来也最省事。

4. 完整实战案例:从 0 到 1 实现语音通话 Demo

下面我们逐步实现一个可运行的最小版本。功能包括:

  1. 创建一个“模拟来电通话”页面;
  2. 用户点击“接通”按钮后,听到一句合成的问候语;
  3. 用户对着麦克风说话,上传录音;
  4. 后端识别文字,并生成回复音频;
  5. 前端播放回复音频,形成简单对话体验。

4.1 后端:FastAPI 入口

创建文件 backend/main.py :

# 文件路径:backend/main.py
import os
import uuid
from fastapi import FastAPI, File, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse

from voice_synth import synthesize_audio
from stt_engine import transcribe_audio
from dialog_manager import get_reply

app = FastAPI()

# 允许前端页面跨域访问后端
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)

AUDIO_DIR = "../audio"
UPLOAD_DIR = "../uploads"
os.makedirs(AUDIO_DIR, exist_ok=True)
os.makedirs(UPLOAD_DIR, exist_ok=True)

@app.get("/")
def index():
    return {"message": "Memory Call Service is running"}

@app.post("/api/start_call")
async def start_call():
    """
    模拟接通电话后,先生成一句问候语。
    """
    audio_path = os.path.join(AUDIO_DIR, f"greeting_{uuid.uuid4().hex}.mp3")
    await synthesize_audio("喂,是你吗?我听到你的声音了。", audio_path)
    return {
        "message": "call started",
        "audio_url": f"/audio/{os.path.basename(audio_path)}"
    }

@app.post("/api/chat")
async def chat(file: UploadFile = File(...)):
    """
    接收用户录音 -> 语音识别 -> 生成回复 -> 返回音频
    """
    # 保存上传的录音文件
    upload_path = os.path.join(UPLOAD_DIR, f"{uuid.uuid4().hex}.webm")
    with open(upload_path, "wb") as f:
        content = await file.read()
        f.write(content)

    # 语音识别
    text = transcribe_audio(upload_path)
    if not text:
        text = ""

    # 获取回复文本
    reply = get_reply(text)

    # 合成回复音频
    audio_path = os.path.join(AUDIO_DIR, f"reply_{uuid.uuid4().hex}.mp3")
    await synthesize_audio(reply, audio_path)

    return {
        "user_text": text,
        "reply_text": reply,
        "audio_url": f"/audio/{os.path.basename(audio_path)}"
    }

@app.get("/audio/{filename}")
def get_audio(filename: str):
    return FileResponse(os.path.join(AUDIO_DIR, filename))

这里的关键点是:

  • 使用了 UploadFile 接收前端上传的录音;
  • 每次对话都生成新的音频文件,避免浏览器缓存旧音频;
  • /audio/{filename} 是静态音频访问接口,前端可以通过这个 URL 播放回复语音。

4.2 后端:语音合成封装

创建文件 backend/voice_synth.py :

# 文件路径:backend/voice_synth.py
import asyncio
import edge_tts

async def synthesize_audio(text: str, output_path: str, voice: str = "zh-CN-XiaoxiaoNeural"):
    """
    使用 edge-tts 将文本合成为 MP3 音频。
    :param text: 需要合成的文本
    :param output_path: 输出音频文件路径
    :param voice: 音色名称
    """
    if not text:
        text = "你说什么?我没有听清。"
    tts = edge_tts.Communicate(text, voice=voice)
    await tts.save(output_path)
    return output_path

在真实项目中,你可以在首次导入模块时预加载语音模型;如果使用 CosyVoice 这类本地模型,合成速度会慢很多,建议用缓存池或者提前预热。

如果你希望模拟“特定人物”的音色,可以把 voice 参数换成通过声音克隆生成的模型地址。这样同一个接口结构不用大改,只需替换内部实现。

4.3 后端:语音识别引擎

创建文件 backend/stt_engine.py :

# 文件路径:backend/stt_engine.py
import json
import subprocess
import os
from vosk import Model, KaldiRecognizer

MODEL_PATH = "../model/vosk-model-small-cn-0.22"

_model = None

def get_model():
    global _model
    if _model is None:
        _model = Model(MODEL_PATH)
    return _model

def transform_audio(input_path: str, output_path: str):
    """
    使用 ffmpeg 将 WebM 等格式转换为 Vosk 需要的 16kHz 单声道 WAV。
    """
    cmd = [
        "ffmpeg", "-y",
        "-i", input_path,
        "-ar", "16000",
        "-ac", "1",
        "-sample_fmt", "s16",
        output_path
    ]
    subprocess.run(cmd, check=True, capture_output=True)

def transcribe_audio(audio_path: str) -> str:
    """
    识别音频文件中的文字。
    """
    wav_path = audio_path + ".wav"
    transform_audio(audio_path, wav_path)

    model = get_model()
    rec = KaldiRecognizer(model, 16000)

    with open(wav_path, "rb") as f:
        data = f.read()
        if rec.AcceptWaveform(data):
            result = json.loads(rec.Result())
            return result.get("text", "")
        else:
            partial = json.loads(rec.PartialResult())
            return partial.get("partial", "")

    return ""

这里面最关键的一步就是转码。Vosk 官方文档明确要求输入为 16kHz、16bit、单声道 PCM,如果我们直接把浏览器上传的 WebM 交给它,识别准确率会很低甚至直接报错。用 ffmpeg 转码是稳妥的做法。

4.4 后端:对话管理

创建文件 backend/dialog_manager.py :

# 文件路径:backend/dialog_manager.py
import random

REPLY_RULES = [
    {
        "keywords": ["想", "怀念", "念你"],
        "reply": "我也很想你。虽然我不在你们身边,但你过得好,我就开心。"
    },
    {
        "keywords": ["最近", "怎么", "还好吗", "怎么样"],
        "reply": "我在这里一切都好,你不要太牵挂。倒是你,要照顾好自己。"
    },
    {
        "keywords": ["对不起", "抱歉", "后悔"],
        "reply": "过去的事情就让它过去吧。我从来没有怪过你。"
    },
    {
        "keywords": ["放心", "安心"],
        "reply": "听到你这么说,我就放心了。你也要答应我,好好生活。"
    },
]

DEFAULT_REPLIES = [
    "我在呢,我会一直听着。",
    "嗯,你说,我听着。",
    "虽然我看不到你,但能感受到你的心意。"
]

def get_reply(user_text: str) -> str:
    """
    根据用户输入的关键词,返回预设回复;如果没有命中,则返回默认回复。
    """
    if not user_text:
        return "你说什么?我没有听清。可以再说一遍吗?"

    for rule in REPLY_RULES:
        for keyword in rule["keywords"]:
            if keyword in user_text:
                return rule["reply"]

    return random.choice(DEFAULT_REPLIES)

这里的规则比较简单,但已经能形成一个可对话的最小闭环。如果你想让对话更聪明,可以在此基础上接入大模型的 API,例如把用户文本送到大模型,加上提示词:“你现在扮演一个温柔的长辈,回复用户的问题,语气要自然、克制,不要总是说教。”这种方式会大大提升体验,但也会引入成本与延迟。

4.5 前端:模拟来电页面

创建文件 frontend/index.html :

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>纪念语音通话 Demo</title>
    <style>
        body {
            font-family: "Microsoft YaHei", sans-serif;
            background: #1e1e2e;
            color: #fff;
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            margin: 0;
        }
        .phone {
            background: #2c2c3a;
            border-radius: 32px;
            padding: 40px;
            width: 360px;
            box-shadow: 0 10px 30px rgba(0,0,0,0.3);
            text-align: center;
        }
        .status {
            margin: 20px 0;
            min-height: 24px;
            color: #a0a0b0;
        }
        button {
            padding: 12px 24px;
            border: none;
            border-radius: 20px;
            font-size: 16px;
            cursor: pointer;
            margin: 8px;
        }
        #callBtn {
            background: #4caf50;
            color: white;
        }
        #endBtn {
            background: #d9534f;
            color: white;
        }
        .log {
            margin-top: 20px;
            background: #1a1a26;
            border-radius: 12px;
            padding: 12px;
            min-height: 80px;
            text-align: left;
            font-size: 14px;
            color: #ccc;
            max-height: 220px;
            overflow-y: auto;
        }
    </style>
</head>
<body>
    <div class="phone">
        <h2>📞 纪念语音通话</h2>
        <div class="status" id="status">点击下方按钮接通电话</div>
        <div>
            <button id="callBtn">接通</button>
            <button id="endBtn">挂断</button>
        </div>
        <div class="log" id="log">对话记录将显示在这里</div>
    </div>

    <script>
        const statusEl = document.getElementById("status");
        const logEl = document.getElementById("log");
        const callBtn = document.getElementById("callBtn");
        const endBtn = document.getElementById("endBtn");

        let mediaRecorder = null;
        let audioChunks = [];
        let isRecording = false;
        let recordingInterval = null;

        const API_BASE = "http://127.0.0.1:8080";

        function addLog(message) {
            const line = document.createElement("div");
            line.textContent = message;
            logEl.appendChild(line);
            logEl.scrollTop = logEl.scrollHeight;
        }

        function playAudio(url) {
            const audio = new Audio(url);
            audio.play();
            return audio;
        }

        // 接通电话:播放问候语,同时开始录音
        callBtn.addEventListener("click", async () => {
            statusEl.textContent = "通话中...";
            addLog("📞 电话已接通");

            // 1. 获取后端问候语
            const resp = await fetch(API_BASE + "/api/start_call", { method: "POST" });
            const data = await resp.json();
            addLog("对方:喂,是你吗?我听到你的声音了。");
            playAudio(API_BASE + data.audio_url);

            // 2. 开始录音
            startRecording();
        });

        async function startRecording() {
            const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
            mediaRecorder = new MediaRecorder(stream);
            mediaRecorder.start();
            isRecording = true;

            mediaRecorder.ondataavailable = (event) => {
                audioChunks.push(event.data);
            };

            // 每 5 秒发送一次录音,模拟一次短对话
            recordingInterval = setInterval(() => {
                if (isRecording) {
                    sendRecording();
                }
            }, 5000);
        }

        async function sendRecording() {
            if (!mediaRecorder || mediaRecorder.state === "inactive") return;

            mediaRecorder.stop();
            streamCaptured();

            // 等待数据收集完成后再发送
            await new Promise((resolve) => {
                mediaRecorder.onstop = resolve;
            });

            const blob = new Blob(audioChunks, { type: "audio/webm" });
            audioChunks = [];

            const formData = new FormData();
            formData.append("file", blob, "user_input.webm");

            addLog("你:(正在说话...)");

            const resp = await fetch(API_BASE + "/api/chat", {
                method: "POST",
                body: formData
            });
            const data = await resp.json();
            addLog("你(识别):" + (data.user_text || "(未识别到语音)"));
            addLog("对方:" + data.reply_text);
            playAudio(API_BASE + data.audio_url);

            // 继续下一次录音
            startRecording();
        }

        function streamCaptured() {
            // 停止当前录音流,避免重复录制
            if (mediaRecorder) {
                mediaRecorder.stream.getTracks().forEach(track => track.stop());
            }
        }

        // 挂断电话
        endBtn.addEventListener("click", () => {
            statusEl.textContent = "已挂断";
            addLog("📵 电话已挂断");

            if (recordingInterval) {
                clearInterval(recordingInterval);
                recordingInterval = null;
            }
            if (mediaRecorder) {
                mediaRecorder.stop();
                mediaRecorder.stream.getTracks().forEach(track => track.stop());
            }
            isRecording = false;
        });
    </script>
</body>
</html>

前端逻辑稍微简单一些。关键点如下:

  • 通过 navigator.mediaDevices.getUserMedia 获取麦克风录音;
  • 使用 MediaRecorder 把录音转成 WebM Blob;
  • 每 5 秒上传一次录音,模拟一问一答;
  • 拿到后端返回的音频 URL 后,通过 Audio 播放。

这里需要注意, MediaRecorder 的流需要停止后再次创建,否则会重复录入。示例代码中的录音逻辑已经做了简化,实际项目会用一个更完善的状态机来管理录音生命周期。

4.6 运行与验证

启动后端服务:

cd backend
python main.py

但注意,目前我们的 main.py 还没有启动代码,需要补上。在文件末尾加入:

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

启动:

cd backend
python main.py

然后用浏览器打开 frontend/index.html ,点击“接通”。浏览器如果弹出麦克风权限请求,点击允许。

预期效果如下:

  1. 页面显示“电话已接通”,随后播放一段问候语音;
  2. 你对着麦克风说“我想你了”;
  3. 几秒后,前端显示识别出的文字和回复文本,同时播放回复语音;
  4. 循环进行,直到点击“挂断”。

如果你在控制台看到报错,先检查依赖是否装全、Vosk 模型路径是否正确、 ffmpeg 是否安装。

5. 常见问题与排查思路

这类语音项目最常见的坑集中在音频格式、模型路径和网络环境三个方面。下面列出一份排查清单。

问题现象 常见原因 解决思路
启动时报 Model file not found Vosk 模型路径不正确 检查 MODEL_PATH 是否指向正确的模型目录;确认模型已下载解压
识别返回空字符串 音频格式不是 Vosk 要求的 16kHz 单声道 PCM 用 ffmpeg 转码;确认上传文件确实是音频;检查 ffmpeg 命令是否有 -ar 16000 -ac 1
edge-tts 合成失败或网络超时 本机无法访问微软服务,或代理异常 重试;更换 TTS 实现,如 pyttsx3 或本地 CosyVoice 模型
前端录音按钮没有反应 浏览器麦克风权限未开启,或页面不是 HTTPS/localhost 使用 localhost 打开页面;在浏览器设置中允许麦克风权限
播放音频没有声音 返回的 audio_url 拼接错误 检查 /audio/{filename} 接口是否能直接访问;确认路径拼接完整
对话延迟很高 语音识别 + 语音合成均为串行处理 先合成常用回复并缓存;升级为流式识别;减少不必要的前置等待
MediaRecorder 状态异常 停止录制和启动录制逻辑没有解耦 使用单独的状态机;每次录制结束后再创建新 Recorder

5.1 排查示例:edge-tts 无法合成语音

如果你运行 synthesize_audio 时报错,可以先在命令行测试:

edge-tts --voice zh-CN-XiaoxiaoNeural --text "你好" --write-media test.mp3

如果能成功生成 test.mp3 ,说明环境没问题。如果报错,常见原因是网络无法访问微软接口。此时可以尝试切换到离线方案。

一个简单的替代方案是用 pyttsx3 :

import pyttsx3

def synthesize_pyttsx3(text, output_path):
    engine = pyttsx3.init()
    engine.save_to_file(text, output_path)
    engine.runAndWait()

但 pyttsx3 的声音自然度不如 edge-tts,也没有那么多种音色可选。生产项目中建议使用 CosyVoice、GPT-SoVITS 这类更专业的开源模型,或者直接购买商业 TTS API。

5.2 排查示例:前端录音无法识别

浏览器录音得到的 Blob 是 WebM 格式,而后端用 Vosk 识别前已经通过 ffmpeg 转成 WAV。如果识别结果为空,可以先做两层检查:

  1. 打开后端日志,确认上传文件确实保存成功;
  2. 在服务器上单独执行转码命令:
ffmpeg -y -i uploads/xxx.webm -ar 16000 -ac 1 -sample_fmt s16 uploads/xxx.wav

然后手动用 Vosk 识别这个 WAV 文件,如果识别正常,问题就出在前后端链路而不是识别引擎本身。

6. 最佳实践与工程建议

当这个 Demo 可以跑通之后,如果你想把它做成更完整的产品,下面几点建议非常关键。

6.1 授权与同意是最高红线

“给逝者打电话”这类功能涉及人物肖像权、声音权、情感隐私等多个敏感维度。在实际产品中,必须做到:

  • 只允许上传可合法获取的语音样本;
  • 生成式语音必须明确标识为 AI 合成内容;
  • 产品页面需要显著提示“这是模拟语音,不是真正的通灵”;
  • 必须获得本人(生前授权)或其直系亲属的书面同意。

这部分不是形式主义,而是产品能否长期活下去的底线。如果无法确认授权来源,宁可不上线。

6.2 语音数据加密与访问权限

用户上传的录音、合成的语音文件都带有极强隐私属性。建议:

  • 录音文件落盘后立即加密存储;
  • 音频访问接口增加鉴权,不能直接把文件名暴露在公网;
  • 日志中不得记录完整识别文本,脱敏后保留关键词即可;
  • 定期删除过期会话数据,避免长期留存。

6.3 延迟优化

用户对通话体验最敏感的就是等待时间。以下是常见优化方向:

优化点 做法
合成结果缓存 相同回复文本直接返回缓存音频,不再调用 TTS
预加载音色 启动时加载声音模型,避免首次请求卡顿
识别与合成并行 上一轮回复播放时,提前识别下一轮录音
使用流式识别 边录音边识别,而不是等整段录完再传
音频大小控制 限制单次录音时长(新手建议 5-10 秒)

6.4 内容安全与情感边界

千万不要小看这个产品的“情感杀伤力”。在测试中,很多人听到已故亲人的声音后会情绪失控。因此产品设计上要克制:

  • 不要刻意模仿临终语气,不要使用“我在下面过得很好”这类暗示性表达;
  • 回复文本尽量温和、中立、带支持性,而不是加深哀伤;
  • 可以在对话结束后引导用户寻求专业心理支持;
  • 设置每日使用时长,提醒用户关注当下的现实生活。

好的技术不应该是为了“沉迷”而存在,而是为了让人更好地面对现实。

7. 总结与后续扩展方向

本文从一个“俄罗斯酒吧能给死去的人打电话”的传闻切入,把神秘面纱拆开后,落地成了一个可以动手运行的语音交互 Demo。我们做了如下几个核心工作:

  • 分析了“模拟通话”类应用的技术链路;
  • 使用 FastAPI 搭建了后端服务;
  • 用 Vosk 实现了离线语音识别;
  • 用 edge-tts 实现了自然语音合成;
  • 写了一个浏览器页面,模拟了接通电话、对话、挂断的完整流程;
  • 整理了常见报错与最佳实践。

如果你对这个项目感兴趣,接下来可以继续往以下几个方向扩展:

  1. 接真实电话线路 :接入 SIP 服务或云通信平台,让用户用手机号直接拨打;
  2. 接入大模型 :用 LLM 替代关键词匹配,让人物对话更有记忆点;
  3. 声音克隆 :使用 CosyVoice、GPT-SoVITS 等模型,仅用数十秒录音生成目标音色;
  4. 多轮记忆 :把每次对话内容存到数据库,让 AI 能在后续对话中引用之前的信息;
  5. 情绪检测 :在识别文本的同时做情感分析,遇到极度悲伤的情绪及时插入安抚语。

最后再次提醒,这类产品无论技术做得多炫酷,都需要把“授权、透明、情感安全”放在最前面。技术本身是中性的,但如何使用技术,决定了它带来的是安慰还是伤害。希望这篇文章能帮你少踩一些坑,也让你在动手写代码时,多一层对产品意义的思考。如果你在运行过程中遇到问题,欢迎按上面的排查清单逐项检查;也建议先跑通最小闭环,再逐步替换更高阶的模型和能力。

Logo

火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。

更多推荐