{
  "name": "AI Voice Receptionist - TTS Proxy (Deepgram Aura)",
  "nodes": [
    {
      "parameters": {
        "content": "## Med-Spa 07 — Voice TTS\n\nTurns a line of text into a real human voice.\n\n`POST /webhook/medspa-tts`  →  `{ \"text\": \"...\" }`  →  **audio/mpeg bytes**\n\n### Why this is a separate webhook\n\nThe reply text and the audio are wanted at different moments. The\nbrowser shows the transcript the instant `medspa-voice` answers, then\nfetches audio here while the caller is already reading it. Folding TTS\ninto the main webhook would hold the text hostage to the audio and make\nthe visible part of the demo slower, not faster.\n\nIt also means a TTS failure costs the voice, not the answer: the widget\nfalls back to the browser's own speech engine and the conversation\ncarries on.\n\n### Cost\n\nEvery call here is billed Deepgram characters. It is a public endpoint,\nso the Guard is not decoration — same reasoning as workflow 06.",
        "height": 560,
        "width": 440,
        "color": 7
      },
      "id": "t-note",
      "name": "About",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -560,
        40
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "voice-tts",
        "responseMode": "responseNode",
        "options": {
          "allowedOrigins": "https://wizbitofficial.com,https://www.wizbitofficial.com,http://localhost:5173"
        }
      },
      "id": "t-webhook",
      "name": "TTS Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        -60,
        300
      ],
      "webhookId": "voice-tts-webhook"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// =====================================================================\n// Guard, and clean the text up for a speech engine.\n//\n// Deepgram bills per character, and this endpoint is reachable by anyone\n// who can read the page source. Same three limits as workflow 06's guard,\n// minus the session logic, because a TTS call carries no conversation.\n// =====================================================================\n\nconst ALLOWED_ORIGINS = [\n  'https://wizbitofficial.com',\n  'https://www.wizbitofficial.com',\n  'http://localhost:5173',\n];\n\nconst MAX_CHARS = 600;\nconst MAX_CALLS_PER_IP_HOUR = 150;\n\nconst item = $input.first().json;\nconst headers = item.headers || {};\nconst origin = headers.origin || headers.referer || '';\nconst ip = headers['x-forwarded-for']\n  ? String(headers['x-forwarded-for']).split(',')[0].trim()\n  : (headers['x-real-ip'] || 'unknown');\n\nconst store = $getWorkflowStaticData('global');\nstore.ips = store.ips || {};\nconst now = Date.now();\nfor (const [k, v] of Object.entries(store.ips)) {\n  store.ips[k] = v.filter((t) => now - t < 3600000);\n  if (!store.ips[k].length) delete store.ips[k];\n}\n\nif (!ALLOWED_ORIGINS.some((o) => origin.startsWith(o))) {\n  return [{ json: { blocked: true, reason: 'bad-origin' } }];\n}\n\nstore.ips[ip] = store.ips[ip] || [];\nstore.ips[ip].push(now);\nif (store.ips[ip].length > MAX_CALLS_PER_IP_HOUR) {\n  return [{ json: { blocked: true, reason: 'ip-cap' } }];\n}\n\nlet text = String((item.body && item.body.text) || '').trim();\nif (!text) return [{ json: { blocked: true, reason: 'no-text' } }];\n\n// Strip anything that a voice reads out as noise. The Brain's WhatsApp\n// copy still leaks the odd emoji or asterisk through, and \"one emoji\"\n// spoken aloud is the single most obvious tell that this is a machine.\ntext = text\n  .replace(/[\\u{1F000}-\\u{1FAFF}\\u{2600}-\\u{27BF}\\u{FE0F}\\u{20E3}]/gu, ' ')\n  .replace(/https?:\\/\\/\\S+/g, ' ')\n  .replace(/[*_`#]/g, ' ')\n  .replace(/\\s+/g, ' ')\n  .trim();\n\nif (text.length > MAX_CHARS) text = text.slice(0, MAX_CHARS);\n\nreturn [{ json: { blocked: false, text } }];\n"
      },
      "id": "t-guard",
      "name": "Guard & clean",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        160,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 1
          },
          "conditions": [
            {
              "id": "not-blocked",
              "leftValue": "={{ $json.blocked }}",
              "rightValue": false,
              "operator": {
                "type": "boolean",
                "operation": "false",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "t-allowed",
      "name": "Allowed?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        380,
        300
      ]
    },
    {
      "parameters": {
        "content": "### Voice choice\n\n`aura-asteria-en` is Aura-1, the low-latency family. Aura-2 sounds a shade richer but measured 3.6s per reply on this account, which is unusable on a call. Quality is not worth four seconds of silence.\nTo change the voice, edit the `model` query parameter on the node to the\nright - it is a one-word change and needs no redeploy of the website.\n\nDeepgram's voice list: developers.deepgram.com/docs/tts-models",
        "height": 240,
        "width": 380,
        "color": 6
      },
      "id": "t-voice-note",
      "name": "Voice note",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        600,
        20
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.deepgram.com/v1/speak",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "model",
              "value": "aura-asteria-en"
            },
            {
              "name": "encoding",
              "value": "mp3"
            }
          ]
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "content-type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ text: $json.text }) }}",
        "options": {
          "timeout": 10000,
          "response": {
            "response": {
              "responseFormat": "file",
              "outputPropertyName": "data"
            }
          }
        }
      },
      "id": "t-deepgram",
      "name": "Deepgram Aura",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        600,
        280
      ],
      "notes": "Deepgram credential is pinned by id in the file, so an import cannot attach the wrong Header Auth credential.",
      "credentials": {
        "httpHeaderAuth": {
          "name": "Deepgram"
        }
      }
    },
    {
      "parameters": {
        "respondWith": "binary",
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "content-type",
                "value": "audio/mpeg"
              },
              {
                "name": "cache-control",
                "value": "no-store"
              }
            ]
          }
        }
      },
      "id": "t-respond-audio",
      "name": "Send audio",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        860,
        280
      ]
    },
    {
      "parameters": {
        "respondWith": "text",
        "responseBody": "blocked",
        "options": {
          "responseCode": 403
        }
      },
      "id": "t-refuse",
      "name": "Refuse",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        600,
        480
      ],
      "notes": "A refusal here is silent by design: the widget just falls back to the browser voice and the caller notices nothing."
    }
  ],
  "connections": {
    "TTS Webhook": {
      "main": [
        [
          {
            "node": "Guard & clean",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Guard & clean": {
      "main": [
        [
          {
            "node": "Allowed?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Allowed?": {
      "main": [
        [
          {
            "node": "Deepgram Aura",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Refuse",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Deepgram Aura": {
      "main": [
        [
          {
            "node": "Send audio",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  }
}