vipan-kumar's picture
Initial commit: Audio Deepfake Detector with 8 detectors trained on jay15k
e6a1f55
Raw
History Blame Contribute Delete
12.4 kB
"""High-quality speech synthesiser used as the offline fallback for sample audio.
Implements a simple but more credible source-filter speech model:
* Glottal pulse train with F0 contour, jitter, shimmer
* Cascaded formant filters with time-varying formant frequencies
(modelling phoneme transitions)
* Aspiration / breath noise during voicing
* Voiced / unvoiced alternation (mimics consonants)
* Slow amplitude envelope for prosody
* Light pre-emphasis
It is a vast improvement over the previous formant-resonator approach, but it
is *not* trying to fool a fully-trained Wav2Vec2 deepfake detector — that
would require neural synthesis. It's good enough that the LF/HF physics
analyser sees clear vocal-tract co-modulation (Pearson r > 0.5) on real
slots, and clearly absent on fake slots.
For "fake" slots we deliberately break the LF/HF coupling and add codec /
TTS-like artifacts so the detector should flag them.
"""
from __future__ import annotations
from typing import Iterable
import numpy as np
SR = 16000
# --------------------------------------------------------------------------- #
# Building blocks
# --------------------------------------------------------------------------- #
def _formant_iir(x: np.ndarray, freq: float, bandwidth: float) -> np.ndarray:
"""2-pole resonant filter implementing one formant.
Args:
x: source signal (e.g. glottal pulse train) [N]
freq: formant centre frequency (Hz)
bandwidth: formant bandwidth (Hz). 50–80 Hz is typical for vowels.
"""
r = np.exp(-np.pi * bandwidth / SR)
theta = 2.0 * np.pi * freq / SR
a1 = -2.0 * r * np.cos(theta)
a2 = r * r
out = np.zeros_like(x, dtype=np.float64)
z1 = z2 = 0.0
for i in range(x.size):
v = x[i] - a1 * z1 - a2 * z2
out[i] = v
z2 = z1
z1 = v
return out
def _glottal_pulse_train(
duration: float,
f0_curve: np.ndarray,
jitter_pct: float,
rng: np.random.Generator,
) -> np.ndarray:
"""Place glottal pulses at locations dictated by F0(t).
f0_curve must be the same length as the output samples.
"""
n = int(duration * SR)
if f0_curve.size != n:
f0_curve = np.interp(np.linspace(0, 1, n), np.linspace(0, 1, f0_curve.size), f0_curve)
pulses = np.zeros(n, dtype=np.float32)
t = 0.0
while int(t) < n:
idx = int(t)
# Use a softened pulse (Rosenberg-like) for richer harmonics.
width = max(int(0.3 * SR / max(f0_curve[idx], 50.0)), 2)
a = np.linspace(0, np.pi, width)
glottal = (1.0 - np.cos(a)) * 0.5 # rising 1/2 cycle
end = min(idx + width, n)
pulses[idx:end] += glottal[: end - idx].astype(np.float32)
# Period for next pulse, with jitter
period = SR / max(f0_curve[idx], 50.0)
t += period * (1.0 + rng.normal(0.0, jitter_pct))
return pulses
def _shimmer(signal: np.ndarray, rng: np.random.Generator, depth: float = 0.05, rate_hz: float = 6.0) -> np.ndarray:
"""Slow amplitude modulation simulating natural intensity variation."""
t = np.arange(signal.size) / SR
mod = 1.0 + depth * np.sin(2 * np.pi * rate_hz * t + rng.uniform(0, 2 * np.pi))
return signal * mod.astype(np.float32)
def _pre_emphasis(x: np.ndarray, alpha: float = 0.97) -> np.ndarray:
out = np.empty_like(x)
out[0] = x[0]
out[1:] = x[1:] - alpha * x[:-1]
return out
# --------------------------------------------------------------------------- #
# REAL speech synthesis
# --------------------------------------------------------------------------- #
def _make_f0_contour(seconds: float, base_hz: float, rng: np.random.Generator) -> np.ndarray:
"""Smooth F0 contour with phrasal variation + small vibrato."""
n = int(seconds * SR)
t = np.arange(n) / SR
# Phrasal envelope: F0 rises and falls across the utterance
phrase = base_hz * (1.0 + 0.18 * np.sin(2 * np.pi * (0.4 / seconds) * t + rng.uniform(0, np.pi)))
vibrato = 1.0 + 0.015 * np.sin(2 * np.pi * 5.0 * t)
drift = 1.0 + 0.04 * np.sin(2 * np.pi * (0.18 / seconds) * t + rng.uniform(0, np.pi))
return (phrase * vibrato * drift).astype(np.float32)
def _formant_track(
seconds: float,
targets: list[tuple[float, float, float]],
rng: np.random.Generator,
) -> list[np.ndarray]:
"""Build N time-varying formant tracks.
Each entry of ``targets`` is ``(centre_hz, sweep_hz, bandwidth_hz)``.
The frequency walks slowly between centre±sweep to model phoneme transitions.
"""
n = int(seconds * SR)
t = np.arange(n) / SR
tracks = []
for centre, sweep, _bw in targets:
rate = rng.uniform(0.35, 0.6)
phase = rng.uniform(0, 2 * np.pi)
track = centre + sweep * np.sin(2 * np.pi * rate * t + phase)
# Add slower drift so it doesn't feel mechanically periodic
track += 0.4 * sweep * np.sin(2 * np.pi * (rate * 0.3) * t + rng.uniform(0, 2 * np.pi))
tracks.append(track.astype(np.float32))
return tracks
def _voicing_mask(seconds: float, rng: np.random.Generator) -> np.ndarray:
"""Gate that turns voicing on/off to mimic consonants/silences."""
n = int(seconds * SR)
mask = np.ones(n, dtype=np.float32)
t = 0
while t < n:
# Voiced segment 200–500 ms, then a brief unvoiced 30–80 ms.
voiced = int(rng.uniform(0.20, 0.50) * SR)
t_end = min(t + voiced, n)
# smooth fade in/out at boundaries
fade = min(int(0.01 * SR), (t_end - t) // 2)
if fade > 0:
mask[t : t + fade] *= np.linspace(0.5, 1.0, fade)
mask[t_end - fade : t_end] *= np.linspace(1.0, 0.5, fade)
t = t_end
if t >= n:
break
unvoiced = int(rng.uniform(0.03, 0.09) * SR)
u_end = min(t + unvoiced, n)
mask[t:u_end] = rng.uniform(0.05, 0.15)
t = u_end
return mask
def synth_real_speech(seconds: float, rng: np.random.Generator) -> np.ndarray:
"""Generate a clip that is *plausibly* speech: source-filter model."""
n = int(seconds * SR)
# F0: tenor / alto range, ~120 Hz median
base_hz = float(rng.uniform(105, 165))
f0 = _make_f0_contour(seconds, base_hz, rng)
# Glottal pulses with realistic jitter (~1 %)
src = _glottal_pulse_train(seconds, f0, jitter_pct=0.012, rng=rng)
# Add aspiration / breath noise (low-amplitude pink-ish)
breath = 0.04 * rng.standard_normal(n).astype(np.float32)
# Voiced formant resonators (typical neutral vowel set, slightly varied)
formant_targets = [
(rng.uniform(620, 720), 90.0, 60.0), # F1
(rng.uniform(1100, 1500), 150.0, 80.0), # F2
(rng.uniform(2300, 2700), 180.0, 100.0), # F3
(rng.uniform(3300, 3700), 200.0, 120.0), # F4
]
tracks = _formant_track(seconds, formant_targets, rng)
# Apply formants in cascade. Because filter freqs vary in time we
# implement the cascade as a smoothly time-varying filter by chunking.
voiced = src + breath
chunk = max(SR // 50, 64) # ~20 ms chunks
out = np.zeros(n, dtype=np.float32)
pos = 0
while pos < n:
end = min(pos + chunk, n)
seg = voiced[pos:end]
for tr in tracks:
f = float(np.mean(tr[pos:end]))
seg = _formant_iir(seg, freq=f, bandwidth=70.0).astype(np.float32)
out[pos:end] = seg
pos = end
# Voicing gate (mimic phonemes/silences)
vmask = _voicing_mask(seconds, rng)
out = out * vmask
# Fricative-style noise during unvoiced regions
fric_band = rng.standard_normal(n).astype(np.float32)
fric_band = _formant_iir(fric_band, freq=4500.0, bandwidth=2000.0).astype(np.float32) * 0.05
out = out + fric_band * (1.0 - vmask)
# Shimmer + pre-emphasis for "speech-like" spectral tilt
out = _shimmer(out, rng, depth=0.07, rate_hz=4.0)
out = _pre_emphasis(out, alpha=0.95).astype(np.float32)
# Normalise
peak = float(np.max(np.abs(out)))
if peak > 1e-6:
out = 0.7 * out / peak
return out.astype(np.float32)
# --------------------------------------------------------------------------- #
# FAKE generators — deliberately break LF/HF physics + add TTS-like artifacts
# --------------------------------------------------------------------------- #
def synth_fake_tts_commercial(seconds: float, rng: np.random.Generator) -> np.ndarray:
"""Overly clean, rigidly periodic — very TTS-like."""
n = int(seconds * SR)
t = np.arange(n) / SR
f0 = 200.0
sig = np.zeros(n, dtype=np.float32)
# 8 perfect harmonics, no jitter
for k in range(1, 9):
sig += (1.0 / k) * np.sin(2 * np.pi * f0 * k * t)
# Add unnaturally smooth amplitude modulation
sig *= 0.5 + 0.5 * np.sin(2 * np.pi * 4.0 * t)
# Strong low-pass to kill the upper band — breaks LF/HF coupling
a = np.exp(-2 * np.pi * 3500.0 / SR)
smoothed = np.zeros_like(sig)
smoothed[0] = sig[0]
for i in range(1, n):
smoothed[i] = (1 - a) * sig[i] + a * smoothed[i - 1]
# Reinject a flat HF tone unrelated to LF content
hf_inject = 0.06 * np.sin(2 * np.pi * 7400.0 * t)
out = smoothed + hf_inject
out = 0.6 * out / max(np.abs(out).max(), 1e-6)
return out.astype(np.float32)
def synth_fake_voice_clone(seconds: float, rng: np.random.Generator) -> np.ndarray:
"""Voice-clone-like: speech-base + a 'plastic' aliasing tone."""
base = synth_real_speech(seconds, rng)
n = base.size
t = np.arange(n) / SR
# Aliasing artifact: high-frequency tone modulated unrelated to LF energy
alias = 0.18 * np.sin(2 * np.pi * 7600.0 * t) * np.sin(2 * np.pi * 0.7 * t)
# Subtle pitch instability (fast warble)
warble = 1.0 + 0.04 * np.sin(2 * np.pi * 11.0 * t)
out = (0.78 * base * warble.astype(np.float32)) + alias.astype(np.float32)
out = 0.65 * out / max(np.abs(out).max(), 1e-6)
return out.astype(np.float32)
def synth_fake_neural_tts(seconds: float, rng: np.random.Generator) -> np.ndarray:
"""Neural TTS-like: smooth low-frequency energy, completely flat high band."""
base = synth_real_speech(seconds, rng)
n = base.size
# Aggressively low-pass the speech (kill HF correlation)
a = np.exp(-2 * np.pi * 3800.0 / SR)
lp = np.zeros_like(base)
lp[0] = base[0]
for i in range(1, n):
lp[i] = (1 - a) * base[i] + a * lp[i - 1]
# Inject a constant decoupled HF carrier (not co-modulated with LF)
t = np.arange(n) / SR
hf = 0.08 * (np.sin(2 * np.pi * 7400.0 * t) + 0.5 * np.sin(2 * np.pi * 7800.0 * t))
out = 0.85 * lp + hf.astype(np.float32)
out = 0.65 * out / max(np.abs(out).max(), 1e-6)
return out.astype(np.float32)
def synth_fake_codec_resynth(seconds: float, rng: np.random.Generator) -> np.ndarray:
"""Codec-resynthesis-like artifact: real speech but heavy quantisation
+ low-bitrate spectral hole pattern. Mimics older neural vocoder output.
"""
base = synth_real_speech(seconds, rng)
n = base.size
# Low-bitrate quantisation
levels = 32
q = np.round(base * (levels / 2)) / (levels / 2)
# Carve narrow spectral notches (codec band-elimination)
t = np.arange(n) / SR
notch = 1.0 - 0.4 * np.sin(2 * np.pi * 2500.0 * t) ** 2
out = q * notch.astype(np.float32)
out = 0.6 * out / max(np.abs(out).max(), 1e-6)
return out.astype(np.float32)
def synth_fake_griffin_lim(seconds: float, rng: np.random.Generator) -> np.ndarray:
"""Griffin-Lim-style phase artefacts: real magnitude spectrum, randomised phase.
Produces phasey, robotic-sounding output common in early neural vocoders.
"""
base = synth_real_speech(seconds, rng)
n = base.size
n_fft = 1024
hop = 256
# Naive STFT/iSTFT with phase scrambling
out = np.zeros(n + n_fft, dtype=np.float32)
win = np.hanning(n_fft).astype(np.float32)
pad = np.pad(base, (0, n_fft))
for i in range(0, n, hop):
seg = pad[i : i + n_fft] * win
spec = np.fft.rfft(seg)
mag = np.abs(spec)
# Scramble phase
random_phase = np.exp(1j * rng.uniform(0, 2 * np.pi, mag.size))
spec_new = mag * random_phase
seg_new = np.fft.irfft(spec_new, n=n_fft).astype(np.float32) * win
out[i : i + n_fft] += seg_new
out = out[:n]
out = 0.6 * out / max(np.abs(out).max(), 1e-6)
return out.astype(np.float32)