{
  "name": "Create voiceovers with karaoke subtitles using Edge TTS and FFmpeg",
  "nodes": [
    {
      "id": "overview",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -720,
        -420
      ],
      "parameters": {
        "color": 1,
        "width": 660,
        "height": 1180,
        "content": "## Create voiceovers with karaoke subtitles using Edge TTS\n\nTurns any text into an **MP3 voiceover plus a word-by-word karaoke subtitle file**, for free. No API key, no account, no credits \u2014 Edge TTS is Microsoft Edge's built-in Read Aloud voice, reachable from the open-source `edge-tts` CLI.\n\nMost caption workflows fake karaoke timing by dividing the clip length evenly across the words. Edge TTS audio is roughly a quarter silence \u2014 a lead-in, a ~1s pause at every full stop, a tail \u2014 so that stretches every word and the highlight drifts behind the voice. This one measures the real silences with ffmpeg and lays the words on speech time only, so each sentence starts exactly on its first syllable.\n\n### How it works\n\n1. A form takes your text and a language (7 included).\n2. The language is mapped to an Edge TTS voice name.\n3. `edge-tts` speaks the text to an MP3.\n4. `ffprobe` reads the duration and `silencedetect` finds every pause.\n5. Word timings are laid out on the speech, sentence by sentence.\n6. An `.ass` subtitle is written with `\\k` karaoke tags, wrapped by character width so long-word languages stay inside the frame.\n\nYou get back the MP3 and the .ass file. Burn them in with ffmpeg, or drop them into any editor.\n\n### Setup\n\n- `pipx install edge-tts`\n- ffmpeg + ffprobe on the PATH of the process running n8n\n- **Self-hosted n8n only.** This uses Execute Command, which n8n Cloud does not allow. n8n v2 also disables it by default \u2014 start n8n with `NODES_EXCLUDE=\"[]\"` and `N8N_RESTRICT_FILE_ACCESS_TO=\"/tmp\"` or the file steps fail silently.\n\n### Customization tips\n\nAdd a language by adding one row to the VOICES map. Run `edge-tts --list-voices` to see all 300+. Font, size and margins live in the ASS style line.\n"
      },
      "typeVersion": 1
    },
    {
      "id": "section---input",
      "name": "Section - input",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -40,
        -180
      ],
      "parameters": {
        "color": 7,
        "width": 620,
        "height": 420,
        "content": "## 1. Input\nYour text and language. Add languages in the voice map."
      },
      "typeVersion": 1
    },
    {
      "id": "section---speak",
      "name": "Section - speak",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        600,
        -180
      ],
      "parameters": {
        "color": 7,
        "width": 620,
        "height": 420,
        "content": "## 2. Speak the text\nedge-tts writes an MP3. Free, no account."
      },
      "typeVersion": 1
    },
    {
      "id": "section---measure",
      "name": "Section - measure",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1240,
        -180
      ],
      "parameters": {
        "color": 7,
        "width": 400,
        "height": 420,
        "content": "## 3. Measure the real speech\nffprobe reads the length, silencedetect finds every pause."
      },
      "typeVersion": 1
    },
    {
      "id": "section---build",
      "name": "Section - build",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1660,
        -180
      ],
      "parameters": {
        "color": 7,
        "width": 620,
        "height": 420,
        "content": "## 4. Build the karaoke subtitle\nWord timings on speech only, then an .ass file with \\k tags."
      },
      "typeVersion": 1
    },
    {
      "id": "note---self-hosted-only",
      "name": "Note - self hosted only",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -40,
        280
      ],
      "parameters": {
        "color": 3,
        "width": 300,
        "height": 160,
        "content": "## Self-hosted only\nExecute Command does not exist on n8n Cloud. See Setup in the overview."
      },
      "typeVersion": 1
    },
    {
      "id": "form-trigger",
      "name": "Text and language input form",
      "type": "n8n-nodes-base.formTrigger",
      "notes": "Entry point. Everything downstream reads Text and Language from here.",
      "position": [
        0,
        0
      ],
      "parameters": {
        "options": {},
        "formTitle": "Text to voiceover + karaoke subtitles",
        "formFields": {
          "values": [
            {
              "fieldType": "textarea",
              "fieldLabel": "Text",
              "placeholder": "Paste the text you want spoken.",
              "requiredField": true
            },
            {
              "fieldType": "dropdown",
              "fieldLabel": "Language",
              "fieldOptions": {
                "values": [
                  {
                    "option": "English (US)"
                  },
                  {
                    "option": "Vietnamese"
                  },
                  {
                    "option": "Spanish"
                  },
                  {
                    "option": "French"
                  },
                  {
                    "option": "German"
                  },
                  {
                    "option": "Portuguese (Brazil)"
                  },
                  {
                    "option": "Indonesian"
                  }
                ]
              },
              "requiredField": true
            }
          ]
        },
        "formDescription": "Paste any text and pick a language. You get back an MP3 and a word-by-word karaoke .ass subtitle file."
      },
      "typeVersion": 2.2
    },
    {
      "id": "pick-voice",
      "name": "Pick the Edge TTS voice",
      "type": "n8n-nodes-base.code",
      "notes": "Single source of truth for languages. Add a row to add a language.",
      "position": [
        220,
        0
      ],
      "parameters": {
        "jsCode": "// Maps the language you picked to an Edge TTS voice name.\n// Add a language by adding ONE row. Any voice from `edge-tts --list-voices` works.\nconst VOICES = {\n  'English (US)':        { name: 'English',              voice: 'en-US-GuyNeural' },\n  'Vietnamese':          { name: 'Vietnamese',           voice: 'vi-VN-NamMinhNeural' },\n  'Spanish':             { name: 'Spanish',              voice: 'es-ES-AlvaroNeural' },\n  'French':              { name: 'French',               voice: 'fr-FR-HenriNeural' },\n  'German':              { name: 'German',               voice: 'de-DE-ConradNeural' },\n  'Portuguese (Brazil)': { name: 'Brazilian Portuguese', voice: 'pt-BR-AntonioNeural' },\n  'Indonesian':          { name: 'Indonesian',           voice: 'id-ID-ArdiNeural' },\n};\n\nconst picked = $json['Language'];\nconst cfg = VOICES[picked];\nif (!cfg) {\n  throw new Error(\n    `No Edge TTS voice mapped for \"${picked}\". Add a row to the VOICES map in this node. ` +\n    `Run \"edge-tts --list-voices\" to see every available voice.`\n  );\n}\n\nconst text = ($json['Text'] || '').trim();\nif (!text) throw new Error('The Text field is empty - nothing to speak.');\n\nreturn { ...$json, text, languageName: cfg.name, voice: cfg.voice };\n"
      },
      "typeVersion": 2
    },
    {
      "id": "prepare-paths",
      "name": "Prepare file paths for this run",
      "type": "n8n-nodes-base.code",
      "notes": "One /tmp folder per submission so runs never overwrite each other.",
      "position": [
        440,
        0
      ],
      "parameters": {
        "jsCode": "// One folder per run so two runs can never overwrite each other.\n// submittedAt is unique per form submission and safe to use as an id.\nconst runId = ($('Text and language input form').item.json.submittedAt || String(Date.now()))\n  .replace(/[^a-zA-Z0-9]/g, '');\nconst dir = `/tmp/edge-tts-${runId}`;\nreturn {\n  ...$json,\n  dir,\n  textPath: `${dir}/input.txt`,\n  audioPath: `${dir}/voiceover.mp3`,\n  subtitlePath: `${dir}/voiceover.ass`,\n};\n"
      },
      "typeVersion": 2
    },
    {
      "id": "make-folder",
      "name": "Create the temp folder",
      "type": "n8n-nodes-base.executeCommand",
      "notes": "mkdir -p is safe to run again.",
      "position": [
        660,
        0
      ],
      "parameters": {
        "command": "=mkdir -p \"{{ $json.dir }}\"",
        "executeOnce": false
      },
      "typeVersion": 1
    },
    {
      "id": "text-binary",
      "name": "Turn the text into a file",
      "type": "n8n-nodes-base.code",
      "notes": "edge-tts reads from a file, which avoids shell-quoting the text.",
      "position": [
        880,
        0
      ],
      "parameters": {
        "jsCode": "// The Read/Write File node writes a binary, so turn the text into one first.\n// Read the text from Prepare file paths, not from $json: an Execute Command node\n// replaces the item with its own stdout/stderr/exitCode, so anything set before it\n// is gone by the time you get here. Cross-reference the node that still has it.\nconst paths = $('Prepare file paths for this run').item.json;\nreturn {\n  json: paths,\n  binary: {\n    textFile: {\n      data: Buffer.from(paths.text, 'utf8').toString('base64'),\n      mimeType: 'text/plain',\n      fileName: 'input.txt',\n    },\n  },\n};\n"
      },
      "typeVersion": 2
    },
    {
      "id": "save-text",
      "name": "Save the text file",
      "type": "n8n-nodes-base.readWriteFile",
      "notes": "Needs N8N_RESTRICT_FILE_ACCESS_TO to include /tmp.",
      "position": [
        1100,
        0
      ],
      "parameters": {
        "options": {},
        "fileName": "={{ $('Prepare file paths for this run').item.json.textPath }}",
        "operation": "write",
        "dataPropertyName": "textFile"
      },
      "typeVersion": 1
    },
    {
      "id": "run-edge-tts",
      "name": "Speak the text with Edge TTS",
      "type": "n8n-nodes-base.executeCommand",
      "notes": "Free, no account, no key. Install with: pipx install edge-tts",
      "position": [
        1320,
        0
      ],
      "parameters": {
        "command": "=edge-tts --voice {{ $('Pick the Edge TTS voice').item.json.voice }} --file \"{{ $('Prepare file paths for this run').item.json.textPath }}\" --write-media \"{{ $('Prepare file paths for this run').item.json.audioPath }}\"",
        "executeOnce": false
      },
      "typeVersion": 1
    },
    {
      "id": "measure",
      "name": "Measure duration and find the pauses",
      "type": "n8n-nodes-base.executeCommand",
      "notes": "silencedetect logs at INFO level, so -v error would hide it. '|| true' because grep exits 1 when a clip has no silence at all, which is a valid result.",
      "position": [
        1540,
        0
      ],
      "parameters": {
        "command": "=ffprobe -v error -show_entries format=duration -of csv=p=0 \"{{ $('Prepare file paths for this run').item.json.audioPath }}\" && ffmpeg -hide_banner -nostats -i \"{{ $('Prepare file paths for this run').item.json.audioPath }}\" -af silencedetect=noise=-40dB:d=0.15 -f null - 2>&1 | grep -oE \"silence_(start|end): [0-9.]+\" || true",
        "executeOnce": false
      },
      "typeVersion": 1
    },
    {
      "id": "build-timings",
      "name": "Build word timings from the speech",
      "type": "n8n-nodes-base.code",
      "notes": "Lays words on speech time only and anchors every sentence to the audio it is spoken in.",
      "position": [
        1760,
        0
      ],
      "parameters": {
        "jsCode": "// stdout: line 1 = total duration, then the silencedetect markers.\nconst lines = ($json.stdout || '').trim().split('\\n');\nconst duration = parseFloat(lines[0]) || 0;\nconst paths = $('Prepare file paths for this run').item.json;\nconst text = paths.text || '';\nconst words = text.split(/\\s+/).filter(Boolean);\nif (!duration || !words.length) {\n  throw new Error(\n    'Could not read the audio duration, or the text was empty. ' +\n    'Check that edge-tts and ffmpeg/ffprobe are installed and on the PATH of the process running n8n.'\n  );\n}\n\n// \u2500\u2500 Pair the silencedetect markers into intervals \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst marks = [];\nfor (const l of lines.slice(1)) {\n  const m = l.match(/silence_(start|end):\\s*([0-9.]+)/);\n  if (m) marks.push({ type: m[1], t: parseFloat(m[2]) });\n}\nconst silences = [];\nlet open = null;\nfor (const m of marks) {\n  if (m.type === 'start') open = { start: m.t };\n  else if (open) { open.end = m.t; silences.push(open); open = null; }\n}\nif (open) { open.end = duration; silences.push(open); }\n\n// \u2500\u2500 Speech segments = everything that is not silence \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Edge TTS pads ~0.2s of lead-in, ~0.9s of tail, and pauses ~1s at every full stop.\n// On a 12s clip that is over 20% silence. Spreading the whole file duration across\n// the words stretches every caption by that much and the voice ends up ahead of the\n// highlight. Lay the words out on speech time only.\nconst speech = [];\nlet cursor = 0;\nfor (const s of silences) {\n  if (s.start > cursor) speech.push({ start: cursor, end: s.start });\n  cursor = Math.max(cursor, s.end);\n}\nif (cursor < duration) speech.push({ start: cursor, end: duration });\nif (!speech.length) speech.push({ start: 0, end: duration });\n\n// \u2500\u2500 Anchor each sentence to the speech it is actually spoken in \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Character-proportional layout across ALL speech does not line up with where the\n// sentences really start: measured on a real clip a sentence was drawn 1.06s before\n// the voice reached it. Edge TTS pauses ~1s at a full stop but only ~0.2s at a comma\n// (measured: 0.87-1.04s vs 0.16-0.25s), so half a second separates the two cleanly.\n// Merge speech split by a comma back together and each group is one sentence.\nconst SENTENCE_GAP = 0.5;\nconst groups = [];\nfor (const seg of speech) {\n  const last = groups[groups.length - 1];\n  if (last && seg.start - last.end < SENTENCE_GAP) {\n    last.end = seg.end;\n    last.segs.push(seg);\n  } else {\n    groups.push({ start: seg.start, end: seg.end, segs: [seg] });\n  }\n}\nconst sentences = (text.match(/[^.!?\u2026]+[.!?\u2026]+[\"'\u201d\u2019)\\]]*\\s*/g) || [text])\n  .map((s) => s.trim()).filter(Boolean);\n\nfunction layout(wordList, segs) {\n  const total = segs.reduce((a, s) => a + (s.end - s.start), 0) || 0.001;\n  const chars = wordList.reduce((a, w) => a + w.length, 0) || 1;\n  const toReal = (offset) => {\n    let acc = 0;\n    for (const s of segs) {\n      const len = s.end - s.start;\n      if (offset <= acc + len) return s.start + (offset - acc);\n      acc += len;\n    }\n    return segs[segs.length - 1].end;\n  };\n  let o = 0;\n  return wordList.map((w) => {\n    const dur = (w.length / chars) * total;\n    const start = toReal(o);\n    o += dur;\n    return { word: w, start, end: toReal(o) };\n  });\n}\n\n// Exact when the sentence count matches the speech groups; falls back to the plain\n// global layout rather than guessing when it does not (abbreviations, other voices).\nlet captions;\nif (sentences.length === groups.length && sentences.length > 0) {\n  captions = [];\n  sentences.forEach((sent, i) => {\n    captions.push(...layout(sent.split(/\\s+/).filter(Boolean), groups[i].segs));\n  });\n} else {\n  captions = layout(words, speech);\n}\n\nreturn { json: { ...paths, duration, captions } };\n"
      },
      "typeVersion": 2
    },
    {
      "id": "build-ass",
      "name": "Build the karaoke subtitle",
      "type": "n8n-nodes-base.code",
      "notes": "Wraps by character width so long-word languages stay inside the frame.",
      "position": [
        1980,
        0
      ],
      "parameters": {
        "jsCode": "const item = $json;\nconst captions = item.captions || [];\nfunction fmt(t) {\n  const cs = Math.round(t * 100);\n  const h = Math.floor(cs / 360000);\n  const m = Math.floor((cs % 360000) / 6000);\n  const s = Math.floor((cs % 6000) / 100);\n  const c = cs % 100;\n  return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(c).padStart(2, '0')}`;\n}\n\n// Group words into lines by CHARACTER budget, not word count. A fixed word count\n// breaks on languages with long words: 6 short English words fit on screen, 6 German\n// compounds (\"Zufriedenheit auszudrucken\") run off both edges. ~28 chars is what fits\n// 1080px at Arial 64 with the 60px side margins set below.\nconst MAX_CHARS = 28;\nconst MAX_WORDS = 7;\n\n// A line must never carry the start of the next sentence. Edge TTS pauses ~1s at a\n// full stop, so \"blue? It is not\" on one line shows \"It is not\" a second before it is\n// spoken. Breaking on the sentence costs a short tail line; that line is still correct.\nconst SENTENCE_END = /[.!?\u2026][\"'\u201d\u2019)\\]]?$/;\nconst groups = [];\nlet cur = [];\nlet curLen = 0;\nfor (const w of captions) {\n  const wLen = w.word.length + 1;\n  if (cur.length && (curLen + wLen > MAX_CHARS || cur.length >= MAX_WORDS)) {\n    groups.push(cur); cur = []; curLen = 0;\n  }\n  cur.push(w);\n  curLen += wLen;\n  if (SENTENCE_END.test(w.word)) { groups.push(cur); cur = []; curLen = 0; }\n}\nif (cur.length) groups.push(cur);\n\nconst lines = groups.map((chunk, i) => {\n  const start = chunk[0].start;\n  // Hold each line until the next begins. Word timings stop where the voice stops,\n  // which would otherwise blank the screen for the ~1s pause at every full stop.\n  // The karaoke still finishes on the voice; the line just stays fully highlighted.\n  const next = groups[i + 1];\n  const end = next ? next[0].start : chunk[chunk.length - 1].end;\n  const text = chunk.map((w) => {\n    const dur = Math.max(1, Math.round((w.end - w.start) * 100));\n    return `{\\\\k${dur}}${w.word} `;\n  }).join('');\n  return `Dialogue: 0,${fmt(start)},${fmt(end)},Default,,0,0,0,,${text.trim()}`;\n});\n\n// WrapStyle 0 = smart wrapping. WrapStyle 2 means NO wrapping at all: an over-long\n// line silently runs off both edges instead of breaking. This is the safety net for a\n// single very long word that still busts MAX_CHARS.\nconst ass = `[Script Info]\nScriptType: v4.00+\nPlayResX: 1080\nPlayResY: 1920\nWrapStyle: 0\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,64,&H00FFFFFF,&H0000FFFF,&H+1234567890,&H80000000,-1,0,0,0,100,100,0,0,1,3,2,2,60,60,150,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n${lines.join('\\n')}\n`;\n\nreturn {\n  json: { ...item, lineCount: lines.length },\n  binary: {\n    subtitle: {\n      data: Buffer.from(ass).toString('base64'),\n      mimeType: 'text/plain',\n      fileName: 'voiceover.ass',\n    },\n  },\n};\n"
      },
      "typeVersion": 2
    },
    {
      "id": "save-ass",
      "name": "Save the subtitle file",
      "type": "n8n-nodes-base.readWriteFile",
      "notes": "Writes voiceover.ass next to the MP3.",
      "position": [
        2200,
        0
      ],
      "parameters": {
        "options": {},
        "fileName": "={{ $('Prepare file paths for this run').item.json.subtitlePath }}",
        "operation": "write",
        "dataPropertyName": "subtitle"
      },
      "typeVersion": 1
    }
  ],
  "settings": {
    "executionOrder": "v1"
  },
  "connections": {
    "Save the text file": {
      "main": [
        [
          {
            "node": "Speak the text with Edge TTS",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create the temp folder": {
      "main": [
        [
          {
            "node": "Turn the text into a file",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick the Edge TTS voice": {
      "main": [
        [
          {
            "node": "Prepare file paths for this run",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Turn the text into a file": {
      "main": [
        [
          {
            "node": "Save the text file",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build the karaoke subtitle": {
      "main": [
        [
          {
            "node": "Save the subtitle file",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Speak the text with Edge TTS": {
      "main": [
        [
          {
            "node": "Measure duration and find the pauses",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Text and language input form": {
      "main": [
        [
          {
            "node": "Pick the Edge TTS voice",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare file paths for this run": {
      "main": [
        [
          {
            "node": "Create the temp folder",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build word timings from the speech": {
      "main": [
        [
          {
            "node": "Build the karaoke subtitle",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Measure duration and find the pauses": {
      "main": [
        [
          {
            "node": "Build word timings from the speech",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}