AutomationFlowsAI & RAG › Convert PDF Articles to Audio Podcasts with Google Tts & Cloudflare R2

Convert PDF Articles to Audio Podcasts with Google Tts & Cloudflare R2

ByDev Dutta @devdutta on n8n.io

Workflow Name: Convert PDF Articles to Podcast Author: Devjothi Dutta Category: Productivity, Content Creation, Automation Complexity: Medium Setup Time: 45-60 minutes

Event trigger★★★★☆ complexity17 nodesForm TriggerRead PdfHTTP RequestN8N Nodes Cloudflare R2 StorageEmail Send
AI & RAG Trigger: Event Nodes: 17 Complexity: ★★★★☆ Added:

This workflow corresponds to n8n.io template #9521 — we link there as the canonical source.

This workflow follows the Emailsend → Form Trigger recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "name": "Convert PDF Articles to Podcast - N8N",
  "nodes": [
    {
      "id": "8616c449-1148-438e-b1a0-57dcc9b6caed",
      "name": "\u2699\ufe0f Workflow Config",
      "type": "n8n-nodes-base.set",
      "position": [
        32,
        480
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "r2-public-url",
              "name": "R2_PUBLIC_URL",
              "type": "string",
              "value": "https://pub-YOUR-R2-ID-HERE.r2.dev"
            },
            {
              "id": "r2-bucket",
              "name": "R2_BUCKET",
              "type": "string",
              "value": "your-bucket-name"
            },
            {
              "id": "rss-filename",
              "name": "RSS_FILENAME",
              "type": "string",
              "value": "your-podcast-feed.xml"
            },
            {
              "id": "c7427d04-8c09-4e62-bc17-073033449664",
              "name": "PODCAST_ARTWORK_URL",
              "type": "string",
              "value": "https://pub-YOUR-R2-ID-HERE.r2.dev/artwork/podcast-image.png"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "0cb6f54b-34b7-449c-b42c-1a8eb341001a",
      "name": "Upload PDF for Podcast",
      "type": "n8n-nodes-base.formTrigger",
      "position": [
        80,
        128
      ],
      "parameters": {
        "options": {},
        "formTitle": "Upload PDF for Podcast Conversion",
        "formFields": {
          "values": [
            {
              "fieldType": "file",
              "fieldLabel": "pdfFile",
              "multipleFiles": false,
              "requiredField": true,
              "acceptFileTypes": "pdf"
            }
          ]
        },
        "formDescription": "Upload a PDF file to convert to audio podcast. Processing will start immediately after upload"
      },
      "typeVersion": 2.3
    },
    {
      "id": "4f7236ac-852d-4c6f-9393-1fd6ed1c5610",
      "name": "Extract PDF Text",
      "type": "n8n-nodes-base.readPDF",
      "position": [
        336,
        128
      ],
      "parameters": {
        "binaryPropertyName": "pdfFile"
      },
      "typeVersion": 1
    },
    {
      "id": "b533785e-e3bb-4bc6-b3a4-ab1f452ae330",
      "name": "Clean & Process Text",
      "type": "n8n-nodes-base.code",
      "position": [
        544,
        128
      ],
      "parameters": {
        "jsCode": "// Get PDF text and metadata\nconst pdfText = $input.item.json.text;\nconst fileName = $input.item.binary.pdfFile?.fileName || 'uploaded-document.pdf';\nconst documentTitle = fileName.replace('.pdf', '').replace(/_/g, ' ');\n\n// Clean text\nlet cleanedText = pdfText\n  .replace(/\\f/g, '\\n')\n  .replace(/\\n{3,}/g, '\\n\\n')\n  .replace(/^\\d+\\s*$/gm, '')\n  .replace(/PSYCHOLOGY SECONDARY COURSE/gi, '')\n  .replace(/MODULE - I.*?Psychology/gis, '')\n  .trim();\n\n// Extract document metadata\nconst wordCount = cleanedText.split(/\\s+/).length;\nconst charCount = cleanedText.length;\nconst estimatedMinutes = Math.ceil(charCount / 1000);\n\nreturn {\n  json: {\n    documentTitle,\n    fileName,\n    cleanedText,\n    wordCount,\n    charCount,\n    estimatedMinutes,\n    uploadDate: new Date().toISOString()\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "aac02256-376d-421d-897b-06bdfd1b9494",
      "name": "Detect Sections & Split",
      "type": "n8n-nodes-base.code",
      "position": [
        736,
        128
      ],
      "parameters": {
        "jsCode": "const input = $input.item.json;\nconst cleanedText = input.cleanedText;\nconst documentTitle = input.documentTitle;\n\nconst MAX_BYTES = 4500;\n\nconst sectionRegex = /(\\d+\\.\\d+)\\s+([A-Z][A-Z\\s]{10,})/g;\nlet sections = [];\nlet lastIndex = 0;\nlet match;\n\nwhile ((match = sectionRegex.exec(cleanedText)) !== null) {\n  const sectionNumber = match[1];\n  const sectionTitle = match[2].trim();\n  const startIndex = match.index;\n\n  if (lastIndex < startIndex) {\n    const prevText = cleanedText.substring(lastIndex, startIndex).trim();\n    if (prevText.length > 100) {\n      sections.push({ text: prevText, number: sections.length + 1, title: 'Introduction' });\n    }\n  }\n\n  sections.push({ number: sectionNumber, title: sectionTitle, startIndex });\n  lastIndex = startIndex;\n}\n\nconst episodes = [];\nfor (let i = 0; i < sections.length; i++) {\n  const section = sections[i];\n  const nextSection = sections[i + 1];\n  const endIndex = nextSection ? nextSection.startIndex : cleanedText.length;\n  const sectionText = cleanedText.substring(section.startIndex, endIndex).trim();\n\n  if (Buffer.byteLength(sectionText, 'utf8') > MAX_BYTES) {\n    const chunks = splitTextIntoChunks(sectionText, MAX_BYTES);\n    chunks.forEach((chunk, idx) => {\n      episodes.push({\n        episodeNumber: `${section.number}.${idx + 1}`,\n        episodeTitle: `${documentTitle} - Section ${section.number}.${idx + 1}: ${section.title}`,\n        sectionTitle: section.title,\n        textContent: chunk,\n        charCount: chunk.length,\n        totalEpisodes: null\n      });\n    });\n  } else {\n    episodes.push({\n      episodeNumber: section.number,\n      episodeTitle: `${documentTitle} - Section ${section.number}: ${section.title}`,\n      sectionTitle: section.title,\n      textContent: sectionText,\n      charCount: sectionText.length,\n      totalEpisodes: null\n    });\n  }\n}\n\nif (episodes.length === 0) {\n  const chunks = splitTextIntoChunks(cleanedText, MAX_BYTES);\n  chunks.forEach((chunk, idx) => {\n    episodes.push({\n      episodeNumber: idx + 1,\n      episodeTitle: `${documentTitle} - Part ${idx + 1}`,\n      sectionTitle: `Part ${idx + 1}`,\n      textContent: chunk,\n      charCount: chunk.length,\n      totalEpisodes: chunks.length\n    });\n  });\n}\n\nepisodes.forEach(ep => ep.totalEpisodes = episodes.length);\n\nfunction splitTextIntoChunks(text, maxBytes) {\n  const chunks = [];\n  let currentChunk = '';\n  const sentences = text.split(/(?<=[.!?])\\s+/);\n\n  for (const sentence of sentences) {\n    const testChunk = currentChunk + (currentChunk ? ' ' : '') + sentence;\n    if (Buffer.byteLength(testChunk, 'utf8') > maxBytes) {\n      if (currentChunk) {\n        chunks.push(currentChunk);\n        currentChunk = sentence;\n      } else {\n        const words = sentence.split(' ');\n        for (const word of words) {\n          const testWord = currentChunk + (currentChunk ? ' ' : '') + word;\n          if (Buffer.byteLength(testWord, 'utf8') > maxBytes) {\n            chunks.push(currentChunk);\n            currentChunk = word;\n          } else {\n            currentChunk = testWord;\n          }\n        }\n      }\n    } else {\n      currentChunk = testChunk;\n    }\n  }\n\n  if (currentChunk) {\n    chunks.push(currentChunk);\n  }\n\n  return chunks;\n}\n\nreturn episodes.map(ep => ({ json: ep }));"
      },
      "typeVersion": 2
    },
    {
      "id": "3a32b49b-bcac-4a61-b405-2f5d412a1191",
      "name": "Check TTS Usage Limit",
      "type": "n8n-nodes-base.code",
      "position": [
        944,
        128
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const now = new Date();\nconst monthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;\n\nconst monthlyUsage = $getWorkflowStaticData('global');\n\nif (!monthlyUsage[monthKey]) {\n  monthlyUsage[monthKey] = { charCount: 0, requestCount: 0 };\n}\n\nconst currentUsage = monthlyUsage[monthKey].charCount;\nconst charCount = $input.item.json.charCount;\nconst MONTHLY_LIMIT = 950000;\n\nif (currentUsage + charCount > MONTHLY_LIMIT) {\n  throw new Error(\n    `\u26a0\ufe0f MONTHLY TTS LIMIT REACHED!\\n` +\n    `Current: ${currentUsage.toLocaleString()} chars\\n` +\n    `This request: ${charCount.toLocaleString()} chars\\n` +\n    `Limit: ${MONTHLY_LIMIT.toLocaleString()} chars`\n  );\n}\n\nreturn {\n  json: {\n    ...$input.item.json,\n    usageInfo: {\n      currentMonthUsage: currentUsage,\n      thisRequestChars: charCount,\n      remainingChars: MONTHLY_LIMIT - currentUsage - charCount,\n      monthKey\n    }\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "ce537777-d89c-445b-b1ed-199beac096ce",
      "name": "Google TTS API",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "position": [
        1136,
        128
      ],
      "parameters": {
        "url": "https://texttospeech.googleapis.com/v1/text:synthesize",
        "method": "POST",
        "options": {
          "timeout": 60000,
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "authentication": "genericCredentialType",
        "bodyParameters": {
          "parameters": [
            {
              "name": "input.text",
              "value": "={{ $json.textContent }}"
            },
            {
              "name": "voice.languageCode",
              "value": "en-US"
            },
            {
              "name": "voice.name",
              "value": "en-US-Wavenet-D"
            },
            {
              "name": "audioConfig.audioEncoding",
              "value": "MP3"
            },
            {
              "name": "audioConfig.speakingRate",
              "value": "1.0"
            },
            {
              "name": "audioConfig.pitch",
              "value": "0.0"
            }
          ]
        },
        "genericAuthType": "httpHeaderAuth"
      },
      "retryOnFail": true,
      "typeVersion": 4.2,
      "alwaysOutputData": true,
      "waitBetweenTries": 2000
    },
    {
      "id": "7948efd4-3d73-4496-95fa-12d25ff97fe0",
      "name": "Convert Audio to Binary",
      "type": "n8n-nodes-base.code",
      "position": [
        1344,
        128
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const audioBase64 = $input.item.json.audioContent;\n\nif (!audioBase64) {\n  console.log(`Skipping item ${$itemIndex} - no audio content`);\n  return null;\n}\n\nconst allSectionItems = $('Detect Sections & Split').all();\nconst sectionData = allSectionItems[$itemIndex] ? allSectionItems[$itemIndex].json : {};\n\nconst episodeTitle = sectionData.episodeTitle || 'Episode';\nconst episodeNumber = sectionData.episodeNumber || 'N/A';\nconst sectionTitle = sectionData.sectionTitle || '';\nconst charCount = sectionData.charCount || 0;\n\nconst date = new Date().toISOString().split('T')[0];\nconst safeTitle = episodeTitle\n  .replace(/[^a-zA-Z0-9\\s-]/g, '')\n  .replace(/\\s+/g, '_')\n  .substring(0, 100);\nconst fileName = `${safeTitle}_${date}.mp3`;\n\nconst binaryBuffer = Buffer.from(audioBase64, 'base64');\n\nreturn {\n  json: {\n    fileName,\n    fileSize: binaryBuffer.length,\n    mimeType: 'audio/mpeg',\n    episodeTitle,\n    episodeNumber,\n    sectionTitle,\n    charCount,\n    audioContent: audioBase64\n  },\n  binary: {\n    audioFile: {\n      data: audioBase64,\n      mimeType: 'audio/mpeg',\n      fileName: fileName,\n      fileExtension: 'mp3'\n    }\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "9b9c9e38-5b5a-491f-b3b7-5d0d39c73ad5",
      "name": "Stitch All Mp3 Together",
      "type": "n8n-nodes-base.code",
      "position": [
        32,
        336
      ],
      "parameters": {
        "jsCode": "const allItems = $input.all();\n\nconst sortedItems = allItems.sort((a, b) => {\n  const numA = a.json.episodeNumber || 0;\n  const numB = b.json.episodeNumber || 0;\n  return numA - numB;\n});\n\nconst mp3Buffers = [];\nlet totalSize = 0;\nlet totalChars = 0;\nconst episodesList = [];\n\nfor (const item of sortedItems) {\n  const audioData = item.json.audioContent;\n  const buffer = Buffer.from(audioData, 'base64');\n  mp3Buffers.push(buffer);\n  totalSize += buffer.length;\n  totalChars += item.json.charCount || 0;\n\n  episodesList.push({\n    number: item.json.episodeNumber,\n    title: item.json.episodeTitle,\n    charCount: item.json.charCount\n  });\n}\n\nconst combinedBuffer = Buffer.concat(mp3Buffers);\nconst combinedBase64 = combinedBuffer.toString('base64');\n\nconst date = new Date().toISOString().split('T')[0];\nconst fileName = `Chapter-2_Complete_${date}.mp3`;\n\nreturn [{\n  json: {\n    fileName,\n    fileSize: totalSize,\n    mimeType: 'audio/mpeg',\n    totalEpisodes: sortedItems.length,\n    totalCharacters: totalChars,\n    episodes: episodesList,\n    episodeTitle: `Chapter-2 - Complete (${sortedItems.length} sections)`,\n    episodeNumber: 'Complete',\n    charCount: totalChars\n  },\n  binary: {\n    audioFile: {\n      data: combinedBase64,\n      mimeType: 'audio/mpeg',\n      fileName: fileName,\n      fileExtension: 'mp3'\n    }\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "72ae9ece-7ea5-48f8-969c-79f70376ff1b",
      "name": "Upload MP3 to R2",
      "type": "n8n-nodes-cloudflare-r2-storage.cloudflareR2Storage",
      "position": [
        432,
        384
      ],
      "parameters": {
        "objectKey": "={{ $json.fileName }}",
        "bucketName": "={{ $('\u2699\ufe0f Workflow Config').first().json.R2_BUCKET }}",
        "contentType": "audio/mpeg",
        "binaryPropertyName": "audioFile"
      },
      "typeVersion": 1
    },
    {
      "id": "9789ab0b-b679-4314-a814-5407a42fb7d1",
      "name": "Build RSS XML",
      "type": "n8n-nodes-base.code",
      "position": [
        624,
        384
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Enhanced RSS Feed Code v2 - \"Build RSS XML\" Node\n// Copy this ENTIRE code and replace the existing code in \"Build RSS XML\" node\n\n// Get data from Merge node (has original MP3 data)\nconst mp3Data = $('Merge').first().json;\nconst episodes = mp3Data.episodes || [];\nconst totalEpisodes = mp3Data.totalEpisodes || 1;\nconst fileName = mp3Data.fileName || 'Podcast.mp3';\nconst fileSize = mp3Data.fileSize || 0;\n\n// Get configuration from standalone config node\nconst config = $('\u2699\ufe0f Workflow Config').first().json;\nconst R2_PUBLIC_URL = config.R2_PUBLIC_URL;\nconst RSS_FILENAME = config.RSS_FILENAME;\nconst PODCAST_ARTWORK_URL = config.PODCAST_ARTWORK_URL || \"https://via.placeholder.com/3000x3000.png?text=Personal+Podcast\";\n\n// Construct R2 public URLs\nconst r2Mp3Url = `${R2_PUBLIC_URL}/${fileName}`;\nconst pubDate = new Date().toUTCString();\n\n// Extract document title\nconst docTitle = fileName.replace(/_Complete_\\d{4}-\\d{2}-\\d{2}\\.mp3$/, '').replace(/_/g, ' ');\n\n// Calculate estimated duration (MP3 128kbps = ~16KB/sec)\nconst estimatedDurationSeconds = Math.round(fileSize / 16000);\nconst durationHours = Math.floor(estimatedDurationSeconds / 3600);\nconst durationMinutes = Math.floor((estimatedDurationSeconds % 3600) / 60);\nconst durationSeconds = estimatedDurationSeconds % 60;\nconst durationFormatted = durationHours > 0\n  ? `${durationHours}:${String(durationMinutes).padStart(2, '0')}:${String(durationSeconds).padStart(2, '0')}`\n  : `${durationMinutes}:${String(durationSeconds).padStart(2, '0')}`;\n\n// Build episode description (plain text)\nconst episodeDescription = episodes.map((ep, idx) =>\n  `${idx + 1}. ${ep.title} (${ep.charCount.toLocaleString()} characters)`\n).join('\\n');\n\n// Build episode description (HTML for better formatting)\nconst episodeDescriptionHTML = episodes.map((ep, idx) =>\n  `<p><strong>${idx + 1}. ${ep.title}</strong><br/><em>${ep.charCount.toLocaleString()} characters</em></p>`\n).join('\\n');\n\n// Current year for copyright\nconst currentYear = new Date().getFullYear();\n\n// Build enhanced RSS feed XML with improved metadata\nconst rssXml = `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<rss version=\"2.0\"\n     xmlns:itunes=\"http://www.itunes.com/dtds/podcast-1.0.dtd\"\n     xmlns:atom=\"http://www.w3.org/2005/Atom\"\n     xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">\n  <channel>\n    <title>Personal Podcast</title>\n    <description>Transform any PDF article, research paper, or document into high-quality audio. Perfect for learning while commuting, exercising, or multitasking.</description>\n    <language>en-us</language>\n    <link>https://your-website-url.com</link>\n    <atom:link href=\"${R2_PUBLIC_URL}/${RSS_FILENAME}\" rel=\"self\" type=\"application/rss+xml\"/>\n\n    <!-- iTunes Podcast Metadata -->\n    <itunes:author>Your Name</itunes:author>\n    <itunes:owner>\n      <itunes:name>Your Name</itunes:name>\n      <itunes:email>user@example.com</itunes:email>\n    </itunes:owner>\n    <itunes:image href=\"${PODCAST_ARTWORK_URL}\"/>\n    <itunes:category text=\"Education\">\n      <itunes:category text=\"Self-Improvement\"/>\n    </itunes:category>\n    <itunes:category text=\"Technology\"/>\n    <itunes:explicit>no</itunes:explicit>\n    <itunes:type>episodic</itunes:type>\n    <itunes:summary>Your personal AI narrator for all your reading materials. Convert PDFs to audio instantly.</itunes:summary>\n    <itunes:subtitle>PDF Articles converted to audio for personal listening</itunes:subtitle>\n\n    <!-- Podcast Metadata -->\n    <copyright>\u00a9 ${currentYear} Your Podcast Name</copyright>\n    <generator>n8n.io - Convert PDF Articles to Podcast</generator>\n    <lastBuildDate>${pubDate}</lastBuildDate>\n\n    <!-- Episode Item -->\n    <item>\n      <title>${docTitle} - Complete (${totalEpisodes} sections)</title>\n      <description><![CDATA[\n        \ud83d\udcda Complete audio edition of ${docTitle}\n\n        \ud83c\udfaf What's inside:\n        ${episodeDescription}\n\n        \u23f1\ufe0f Duration: ${durationFormatted}\n        \ud83d\udcc4 Sections: ${totalEpisodes}\n        \ud83d\uddd3\ufe0f Published: ${new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}\n      ]]></description>\n      <enclosure url=\"${r2Mp3Url}\" length=\"${fileSize}\" type=\"audio/mpeg\"/>\n      <guid isPermaLink=\"false\">${fileName}</guid>\n      <pubDate>${pubDate}</pubDate>\n\n      <!-- iTunes Episode Metadata -->\n      <itunes:title>${docTitle} - Complete Edition</itunes:title>\n      <itunes:author>Your Name</itunes:author>\n      <itunes:duration>${durationFormatted}</itunes:duration>\n      <itunes:summary><![CDATA[Complete audio version with ${totalEpisodes} sections. ${episodeDescription.substring(0, 150)}...]]></itunes:summary>\n      <itunes:subtitle>${totalEpisodes} sections \u2022 ${durationFormatted}</itunes:subtitle>\n      <itunes:image href=\"${PODCAST_ARTWORK_URL}\"/>\n      <itunes:episodeType>full</itunes:episodeType>\n      <itunes:explicit>no</itunes:explicit>\n\n      <!-- Rich Content -->\n      <content:encoded><![CDATA[\n        <h2>\ud83c\udfa7 ${docTitle} - Complete Audio Edition</h2>\n        <p><strong>Duration:</strong> ${durationFormatted} | <strong>Sections:</strong> ${totalEpisodes} | <strong>Size:</strong> ${Math.round(fileSize / 1024 / 1024 * 100) / 100} MB</p>\n        <hr/>\n        <h3>\ud83d\udcd1 Sections Included:</h3>\n        ${episodeDescriptionHTML}\n        <hr/>\n        <p><em>Generated automatically by Convert PDF Articles to Podcast workflow</em></p>\n      ]]></content:encoded>\n    </item>\n\n  </channel>\n</rss>`;\n\n// Convert XML to base64 for binary storage\nconst rssBase64 = Buffer.from(rssXml, 'utf-8').toString('base64');\n\n// Return data with enhanced metadata\nreturn {\n  json: {\n    ...mp3Data,\n    r2Mp3Url: r2Mp3Url,\n    rssGenerated: true,\n    rssFileName: RSS_FILENAME,\n    estimatedDuration: durationFormatted,\n    podcastArtwork: PODCAST_ARTWORK_URL\n  },\n  binary: {\n    rssFile: {\n      data: rssBase64,\n      mimeType: 'application/rss+xml',\n      fileName: RSS_FILENAME,\n      fileExtension: 'xml'\n    }\n  }\n};\n"
      },
      "typeVersion": 2
    },
    {
      "id": "dd7a9af8-5031-48a7-a9b8-9658941792d8",
      "name": "Upload RSS to R2",
      "type": "n8n-nodes-cloudflare-r2-storage.cloudflareR2Storage",
      "position": [
        832,
        384
      ],
      "parameters": {
        "objectKey": "={{ $('\u2699\ufe0f Workflow Config').first().json.RSS_FILENAME }}",
        "bucketName": "={{ $('\u2699\ufe0f Workflow Config').first().json.R2_BUCKET }}",
        "contentType": "application/rss+xml",
        "binaryPropertyName": "rssFile"
      },
      "typeVersion": 1
    },
    {
      "id": "b600862c-f392-49da-b9dd-4fe550c76615",
      "name": "Update Monthly Usage",
      "type": "n8n-nodes-base.code",
      "position": [
        1040,
        384
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Get data from Merge node (has original MP3 data)\nconst data = $('Merge').first().json;\nconst totalChars = data.totalCharacters || data.charCount || 0;\nconst totalEpisodes = data.totalEpisodes || 1;\n\nconst now = new Date();\nconst monthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;\n\nconst monthlyUsage = $getWorkflowStaticData('global');\n\nif (!monthlyUsage[monthKey]) {\n  monthlyUsage[monthKey] = {\n    charCount: 0,\n    requestCount: 0,\n    lastUpdated: new Date().toISOString()\n  };\n}\n\nmonthlyUsage[monthKey].charCount += totalChars;\nmonthlyUsage[monthKey].requestCount += totalEpisodes;\nmonthlyUsage[monthKey].lastUpdated = new Date().toISOString();\n\nreturn {\n  json: {\n    ...data,\n    updatedUsage: {\n      monthKey,\n      totalChars: monthlyUsage[monthKey].charCount,\n      totalRequests: monthlyUsage[monthKey].requestCount,\n      lastUpdated: monthlyUsage[monthKey].lastUpdated,\n      percentUsed: Math.round((monthlyUsage[monthKey].charCount / 950000) * 100)\n    }\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "f532fce3-46d4-42b3-8ae4-645805f90cd1",
      "name": "Aggregate for Email",
      "type": "n8n-nodes-base.code",
      "position": [
        1248,
        384
      ],
      "parameters": {
        "jsCode": "// Get data from Merge node (has original MP3 data)\nconst data = $('Merge').first().json;\nconst episodes = data.episodes || [];\nconst totalChars = data.totalCharacters || data.charCount || 0;\nconst totalEpisodes = data.totalEpisodes || episodes.length;\nconst mp3FileSize = data.fileSize || 0;\nconst mp3FileName = data.fileName || 'Podcast.mp3';\nconst mp3FileNameWithoutExt = mp3FileName.replace(/\\.mp3$/i, '').replace(/_Complete_\\d{4}-\\d{2}-\\d{2}$/, '');\n\n// Get R2 RSS feed URL from standalone config node\nconst config = $('\u2699\ufe0f Workflow Config').first().json;\nconst R2_PUBLIC_URL = config.R2_PUBLIC_URL;\nconst RSS_FILENAME = config.RSS_FILENAME;\nconst rssFeedUrl = `${R2_PUBLIC_URL}/${RSS_FILENAME}`;\n\n// Get usage data\nconst usageData = data.updatedUsage || {\n  totalChars: totalChars,\n  totalRequests: totalEpisodes,\n  percentUsed: Math.round((totalChars / 950000) * 100)\n};\n\n// Build episode list HTML\nconst episodeListHTML = episodes.map((ep, idx) =>\n  `<li><strong>${ep.title}</strong><br>Episode ${ep.number} | ${ep.charCount.toLocaleString()} characters</li>`\n).join('\\n');\n\n// Build email HTML\nconst emailHTML = `\n<h2>\ud83c\udfa7 New Podcast Episode Available!</h2>\n\n<p><strong>Total Sections:</strong> ${totalEpisodes} (stitched into 1 complete file)</p>\n<p><strong>Total Characters:</strong> ${totalChars.toLocaleString()}</p>\n<p><strong>File Size:</strong> ${Math.round(mp3FileSize / 1024 / 1024 * 100) / 100} MB</p>\n<p><strong>File Name:</strong> ${mp3FileNameWithoutExt}</p>\n\n<hr>\n\n<h3>\ud83d\udccb Sections Created:</h3>\n<ul>\n${episodeListHTML}\n</ul>\n\n<hr>\n\n<h3>\ud83d\udce1 Subscribe to Podcast Feed</h3>\n<p>Copy this RSS feed URL and paste it into your podcast app:</p>\n<p style=\"background-color: #f0f0f0; padding: 10px; font-family: monospace; word-break: break-all; font-size: 12px;\">${rssFeedUrl}</p>\n<p><em>Supported apps: Apple Podcasts, Spotify, Pocket Casts, Overcast, Castro, and most other podcast apps</em></p>\n\n<hr>\n\n<h3>\ud83d\udcca Monthly Usage</h3>\n<p><strong>Used:</strong> ${usageData.totalChars.toLocaleString()} / 950,000 characters (${usageData.percentUsed}%)</p>\n<p><strong>Total Episodes This Month:</strong> ${usageData.totalRequests}</p>\n\n<hr>\n\n<p style=\"color: #666;\">\u2705 All ${totalEpisodes} sections are stitched into 1 MP3 file hosted on Cloudflare R2!</p>\n<p style=\"color: #666;\">\u2705 RSS feed generated and ready for subscription!</p>\n`;\n\nreturn {\n  json: {\n    totalEpisodes: totalEpisodes,\n    totalCharacters: totalChars,\n    rssFeedUrl: rssFeedUrl,\n    emailSubject: `New Podcast: ${totalEpisodes} Sections from ${mp3FileNameWithoutExt}`,\n    emailHTML: emailHTML,\n    usageData: usageData\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "67f70d82-cc76-471f-8cbd-986f24e3e84b",
      "name": "Send Email",
      "type": "n8n-nodes-base.emailSend",
      "position": [
        1456,
        384
      ],
      "parameters": {
        "html": "={{ $json.emailHTML }}",
        "options": {},
        "subject": "={{ $json.emailSubject }}",
        "toEmail": "user@example.com",
        "fromEmail": "user@example.com",
        "emailFormat": "html"
      },
      "typeVersion": 2
    },
    {
      "id": "9c67b04e-d782-49dd-b577-f6abf22ad944",
      "name": "Merge",
      "type": "n8n-nodes-base.merge",
      "position": [
        256,
        384
      ],
      "parameters": {
        "mode": "combine",
        "options": {},
        "combineBy": "combineByPosition"
      },
      "typeVersion": 3.2
    },
    {
      "id": "ad592fb6-b1b5-4af6-a572-7e2994512bea",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -560,
        -80
      ],
      "parameters": {
        "color": 2,
        "width": 520,
        "height": 880,
        "content": "# Convert PDF Articles to Podcast\n\n**Transform any PDF into an audio podcast automatically!**\n\nThis workflow extracts text from PDFs, converts it to natural-sounding speech using Google Cloud Text-to-Speech, stores files in cloud storage, and generates an RSS feed compatible with all major podcast apps.\n\n## \ud83c\udfaf Use Case\nPerfect for consuming long-form content (articles, research papers, study materials) while commuting, exercising, or multitasking. Turn your reading list into a personal podcast feed.\n\n## \ud83d\udd27 How it Works\n1. Upload a PDF article via web form\n2. Extract and split text into sections\n3. Convert text to speech using Google TTS API\n4. Stitch audio sections into complete MP3 file\n5. Upload MP3 to Cloudflare R2 storage\n6. Generate iTunes-compatible RSS feed\n7. Upload RSS feed to R2\n8. Send email notification with RSS feed link\n9. Subscribe to feed in any podcast app!\n\n## \ud83d\udccb Requirements\n* **Google Cloud Text-to-Speech API** - Free tier: 1M characters/month (WaveNet voices) - [Setup Guide](https://cloud.google.com/text-to-speech/docs)\n* **Cloudflare R2 Object Storage** - Free tier: 10GB storage, unlimited egress - [Setup Guide](https://developers.cloudflare.com/r2/)\n* **Cloudflare R2 Storage Community Node** - Install via: Settings \u2192 Community Nodes \u2192 Install \u2192 `n8n-nodes-cloudflare-r2-storage`\n* **Email Service** - SMTP or OAuth credentials (Gmail, SendGrid, etc.)\n\n## \u2699\ufe0f Configuration\nUpdate the **Workflow Config** node with:\n- R2 bucket name and public URL\n- RSS feed filename\n- Podcast artwork URL\n- Your email address\n\nSee full setup instructions in the [GitHub repo](https://github.com/devdutta/PDF-to-Podcast---N8N)!\n"
      },
      "typeVersion": 1
    }
  ],
  "settings": {
    "executionOrder": "v1"
  },
  "connections": {
    "Merge": {
      "main": [
        [
          {
            "node": "Upload MP3 to R2",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build RSS XML": {
      "main": [
        [
          {
            "node": "Upload RSS to R2",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Google TTS API": {
      "main": [
        [
          {
            "node": "Convert Audio to Binary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract PDF Text": {
      "main": [
        [
          {
            "node": "Clean & Process Text",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload MP3 to R2": {
      "main": [
        [
          {
            "node": "Build RSS XML",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload RSS to R2": {
      "main": [
        [
          {
            "node": "Update Monthly Usage",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Aggregate for Email": {
      "main": [
        [
          {
            "node": "Send Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Clean & Process Text": {
      "main": [
        [
          {
            "node": "Detect Sections & Split",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Update Monthly Usage": {
      "main": [
        [
          {
            "node": "Aggregate for Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check TTS Usage Limit": {
      "main": [
        [
          {
            "node": "Google TTS API",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload PDF for Podcast": {
      "main": [
        [
          {
            "node": "Extract PDF Text",
            "type": "main",
            "index": 0
          },
          {
            "node": "\u2699\ufe0f Workflow Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\u2699\ufe0f Workflow Config": {
      "main": [
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Convert Audio to Binary": {
      "main": [
        [
          {
            "node": "Stitch All Mp3 Together",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Detect Sections & Split": {
      "main": [
        [
          {
            "node": "Check TTS Usage Limit",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Stitch All Mp3 Together": {
      "main": [
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}
Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

Workflow Name: Convert PDF Articles to Podcast Author: Devjothi Dutta Category: Productivity, Content Creation, Automation Complexity: Medium Setup Time: 45-60 minutes

Source: https://n8n.io/workflows/9521/ — original creator credit. Request a take-down →

More AI & RAG workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

AI & RAG

This n8n workflow automates downloading Pinterest videos as MP4 files using the Pinterest Video Downloader API, uploads them to Google Drive, sets public access permissions, and emails the sharable do

Form Trigger, HTTP Request, Email Send +1
AI & RAG

Goal: This workflow demonstrates the full fluidX THE EYE integration — starting a live session, inviting both the customer (via SMS) and the service agent (via email), and then accessing the media (ph

Form Trigger, Google Drive, Email Send +3
AI & RAG

This workflow automates the process of finding LinkedIn leads and writing personalized outreach messages. It takes user input (keywords + purpose), generates a Boolean LinkedIn search query with Gemin

Form Trigger, Google Gemini, HTTP Request +2
AI & RAG

Grain Real Estate Land Showcase v1. Uses formTrigger, httpRequest, openAi, emailSend. Event-driven trigger; 13 nodes.

Form Trigger, HTTP Request, OpenAI +3
AI & RAG

Turns a simple form submission into a polished, priced PDF quote—automatically. Captures lead details via Form: Request a Quote. Loads your Google Sheets catalog (SKUs, price, stock, min qty, etc.). U

Form Trigger, Google Sheets, OpenAI +3