📌 Part of Blueprint: AI Agent & Local GPU Automation → L1: Deterministic Orchestration & State Machines

Why Is My TTS Model Repeating "Percent"? The URL-Encoding Trap in Speech Pipelines

· 5 min read · Notes ttsdebuggingn8nurlencodepythonai

We had just wired Alibaba’s open-source CosyVoice 2 into our self-hosted automation cluster. The direct Python test scripts worked cleanly, but when we fired our first test request through a Synology-hosted n8n Webhook, the audio response sounded like an alien countdown.

Instead of the Chinese phrase “恭喜!本地模型通过 N8N 联动测试成功!” (Congratulations! Local model test via n8n succeeded!), the speaker rattled off a robotic avalanche:

“Percent E 6, percent 81, percent 81, percent B 9, percent A 7, percent 9… percent…”

For fifteen agonizing seconds, the model faithfully enunciated dozens of hexadecimal percentage codes.

Here is the post-mortem of why this happened, why neural speech text frontends amplify subtle encoding oversights, and the two defensive layers required to make speech webhooks robust.


1. The Symptom: The “Percent” Loop

We tested the n8n endpoint via a standard HTTP GET request from a terminal:

curl -G "http://192.168.1.1:5678/webhook/ltts" \
  --data-urlencode "text=恭喜!本地模型测试成功!" \
  --data-urlencode "voice=child" \
  -o test.wav

The server returned HTTP 200 and a valid 2.4MB WAV audio file. But when passed to a speech-to-text transcriber to inspect the synthesized words, the transcription revealed the disaster:

{
  "text": "percent E 6 percent 81 percent 81 percent B 9 percent A 7 percent..."
}

The model had not hallucinated; it had pronounced the exact string it received with meticulous phonetic clarity.


2. Root Cause 1: Double-Encoding & Missing Query Decoding

To understand why, you have to trace how query parameters travel through a webhook:

  1. When curl (or a web browser) sends a GET request, Unicode characters are percent-encoded into ASCII octets (UTF-8 bytes formatted as %XX). For example, the Chinese character 恭 (Unicode U+606D) becomes %E6%81%AD.
  2. When the n8n Webhook node received the GET query parameters, it passed the raw string directly into the node context:
    {
      "query": {
        "text": "%E6%81%AD%E5%96%9C%EF%BC%81..."
      }
    }
  3. In our initial n8n HTTP Request node, the outgoing JSON body was constructed with a direct template string:
    {
      "text": "={{ $json.query.text }}"
    }
  4. n8n serialized this raw percent-encoded string into the JSON payload forwarded to our local FastAPI service on 127.0.0.1:50000.
  5. The Python backend received the string "%E6%81%AD..." literally.

3. Why LLM-Based Speech Frontends Don’t Auto-Decode

In traditional text processing, non-ASCII characters might trigger an encoding error or render as replacement diamonds (“).

However, modern neural TTS systems like CosyVoice 2 and GPT-SoVITS rely on Text Normalization (TN) modules and byte/token vocabularies designed to read arbitrary technical jargon, symbols, and mathematical notation.

When the Text Normalizer encountered %:

Consequently, a 12-character Chinese sentence was translated into a 36-token alphanumeric tongue-twister. The model simply did what it was trained to do: speak whatever was written.


4. Root Cause 2: The N8N Sandbox Trap

We attempted to fix this inside n8n by adding an inline expression:

// The trap inside n8n node expression:
{{ decodeURIComponent($json.query.text) }}

This immediately broke on two edge cases:

  1. POST requests don’t have $json.query: Accessing $json.query.text on a POST JSON call threw Cannot read property 'text' of undefined.
  2. Optional Chaining (?.) syntax limitations: In older embedded V8 runtimes used by certain n8n releases, modern ECMAScript features like $json.query?.text can cause silent parse failures or throw Unexpected token '.'.
  3. Double-decoding URI errors: If the input string contains an unescaped % (such as “The price dropped by 50%”), a naked decodeURIComponent("50%") throws an unhandled URIError: URI malformed, terminating the entire workflow.

5. The Two-Layer Defense

To make the speech pipeline bulletproof against any client library, we implemented defensive decoding at both the orchestration layer and the model backend.

Layer 1: N8N Code Node Pre-Processing

Between the Webhook node and the HTTP Request node, we inserted a dedicated JavaScript Code Node that safely extracts and sanitizes incoming text from either GET queries or POST bodies:

const item = $input.first().json;
const query = item.query || {};
const body = item.body || {};

let text = query.text || body.text || '语音测试';
let voice = query.voice || body.voice || 'default';

// Safe percent-decoding with error fallback
if (typeof text === 'string' && text.indexOf('%') !== -1) {
  try {
    text = decodeURIComponent(text.replace(/\+/g, ' '));
  } catch (err) {
    // Retain original string if it is not valid percent-encoding
  }
}

return [{
  json: {
    text: text.trim(),
    voice: String(voice).trim()
  }
}];

Layer 2: Backend Python Fallback

On the receiving FastAPI side (webui.py), we added an unconditional unquote step in the request handler:

import urllib.parse

@app.post("/tts")
async def api_tts(request: Request):
    body = await request.json()
    raw_text = body.get("text", "").strip()
    
    # Defensive unquote handles both encoded and pre-decoded strings
    text = urllib.parse.unquote(raw_text).strip()
    voice = body.get("voice", "default")
    
    # Pass clean string to the audio generator...

urllib.parse.unquote is idempotent: if the string is already plain Chinese or English, it returns it unchanged. If residual percent signs from an upstream proxy survive, they are decoded before hitting the tokenizer.


6. The Result

After deploying both layers, we re-ran the exact same curl query:

curl -G "http://192.168.1.1:5678/webhook/ltts" \
  --data-urlencode "text=恭喜!本地模型通过 N8N 联动测试成功!" \
  --data-urlencode "voice=child" \
  -o test_clean.wav

We verified the output through our local Whisper/SenseVoice ASR engine:

{
  "text": "恭喜!本地模型通过N8N联动测试成功!"
}

The “percent” loop vanished, replaced by crisp, natural speech.

When building AI pipelines that bridge HTTP gateways with neural text frontends, never assume the string arriving at your API boundary is in its final semantic form. Defensive decoding at both ends of the bridge costs fractions of a millisecond—and prevents your AI from reading URL headers as bedtime stories.


Need Production-Grade Local AI Audio Pipelines?

I build and operate local GPU-accelerated speech pipelines (TTS/ASR), automated n8n workflows, and private inference services for teams requiring high reliability and complete data sovereignty:

Lee Teong Hoe

Lee Teong Hoe · Mr Hoelee

Full-stack developer & DevOps engineer based in Malaysia. I run my own production infrastructure and build web systems for small businesses — this blog is the living record.

Hiring? Profile & stack · Malaysian SME? Website design & development ↗ · All posts