一、为什么做这件事

用蓝牙耳机开会,会后自动拿到一份结构化的会议纪要——这件事完全可以在自己的 VPS 上免费实现,不需要买带 AI 的专属耳机,也不需要按分钟付费的商业订阅。

自建的三个直接收益:

  • 零按分钟费用:转录用本地 faster-whisper,摘要走自有 OneAPI 网关,没有时长上限。
  • 数据自持:音频与转写文本只在自己的 VPS 上处理,不进入第三方云。
  • 完全可控:摘要模板、语言、输出格式随时可改。

本文记录从零搭建到跑通的完整过程,包括中间踩过的网络坑和最终解法。

二、整体架构

VPS 在云端,无法直接”听”到你 Mac 上的会议声音,所以流程必然是本地录音 → 上传 VPS → 转写 → 摘要:

[BlackHole 聚合设备] --录音--> [QuickTime 导出 wav/m4a]
        |
        v  (文件落到 ~/Downloads/Meetings)
[Mac: meeting_watcher.py (launchd 常驻)]
        |  HTTP POST (Tailscale 内网)
        v
[VPS: FastAPI /summarize]
        |
        v
[faster-whisper 本地转写]
        |
        v
[OneAPI → LLM 生成结构化摘要]
        |
        v
[同目录生成 *_SUMMARY.txt] <--+

核心只有两个组件:VPS 上的处理 API,和 Mac 上的文件夹监控脚本。

三、VPS 端部署

3.1 项目结构

/opt/meeting-processor/
├── app/main.py
└── docker-compose.yml

3.2 main.py

import os
import threading
from fastapi import FastAPI, UploadFile, File
import requests
from faster_whisper import WhisperModel

app = FastAPI()
ONEAPI_URL = os.getenv("ONEAPI_URL")
ONEAPI_KEY = os.getenv("ONEAPI_KEY")
LLM_MODEL   = os.getenv("LLM_MODEL")

print("Loading Whisper Model...")
whisper_model = WhisperModel("base", device="cpu", compute_type="int8")
print("Whisper model ready.")

_transcribe_lock = threading.Lock()

@app.get("/health")
def health():
    return {"ok": True}

@app.post("/summarize")
async def summarize_meeting(file: UploadFile = File(...)):
    temp_path = f"temp_{file.filename}"
    with open(temp_path, "wb") as f:
        f.write(await file.read())

    with _transcribe_lock:
        segments, info = whisper_model.transcribe(temp_path, beam_size=5)
        detected_lang = info.language
        transcript = " ".join([s.text for s in segments])
    os.remove(temp_path)

    headers = {"Authorization": f"Bearer {ONEAPI_KEY}",
               "Content-Type": "application/json"}
    system_instruction = (
        "You are an expert executive secretary. Analyze the transcript, detect the "
        "primary language (English, Japanese, or Chinese) and answer in THAT language. "
        "Structure the output exactly as: 1. Executive Summary / 核心摘要 / 概要, "
        "2. Key Decisions / 关键决策 / 決定事項, "
        "3. Action Items with owners / 待办事项与负责人 / タスクと担当者."
    )
    payload = {
        "model": LLM_MODEL,
        "messages": [
            {"role": "system", "content": system_instruction},
            {"role": "user", "content": f"Transcript:\n{transcript}"},
        ],
        "temperature": 0.2,
        "max_tokens": 2000,
    }
    r = requests.post(f"{ONEAPI_URL}/chat/completions",
                      json=payload, headers=headers, timeout=600)
    try:
        summary = r.json()["choices"][0]["message"]["content"]
    except Exception:
        return {"success": False, "error": f"OneAPI error {r.status_code}: {r.text[:500]}"}
    return {"success": True, "summary": summary, "language": detected_lang}

3.3 docker-compose.yml(带资源限制)

services:
  meeting-processor:
    image: python:3.11-slim
    container_name: meeting_processor
    ports:
      - "100.99.4.120:8001:8000"   # 只绑 Tailscale IP,公网不可达
    environment:
      - ONEAPI_URL=http://172.17.0.1:3000/v1
      - ONEAPI_KEY=sk-xxx
      - LLM_MODEL=gpt-oss:120b
      - OMP_NUM_THREADS=2
    volumes:
      - ./app:/app
      - meeting_models:/root/.cache
    working_dir: /app
    command: >
      bash -c "pip install --no-cache-dir fastapi uvicorn requests python-multipart faster-whisper && uvicorn main:app --host 0.0.0.0 --port 8000 --workers 1"
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: '2.0'
          memory: 3G
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

volumes:
  meeting_models:

资源限制是关键——VPS 上还跑着 nginx、MySQL、n8n、OneAPI 等一堆服务,转写任务不能把 CPU 吃满。

四、Mac 端录音与监控

4.1 BlackHole 聚合设备

brew install blackhole-2ch,重启后在”音频 MIDI 设置”里建两个虚拟设备:

  • Meeting Output(多输出设备):BlackHole 2ch + 耳机/扬声器
  • Meeting Input(聚合设备):BlackHole 2ch + 麦克风,时钟源选 BlackHole 2ch

开会时系统输出选 Meeting Output,QuickTime 录音输入选 Meeting Input,录完存到 ~/Downloads/Meetings/。

4.2 watcher 脚本(launchd 常驻)

~/meeting_watcher.py 监控文件夹,文件大小稳定后上传,摘要写回同目录:

import os, time, requests
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler

WATCH_FOLDER = os.path.expanduser("~/Downloads/Meetings")
VPS_API_URL = "http://100.99.4.120:8001/summarize"

class Handler(FileSystemEventHandler):
    def on_created(self, event):
        if event.is_directory or os.path.basename(event.src_path).startswith('.'):
            return
        path = event.src_path
        if not path.lower().endswith(('.mp3', '.m4a', '.wav')):
            return
        size = -1
        while True:
            time.sleep(3)
            try:
                cur = os.path.getsize(path)
                if cur == size and cur > 0:
                    break
                size = cur
            except OSError:
                time.sleep(1)
        with open(path, 'rb') as f:
            files = {'file': (os.path.basename(path), f, 'audio/mpeg')}
            r = requests.post(VPS_API_URL, files=files, timeout=900)
        if r.status_code == 200 and r.json().get("success"):
            out = os.path.splitext(path)[0] + "_SUMMARY.txt"
            with open(out, "w", encoding="utf-8") as o:
                o.write(r.json()["summary"])
            os.system('osascript -e \'display notification "Summary ready!" with title "Meeting Assistant"\' 2>/dev/null')

用 launchd plist 让它开机自启、崩溃自重启。注意:macOS 的 Homebrew Python 受 PEP 668 保护,pip3 install 会被拒。正确做法是给 watcher 建一个专用 venv:

python3 -m venv ~/.meeting-watcher-venv
~/.meeting-watcher-venv/bin/pip install watchdog requests

plist 里的 ProgramArguments 指向 ~/.meeting-watcher-venv/bin/python3。

五、踩坑记录:容器为什么连不上 OneAPI

这是整个项目耗时最长的部分。VPS 上 OneAPI 跑在 0.0.0.0:3000,Mac 从公网访问完全正常,但 meeting_processor 容器内 requests.post("http://172.17.0.1:3000/...") 一直报 Connection refused。

5.1 逐层排除

尝试 结果 原因
ufw allow from 172.17.0.0/16 to any port 3000 失败 容器实际在 172.21.0.0/16,不是 172.17
ufw allow from 172.21.0.0/16 to any port 3000 失败 流量走 FORWARD,不是 INPUT
DOCKER-USER 放行 172.16.0.0/12 互访 失败 UFW 的 DEFAULT_FORWARD_POLICY=DROP 在 DOCKER-USER 之前就丢包
改 DEFAULT_FORWARD_POLICY=ACCEPT 失败 容器到宿主机网关走 INPUT,不是 FORWARD
host.docker.internal:host-gateway 超时 最终仍经 docker-proxy 转发,路径没变
extra_hosts 写死 one-api 容器 IP 超时 OneAPI 有多个 IP,取到的那个从容器不可达
socat 转发 172.21.0.1:3001 → 127.0.0.1:3000 超时 3001 没在 INPUT 链放行
iptables -I INPUT 1 -s 172.21.0.0/16 --dport 3001 -j ACCEPT 成功 插到 INPUT 最前面,绕过 UFW 链式结构

5.2 根因

meeting_processor 在 172.21.0.0/16 网桥,它访问 172.21.0.1:3001(socat 转发端口)时,流量进入宿主机 INPUT 链。UFW 默认 deny (incoming),而 ufw allow 添加的规则在 ufw-user-input 链里,包在到达那条规则前就被 before-input 链或默认策略丢了。

关键教训:ufw allow 不保证生效——它只是往 UFW 自己的链里加规则,如果包在到达那条链之前就被别的规则处理了,放行规则形同虚设。直接用 iptables -I INPUT 1 插到最前面,是验证”是不是 INPUT 拦的”最快方式。

5.3 最终方案

# 容器访问宿主机 socat 端口
iptables -I INPUT 1 -s 172.21.0.0/16 -p tcp --dport 3001 -j ACCEPT
iptables -I INPUT 1 -i br-<网桥名> -p tcp --dport 3001 -j ACCEPT

# socat 转发
# /etc/systemd/system/oneapi-forward.service
# ExecStart=/usr/bin/socat TCP-LISTEN:3001,bind=172.21.0.1,fork,reuseaddr TCP:127.0.0.1:3000

# 持久化
apt-get install -y iptables-persistent
netfilter-persistent save

meeting_processor 的 ONEAPI_URL 改成 http://172.21.0.1:3001/v1。OneAPI 容器全程未动,StartedAt 不变。

5.4 为什么 Mac 能访问但容器不能

Mac 通过公网 162.43.92.249:3000 访问,走外部 → 宿主机 INPUT,UFW 里已有 3000/tcp ALLOW Anywhere,所以通。

容器访问 172.17.0.1:3000 走内部网桥 → 宿主机,被 UFW 的 deny (incoming) 拦。两者路径完全不同,不能互相推断。

六、验证结果

真实中文测试(say -v Tingting 生成 30 秒会议音频):

耗时: 5s
{"success":true,
 "summary":"**1. 概要** 今天下午召开了产品评审会议,决定在下周一上线新的移动端功能。
 **2. 关键决策** - 新功能的上线时间定为下周一…
 **3. 待办事项与负责人** - **功能测试**:张三…",
 "language":"zh"}

watcher 常驻后,丢文件 10 秒内自动出 _SUMMARY.txt,右上角弹通知。

七、日常使用

  • 系统声音输出选 Meeting Output,QuickTime 录音输入选 Meeting Input
  • 录完存到 ~/Downloads/Meetings/
  • 10 秒后同目录出现 _SUMMARY.txt
  • 查看状态:launchctl list | grep meeting-watcher、tail -f /tmp/meeting_watcher.log

八、踩坑清单

# 坑 解法
1 Homebrew Python 拒绝 pip3 install 给 watcher 建专用 venv
2 launchd 加载后立刻生成测试文件,watcher 没捕捉到 真实使用时无此问题;测试时先确认服务在跑再丢文件
3 ufw allow from 172.21.0.0/16 不生效 流量走 INPUT,用 iptables -I INPUT 1 插到最前
4 DEFAULT_FORWARD_POLICY 改了仍不通 容器到宿主机走 INPUT,不是 FORWARD
5 extra_hosts 写死容器 IP OneAPI 多网络有多个 IP,取到的可能不可达
6 socat 端口没放行 同样用 iptables -I INPUT 1
7 iptables -I 重启丢失 iptables-persistent + netfilter-persistent save
8 容器镜像没有 curl,诊断脚本假阴性 用镜像自带的 python -c 测连通性
9 say 和 watcher 启动竞态 真实场景不存在;测试时等 watcher 就绪
10 推理模型 max_tokens 太小导致 content: null max_tokens=2000,或换非推理模型

九、已知限制

  • CPU 转写速度:base 模型 + int8,1 小时音频约 2–5 分钟。长会议可换 small 模型,代价是内存。
  • 单任务锁:并发上传会排队,避免 CPU 峰值。
  • Tailscale 依赖:Mac 和 VPS 需在同一 Tailscale 网络。公网访问需另行开放端口并加认证。
  • OneAPI 上游限流:DeepSeek 偶尔 429,OneAPI 会自动重试其他渠道,不是代码问题。

十、小结

整套方案的核心是分层解耦:本地负责录音,VPS 负责转写和摘要,OneAPI 负责模型接入。每一层都可以独立替换——换录音工具、换 Whisper 模型、换 LLM 都不影响其他层。

最耗时的不是代码,而是 Docker 容器访问宿主机服务的网络路径。ufw allow 不等于放行,172.17.0.1 不是万能网关,容器 IP 会变——这些只有逐层实测才能定位。最终用”socat 转发 + iptables -I INPUT 1 放行 + 持久化”这套组合拳解决,OneAPI 全程未动。

开完会丢个文件,10 秒后拿到结构化纪要,零按分钟费用,数据全在自己 VPS 上。

参考环境:Ubuntu VPS(CPU-only,约 6GB 内存,含 nginx/MySQL/n8n/OneAPI/Tailscale)、macOS + BlackHole 2ch、Python 3.11、faster-whisper base + int8、OneAPI 网关、gpt-oss:120b