← 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.

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.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 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
    
    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 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
    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 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
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:

  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,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 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
    
    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 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
    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 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
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.