← Journal ← Diario

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.

Update — 23 May 2026

A practical note on audio source quality that came up during the Hondurasgate forensic work: always analyze audio files as close to the original recording as possible. Every platform that touches an audio file re-encodes it — YouTube converts to OGG/Opus at 8,000 Hz; WhatsApp and Telegram apply their own codecs; streaming platforms resample and compress further. Each re-encoding introduces systematic artifacts — periodic spectral irregularities, phase discontinuities, gain changes at codec boundaries — that the ENF and spectral flux detectors in this guide will flag as anomalies. On heavily re-encoded audio you can end up with dozens of false positives that are indistinguishable, at the signal level, from real edit cuts.

The rule is simple: always work from the file closest to the original recording. For Hondurasgate specifically, the original OGG files are publicly downloadable at hondurasgate.ch — use those, not the YouTube copies. For other investigations: if the publication makes originals available on request, request them. If you only have access to a platform re-encoded copy, document that explicitly and interpret ENF and spectral flux results with extra caution: the baseline behavior of these methods changes significantly with codec-compressed audio. When in doubt, compare the SHA-256 of your file against any published hash to confirm exactly which version you are working with.

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:

  1. Is this voice the person it’s claimed to be? — speaker verification with WavLM-Base+
  2. 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 numpy

ffmpeg 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.wav

For 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.23 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 rangeInterpretation
≥ 0.95Same speaker — high confidence (within inter-reference range)
0.88–0.95Same speaker — moderate confidence (above calibrated threshold)
0.80–0.88Uncertain — requires additional verification
< 0.80Different 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

QuestionAnswer
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
    harmonics = [grid_freq, grid_freq*2, grid_freq*3, grid_freq*4]
    
    # Track each harmonic separately over time
    phases = {h: [] for h in harmonics}
    
    for start in range(0, len(audio) - window_size, hop_size):
        segment = audio[start:start + window_size]
        for h in harmonics:
            sos = signal.butter(4, [h - 0.5, h + 0.5],
                                btype='band', fs=sr, output='sos')
            filtered = signal.sosfilt(sos, segment)
            analytic = signal.hilbert(filtered)
            phase = np.angle(np.mean(analytic))  # circular mean — correct for angles
            phases[h].append(phase)
    
    # Detect phase discontinuities — compare same harmonic across time
    hits = []
    for h in harmonics:
        for i in range(1, len(phases[h])):
            delta = abs(phases[h][i] - phases[h][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 hits

Method 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
    dists = [np.linalg.norm(profiles[i] - profiles[i-1])
             for i in range(1, len(profiles))]
    if len(dists) < 2:
        return []
    mean_d = np.mean(dists)
    std_d = np.std(dists) + 1e-10
    changes = [i + 1 for i, d in enumerate(dists)
               if (d - mean_d) / std_d > 2.5]
    
    print(f"Noise profile changes: {len(changes)}")
    return changes

Zero 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 hits

Interpreting 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 seeWhat it means
Hit rate equal to baseline, no noise changesNo editing indicators — continuous recording
ENF hits concentrated at one momentPossible splice at that timestamp
Noise profile change at a specific pointPossible splice — different environment or device
Spectral flux peak isolated at one pointPossible 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
reference_file = "reference_unedited.wav"  # known clean recording
audio_file = "unknown.wav"

# Convert first if needed
# subprocess.run(["ffmpeg", "-y", "-i", "unknown.mp3",
#                 "-ar", "16000", "-ac", "1", audio_file], check=True)

# Establish baseline on unedited reference
ref_audio, _ = sf.read(reference_file)
ref_duration = len(ref_audio) / 16000
ref_flux_hits = analyze_spectral_flux(reference_file)
ref_noise_changes = analyze_noise_profile(reference_file)
baseline_hits_per_sec = len(ref_flux_hits) / ref_duration
baseline_noise_changes = len(ref_noise_changes)

# Analyze unknown
unknown_audio, _ = sf.read(audio_file)
unknown_duration = len(unknown_audio) / 16000

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)/unknown_duration:.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.


Sources and tools · WavLM-Base+ (microsoft/wavlm-base-plus-sv): huggingface.co/microsoft/wavlm-base-plus-sv · Chen et al. (2022), “WavLM: Large-Scale Self-Supervised Pre-Training for Full Stack Speech Processing”, IEEE Journal of Selected Topics in Signal Processing · ffmpeg: ffmpeg.org · SUPERB benchmark: superbbenchmark.org · For the applied analysis and findings: Hondurasgate — How the Verification Was Done · For context on what was being verified: Hondurasgate — What the Audios Say

Actualización — 23 de mayo de 2026

Una nota práctica sobre calidad de audio que surgió durante el trabajo forense de Hondurasgate: analizar siempre el audio lo más cerca posible de la grabación original. Cada plataforma que toca un archivo de audio lo recodifica — YouTube convierte a OGG/Opus a 8.000 Hz; WhatsApp y Telegram aplican sus propios codecs; las plataformas de streaming remuestrean y comprimen. Cada recodificación introduce artefactos sistemáticos — irregularidades espectrales periódicas, discontinuidades de fase, cambios de ganancia en los límites del codec — que los detectores de ENF y flux espectral de esta guía marcarán como anomalías. En audio muy recodificado pueden acumularse decenas de falsos positivos que son, a nivel de señal, indistinguibles de un corte de edición real.

La regla es simple: trabajar siempre desde el archivo más cercano a la grabación original. Para Hondurasgate específicamente, los archivos OGG originales están disponibles para descarga directa en hondurasgate.ch — usar esos, no las copias de YouTube. Para otras investigaciones: si la publicación pone los originales a disposición bajo solicitud, hay que pedirlos. Si solo se tiene acceso a una copia recodificada por una plataforma, documentarlo explícitamente e interpretar los resultados de ENF y flux espectral con precaución adicional: el comportamiento base de estos métodos cambia significativamente con audio comprimido por codec. En caso de duda, comparar el SHA-256 del archivo con cualquier hash publicado para confirmar exactamente qué versión se está usando.

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:

  1. ¿Esta voz es la persona que se dice? — verificación de hablante con WavLM-Base+
  2. ¿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 numpy

Instalació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.wav

Para 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,23 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

ScoreInterpretación
≥ 0,95Mismo hablante — alta confianza (dentro del rango inter-referencia)
0,88–0,95Mismo hablante — confianza moderada (por encima del umbral calibrado)
0,80–0,88Incierto — requiere verificación adicional
< 0,80Hablante 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

PreguntaRespuesta
¿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
    harmonics = [grid_freq, grid_freq*2, grid_freq*3, grid_freq*4]
    
    # Rastrear cada armónico por separado a lo largo del tiempo
    phases = {h: [] for h in harmonics}
    
    for start in range(0, len(audio) - window_size, hop_size):
        segment = audio[start:start + window_size]
        for h in harmonics:
            sos = signal.butter(4, [h - 0.5, h + 0.5],
                                btype='band', fs=sr, output='sos')
            filtered = signal.sosfilt(sos, segment)
            analytic = signal.hilbert(filtered)
            phase = np.angle(np.mean(analytic))  # media circular — correcto para ángulos
            phases[h].append(phase)
    
    # Detectar discontinuidades — comparar el mismo armónico en el tiempo
    hits = []
    for h in harmonics:
        for i in range(1, len(phases[h])):
            delta = abs(phases[h][i] - phases[h][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 hits

Mé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
    dists = [np.linalg.norm(profiles[i] - profiles[i-1])
             for i in range(1, len(profiles))]
    if len(dists) < 2:
        return []
    mean_d = np.mean(dists)
    std_d = np.std(dists) + 1e-10
    changes = [i + 1 for i, d in enumerate(dists)
               if (d - mean_d) / std_d > 2.5]
    
    print(f"Cambios en perfil de ruido: {len(changes)}")
    return changes

Cero 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 hits

Interpretació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 observaLo que significa
Tasa de hits igual al baseline, sin cambios de ruidoSin indicadores de edición — grabación continua
Hits ENF concentrados en un momentoPosible empalme en ese timestamp
Cambio en perfil de ruido en un punto específicoPosible empalme — entorno o dispositivo diferente
Pico de flux espectral aislado en un puntoPosible 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
reference_file = "referencia_no_editada.wav"  # grabación limpia conocida
audio_file = "desconocido.wav"

# Convertir primero si hace falta:
# subprocess.run(["ffmpeg", "-y", "-i", "desconocido.mp3",
#                 "-ar", "16000", "-ac", "1", audio_file], check=True)

# Establecer baseline sobre referencia no editada
ref_audio, _ = sf.read(reference_file)
ref_duration = len(ref_audio) / 16000
ref_flux_hits = analyze_spectral_flux(reference_file)
ref_noise_changes = analyze_noise_profile(reference_file)
baseline_hits_per_sec = len(ref_flux_hits) / ref_duration
baseline_noise_changes = len(ref_noise_changes)

# Analizar audio desconocido
unknown_audio, _ = sf.read(audio_file)
unknown_duration = len(unknown_audio) / 16000

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)/unknown_duration:.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.


Fuentes y herramientas · WavLM-Base+ (microsoft/wavlm-base-plus-sv): huggingface.co/microsoft/wavlm-base-plus-sv · Chen et al. (2022), “WavLM: Large-Scale Self-Supervised Pre-Training for Full Stack Speech Processing”, IEEE Journal of Selected Topics in Signal Processing · ffmpeg: ffmpeg.org · Benchmark SUPERB: superbbenchmark.org · Para el análisis aplicado y los hallazgos: Hondurasgate — Cómo se Hizo la Verificación · Para el contexto de qué se estaba verificando: Hondurasgate — Qué Dicen los Audios