# AI影片工廠:用 n8n 串 Notion、Gemini TTS 自動產生配音
## 前言
最近在整理短影音自動化流程時,我把「文稿轉語音」這段獨立拉出來做,目標很單純:
1. 從 **Notion Database** 讀出要配音的文稿
2. 呼叫 **Gemini TTS API** 產生語音
3. 把 API 回傳的 **base64 PCM 音訊** 轉成檔案
4. 再補成可直接播放的 **WAV**
5. 最後自動上傳到 **Google Drive**
這篇記錄的是我目前的 n8n 流程與實作方式,特別是 **Gemini TTS 回來的是 PCM,不是可直接播放的 WAV** 這件事,這裡比較容易踩坑。
---
## 這個流程解決什麼問題?
如果你的短影音或配音流程是這種型態:
- 文案先寫在 Notion
- 想要批次產生旁白
- 音檔希望自動存進 Google Drive
- 後面再接剪輯、字幕、影片合成
那這一段流程就很適合拆出來獨立做。
---
## 流程總覽
```mermaid
flowchart LR
A[Manual Trigger] --> B[Notion 讀取 active 文稿]
B --> C[Split In Batches]
C --> D[Data Table 取 Gemini API Key]
D --> E[HTTP Request 呼叫 Gemini TTS]
E --> F[Set 節點取出 audio_base64]
F --> G[Convert to File 轉為 pcm_raw]
G --> H[Code 節點補 WAV Header]
H --> I[Google Drive Upload]
```
▼n8n flow

---
## 使用到的服務
- **n8n**
- **Notion Database**
- **Gemini TTS API**
- **n8n Data Table**
- **Google Drive**
---
## Notion 資料結構
這個流程至少會用到以下欄位:
- `scene_script`:要送進 TTS 的配音文稿
- `Status`:目前用來篩選 `active`
也就是說,只有狀態為 `active` 的資料會進入配音流程。
---
## 流程節點說明
## 1. Manual Trigger
手動執行流程測試用。
---
## 2. Notion - Get many database pages
從 Notion Database 抓出狀態為 `active` 的資料。
目前設定重點:
- Resource: `databasePage`
- Operation: `getAll`
- Filter:
- `Status|select = active`
這樣做的好處是可以把配音清單放在 Notion 管理,不需要每次都改 n8n 流程。

---
## 3. Split In Batches
我目前設 `batchSize: 6`。
原因是批次跑的時候,不想一次把全部文稿都灌進去,避免:
- API 限流
- 失敗時不好追
- 後續 Google Drive 檔案過多不好觀察
---
## 4. Data Table - 讀取 API Key
我把 API Key 放在 n8n 的 Data Table 裡,透過 `KeyName = Gemini` 去抓。
這樣比直接把 Key 寫死在 HTTP Request 節點裡好一些,之後換 Key 比較方便。
---
## 5. HTTP Request - 呼叫 Gemini TTS
這是整個流程的核心。
### Request URL
```
https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-tts:generateContent
```
### Header
```
x-goog-api-key: {{ $json.KeyValue }}
```
### Body
<details>
<summary>點擊查看程式碼</summary>
```json
{
"contents": [
{
"parts": [
{
"text": "{{ $('Get many database pages').item.json.property_scene_script }}"
}
]
}
],
"generationConfig": {
"responseModalities": ["AUDIO"],
"speechConfig": {
"voiceConfig": {
"prebuiltVoiceConfig": {
"voiceName": "Kore"
}
}
}
},
"model": "gemini-2.5-flash-preview-tts"
}
```
</details>

### 這裡的重點
Gemini TTS 回來的JSON音訊資料會放在:
```text
candidates[0].content.parts[0].inlineData.data
```
這個欄位是 **base64 字串**。
而 MIME Type 會在:
```text
candidates[0].content.parts[0].inlineData.mimeType
```
<details>
<summary>Gemini回傳JSON</summary>
```json
[
{
"candidates": [
{
"content": {
"parts": [
{
"inlineData": {
"mimeType": "audio/L16;codec=pcm;rate=24000",
"data": "略…" }
}
],
"role": "model"
},
"finishReason": "STOP",
"index": 0
}
],
"usageMetadata": {
"promptTokenCount": 28,
"candidatesTokenCount": 221,
"totalTokenCount": 249,
"promptTokensDetails": [
{
"modality": "TEXT",
"tokenCount": 28
}
],
"candidatesTokensDetails": [
{
"modality": "AUDIO",
"tokenCount": 221
}
]
},
"modelVersion": "gemini-2.5-flash-preview-tts",
"responseId": "LpKvabm8Bvml1e8P0JqdgA0"
}
]
```
</details>
---
## 6. Edit Fields - 把Gemini API回傳內容整理出來
我用 Set 節點把需要的欄位先拉平:
- `audio_base64`
- `audio_mime`
設定如下:
```j
audio_base64 = {{ $json.candidates[0].content.parts[0].inlineData.data }}
audio_mime = {{ $json.candidates[0].content.parts[0].inlineData.mimeType }}
```

這一步的目的很簡單:
先把後面會用到的欄位整理乾淨,後續比較好接。
---
## 7. Convert to File - 先轉成 PCM 檔
這一步非常重要。
Gemini TTS 回來的是音訊 base64,但不是直接可播的 WAV。
所以先用 `Convert to File` 把它轉成 binary。
### 目前設定
- Operation: `toBinary`
- Source Property: `audio_base64`
- Binary Property Name: `pcm_raw`

### Options
- File Name: `voice.pcm`
- Mime Type: `audio/L16;codec=pcm;rate=24000`
這裡代表目前拿到的是:
- 16-bit PCM
- 24kHz
- 單純原始音訊資料
---
## 8. Code - 幫 PCM 補上 WAV Header

這一段是最容易卡住的地方。
因為很多人會以為:
> base64 轉成檔案後就能播了
但其實 **PCM 原始資料沒有 WAV Header**,播放器通常無法正常辨識。
所以需要自己補 44 bytes 的 WAV Header,拼回完整 WAV 檔。
### Code 節點內容
```javascript
function createWavHeader(dataLength, sampleRate = 24000, numChannels = 1, bitsPerSample = 16) {
const byteRate = sampleRate * numChannels * bitsPerSample / 8;
const blockAlign = numChannels * bitsPerSample / 8;
const header = Buffer.alloc(44);
header.write('RIFF', 0, 'ascii');
header.writeUInt32LE(36 + dataLength, 4);
header.write('WAVE', 8, 'ascii');
header.write('fmt ', 12, 'ascii');
header.writeUInt32LE(16, 16);
header.writeUInt16LE(1, 20); // PCM
header.writeUInt16LE(numChannels, 22);
header.writeUInt32LE(sampleRate, 24);
header.writeUInt32LE(byteRate, 28);
header.writeUInt16LE(blockAlign, 32);
header.writeUInt16LE(bitsPerSample, 34);
header.write('data', 36, 'ascii');
header.writeUInt32LE(dataLength, 40);
return header;
}
const item = $input.item;
// 不要直接讀 item.binary.pcm_raw.data
// 用 helper 取真正的 binary buffer
const pcmBuffer = await this.helpers.getBinaryDataBuffer($itemIndex, 'pcm_raw');
if (!pcmBuffer || !pcmBuffer.length) {
throw new Error('讀不到 pcm_raw 的實際 binary buffer');
}
const wavHeader = createWavHeader(pcmBuffer.length, 24000, 1, 16);
const wavBuffer = Buffer.concat([wavHeader, pcmBuffer]);
item.binary = item.binary || {};
item.binary.data = {
data: wavBuffer.toString('base64'),
mimeType: 'audio/wav',
fileName: 'voice.wav',
fileExtension: 'wav',
};
item.json = item.json || {};
item.json.output_file_name = 'voice.wav';
item.json.output_mime_type = 'audio/wav';
item.json.output_bytes = wavBuffer.length;
item.json.pcm_bytes = pcmBuffer.length;
return item;
```
---
## 為什麼不能直接讀 `item.binary.pcm_raw.data`?
這是我這次一個比較關鍵的坑。
在 n8n 裡,binary 資料不一定適合直接從:
```javascript
item.binary.pcm_raw.data
```
硬抓字串來處理。
比較穩定的方式是:
```
await this.helpers.getBinaryDataBuffer($itemIndex, 'pcm_raw');
```
這樣才能拿到真正的 binary buffer,不然很容易出現:
- 檔案大小怪怪的
- WAV 只有幾十 bytes
- 播放時間 0 秒
- 聲音雜訊或完全不能播
---
## 9. Google Drive Upload
WAV 產生完成後,我直接上傳到 Google Drive。
目前設定:
- Input Data Field Name: `data`
- Upload Folder: 指定目標資料夾
- 檔名:目前使用 `scene_script` 當名稱
---
## 實作完成後的結果
完成後,每一筆 Notion 的文稿都會變成一個 WAV 音檔,自動上傳到指定的 Google Drive 資料夾。
這樣後面如果要再接:
- 字幕生成
- 圖片搭配
- FFmpeg 合成影片
- 自動上傳社群平台
就會很順。
---
## 這個流程我覺得最容易踩的坑
## 1. 以為 API 回傳的是可直接播放的 WAV
不是。
它本質上是 **PCM raw audio**,還要補 WAV Header。
---
## 2. Convert to File 成功,不代表音檔可播放
`Convert to File` 只是把 base64 轉成 binary。
如果來源本身是 raw PCM,還是不能直接當 WAV 用。
---
## 3. Binary 不能亂抓
很多時候不是資料沒回來,而是抓法錯了。
建議直接用:
```javascript
this.helpers.getBinaryDataBuffer()
```
---
## 4. 檔名不要直接吃整段文稿
目前你的 Google Drive 節點是:
```text
name = {{ $('Loop Over Items').item.json.property_scene_script }}
```
這在測試時可以,但正式使用比較容易出問題,例如:
- 檔名太長
- 含特殊字元
- 同名覆蓋
- Google Drive 看起來很亂
比較建議改成:
```text
scene_001.wav
scene_002.wav
```
或是至少加一個可控欄位,例如:
- `scene_no`
- `title`
- `file_name`
---
## 我後續可能會再補的優化方向
後面如果要把這段流程變成可長期使用,我覺得還可以補幾件事:
1. **產完音檔後回寫 Notion 狀態**
- 例如從 `active` 改成 `done`
- 避免重複生成
2. **Google Drive 回存檔案網址到 Notion**
- 後續剪片流程更好接
3. **增加失敗重試機制**
- 特別是 Gemini API quota 或暫時性錯誤
4. **檔名正規化**
- 自動過濾特殊字元
- 自動截斷過長名稱
5. **接 FFmpeg / 影片合成流程**
- 後面就能直接串成完整短影音生產線
---
## 總結
這條 n8n 流程的核心重點其實只有一句:
> **Gemini TTS 回來的是 PCM raw audio,不是可直接播放的 WAV。**
所以如果你遇到:
- 音檔只有 0 秒
- 下載後不能播
- 聲音怪怪的
- 檔案大小不合理
大機率問題不是 TTS 沒成功,而是 **你還沒把 PCM 正確包成 WAV**。
我目前這套作法是:
- 先從 Notion 讀文稿
- 呼叫 Gemini TTS
- 取出 base64 音訊
- 轉成 PCM binary
- 用 Code 節點補 WAV Header
- 上傳 Google Drive
這樣就能穩定輸出可用的 WAV 檔。
---
## 附註
如果你也在做:
- 短影音自動生成
- AI 配音批次化
- n8n 串 Notion / Google Drive / Gemini
- 後續再接字幕與影片合成
這段流程可以直接拿去改成自己的版本。