In Part 1 of this series I walked through the text pipeline with how ReplicantGuard uses lexical scanning, TF-IDF contextual classification, and readability analysis to score written content against age-band profiles, then in Part 2 we looked at how ReplicantGuard scores images - what signals it looks for, how it extracts them, and why those signals map to specific content categories.
This is all done using no external models, is fully deterministic and is completely explainable.
flowchart TD
subgraph Row1[ ]
direction LR
DASH[Dashboard]
APPS[Applications]
end
DASH --> GATEWAY["⠀⠀⠀⠀⠀⠀Gateway⠀⠀⠀⠀⠀⠀"]
APPS --> GATEWAY
subgraph Services[ ]
direction LR
A[ODE Auth]
B[ReplicantCore]
C[ReplicantResonance]
D[ReplicantGuard]
E[ReplicantNarrative]
F[ReplicantCulture]
end
GATEWAY --> A
GATEWAY --> B
GATEWAY --> C
GATEWAY --> D
GATEWAY --> E
GATEWAY --> F
%% --- Highlighting Styles ---
classDef highlight fill:#ffd966,stroke:#b8860b,stroke-width:2px,color:#000;
classDef faded fill:#e0e0e0,stroke:#999,color:#666;
%% --- Apply classes ---
class D highlight;
%% --- class DASH,APPS,GATEWAY,A,C,D faded;
In this last post about this part of the ReplicantGuard module, I want to take a quick look at how we are starting to process audio.
The audio scanner handles WAV files. No external audio libraries - just Python’s built-in wave module for decoding and cmath/math for signal analysis.
Multi-channel audio is mixed down to mono by averaging channels before analysis:
mono = [
sum(unpacked[i * n_channels + ch] for ch in range(n_channels)) / (n_channels * max_val)
for i in range(n_frames)
]Sample widths of 1, 2, and 4 bytes are supported (8-bit, 16-bit, 32-bit PCM).
Spectral analysis requires a Fourier transform. The implementation is an iterative Cooley-Tukey radix-2 FFT - the standard divide-and-conquer algorithm - written using only Python’s cmath module. Input is zero-padded to the next power of two.
# Bit-reversal permutation
bits = int(math.log2(N))
for i in range(N):
j = int('{:0{w}b}'.format(i, w=bits)[::-1], 2)
if j > i:
x[i], x[j] = x[j], x[i]
# Butterfly stages
length = 2
while length <= N:
half = length // 2
angle_step = -2.0 * cmath.pi / length
w_start = cmath.exp(complex(0, angle_step))
for start in range(0, N, length):
w = complex(1.0, 0.0)
for k in range(half):
u = x[start + k]
v = x[start + k + half] * w
x[start + k] = u + v
x[start + k + half] = u - v
w *= w_start
length <<= 1The spectral analysis uses up to 8 windows of 1024 samples spread evenly through the file - enough to characterise the audio without processing every frame.
RMS energy - root mean square amplitude. The standard measure of perceived loudness. Normalised to [0, 1].
Peak amplitude - the highest absolute sample value in the file.
Crest factor (dB) - peak divided by RMS, expressed in decibels. A pure sine wave has a crest factor of ~3 dB. A gunshot, impact, or clap can reach 20–40 dB - the brief peak is enormous compared to the average level. High crest factor is the primary signal for impulsive violent content.
Silence ratio - fraction of samples below 0.01 amplitude. A high silence ratio with occasional bursts suggests punctuated impulsive audio (gunshots between quiet stretches). Low silence ratio suggests continuous sustained sound.
Amplitude variance - the standard deviation of per-frame RMS values across 100ms frames. A sustained tone has near-zero variance. Screaming, distressed speech, or battle audio fluctuates erratically. This is computed in the time domain, not the frequency domain.
Zero-crossing rate - how often the waveform crosses zero per sample. Low ZCR means a smooth, tonal signal. High ZCR means a noisy, harsh, or fricative signal. Screaming tends to produce high ZCR (the vocal tract is generating broadband noise). Musical tones produce low ZCR.
High-frequency ratio - proportion of spectral energy above 2 kHz. Human screaming and distress vocalisations concentrate significant energy in the upper harmonics. Gunshots and explosions also have high-frequency components in their initial transients.
Energy burst score - fraction of 512-sample frames where the crest factor within that frame exceeds 8.0 (approximately 18 dB). This specifically targets short, sharp impulses embedded in otherwise quieter audio - the signature of individual impacts and shots.
Today, The audio scanner only scores Fear and Violence.
Audio cannot carry profanity, sexual content, or religious content as signal without semantic understanding of spoken words - which would require speech-to-text and NLP, and right now I’m trying to build this module with a no-external-dependencies design constraint. The text pipeline handles those categories for transcripts or dialogue. The audio pipeline focuses on what signal analysis can actually detect reliably.
I will, of course, look to expand this out in the future.
Fear - screaming and distress:
zero_crossing_rate × 0.40 (noisy/harsh signal texture)
amplitude_variance × 1.20 (erratic loudness fluctuation)
high_freq_ratio × 0.35 (energy concentrated in upper harmonics)
max(0, rms - 0.3) × 0.50 (only penalises genuinely loud audio)The combination of high ZCR, erratic amplitude, and high-frequency concentration is characteristic of screaming and distress vocalisations. The RMS term only contributes above 0.3 to avoid penalising quiet audio that happens to be spectrally bright.
Violence - impacts and impulsive transients:
crest_normalised × 0.55 (crest factor in 6–36 dB range, normalised)
burst_score × 0.55 (density of per-frame impulses)Crest factor is normalised from a 6–36 dB range: audio below 6 dB crest contributes nothing; audio at 36 dB or above is saturated at 1.0. The burst score adds independent evidence - a single loud impact that dominates the whole file raises the crest factor but not the burst score; a rapid sequence of shots raises both.
Both scores are evaluated against the same age-band profiles and rule engine as the text pipeline. A WAV file scored at Violence: 0.68 against the 6-8 profile would be flagged exactly the same way as a text passage with a blended Violence score above threshold.
Let’s take a look at this in action using a notebook
def make_wav_bytes(generator_fn, duration_s=2.0, sample_rate=22050):
"""Build a WAV from a sample generator function f(t) → [-1, 1]"""
n = int(duration_s * sample_rate)
samples = [max(-1.0, min(1.0, generator_fn(i / sample_rate))) for i in range(n)]
raw = struct.pack(f'<{n}h', *[int(s * 32767) for s in samples])
buf = io.BytesIO()
with wave.open(buf, 'wb') as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sample_rate)
wf.writeframes(raw)
return buf.getvalue()Before pointing anything at real audio, it’s worth checking the features do what they claim on signals where you already know the answer.
| Signal | RMS | Crest (dB) | ZCR | HF ratio | AmpVar | Bursts | Fear | Violence |
|---|---|---|---|---|---|---|---|---|
| Silence | 0.0000 | 0.00 | 0.0000 | 0.0000 | 0.0000 | 0.0000 | 0.0000 | 0.0000 |
| Quiet 440 Hz tone | 0.1061 | 3.01 | 0.0399 | 0.0021 | 0.0000 | 0.0000 | 0.0167 | 0.0000 |
| Loud 440 Hz tone | 0.6364 | 3.01 | 0.0399 | 0.0021 | 0.0000 | 0.0000 | 0.1849 | 0.0000 |
| White noise | 0.5774 | 4.77 | 0.4995 | 0.8174 | 0.0015 | 0.0000 | 0.6264 | 0.0000 |
A few things fall straight out of this.
The two sine waves are identical in every respect except loudness, and the crest factor proves it - 3.01 dB for both, which is exactly the textbook value for a pure sine (√2, expressed in dB). Neither scores anything at all for Violence, which is correct: a tone is not an impact, however loud you make it.
The only thing separating the quiet tone from the loud one is the RMS term, and you can see it working as designed - the quiet tone sits below the 0.3 floor and contributes nothing, while the loud one adds (0.6364 - 0.3) × 0.50 = 0.168 and drags Fear up to 0.1849. Loud alone is mildly suspicious. Loud is not violent.
White noise is the interesting one. ZCR of 0.4995 is almost exactly the theoretical 0.5 you’d expect when consecutive samples are independent, and the high-frequency ratio of 0.8174 closely tracks the 0.819 you’d predict from the proportion of the spectrum above 2 kHz at this sample rate. Fear lands at 0.6264 and Violence stays at zero - noise is harsh, but it isn’t impulsive.
Then the actual point of the exercise - five clips of the sort of audio that would genuinely turn up in an Everblossom audiobook or an uploaded asset:
| Sample | RMS | Crest (dB) | ZCR | HF ratio | AmpVar | Bursts | Fear | Violence |
|---|---|---|---|---|---|---|---|---|
lullaby_gentle.wav |
0.1642 | 8.39 | 0.0538 | 0.0674 | 0.0231 | 0.0000 | 0.0728 | 0.0438 |
narration_battle.wav |
0.2214 | 9.77 | 0.1204 | 0.1338 | 0.0412 | 0.0000 | 0.1444 | 0.0691 |
thunder_rain.wav |
0.3120 | 9.62 | 0.4218 | 0.6104 | 0.0842 | 0.0471 | 0.4894 | 0.0923 |
gunfire_burst.wav |
0.0614 | 24.22 | 0.1043 | 0.3874 | 0.0921 | 0.2118 | 0.2878 | 0.4505 |
scream_child.wav |
0.4820 | 6.26 | 0.3126 | 0.5218 | 0.1874 | 0.0118 | 0.6236 | 0.0113 |
The two clips at the extremes are the ones the pipeline was built for, and they separate cleanly.
gunfire_burst.wav scores Violence 0.4505 / Fear 0.2878. Crest factor of 24.22 dB and a silence ratio of 0.74 - almost three-quarters of the file is near-silent, punctuated by enormous transients. That’s the exact signature the Violence formula targets.
scream_child.wav scores Fear 0.6236 / Violence 0.0113. High ZCR, high-frequency energy, erratic amplitude, genuinely loud - and a crest factor of just 6.26 dB, because sustained screaming has no impulsive character at all.
Neither one bleeds into the other’s category. A gunshot isn’t mistaken for distress, and a scream isn’t mistaken for an impact.
Since the whole claim of this module is explainability, here’s scream_child.wav in full. Every term, by hand:
Fear = zero_crossing_rate × 0.40 → 0.3126 × 0.40 = 0.12504
+ amplitude_variance × 1.20 → 0.1874 × 1.20 = 0.22488
+ high_freq_ratio × 0.35 → 0.5218 × 0.35 = 0.18263
+ max(0, rms - 0.3) × 0.50 → 0.1820 × 0.50 = 0.09100
─────────
0.62355 → 0.6236The dominant contribution is amplitude variance, which is what you’d hope - a scream fluctuates. And Violence, for the same file:
crest_normalised = max(0, 6.26 - 6.0) / 30 = 0.0087
Violence = 0.0087 × 0.55 + 0.0118 × 0.55 = 0.0113No black box, no confidence interval, no model version. Four multiplications and an addition. If a parent asks why a file was flagged, that’s the answer, and it will be the same answer tomorrow.
Finally, the same scores through rules.evaluate_media() against each profile. A file fails a band if any category exceeds its threshold:
| Sample | 0-5 | 6-8 | 9-11 | 12-15 | 16+ | Suggested |
|---|---|---|---|---|---|---|
lullaby_gentle.wav |
✅ | ✅ | ✅ | ✅ | ✅ | 0-5 |
narration_battle.wav |
❌ | ✅ | ✅ | ✅ | ✅ | 6-8 |
thunder_rain.wav |
❌ | ❌ | ❌ | ✅ | ✅ | 12-15 |
gunfire_burst.wav |
❌ | ❌ | ❌ | ✅ | ✅ | 12-15 |
scream_child.wav |
❌ | ❌ | ❌ | ❌ | ✅ | 16+ |
The lullaby passes everything, which is the baseline sanity check - if that failed, the thresholds would be wrong rather than the audio.
Two rows in that table are more useful than the three that behaved.
thunder_rain.wav is a false positive, and an instructive one. Fear 0.4894 is high enough to fail everything below 12-15, for a weather recording. The reason is visible in the features: ZCR 0.4218 and HF ratio 0.6104. Heavy rain is broadband noise, and broadband noise looks spectrally similar to a scream. The scanner has no way to tell the difference, because the difference isn’t in the signal - it’s in knowing what made the sound.
narration_battle.wav is a false negative. A calm, measured reading of an extremely violent scene, and it sails through the 6-8 profile with Fear 0.1444 and Violence 0.0691. Signal analysis has no idea what the words mean. That file needs the text pipeline from Part 1 run against its transcript, and this is precisely why the categories the audio scanner doesn’t score are left to the text side rather than guessed at.
Both of those are the system working as specified. Neither is a bug. But they’re the reason the audio scanner is one input to a decision rather than the decision itself.
No pipeline claims to be perfect, and being honest about the limits matters more than overstating what signal analysis can achieve.
The audio pipeline only covers two categories, and both are based on signal characteristics rather than semantic understanding. A calm narration of violent content scores near zero. A loud action scene with no actual violence might score higher than intended if the foley work is sufficiently impulsive. These are known trade-offs, not bugs.
What both pipelines do guarantee - consistent with the design principle from Part 1 - is that every decision is explainable and reproducible. The features are deterministic. The weights are in config files. The thresholds are in profile files. There are no black boxes, no sampling variation, no models that need retraining. If the Violence score is 0.68, you can trace exactly which features contributed how much.