AI围炉茶话会 — 工作流文档

1. 概述

  • 工作流名称:AI围炉茶话会
  • 工作流定位:用户输入一个聊天主题,自动生成两位说话人的口语化对话台词,并逐句合成为双人对话音频
  • 触发方式:n8n 表单(表单标题「话题闲聊」,字段「主题」)
  • 核心链路:主题输入 → 台词生成(LongCat-2.5)→ 双路语音合成(MiMo TTS)→ 音频合并(FFmpeg)→ 输出 merged_audio.wav
  • 输出:合并后的双人对话音频文件(merged_audio.wav)
  • 关键模型与服务:
    • 台词生成:LongCat-2.5(OpenAI 兼容节点,按 JSON Schema 约束返回结构化台词)
    • 语音合成:mimo-v2.5-tts,音色 Chloe(说话人 $1)、白桦(说话人 $2)
    • 音频合并:n8n FFMPEG 节点(concat 拼接,pcm_s16le 编码)

2. 整体流程架构

  • 主链路:表单填写 → 解析输入字段 → 生成编剧台词 → 转换数据类型 → 双分支拆分 → 双路语音合成 → 合并 → 重排 → 存储 → 音频拼接
  • 分支结构:台词按说话人拆为两路,分别走「发起生成语音请求」→「base64转文件」→「写盘」,最终汇入 Merge 节点
  • 数据流:主题文本 → 结构化台词数组 → 逐句音频(base64)→ 磁盘文件(1.xxx.wav / 2.xxx.wav)→ 文件列表 → 合并后的对话音频(merged_audio.wav)
  • 关键中间产物:
    • dialogue:台词数组,含 $1、$2 两位说话人各自的 content 列表
    • 双路文件命名:1..wav(说话人 $1)、2..wav(说话人 $2)
    • mylist.txt:按对话顺序排列的音频文件清单,供 FFmpeg concat 使用

3. 节点详解

3.1 表单填写(Form Trigger)

  • 类型:n8n-nodes-base.formTrigger,版本 2.6
  • 位置:[0, 0],整条工作流的触发器
  • 作用:生成表单页,用户填写「主题」后提交触发工作流
  • 参数:
    • formTitle:话题闲聊
    • formDescription:输入主题,给你返回闲聊
    • formFields.values[0].fieldLabel:主题
    • formFields.values[0].placeholder:请输入您要聊的主题
    • formFields.values[0].requiredField:true
    • options.appendAttribution:true
    • options.buttonLabel:Submit
  • 输出(每条 item 的 json):
1
2
3
4
5
6
{
"主题": "用户输入的主题文本",
"headers": { ... },
"query": { ... },
"params": { ... }
}
  • 说明:下游统一通过 $json[‘主题’] 读取,中文字段名必须用方括号写法访问

3.2 解析输入字段(Set)

  • 类型:n8n-nodes-base.set,版本 3.5
  • 位置:[224, 0]
  • 作用:把表单输出的中文字段「主题」映射为英文字段 topic,供下游节点引用
  • 参数(assignments.assignments):
    • id:680295b0-2c51-4820-8f79-c281a6f5aca8
    • name:topic
    • value:=
    • type:string
  • 输出:{ “topic”: “用户输入的主题文本” }

3.3 生成编剧台词(OpenAI)

  • 类型:@n8n/n8n-nodes-langchain.openAi,版本 2.3
  • 位置:[448, 0]
  • 作用:调用大模型,根据主题生成两位说话人的口语化对话台词,强制返回 JSON 结构
  • 参数:
    • modelId:{ “__rl”: true, “value”: “LongCat-2.5”, “mode”: “list”, “cachedResultName”: “LONGCAT-2.5” }
    • builtInTools:{}
    • responses.values[0].content:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
=请根据如下主题:{{ $json.topic }}

生成每人 10 句围绕这个主题闲谈的语句,要求口语化,有对话感,后续我需要转成双人说话的音频用

返回示例格式($1 为第一个开始说话的人,$2 为第二个开始说话的人):
[
{
"speaker": "$1",
"content": [
{"content": "台词"},
{"content": "台词"},
{"content": "台词"}
...
]
},
{
"speaker": "$2",
"content": [
{"content": "台词"},
{"content": "台词"},
{"content": "台词"}
...
]
}
]
  • options.textFormat.textOptions.type:json_schema
  • options.textFormat.textOptions.schema(完整):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "array",
"minItems": 2,
"maxItems": 2,
"prefixItems": [
{
"type": "object",
"required": ["speaker", "content"],
"additionalProperties": false,
"properties": {
"speaker": { "const": "$1" },
"content": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["content"],
"additionalProperties": false,
"properties": { "content": { "type": "string" } }
}
}
}
},
{
"type": "object",
"required": ["speaker", "content"],
"additionalProperties": false,
"properties": {
"speaker": { "const": "$2" },
"content": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["content"],
"additionalProperties": false,
"properties": { "content": { "type": "string" } }
}
}
}
}
],
"items": false
}
  • 输出:$json.output[0].content[0].text 为 JSON 字符串,解析后:
1
2
3
4
[
{ "speaker": "$1", "content": [ {"content": "..."}, ... ] },
{ "speaker": "$2", "content": [ {"content": "..."}, ... ] }
]

3.4 转换数据类型(Set)

  • 类型:n8n-nodes-base.set,版本 3.5
  • 位置:[816, 0]
  • 作用:把模型输出中的 JSON 字符串提取为字段 dialogue,并标记为数组类型,供后续 Split Out 使用
  • 参数:
    • assignments.assignments[0].id:d4f54230-93b9-4890-98e6-7e43f097f048
    • assignments.assignments[0].name:dialogue
    • assignments.assignments[0].value:=
    • assignments.assignments[0].type:array
    • options:{}
  • 输出:
1
2
3
4
5
6
{
"dialogue": [
{ "speaker": "$1", "content": [ {"content": "..."}, ... ] },
{ "speaker": "$2", "content": [ {"content": "..."}, ... ] }
]
}
  • 说明:type 设为 array 后,下游 Split Out 才能正确按数组下标取 dialogue[0].content / dialogue[1].content

3.5 Split Out / Split Out1(拆分双方台词)

3.5.1 Split Out(说话人 $1)

  • 类型:n8n-nodes-base.splitOut,版本 1.1
  • 位置:[1056, -176](上分支)
  • 作用:把 dialogue[0].content 数组逐项拆成独立 item,每个 item 是一句 $1 的台词
  • 参数:
    • fieldToSplitOut:dialogue[0].content
    • options:{}
  • 输出(每个 item):{ “content”: “一句台词” }
  • 说明:拆分后每个 item 的 $json.content 即为该句台词文本,直接作为 TTS 输入

3.5.2 Split Out1(说话人 $2)

  • 类型:n8n-nodes-base.splitOut,版本 1.1
  • 位置:[1024, 208](下分支)
  • 作用:把 dialogue[1].content 数组逐项拆成独立 item,每个 item 是一句 $2 的台词
  • 参数:
    • fieldToSplitOut:dialogue[1].content
    • options:{}
  • 输出(每个 item):{ “content”: “一句台词” }
  • 说明:与 Split Out 并行,分别对应两位说话人

3.6 发起生成语音请求 / 1(HTTP Request → MiMo TTS)

3.6.1 发起生成语音请求(说话人 $1)

  • 类型:n8n-nodes-base.httpRequest,版本 4.5
  • 位置:[1232, -176](上分支)
  • 作用:把单句台词发给 MiMo TTS 接口,返回 base64 音频
  • 参数:
    • method:POST
    • url:https://api.xiaomimimo.com/v1/chat/completions
    • sendHeaders:true
    • headerParameters.parameters[0].name:api-key(值为接口密钥)
    • headerParameters.parameters[1].name:Content-Type,value:application/json
    • sendBody:true
    • specifyBody:json
    • jsonBody:
1
2
3
4
5
6
7
8
{
"model": "mimo-v2.5-tts",
"messages": [
{ "role": "user", "content": "" },
{ "role": "assistant", "content": "{{ $json.content }}" }
],
"audio": { "format": "wav", "voice": "Chloe" }
}
  • options:{}
  • 说明:assistant 角色承载真正要合成的台词,user 角色为空串;voice 固定为 Chloe,代表说话人 $1
  • 输出:
1
2
3
4
5
{
"choices": [
{ "message": { "audio": { "id": "音频唯一标识", "data": "base64 编码的 wav 音频" } } }
]
}

3.6.2 发起生成语音请求1(说话人 $2)

  • 类型:n8n-nodes-base.httpRequest,版本 4.5
  • 位置:[1200, 208](下分支)
  • 作用:与 3.6.1 相同,但使用另一个音色,生成说话人 $2 的语音
  • 参数:与 3.6.1 完全一致,唯一差异:
    • jsonBody.audio.voice:白桦
  • 说明:两个 HTTP 节点形成并行双路,分别产出两位说话人的音频

3.7 base64转文件 / 1(Convert to File)

3.7.1 base64转文件(说话人 $1)

  • 类型:n8n-nodes-base.convertToFile,版本 1.2
  • 位置:[1392, -176]
  • 作用:把接口返回的 base64 音频字符串转成二进制文件
  • 参数:
    • operation:toBinary
    • sourceProperty:choices[0].message.audio.data
    • options.fileName:=.wav
    • options.mimeType:audio/wav
  • 输出:
1
2
3
4
{
"json": { "choices": [ ... ] },
"binary": { "data": { "fileName": "<id>.wav", "mimeType": "audio/wav", "fileData": "..." } }
}
  • 说明:文件名使用接口返回的 audio.id,保证唯一性

3.7.2 base64转文件1(说话人 $2)

  • 类型:n8n-nodes-base.convertToFile,版本 1.2
  • 位置:[1392, 208]
  • 作用:与 3.7.1 完全相同,处理说话人 $2 的音频
  • 参数:与 3.7.1 一致
  • 说明:两路产物文件名同为.wav,靠后续写盘时的前缀区分说话人

3.8 Read/Write Files from Disk1 / 2(写盘)

3.8.1 Read/Write Files from Disk1(说话人 $1)

  • 类型:n8n-nodes-base.readWriteFile,版本 1.1
  • 位置:[1584, -176]
  • 作用:把音频二进制写到本地磁盘
  • 参数:
    • operation:write
    • fileName:=/home/node/.n8n/1.
  • 输出:{ “fileName”: “/home/node/.n8n/1..wav”, “binary”: { … } }
  • 说明:统一加前缀 “1.”,标记为说话人 $1;后续 Code 节点据此区分两组文件

3.8.2 Read/Write Files from Disk2(说话人 $2)

  • 类型:n8n-nodes-base.readWriteFile,版本 1.1
  • 位置:[1568, 208]
  • 作用:与 3.8.1 相同,写入说话人 $2 的音频
  • 参数:
    • operation:write
    • fileName:=/home/node/.n8n/2.
  • 输出:{ “fileName”: “/home/node/.n8n/2..wav”, “binary”: { … } }
  • 说明:统一加前缀 “2.”,与说话人 $1 的文件区分

3.9 Merge(合并双分支)

  • 类型:n8n-nodes-base.merge,版本 3.2
  • 位置:[1808, 16]
  • 作用:把上分支(说话人 $1)与下分支(说话人 $2)的所有 item 汇总到一条链路
  • 参数:{}(无显式参数,使用默认合并模式)
  • 连接方式:
    • input(0) ← Read/Write Files from Disk1
    • input(1) ← Read/Write Files from Disk2
  • 输出:包含两路全部 item 的数组,每个 item 形如
1
2
3
{
"fileName": "/home/node/.n8n/1.xxx.wav" 或 "/home/node/.n8n/2.xxx.wav"
}
  • 说明:默认合并模式为 append,输出顺序为 $1 全部在前、$2 全部在后,因此后续 Code 节点必须按说话人重新交替排序

3.10 拉链式合并:两个数组改成对话方式(Code)

  • 类型:n8n-nodes-base.code,版本 2
  • 位置:[1968, 16]
  • 作用:把「先 $1 全部、后 $2 全部」的列表,重排成两人交替对话的顺序,并生成 FFmpeg concat 可用的文件清单文本
  • 参数:
    • jsCode:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const items = $input.all();

const isTarget = (item) => {
const fileName = item.json.fileName ?? '';
const base = fileName.split(/[\\/]/).pop() ?? '';
return base.startsWith('2.');
};

const groupA = items.filter(it => !isTarget(it));
const groupB = items.filter(it => isTarget(it));

const ordered = [];
const maxLen = Math.max(groupA.length, groupB.length);
for (let i = 0; i < maxLen; i++) {
if (i < groupA.length) ordered.push(`file '${groupA[i].json.fileName}'`);
if (i < groupB.length) ordered.push(`file '${groupB[i].json.fileName}'`);
}
return {
file_content: ordered.join(`\n`)
};
  • 输出:
1
2
3
{
"file_content": "file '/home/node/.n8n/1.xxx.wav'\nfile '/home/node/.n8n/2.yyy.wav'\n..."
}
  • 说明:
    • 用文件名前缀 “2.” 而非 speaker 字段判断说话人,因为此处 item 中只有 fileName
    • 交替顺序为 $1 → $2 → $1 → $2,形成你一言我一语的对话节奏
    • 输出内容直接对应 FFmpeg concat demuxer 的 list 文件格式

3.11 文本转二进制文件(Convert to File)

  • 类型:n8n-nodes-base.convertToFile,版本 1.2
  • 位置:[2144, 16]
  • 作用:把 Code 生成的清单文本转为二进制文件对象
  • 参数:
    • operation:toText
    • sourceProperty:=file_content
    • binaryPropertyName:=data
  • 输出:
1
2
3
4
{
"json": { "file_content": "file '...'\nfile '...'..." },
"binary": { "data": { "fileName": "...", "mimeType": "text/plain", "fileData": "..." } }
}
  • 说明:生成的是纯文本清单,不是音频

3.12 存储文件(Read/Write File)

  • 类型:n8n-nodes-base.readWriteFile,版本 1.1
  • 位置:[2336, 16]
  • 作用:把清单文本写到固定路径,供 FFmpeg 读取
  • 参数:
    • operation:write
    • fileName:/home/node/mylist.txt
  • 输出:{ “fileName”: “/home/node/mylist.txt” }

3.13 合并多个音频为一个音频(FFMPEG)

  • 类型:n8n-nodes-ffmpeg.ffmpeg,版本 1
  • 位置:[2512, 16]
  • 作用:按清单顺序把所有单句音频拼接为一个完整对话音频
  • 参数:
    • operation:custom
    • outputFileName:merged_audio.wav
    • outputBinary:mergedAudio
    • ffmpegCustomArgs:=-f concat -safe 0 -i ““ -c:a pcm_s16le -f wav “{output}”
  • 说明:使用 concat demuxer 无损拼接,输出为 wav 格式
  • 输出:二进制属性 mergedAudio,即最终成品音频

4. 关键设计说明

  • 双说话人拆分与并行 TTS 逻辑:
    • 台词生成后由两个 Split Out 节点分别拆分 $1 / $2,形成两条独立并行链路
    • 每条链路独立完成「TTS 请求 → base64 转文件 → 写盘」,互不阻塞
    • 优势:两次合成可并行执行,整体耗时约等于单路耗时,而非两路之和
  • 语音角色分配(Chloe / 白桦):
    • 说话人 $1 → voice: Chloe
    • 说话人 $2 → voice: 白桦
    • 两个 HTTP 节点除 voice 字段外参数完全一致,保证音色切换只影响该字段
  • 文件命名前缀区分说话人:
    • 说话人 $1 文件写为 /home/node/.n8n/1..wav
    • 说话人 $2 文件写为 /home/node/.n8n/2..wav
    • 前缀由写盘节点 fileName 表达式添加,下游仅凭文件名即可判断归属
  • 对话顺序重排算法(交替合并):
    • Merge 默认 append 导致顺序为「$1 全部在前、$2 全部在后」
    • Code 节点按文件名前缀 “2.” 分组,得到 groupA($1)与 groupB($2)
    • 按 maxLen 循环,交替 push file '<$1 文件>' 与 file '<$2 文件>'
    • 结果:$1 → $2 → $1 → $2 的对话节奏,而非两段各自独白

5. 输入输出规范

5.1 表单字段定义

  • 表单标题:话题闲聊
  • 表单描述:输入主题,给你返回闲聊
  • 字段:
    • 字段名(json key):主题
    • fieldLabel:主题
    • placeholder:请输入您要聊的主题
    • requiredField:true
  • 提交按钮文案:Submit
  • 附加归属信息:appendAttribution = true
  • 触发输出(每条 item):
1
2
3
4
5
6
{
"主题": "用户输入的主题文本",
"headers": { ... },
"query": { ... },
"params": { ... }
}

5.2 台词 JSON Schema 结构

  • 顶层:array,minItems = 2,maxItems = 2,items = false
  • prefixItems[0]:
    • type:object
    • required:[“speaker”, “content”]
    • additionalProperties:false
    • properties.speaker:const “$1”
    • properties.content:array,minItems = 1,items 为 object
      • required:[“content”]
      • additionalProperties:false
      • properties.content:string
  • prefixItems[1]:
    • 结构同上,唯一差异:properties.speaker.const = “$2”
  • 解析后结构示例:
1
2
3
4
[
{ "speaker": "$1", "content": [ {"content": "第一句"}, {"content": "第二句"}, ... ] },
{ "speaker": "$2", "content": [ {"content": "第一句"}, {"content": "第二句"}, ... ] }
]

5.3 最终音频文件规格

  • 文件名:merged_audio.wav
  • 格式:wav
  • 编码:pcm_s16le
  • 生成方式:FFmpeg concat demuxer 无损拼接(-f concat -safe 0 -i list.txt -c:a pcm_s16le -f wav)
  • 内容:两位说话人交替的对话音频
  • 输出二进制属性名:mergedAudio

6. 依赖与配置

6.1 模型依赖

  • 台词生成模型:LongCat-2.5
    • 节点:生成编剧台词(OpenAI)
    • modelId:{ “__rl”: true, “value”: “LongCat-2.5”, “mode”: “list”, “cachedResultName”: “LONGCAT-2.5” }
    • 通过 OpenAI 兼容节点调用,使用对应凭据
  • 语音合成模型:mimo-v2.5-tts

6.2 文件路径约定

  • 单句音频目录:/home/node/.n8n/
    • 说话人 $1:/home/node/.n8n/1..wav
    • 说话人 $2:/home/node/.n8n/2..wav
  • 音频清单文件:/home/node/mylist.txt
    • 内容格式:每行 file '<音频绝对路径>',按对话顺序排列
  • 最终成品:merged_audio.wav
    • 由 FFMPEG 节点输出,二进制属性名 mergedAudio

6.3 凭据与密钥说明

  • OpenAI 节点:使用 LongCat-2.5 对应的 OpenAI 兼容凭据(模型列表来自该凭据)
  • HTTP Request 节点(两个):请求头 api-key 字段携带 MiMo 接口密钥,用于调用 TTS 接口
  • 说明:两个 HTTP 节点共用同一接口地址与同一 api-key 请求头,仅 voice 字段不同

7. 运行与测试

7.1 触发方式

7.2 预期结果

  • 台词生成:返回含 $1 / $2 两位说话人的结构化 JSON
  • 语音合成:每位说话人每句台词生成一个 wav 文件
  • 文件落盘:/home/node/.n8n/ 下生成 1..wav 与 2..wav 两组文件
  • 清单生成:/home/node/mylist.txt 按 $1 → $2 交替顺序记录文件路径
  • 最终产物:FFMPEG 节点输出 merged_audio.wav,内容为两人交替对话的完整音频

7.3 常见问题排查

  • 表单提交后无反应:
    • 检查工作流是否已激活(Active)
    • 确认表单地址中的 Webhook ID 与节点一致
  • 台词生成失败或格式错误:
    • 检查 LongCat-2.5 凭据是否有效、模型是否在凭据可用列表中
    • 确认 options.textFormat.textOptions 已启用 json_schema,且 schema 与代码一致
  • TTS 请求失败(401/403):
  • 语音文件未生成或为空:
    • 检查 /home/node/.n8n/ 目录是否存在且可写
    • 确认 Convert to File 节点的 sourceProperty 路径 choices[0].message.audio.data 正确
  • 合并音频顺序错误:
    • 确认 Code 节点按文件名前缀 “2.” 分组逻辑正常
    • 检查 Read/Write Files from Disk1 / 2 是否分别加了 1. / 2. 前缀
  • 合并音频失败:
    • 检查 /home/node/mylist.txt 内容格式是否为 file '<路径>'
    • 确认 FFMPEG 节点 ffmpegCustomArgs 中 能正确引用清单文件路径