How to Verify a Voice with Open-Source Tools Cómo Verificar una Voz con Herramientas de Código Abierto
A step-by-step guide to independent forensic speaker verification using WavLM-Base+ and edit detection with ENF, noise profiling, and spectral flux — no commercial licenses required. Guía paso a paso para la verificación forense independiente de hablante con WavLM-Base+ y detección de edición con ENF, perfil de ruido y flux espectral — sin licencias comerciales.
This is a practical guide to the methods used in the independent forensic verification of the Hondurasgate audio recordings. Everything here runs on free, open-source tools. No commercial software, no proprietary licenses. Any journalist, researcher, or curious person with a laptop can replicate this.
The guide covers two separate questions:
- Is this voice the person it’s claimed to be? — speaker verification with WavLM-Base+
- Was the audio edited or assembled from separate recordings? — edit detection with three independent methods
These are different questions with different methods. Neither answer depends on the other.
What you need
Python 3.8+ and ffmpeg installed on your system.
pip install torch torchaudio transformers soundfile scipy librosa numpyffmpeg installation:
- Linux:
sudo apt install ffmpeg - Mac:
brew install ffmpeg - Windows: download from ffmpeg.org and add to PATH
The WavLM model (~400 MB) downloads automatically from HuggingFace on first use.
Part 1 — Speaker Verification
The model: WavLM-Base+
microsoft/wavlm-base-plus-sv is a 101-million-parameter model developed by Microsoft Research, pre-trained with masked speech prediction and fine-tuned for speaker verification on the SUPERB benchmark. It is peer-reviewed, publicly documented, and free to use. It converts an audio segment into a 256-dimensional embedding — a numerical fingerprint of the speaker’s vocal tract — and compares two embeddings using cosine similarity. Scores range from 0 to 1. Values near 1 mean same speaker.
Step 1 — Convert all audio to WAV 16kHz mono
Do this before anything else. It eliminates irrelevant variation from different microphones and codecs.
ffmpeg -y -i input.mp3 -ar 16000 -ac 1 output.wavFor OGG, M4A, or any other format, replace input.mp3. The flags are always the same: -ar 16000 (sample rate) and -ac 1 (mono).
Step 2 — Load the model
from transformers import Wav2Vec2FeatureExtractor, WavLMForXVector
import torch
import soundfile as sf
import numpy as np
MODEL_ID = "microsoft/wavlm-base-plus-sv"
feature_extractor = Wav2Vec2FeatureExtractor.from_pretrained(MODEL_ID)
model = WavLMForXVector.from_pretrained(MODEL_ID)
model.eval()
TARGET_SR = 16000
def get_embedding(wav_path):
audio, sr = sf.read(wav_path)
if sr != TARGET_SR:
raise ValueError(f"Wrong sample rate: {sr}, expected {TARGET_SR}")
if audio.ndim > 1:
audio = audio.mean(axis=1)
inputs = feature_extractor(
audio,
sampling_rate=TARGET_SR,
return_tensors="pt",
padding=True
)
with torch.no_grad():
output = model(**inputs)
emb = output.embeddings
emb = torch.nn.functional.normalize(emb, dim=-1)
return emb.squeeze()
def cosine_similarity(emb1, emb2):
return torch.nn.functional.cosine_similarity(
emb1.unsqueeze(0), emb2.unsqueeze(0)
).item()Step 3 — Build a reference embedding
You need known reference recordings of the person you’re trying to identify. These should be from documented public appearances — interviews, speeches, press conferences — where there is no doubt about who is speaking. More references is better; use at least two.
ref_files = [
"reference_1.wav",
"reference_2.wav",
"reference_3.wav",
]
ref_embeddings = []
for f in ref_files:
emb = get_embedding(f)
ref_embeddings.append(emb)
# Average and re-normalize — reduces the effect of acoustic variation
# in any single sample
avg_ref = torch.stack(ref_embeddings).mean(dim=0)
avg_ref = torch.nn.functional.normalize(avg_ref, dim=0)Step 4 — Calibrate (the step most people skip, and shouldn’t)
A score of 0.80 is meaningless without context. You need to know what “same speaker” and “different speaker” look like for this model, with these audio conditions, for this specific person.
Before analyzing any unknown audio, run the model against known identities:
# Positive controls — same person, different recordings
# These should score high (same speaker)
for i in range(len(ref_embeddings)):
for j in range(i + 1, len(ref_embeddings)):
s = cosine_similarity(ref_embeddings[i], ref_embeddings[j])
print(f"Ref {i+1} vs Ref {j+1}: {s:.4f} [same speaker — should be high]")
# Negative control — a different known person
# This should score clearly lower
neg_emb = get_embedding("known_different_speaker.wav")
neg_score = cosine_similarity(avg_ref, neg_emb)
print(f"Negative control: {neg_score:.4f} [different speaker — should be low]")What you’re looking for is a clear gap between the two ranges. In the Hondurasgate analysis:
- Same speaker (Cossette vs Cossette references): 0.95–0.98
- Different speaker (Cossette vs JOH negative control): 0.66–0.72
- Gap: ~0.27 points
Set your decision threshold based on this gap — not from a textbook. A conservative threshold sits well above the top of the negative control range. We used 0.88.
Step 5 — Analyze the unknown audio
THRESHOLD = 0.88 # Set this based on YOUR calibration, not this example
test_files = [
"unknown_audio_1.wav",
"unknown_audio_2.wav",
]
for audio_file in test_files:
test_emb = get_embedding(audio_file)
score = cosine_similarity(avg_ref, test_emb)
result = "SAME SPEAKER" if score >= THRESHOLD else "DIFFERENT SPEAKER"
print(f"{audio_file}: {score:.4f} → {result}")Interpreting scores
| Score range | Interpretation |
|---|---|
| ≥ 0.95 | Same speaker — high confidence (within inter-reference range) |
| 0.88–0.95 | Same speaker — moderate confidence (above calibrated threshold) |
| 0.80–0.88 | Uncertain — requires additional verification |
| < 0.80 | Different speaker |
These ranges only apply if your calibration shows a similar gap. If your positive and negative controls are close together, the model is not discriminating well for that speaker/audio combination — and results should not be reported as conclusive.
What this method proves and what it does not
| Question | Answer |
|---|---|
| Is this voice the named person? | Yes/No — with stated confidence |
| Is the voice human (not AI-generated)? | Yes, implicitly — AI voices score differently |
| Did they say what the audio title claims? | Not determinable by this method |
| Was the audio from the claimed context? | Not determinable by this method |
| Was the audio edited? | See Part 2 |
Part 2 — Edit Detection
A recording can contain someone’s real voice saying real things on real occasions — edited together to create a conversation that never happened as presented. This is a different question from speaker identity, and requires different methods.
Three independent methods are applied in parallel. All three must also be run on an unedited reference recording to establish baseline behavior — you need to know how many “false positives” the detector produces on clean audio before you can interpret results on unknown audio.
Method 1 — ENF (Electric Network Frequency)
What it detects: Electrical grids operate at a nominal frequency (50 Hz in Europe, 60 Hz in the Americas) that fluctuates slightly and uniquely over time. Recording devices capture this as a faint signal. A cut between recordings made at different moments produces a phase discontinuity that doesn’t match the natural continuous variation.
What to look for: hits concentrated at one or two specific moments (a real splice) versus hits distributed uniformly throughout (baseline compression artifacts).
import numpy as np
from scipy import signal
def analyze_enf(wav_path, grid_freq=60, sr=16000):
audio, _ = sf.read(wav_path)
window_size = sr # 1-second windows
hop_size = sr // 2 # 0.5s step
phase_sequence = []
for start in range(0, len(audio) - window_size, hop_size):
segment = audio[start:start + window_size]
# Bandpass filter around grid frequency harmonics
for harmonic in [grid_freq, grid_freq*2, grid_freq*3, grid_freq*4]:
sos = signal.butter(4, [harmonic - 0.5, harmonic + 0.5],
btype='band', fs=sr, output='sos')
filtered = signal.sosfilt(sos, segment)
analytic = signal.hilbert(filtered)
phase = np.angle(analytic).mean()
phase_sequence.append(phase)
# Detect phase discontinuities
hits = []
for i in range(1, len(phase_sequence)):
delta = abs(phase_sequence[i] - phase_sequence[i-1])
if delta > 0.8: # radians threshold
hits.append(i)
hits_per_second = len(hits) / (len(audio) / sr)
print(f"ENF hits: {len(hits)} ({hits_per_second:.2f}/s)")
return hitsMethod 2 — Background noise profile
What it detects: Every recording environment has a characteristic spectral noise fingerprint. Splicing recordings from different environments changes that profile at the splice point.
What to look for: abrupt changes in the noise floor profile (a real splice) versus stable profile throughout (continuous recording).
from scipy.signal import welch
def analyze_noise_profile(wav_path, sr=16000):
audio, _ = sf.read(wav_path)
segment_len = sr * 2 # 2-second segments
profiles = []
for start in range(0, len(audio) - segment_len, segment_len):
segment = audio[start:start + segment_len]
freqs, psd = welch(segment, fs=sr, nperseg=sr//2)
# Background noise = 10th percentile of PSD
noise_floor = np.percentile(psd, 10)
profiles.append(psd / (noise_floor + 1e-10))
# Detect changes between consecutive profiles
changes = []
for i in range(1, len(profiles)):
l2_dist = np.linalg.norm(profiles[i] - profiles[i-1])
z = (l2_dist - np.mean([np.linalg.norm(profiles[j] - profiles[j-1])
for j in range(1, len(profiles))])) / \
(np.std([np.linalg.norm(profiles[j] - profiles[j-1])
for j in range(1, len(profiles))]) + 1e-10)
if z > 2.5:
changes.append(i)
print(f"Noise profile changes: {len(changes)}")
return changesZero changes is the most robust indicator that the recording is continuous. This is harder to fake than ENF, because preserving a consistent noise floor across spliced recordings requires extraordinary care.
Method 3 — Spectral flux
What it detects: Natural speech produces gradual spectral changes between frames. An edit cut produces an abrupt spike.
What to look for: isolated high-flux peaks (a real splice) versus uniformly distributed flux (natural speech variation).
import librosa
def analyze_spectral_flux(wav_path, sr=16000):
audio, _ = sf.read(wav_path)
# STFT parameters
n_fft = int(0.030 * sr) # 30ms frames
hop_length = int(0.010 * sr) # 10ms step
stft = np.abs(librosa.stft(audio, n_fft=n_fft, hop_length=hop_length,
window='hann'))
# Flux = sum of positive spectral increments between frames
flux = np.sum(np.maximum(0, np.diff(stft, axis=1)), axis=0)
# Exclude first and last 0.5s (edge artifacts)
margin = int(0.5 * sr / hop_length)
flux_trimmed = flux[margin:-margin]
# Detect peaks
mean_flux = np.mean(flux_trimmed)
std_flux = np.std(flux_trimmed)
hits = np.where(flux_trimmed > mean_flux + 4.0 * std_flux)[0]
hits_per_second = len(hits) / (len(audio) / sr)
print(f"Spectral flux peaks: {len(hits)} ({hits_per_second:.2f}/s)")
return hitsInterpreting edit detection results
Run all three methods on your unedited reference first. Write down the baseline numbers. Then run on the unknown audio.
| What you see | What it means |
|---|---|
| Hit rate equal to baseline, no noise changes | No editing indicators — continuous recording |
| ENF hits concentrated at one moment | Possible splice at that timestamp |
| Noise profile change at a specific point | Possible splice — different environment or device |
| Spectral flux peak isolated at one point | Possible splice — abrupt spectral discontinuity |
In the Hondurasgate analysis, both unknown recordings showed hit rates statistically identical to the unedited baseline and zero noise profile changes. The conclusion: no editing indicators detected.
What edit detection cannot rule out
A skilled editor who carefully preserved ENF phase continuity and noise floor consistency could evade these methods. For higher-confidence analysis, correlate the ENF signal with the power grid’s historical frequency database for the recording period — this can also establish the date and location of the recording. In Honduras, that database is held by ENEE and is not publicly available.
Running it all together
# Full pipeline
audio_file = "unknown.wav"
# Convert first if needed
# subprocess.run(["ffmpeg", "-y", "-i", "unknown.mp3",
# "-ar", "16000", "-ac", "1", audio_file], check=True)
print("=== SPEAKER VERIFICATION ===")
test_emb = get_embedding(audio_file)
score = cosine_similarity(avg_ref, test_emb)
print(f"Score: {score:.4f} | Threshold: {THRESHOLD}")
print(f"Result: {'SAME SPEAKER' if score >= THRESHOLD else 'DIFFERENT SPEAKER'}")
print("\n=== EDIT DETECTION ===")
enf_hits = analyze_enf(audio_file)
noise_changes = analyze_noise_profile(audio_file)
flux_hits = analyze_spectral_flux(audio_file)
print(f"\nBaseline (unedited reference): {baseline_hits_per_sec:.2f} flux hits/s, "
f"{baseline_noise_changes} noise changes")
print(f"Unknown audio: {len(flux_hits)/(len(audio)/sr):.2f} flux hits/s, "
f"{len(noise_changes)} noise changes")The code is mechanical. The judgment is yours — report what the numbers are, what the calibration was, and what the limits of the method are. That is what makes the difference between forensic analysis and a number dressed up as a conclusion.
Esta es una guía práctica de los métodos utilizados en la verificación forense independiente de las grabaciones de audio de Hondurasgate. Todo funciona con herramientas gratuitas de código abierto. Sin software comercial, sin licencias propietarias. Cualquier periodista, investigador o persona curiosa con un portátil puede replicarlo.
La guía cubre dos preguntas separadas:
- ¿Esta voz es la persona que se dice? — verificación de hablante con WavLM-Base+
- ¿Fue editado el audio o ensamblado de grabaciones separadas? — detección de edición con tres métodos independientes
Son preguntas distintas con métodos distintos. Ninguna respuesta depende de la otra.
Qué se necesita
Python 3.8+ y ffmpeg instalados en tu sistema.
pip install torch torchaudio transformers soundfile scipy librosa numpyInstalación de ffmpeg:
- Linux:
sudo apt install ffmpeg - Mac:
brew install ffmpeg - Windows: descargar desde ffmpeg.org y agregar al PATH
El modelo WavLM (~400 MB) se descarga automáticamente desde HuggingFace al primer uso.
Parte 1 — Verificación de Hablante
El modelo: WavLM-Base+
microsoft/wavlm-base-plus-sv es un modelo de 101 millones de parámetros desarrollado por Microsoft Research, preentrenado con predicción de habla enmascarada y ajustado para verificación de hablante en el benchmark SUPERB. Está revisado por pares, documentado públicamente y es gratuito. Convierte un segmento de audio en un embedding de 256 dimensiones — una huella numérica del tracto vocal del hablante — y compara dos embeddings mediante similitud coseno. Los scores van de 0 a 1. Valores próximos a 1 indican el mismo hablante.
Paso 1 — Convertir todo el audio a WAV 16kHz mono
Hacer esto antes de cualquier otra cosa elimina variación irrelevante de distintos micrófonos y codecs.
ffmpeg -y -i entrada.mp3 -ar 16000 -ac 1 salida.wavPara OGG, M4A u otros formatos, reemplazar entrada.mp3. Los flags son siempre los mismos: -ar 16000 (frecuencia de muestreo) y -ac 1 (mono).
Paso 2 — Cargar el modelo
from transformers import Wav2Vec2FeatureExtractor, WavLMForXVector
import torch
import soundfile as sf
import numpy as np
MODEL_ID = "microsoft/wavlm-base-plus-sv"
feature_extractor = Wav2Vec2FeatureExtractor.from_pretrained(MODEL_ID)
model = WavLMForXVector.from_pretrained(MODEL_ID)
model.eval()
TARGET_SR = 16000
def get_embedding(wav_path):
audio, sr = sf.read(wav_path)
if sr != TARGET_SR:
raise ValueError(f"Frecuencia incorrecta: {sr}, esperada {TARGET_SR}")
if audio.ndim > 1:
audio = audio.mean(axis=1)
inputs = feature_extractor(
audio,
sampling_rate=TARGET_SR,
return_tensors="pt",
padding=True
)
with torch.no_grad():
output = model(**inputs)
emb = output.embeddings
emb = torch.nn.functional.normalize(emb, dim=-1)
return emb.squeeze()
def cosine_similarity(emb1, emb2):
return torch.nn.functional.cosine_similarity(
emb1.unsqueeze(0), emb2.unsqueeze(0)
).item()Paso 3 — Construir un embedding de referencia
Se necesitan grabaciones de referencia conocidas de la persona que se quiere identificar. Deben ser de apariciones públicas documentadas — entrevistas, discursos, conferencias de prensa — donde no haya duda sobre quién habla. Más referencias es mejor; usar al menos dos.
ref_files = [
"referencia_1.wav",
"referencia_2.wav",
"referencia_3.wav",
]
ref_embeddings = []
for f in ref_files:
emb = get_embedding(f)
ref_embeddings.append(emb)
# Promedio y re-normalización — reduce el efecto de variación
# acústica en una sola muestra
avg_ref = torch.stack(ref_embeddings).mean(dim=0)
avg_ref = torch.nn.functional.normalize(avg_ref, dim=0)Paso 4 — Calibrar (el paso que más se omite, y no debería)
Un score de 0,80 no significa nada sin contexto. Hay que saber cómo se ve “mismo hablante” y “hablante distinto” para este modelo, con estas condiciones de audio, para esta persona concreta.
Antes de analizar ningún audio desconocido, ejecutar el modelo contra identidades conocidas:
# Controles positivos — misma persona, grabaciones distintas
# Deben dar scores altos (mismo hablante)
for i in range(len(ref_embeddings)):
for j in range(i + 1, len(ref_embeddings)):
s = cosine_similarity(ref_embeddings[i], ref_embeddings[j])
print(f"Ref {i+1} vs Ref {j+1}: {s:.4f} [mismo hablante — debe ser alto]")
# Control negativo — una persona distinta conocida
# Debe dar score claramente más bajo
neg_emb = get_embedding("hablante_distinto_conocido.wav")
neg_score = cosine_similarity(avg_ref, neg_emb)
print(f"Control negativo: {neg_score:.4f} [hablante distinto — debe ser bajo]")Lo que se busca es una brecha clara entre los dos rangos. En el análisis de Hondurasgate:
- Mismo hablante (Cossette vs referencias de Cossette): 0,95–0,98
- Hablante distinto (Cossette vs control negativo JOH): 0,66–0,72
- Brecha: ~0,27 puntos
El umbral de decisión se establece sobre esta brecha — no desde un manual. Un umbral conservador se sitúa bien por encima del extremo superior del control negativo. En este análisis se usó 0,88.
Paso 5 — Analizar el audio desconocido
THRESHOLD = 0.88 # Establecer según TU calibración, no este ejemplo
test_files = [
"audio_desconocido_1.wav",
"audio_desconocido_2.wav",
]
for audio_file in test_files:
test_emb = get_embedding(audio_file)
score = cosine_similarity(avg_ref, test_emb)
result = "MISMO HABLANTE" if score >= THRESHOLD else "HABLANTE DISTINTO"
print(f"{audio_file}: {score:.4f} → {result}")Interpretación de scores
| Rango de score | Interpretación |
|---|---|
| ≥ 0,95 | Mismo hablante — alta confianza (dentro del rango inter-referencia) |
| 0,88–0,95 | Mismo hablante — confianza moderada (por encima del umbral calibrado) |
| 0,80–0,88 | Incierto — requiere verificación adicional |
| < 0,80 | Hablante distinto |
Estos rangos solo aplican si la calibración muestra una brecha similar. Si los controles positivos y negativos están cerca, el modelo no discrimina bien para esa combinación de hablante/audio — y los resultados no deben reportarse como concluyentes.
Qué prueba este método y qué no
| Pregunta | Respuesta |
|---|---|
| ¿Esta voz es la persona nombrada? | Sí/No — con confianza declarada |
| ¿La voz es humana (no generada por IA)? | Sí, implícitamente — las voces de IA puntúan diferente |
| ¿Dijo lo que el título del audio afirma? | No determinable por este método |
| ¿La grabación es del contexto que se alega? | No determinable por este método |
| ¿Fue editado el audio? | Ver Parte 2 |
Parte 2 — Detección de Edición
Una grabación puede contener la voz real de alguien diciendo cosas reales en momentos reales — editadas para crear una conversación que nunca ocurrió como se presenta. Esta es una pregunta distinta a la de identidad del hablante, y requiere métodos distintos.
Se aplican tres métodos independientes en paralelo. Los tres deben ejecutarse también sobre una grabación de referencia no editada para establecer el comportamiento base — hay que saber cuántos “falsos positivos” produce el detector sobre audio limpio antes de poder interpretar resultados sobre audio desconocido.
Método 1 — ENF (Frecuencia de la Red Eléctrica)
Qué detecta: Las redes eléctricas operan a una frecuencia nominal (50 Hz en Europa, 60 Hz en América) que fluctúa ligeramente y de forma única en el tiempo. Los dispositivos de grabación capturan esto como una señal tenue. Un corte entre grabaciones realizadas en momentos distintos produce una discontinuidad de fase que no corresponde a la variación natural continua.
Qué buscar: hits concentrados en uno o dos momentos específicos (empalme real) vs. hits distribuidos uniformemente (artefactos de compresión).
import numpy as np
from scipy import signal
def analyze_enf(wav_path, grid_freq=60, sr=16000):
audio, _ = sf.read(wav_path)
window_size = sr # ventanas de 1 segundo
hop_size = sr // 2 # salto de 0,5s
phase_sequence = []
for start in range(0, len(audio) - window_size, hop_size):
segment = audio[start:start + window_size]
# Filtro paso-banda alrededor de los armónicos de la red
for harmonic in [grid_freq, grid_freq*2, grid_freq*3, grid_freq*4]:
sos = signal.butter(4, [harmonic - 0.5, harmonic + 0.5],
btype='band', fs=sr, output='sos')
filtered = signal.sosfilt(sos, segment)
analytic = signal.hilbert(filtered)
phase = np.angle(analytic).mean()
phase_sequence.append(phase)
# Detectar discontinuidades de fase
hits = []
for i in range(1, len(phase_sequence)):
delta = abs(phase_sequence[i] - phase_sequence[i-1])
if delta > 0.8: # umbral en radianes
hits.append(i)
hits_per_second = len(hits) / (len(audio) / sr)
print(f"Hits ENF: {len(hits)} ({hits_per_second:.2f}/s)")
return hitsMétodo 2 — Perfil de ruido de fondo
Qué detecta: Cada entorno de grabación tiene una huella espectral de ruido característica. Empalmar grabaciones de entornos distintos cambia ese perfil en el punto de unión.
Qué buscar: cambios abruptos en el perfil de ruido de fondo (empalme real) vs. perfil estable a lo largo de toda la grabación (grabación continua).
from scipy.signal import welch
def analyze_noise_profile(wav_path, sr=16000):
audio, _ = sf.read(wav_path)
segment_len = sr * 2 # segmentos de 2 segundos
profiles = []
for start in range(0, len(audio) - segment_len, segment_len):
segment = audio[start:start + segment_len]
freqs, psd = welch(segment, fs=sr, nperseg=sr//2)
# Ruido de fondo = percentil 10 de la PSD
noise_floor = np.percentile(psd, 10)
profiles.append(psd / (noise_floor + 1e-10))
# Detectar cambios entre perfiles consecutivos
changes = []
for i in range(1, len(profiles)):
l2_dist = np.linalg.norm(profiles[i] - profiles[i-1])
z = (l2_dist - np.mean([np.linalg.norm(profiles[j] - profiles[j-1])
for j in range(1, len(profiles))])) / \
(np.std([np.linalg.norm(profiles[j] - profiles[j-1])
for j in range(1, len(profiles))]) + 1e-10)
if z > 2.5:
changes.append(i)
print(f"Cambios en perfil de ruido: {len(changes)}")
return changesCero cambios es el indicador más robusto de que la grabación es continua. Es más difícil de falsificar que el ENF, porque preservar un perfil de ruido consistente a través de grabaciones empalmadas requiere un cuidado extraordinario.
Método 3 — Flux espectral
Qué detecta: La voz natural produce cambios espectrales graduales entre frames. Un corte de edición produce un pico abrupto.
Qué buscar: picos aislados de flux alto (empalme real) vs. flux distribuido uniformemente (variación natural del habla).
import librosa
def analyze_spectral_flux(wav_path, sr=16000):
audio, _ = sf.read(wav_path)
# Parámetros STFT
n_fft = int(0.030 * sr) # frames de 30ms
hop_length = int(0.010 * sr) # salto de 10ms
stft = np.abs(librosa.stft(audio, n_fft=n_fft, hop_length=hop_length,
window='hann'))
# Flux = suma de incrementos positivos del espectro entre frames
flux = np.sum(np.maximum(0, np.diff(stft, axis=1)), axis=0)
# Excluir primeros y últimos 0,5s (artefactos de borde)
margin = int(0.5 * sr / hop_length)
flux_trimmed = flux[margin:-margin]
# Detectar picos
mean_flux = np.mean(flux_trimmed)
std_flux = np.std(flux_trimmed)
hits = np.where(flux_trimmed > mean_flux + 4.0 * std_flux)[0]
hits_per_second = len(hits) / (len(audio) / sr)
print(f"Picos de flux espectral: {len(hits)} ({hits_per_second:.2f}/s)")
return hitsInterpretación de resultados de detección de edición
Ejecutar los tres métodos primero sobre la referencia no editada. Anotar los números base. Luego ejecutar sobre el audio desconocido.
| Lo que se observa | Lo que significa |
|---|---|
| Tasa de hits igual al baseline, sin cambios de ruido | Sin indicadores de edición — grabación continua |
| Hits ENF concentrados en un momento | Posible empalme en ese timestamp |
| Cambio en perfil de ruido en un punto específico | Posible empalme — entorno o dispositivo diferente |
| Pico de flux espectral aislado en un punto | Posible empalme — discontinuidad espectral abrupta |
En el análisis de Hondurasgate, ambas grabaciones desconocidas mostraron tasas de hits estadísticamente idénticas al baseline no editado y cero cambios en el perfil de ruido. Conclusión: no se detectaron indicadores de edición.
Lo que la detección de edición no puede descartar
Un editor experto que preserve cuidadosamente la continuidad de fase ENF y el perfil de ruido podría evadir estos métodos. Para un análisis de mayor confianza, correlacionar la señal ENF con la base de datos histórica de frecuencia de la red eléctrica del período de grabación — esto también puede establecer la fecha y ubicación de la grabación. En Honduras, esa base de datos la tiene la ENEE y no es pública.
Ejecutarlo todo junto
# Pipeline completo
audio_file = "desconocido.wav"
# Convertir primero si hace falta:
# subprocess.run(["ffmpeg", "-y", "-i", "desconocido.mp3",
# "-ar", "16000", "-ac", "1", audio_file], check=True)
print("=== VERIFICACIÓN DE HABLANTE ===")
test_emb = get_embedding(audio_file)
score = cosine_similarity(avg_ref, test_emb)
print(f"Score: {score:.4f} | Umbral: {THRESHOLD}")
print(f"Resultado: {'MISMO HABLANTE' if score >= THRESHOLD else 'HABLANTE DISTINTO'}")
print("\n=== DETECCIÓN DE EDICIÓN ===")
enf_hits = analyze_enf(audio_file)
noise_changes = analyze_noise_profile(audio_file)
flux_hits = analyze_spectral_flux(audio_file)
print(f"\nBaseline (referencia no editada): {baseline_hits_per_sec:.2f} hits flux/s, "
f"{baseline_noise_changes} cambios de ruido")
print(f"Audio desconocido: {len(flux_hits)/(len(audio)/sr):.2f} hits flux/s, "
f"{len(noise_changes)} cambios de ruido")El código es mecánico. El juicio es tuyo — reporta qué dicen los números, cuál fue la calibración y cuáles son los límites del método. Eso es lo que distingue un análisis forense de un número disfrazado de conclusión.