Making Blockquoted Text Sound Ancient: Audio Filter Chains for Narrative Voice

My TTS pipeline generates one voice. That is a limitation of the model I chose — it produces one speaker, one tone, one register. For most narration that works fine. The problem is blockquotes.

In my novel, blockquoted text represents ancient inscriptions, system messages, and memories that are not meant to sound like the narrator. On the page, the formatting shift tells the reader: this is different. The audiobook listener has no formatting.

I considered switching to a multi-voice model, but that meant rebuilding the pipeline around a different architecture and re-generating everything. Instead, I found that post-processing the audio with ffmpeg filter chains could shift the voice enough to create the same signal — a change in texture that tells the listener something is different — without changing the TTS model or re-generating any audio.

Here is what I built, what worked, and what did not.

Building planning-first AI systems, one pipeline at a time. Get weekly deep dives into local AI, Rust, and agent orchestration — no fluff, no hype.

Detecting the Voice Zones

The first step is knowing which segments of audio need treatment. The pipeline already parses manuscripts into segments — paragraphs, dialogue blocks, chapter headers. The parser tags each segment with a type. Blockquoted text gets tagged as blockquote.

Markdown blockquotes are easy to detect. They start with >. But manuscripts are not always clean markdown. Some authors use indentation. Some use a horizontal rule above and below. Some put the text in italics and trust the reader to understand from context.

The parser handles the common cases:

  • Markdown blockquotes (> text) — the standard.
  • Indented blocks (four or more spaces of leading indentation on every line) — catches authors who format visually rather than syntactically.
  • Bracketed blocks (text wrapped in --- or *** horizontal rules) — the "letter" or "journal entry" pattern.

Each detected block gets a subtype based on heuristics. If the text contains archaic language patterns ("thou," "hath," "behold"), it is tagged as blockquote:ancient. If it looks like a system log or technical readout, it gets blockquote:system. Everything else is blockquote:quote — a letter, a journal entry, a recalled conversation.

The detection does not need to be perfect. It needs to be consistent. A passage tagged wrong will sound slightly off, but it will still be intelligible. The goal is audio variation, not classification accuracy.

The Filter Chains

Once the pipeline knows which segments need treatment, the filters do the actual work. Each segment type gets its own ffmpeg filter chain applied after the TTS model generates the raw audio.

Ancient Text

A short clip of raw narration, followed by the same clip with the ancient filter applied:

Original:

Ancient voice filter:

The blockquote:ancient filter chain is the most elaborate. The goal is a voice that sounds like it is coming from somewhere large and far away — a cathedral, a tomb, a memory.

asetrate=24000*1.2,aresample=44100,
atempo=0.833,
aecho=0.6:0.4:100:0.15,
highpass=f=200,lowpass=f=10000,
volume=1.08

asetrate with a higher sample rate shifts the pitch up. The TTS model outputs at 24 kHz, so multiplying by 1.2 raises the pitch significantly — the voice sounds crystalline and otherworldly. The follow-up aresample=44100 converts to standard output rate.

atempo at 0.833 (the inverse of 1.2) restores the original speaking speed. Without this, the pitch shift would also make the speaker talk 20% faster. atempo handles the time-stretching so the words stay at their natural pace while the pitch floats higher.

aecho adds a short reverberant tail — 100ms delay, 0.15 echo volume. The echo is subtle enough to add spatial depth without making the voice sound like it is in a cave. It gives the impression of sound reflecting off crystal surfaces.

highpass at 200 Hz and lowpass at 10 kHz open up the frequency range. TTS output tends to be mid-heavy. The bandpass filter removes the low mud and lets the upper harmonics through, which is where the "crystalline" quality lives.

volume boost at 1.08 compensates for level changes from the filtering.

System Text

System voice filter:

The blockquote:system filter chain is different. Ancient text should sound old. System text should sound flat, mechanical, slightly metallic — like a ship's computer reading a diagnostic.

asetrate=24000*1.04,aresample=44100,
aecho=0.3:0.5:8:0.15,
highpass=f=300,
volume=0.95

Slight sample rate shift pitches the voice up just a touch — 1.04x, about two-thirds of a semitone. Enough to feel slightly off from the narrator.

aecho at 8ms delay with 0.15 volume creates a micro-phasing effect. The delayed copy is so close to the original that it does not sound like an echo — it sounds like the voice is slightly out of phase with itself, giving it a thin, synthetic quality.

highpass at 300 Hz strips some of the low-end warmth, making the voice sound more clinical.

Plain Quotes

Quote voice filter:

The blockquote:quote chain is the simplest. A letter being read aloud should sound like the narrator speaking slightly more deliberately, not like a ghost or a computer.

aecho=0.2:0.2:100:0.06,
loudnorm=I=-16:TP=-1.5:LRA=11

A very light reverb — 100ms delay at 0.06 echo volume — gives the text a "reading from a page" quality. The loudnorm filter normalizes the output to -16 LUFS (the audiobook loudness standard) because even a faint echo can cause the encoder to reduce overall perceived level. Without normalization, the quote segment sounds noticeably quieter than the surrounding narration.

The Problems That Almost Killed It

Clipping on Consonants

The pitch-shifted ancient voice can clip on hard consonants. The asetrate filter shifts energy across frequency bands, and plosives — the "p" and "b" sounds — can spike above 0 dB after the resample step.

The fix was a dynaudnorm filter inserted before the pitch shift:

dynaudnorm=f=150,
asetrate=24000*1.2,aresample=44100,
atempo=0.833,
aecho=0.6:0.4:100:0.15,
highpass=f=200,lowpass=f=10000,
volume=1.08

Dynamic audio normalization evens out the peaks before the pitch shift processes them. It adds about 2% to the processing time per segment. Worth it.

Transitions Between Voice Zones

The first version applied filters per segment independently. When the narration ended and the blockquote began, the audio jumped from dry to reverberant in a single sample. It sounded like walking through a door.

The fix was a 200ms crossfade. The last 200ms of the preceding segment fades out while the first 200ms of the filtered segment fades in. The narration's reverb tail overlaps with the blockquote's onset, smoothing the transition.

This required generating each segment as a separate audio file, applying filters, then stitching them together with ffmpeg-concat and crossfades. The pipeline was already segment-based, but the concatenation step had been a simple join. Adding crossfades meant tracking overlap durations and adjusting timestamps across the entire output file.

Volume Consistency

After filtering, some segments were noticeably quieter than the surrounding narration. The quote chain in particular lost perceived loudness — even a faint echo causes the MP3 encoder to back off overall level.

The fix was loudnorm (EBU R128) applied per segment, normalizing to -16 LUFS. This is the audiobook loudness standard. Applying it per filtered segment rather than as a final pass means each voice zone enters the concatenation step at the correct level.

Loudness normalization is not optional in a pipeline with per-segment filtering. Without it, the volume jumps between narration and blockquotes will be the first thing the listener notices — before they ever hear the filter effects.

What I Would Do Differently

Detect text structure before generating audio. The voice zones are determined by the manuscript formatting, not by the audio. If your pipeline does not parse blockquotes, indentation, and horizontal rules before sending text to the TTS model, you have no way to know which segments need treatment.

Post-processing was the pragmatic choice for my setup. I considered switching to a multi-voice model, but that would have meant re-architecting the pipeline and re-generating existing audio. Filter chains solved the problem in an afternoon. A different model might be the right answer for a new project — but when you already have hours of generated audio and a working pipeline, the question is not "what is the ideal solution" but "what solves the problem with the least rework."

Test with headphones and speakers. The ancient voice filter's upper harmonics sound different depending on the playback device. On laptop speakers, the highpass/lowpass bandpass can make the voice thin. On headphones, the same chain sounds spacious and clear. Test your filter chains on both before committing — the "sounds great in my studio" problem is real.

Less is more. The first version of the ancient chain had heavy pitch-down shifts and 1000ms echoes. It sounded dramatic on a single paragraph and cartoonish across a full chapter. The chain I use now is brighter and subtler — the atempo trick for pitch-without-speed-change, a tight bandpass instead of a muddying lowpass, and a shorter echo. It does 90% of the atmospheric work at a fraction of the listener fatigue.

The blockquote filter system does not solve the fundamental problem — a single-voice TTS model cannot produce distinct characters. What it does is add enough texture variation that the listener can tell when the text has shifted registers, which was the gap I actually needed to fill. The TTS model handles the words. The filter chains handle the context signal.


The pipeline described here was built to produce the audiobook for The Incubator. Read more about the Etrath series.