# 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 ![image](https://hackmd.io/_uploads/BJ4k9Z3YWe.png) --- ## 使用到的服務 - **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 流程。 ![image](https://hackmd.io/_uploads/rknvWfnKZe.png) --- ## 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> ![image](https://hackmd.io/_uploads/r1yyn-2tZl.png) ### 這裡的重點 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 }} ``` ![image](https://hackmd.io/_uploads/HkakzG6t-x.png) 這一步的目的很簡單: 先把後面會用到的欄位整理乾淨,後續比較好接。 --- ## 7. Convert to File - 先轉成 PCM 檔 這一步非常重要。 Gemini TTS 回來的是音訊 base64,但不是直接可播的 WAV。 所以先用 `Convert to File` 把它轉成 binary。 ### 目前設定 - Operation: `toBinary` - Source Property: `audio_base64` - Binary Property Name: `pcm_raw` ![image](https://hackmd.io/_uploads/r12EEMptZl.png) ### Options - File Name: `voice.pcm` - Mime Type: `audio/L16;codec=pcm;rate=24000` 這裡代表目前拿到的是: - 16-bit PCM - 24kHz - 單純原始音訊資料 --- ## 8. Code - 幫 PCM 補上 WAV Header ![image](https://hackmd.io/_uploads/H14fhbhtWl.png) 這一段是最容易卡住的地方。 因為很多人會以為: > 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 - 後續再接字幕與影片合成 這段流程可以直接拿去改成自己的版本。