技术笔记

从钉钉录音到可回听的结构化逐字稿:我的两个 Command 本地工作流

•27 分钟阅读
文章目录

从钉钉录音到可回听的结构化逐字稿:我的两个 Command 本地工作流

我经常用钉钉录音记录会议、培训和访谈。录音越来越多以后,我遇到了两个非常具体的问题:一是每次都要进入应用手动下载;二是即使把音频下载到了电脑里,我也很少愿意重新听完一两个小时的录音。

后来我把整个过程改造成了一条本地工作流:

QwenNote/钉钉录音 → 一键同步到本地 → 无损整理结构化逐字稿 → 浏览器中按时间戳回听

日常使用时,我只需要双击两个文件:

  1. 双击同步QwenNote.command:把新录音、摘要和原始逐字稿同步到本地。
  2. 双击打开逐字稿.command:打开本地音频逐字稿页面,点击任意时间戳即可回听。

两个 Command 中间还有一个关键环节:使用 lecture-notes-lossless 技能,把原始转写稿整理成忠于原文的结构化逐字稿。它不是摘要器,而是一套可以确定性验收的“轻清理+标题结构”流程。

使用虚构录音演示的完整工作流示意图

隐私说明: 本文配图使用脱敏模拟界面。录音标题、正文、日期、时长、段落数和文件大小均为虚构;功能、操作流程和标准文件结构依据真实系统。

本文既是这套工作流的搭建说明,也是给我自己的维护笔记。文中的令牌、个人录音内容和内部地址都不会公开。

一、最后得到什么

每条录音对应一个一级子文件夹,标准结构如下:

records/
├── 双击同步QwenNote.command
├── 双击打开逐字稿.command
├── .qwennote-sync/
│   └── sync_qwennote.py
├── .transcript-app/
│   ├── server.py
│   ├── pyproject.toml
│   └── web/
│       ├── index.html
│       ├── app.js
│       └── style.css
└── 2026-09-24 某次培训/
    ├── 录音.mp3
    ├── 摘要.md
    ├── 逐字稿.md
    └── 逐字稿_结构化.md

其中四个业务文件的分工是:

| 文件 | 用途 | |---|---| | 录音.mp3 | 原始录音,用于回听和核对 | | 摘要.md | QwenNote 生成的摘要、元信息和待办 | | 逐字稿.md | 带时间戳和发言人的原始转写稿 | | 逐字稿_结构化.md | 轻清理、带章节结构、可供回听页面读取的版本 |

有些录音文件夹中还保留了 逐字稿_结构化_回放.html。它可以作为独立页面存档,但当前更推荐使用一套共享的 .transcript-app:程序会自动扫描所有合格的录音文件夹,无需给每条录音重复生成一套页面。

为避免公开内部录音信息,本文统一使用一条虚构录音展示界面:标题为“个人知识管理入门”,时长为 42 分 18 秒,页面载入 86 个时间戳段落,示例资料库包含 12 条录音。图中的正文、日期和文件大小也全部是演示数据,仅用于说明页面如何工作;实际系统会读取本地真实文件。

二、整体架构

这条流程可以拆成三个彼此独立的阶段:

钉钉录音本地工作流整体架构

阶段 A:同步
QwenNote MCP
  ├── 录音列表
  ├── AI 摘要与待办
  ├── 分页逐字稿
  └── 音频下载地址
          ↓
本地标准三件套:摘要.md、逐字稿.md、录音.mp3

阶段 B:无损整理
逐字稿.md
  → prepare_transcript.py
  → outline.json(只规划标题)
  → build_notes.py
  → verify_notes.py
  → 逐字稿_结构化.md

阶段 C:联动回听
录音.mp3 + 逐字稿_结构化.md
  → 本地 Flask 服务
  → 浏览器中的目录、播放器、时间戳和可编辑正文

把三段拆开很重要。同步程序不负责“发挥”或改写正文;整理技能不负责下载;回听页面也不负责生成内容。每一段只处理一个明确问题,出错时比较容易定位。

三、准备环境

这套方案当前针对 macOS,使用 Python 3.11 以上版本,并用 uv 管理 Python 环境。

1. 安装 uv

如果电脑还没有安装 uv,按照 uv 官方方式安装。安装后确认:

uv --version

本文涉及的 Python 项目都遵循同一套约定:

uv sync
uv run script.py

不要把系统 Python 和项目依赖混在一起。

2. 准备资料库目录

例如:

mkdir -p "$HOME/Documents/records"

为了后续双击使用,建议把启动文件和两个隐藏程序目录都放在 records 根目录下。

如果是从零建立这两个 Python 项目,用 uv init 创建,而不是手工拼虚拟环境:

cd "$HOME/Documents/records"

uv init --bare .qwennote-sync
uv init --bare .transcript-app

cd .transcript-app
uv add flask

同步程序当前只使用 Python 标准库,不需要额外依赖;回听程序使用 Flask。现有项目拿到 pyproject.toml 和 uv.lock 后,只需执行 uv sync。

3. 准备 QwenNote 授权

同步脚本通过 QwenNote MCP 读取当前账号的录音数据。需要先完成 QwenNote 的正常授权,并在资料库根目录准备:

.qwennote_creds.json

文件包含以下字段:

{
  "client_id": "<your-client-id>",
  "access_token": "<your-access-token>",
  "refresh_token": "<your-refresh-token>",
  "expires_in": 0,
  "token_obtained_at": 0
}

这里展示的只能是结构,绝对不要把自己的真实令牌复制到博客、截图或代码仓库。建议至少执行:

chmod 600 .qwennote_creds.json

并在 .gitignore 中加入:

.qwennote_creds.json
.sync_state.json
.sync.log
.sync_qwennote.lock/
.transcript-app/runtime/
.transcript-app/backups/

QwenNote 的授权方式或 MCP 接口可能更新。如果你要复刻这套方案,凭据应通过自己账号的正式授权流程获得,不能复制别人的令牌。

四、Command 1:一键同步 QwenNote 录音

1. 同步脚本做了什么

.qwennote-sync/sync_qwennote.py 的主流程是:

  1. 读取本地凭据;
  2. 初始化 QwenNote MCP;
  3. 访问令牌过期时,使用刷新令牌更新;
  4. 分页读取全部录音列表;
  5. 使用 UUID 判断哪些录音已经处理;
  6. 对新录音分别获取摘要、待办、分页逐字稿和音频地址;
  7. 按“日期+标题”创建文件夹;
  8. 统一保存为 摘要.md、逐字稿.md、录音.mp3;
  9. 把处理状态写入 .sync_state.json。

当前使用的核心 MCP 工具包括:

list_my_minutes
get_minutes_ai_summary
list_minutes_todos
get_minutes_transcription
get_minutes_media_url

逐字稿按页拉取,每一段被保存为统一格式:

[00:06] 发言人 3: 行,那我们开始……

如果摘要中包含远程图片,同步脚本还会尝试把图片下载到 records/images/,再把 Markdown 中的链接改成本地相对路径。

2. 为什么要保存同步状态

.sync_state.json 保存 QwenNote 录音 UUID、标题和本地文件夹之间的对应关系。它解决了三个问题:

  • 再次运行时不会重复下载已经处理的录音;
  • 云端标题发生变化时,可以同步调整本地文件夹名;
  • 某个标准文件缺失时,可以单独补齐,而不是重新下载全部内容。

同步程序不会把 逐字稿_结构化.md 当成云端原稿覆盖。结构化文件是后续整理产生的独立资产。

3. 双击启动文件

双击同步QwenNote.command 主要负责把脚本包装成普通用户可以双击的入口:

  • 自动定位资料库目录;
  • 自动查找 uv;
  • 用目录锁防止两个同步任务同时运行;
  • 把终端输出追加到 .sync.log;
  • 成功后发送 macOS 通知;
  • 失败时提示查看日志。

核心执行命令只有一行:

uv run .qwennote-sync/sync_qwennote.py

首次配置后,要保证启动文件可执行:

chmod +x "双击同步QwenNote.command"

之后日常使用只需双击。看到“本次新增 0 条”并不代表失败,它通常表示云端没有新录音。

五、用 lecture-notes-lossless 生成无损结构化逐字稿

这一阶段不是摘要,也不是让大模型重新写一遍。

无损逐字稿的准备、构建和验收流程

我使用的 lecture-notes-lossless 技能把原始转写稿视为不可自由改写的数据源:正文只能由脚本逐段生成,模型只负责标题规划、带证据的疑难纠错,以及有来源标记的 PPT 独有补充。

1. 基本原则

  • 不概括、不缩写、不重排正文;
  • 保留原始顺序、第一人称、例子、互动和判断链;
  • 只删除白名单填充词、精确口吃、重复标点、时间戳和技术元数据;
  • ASR 纠错必须记录原词、新词和证据;
  • 标题最多四层,任一父标题最多七个直接子标题;
  • 构建完成后必须用脚本核对每一段正文。

这也是为什么我把它称为“无损结构化”,而不是“AI 总结”。

2. 准备技能环境

不同电脑上的技能安装目录可能不同,先把路径替换成自己的实际位置:

SKILL_DIR="/path/to/lecture-notes-lossless/1.0.2"
cd "$SKILL_DIR"
uv sync

3. 解析原始逐字稿

假设要处理的录音目录是:

RECORD_DIR="/absolute/path/to/records/2026-09-24 某次培训"
WORK_DIR="$RECORD_DIR/.lossless-work"

执行:

cd "$SKILL_DIR"
uv run scripts/prepare_transcript.py \
  "$RECORD_DIR/逐字稿.md" \
  --work-dir "$WORK_DIR"

脚本会生成:

.lossless-work/
├── segments.jsonl
├── canonical_body.md
├── operations.jsonl
└── manifest.json

其中 segments.jsonl 是逐段数据源,operations.jsonl 是每一次删除或替换的账本,manifest.json 记录段落数、首尾时间戳、字符数和正文哈希。

4. 有证据地纠正 ASR

如果发现明确的专有名词错误,不要直接修改中间文件,而是创建 corrections.json:

[
  {
    "segment_id": "S0018",
    "old": "错误识别词",
    "new": "正确术语",
    "category": "asr",
    "reason": "纠正专有名词",
    "evidence": "PPT 第 12 页"
  }
]

然后重新运行准备脚本:

uv run scripts/prepare_transcript.py \
  "$RECORD_DIR/逐字稿.md" \
  --work-dir "$WORK_DIR" \
  --corrections "$WORK_DIR/corrections.json"

没有证据的疑难词宁可保留,也不要凭常识猜测。

5. 只规划标题

模型在这个阶段只生成 outline.json,不能重写正文:

{
  "title": "某次培训",
  "headings": [
    {"before": "S0001", "level": 2, "title": "一、课程背景与目标"},
    {"before": "S0012", "level": 3, "title": "1.1 现场提出的第一个问题"},
    {"before": "S0040", "level": 2, "title": "二、具体案例与操作"}
  ]
}

标题只能插在段落边界,不能改变任何段落的顺序。第一条标题必须放在 S0001 前,标题层级只能使用 H2、H3 和 H4。

6. 构建并保留时间戳

为了让回听页面能够定位音频,我在构建时启用 --timestamps:

uv run scripts/build_notes.py \
  --segments "$WORK_DIR/segments.jsonl" \
  --outline "$WORK_DIR/outline.json" \
  --output "$RECORD_DIR/逐字稿_结构化.md" \
  --report "$WORK_DIR/build_report.json" \
  --timestamps

结构化正文格式如下:

# 某次培训

## 一、课程背景与目标

### 1.1 现场提出的第一个问题

[00:06] 发言人 3:
行,那我们开始……

7. 强制验收

uv run scripts/verify_notes.py \
  --segments "$WORK_DIR/segments.jsonl" \
  --notes "$RECORD_DIR/逐字稿_结构化.md" \
  --manifest "$WORK_DIR/manifest.json" \
  --report "$WORK_DIR/verify_report.json" \
  --timestamps

只有下面的条件全部成立,才算完成:

body_exact_match = True
原文段落覆盖 = N/N
unknown_body_additions = 0
unlogged_body_deletions = 0
标题最大深度 ≤ 4
每个父标题的直接子标题数 ≤ 7

如果失败,不要直接手工修改最终文件来绕过校验。应回到 corrections.json 或 outline.json 修正,再重新构建和验收。

六、Command 2:打开本地音频逐字稿页面

本地回听页面与安全边界

1. 页面如何发现录音

.transcript-app/server.py 会扫描 records 下的一级子文件夹。只有同时包含下面两个文件的文件夹才会出现在页面中:

录音.mp3
逐字稿_结构化.md

因此新增录音后无需修改网页代码,也不需要复制一份新的播放器程序。重新打开页面,它就会自动出现在录音下拉列表中。

2. 本地应用结构

这个小工具是一个非常轻量的 uv 项目:

[project]
name = "transcript-studio"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
    "flask>=3.1.3",
]

后端负责:

  • 扫描录音目录;
  • 解析 Markdown 标题、发言人和时间戳;
  • 支持 MP3 的浏览器分段读取;
  • 保存逐字稿修改;
  • 修改前自动备份;
  • 用 SHA-256 版本号检测外部修改冲突。

前端负责:

  • 显示 H2/H3 目录;
  • 搜索并切换录音;
  • 点击段落或时间戳跳转音频;
  • 播放时高亮当前段落并滚动跟随;
  • 直接编辑段落;
  • 停止输入约 1.2 秒后自动保存。

3. 为什么使用本地服务,而不是直接双击静态 HTML

浏览器直接打开 file:// 页面时,对本地音频读取、跨文件访问和安全保存会有较多限制。用本地服务可以统一处理这些问题,同时仍然保持数据只在自己的电脑中流动。

服务只监听:

127.0.0.1

它不会绑定局域网地址,也不会把录音上传到服务器。启动时随机选择一个可用端口,浏览器关闭且约 10 分钟没有活动后,服务会自动退出。

4. 双击启动文件做了什么

双击打开逐字稿.command 会:

  1. 检查 .transcript-app 是否存在;
  2. 如果虚拟环境不存在,自动执行 uv sync;
  3. 检查已有服务是否属于当前资料库;
  4. 已有服务可用时直接复用;
  5. 否则选择随机端口并在后台启动 Flask;
  6. 健康检查通过后自动打开浏览器;
  7. 关闭这次启动产生的 Terminal 标签页。

首次使用同样要设置执行权限:

chmod +x "双击打开逐字稿.command"

七、日常使用流程

搭建完成以后,我平时只做下面几步:

第一步:同步

在 Finder 中双击:

双击同步QwenNote.command

等待看到“同步完成”。新录音会以“日期+标题”自动建立文件夹。

第二步:无损整理

让 Codex 对新文件夹中的 逐字稿.md 使用 lecture-notes-lossless,要求:

使用 lecture-notes-lossless 整理这份逐字稿,保留时间戳,
生成逐字稿_结构化.md,并运行强制无损验收。

整理结束后,应查看验收报告,而不是只看文章是否“像一篇好看的笔记”。

第三步:回听和校对

双击:

双击打开逐字稿.command

在页面中选择录音,点击目录或时间戳开始回听。发现 ASR 错误时可以直接修改,页面会在保存前创建旧版本备份。

八、常见问题

1. 双击 .command 没有反应

先检查执行权限:

chmod +x "双击同步QwenNote.command"
chmod +x "双击打开逐字稿.command"

2. 同步失败

查看:

.sync.log

常见原因包括授权失效、网络异常、接口更新和凭据文件缺失。脚本能够自动刷新过期的 access token,但 refresh token 本身失效时,需要重新完成账号授权。

3. 显示“本次新增 0 条”

这通常是正常结果,表示云端列表读取成功,但没有尚未处理的新录音。

4. 页面中找不到某条录音

检查对应一级文件夹中是否同时存在:

录音.mp3
逐字稿_结构化.md

文件名必须完全一致。

5. 回听页面启动失败

查看:

.transcript-app/runtime/server.log

如果虚拟环境损坏,可进入 .transcript-app 后重新同步:

uv sync

6. 页面提示磁盘内容已经变化

这是版本冲突保护生效了。说明浏览器打开页面后,另一个程序又修改了同一份 Markdown。先载入磁盘上的最新版本,再继续编辑,不要强行覆盖。

7. 如何恢复误改内容

每次从页面保存前,旧版本都会按录音文件夹分类保存在:

.transcript-app/backups/

九、隐私与安全边界

录音资料往往包含会议、个人谈话、工作安排甚至敏感信息,所以这套方案把本地优先作为硬约束:

  • 回听服务只监听 127.0.0.1;
  • 不把 MP3 和逐字稿上传到额外的第三方服务;
  • 令牌文件不进入 Git;
  • 对外截图优先选择公开培训,不展示内部谈话标题;
  • 发布博客前再次检查图片、日志和代码块中是否出现令牌;
  • 如果需要远程访问,不要直接把 Flask 开发服务暴露到公网。

还要注意:QwenNote 同步本身需要访问 QwenNote 的云端接口,这是录音从云端进入本地所必需的网络行为;“本地优先”主要指同步完成后的整理、播放和编辑环节。

十、给 Codex 的复刻清单

如果要让 Codex 在另一台 Mac 上重建这套工作流,可以直接把下面这段作为任务要求:

请在 records 目录中搭建一套本地音频逐字稿资料库:

1. 使用 uv 管理所有 Python 环境。
2. 创建 .qwennote-sync/sync_qwennote.py,通过我已授权的 QwenNote MCP:
   - 分页获取全部录音;
   - 下载摘要、待办、分页逐字稿和 MP3;
   - 按“日期+标题”建立一级文件夹;
   - 标准化为 摘要.md、逐字稿.md、录音.mp3;
   - 使用状态文件防止重复下载;
   - 不覆盖 逐字稿_结构化.md;
   - 不在代码、日志或回复中暴露令牌。
3. 创建可双击的 双击同步QwenNote.command:
   - 自动定位目录和 uv;
   - 防止并发同步;
   - 写入 .sync.log;
   - 成功后显示通知。
4. 使用 lecture-notes-lossless 把逐字稿.md 构建成带时间戳的
   逐字稿_结构化.md,并通过 verify_notes.py 的强制验收。
5. 创建 .transcript-app uv 项目和 双击打开逐字稿.command:
   - 只监听 127.0.0.1;
   - 自动扫描同时包含 录音.mp3 与 逐字稿_结构化.md 的一级子目录;
   - 支持录音搜索与切换;
   - 点击时间戳跳转;
   - 播放时高亮并跟随;
   - 文本编辑后自动保存;
   - 保存前备份;
   - 使用内容哈希防止外部修改被静默覆盖;
   - 10 分钟无活动后自动退出。
6. 完成后实际双击两个 Command,并用一条非敏感录音验证完整流程。

十一、这套工作流真正解决了什么

我以前把“已经录音”误认为“已经保存了信息”,把“已经下载”误认为“以后会重新利用”。实际上,只要回听的启动成本仍然很高,录音最终还是会成为数字仓库里的沉没资料。

这套工作流没有试图用一段短摘要替代完整录音,而是同时保留了三种不同精度的入口:

  • 想快速了解时看 摘要.md;
  • 想系统阅读时看 逐字稿_结构化.md;
  • 想核对语气和上下文时,点击时间戳回到原始音频。

对我来说,真正有价值的不是“AI 帮我总结了一次”,而是录音终于从一个很难重新打开的文件,变成了可搜索、可阅读、可定位、可校对、可持续积累的个人资料库。

最后更新:2026年9月26日

评论

加载中...
加载评论中...