← Back to the problem

Spain selection camp 2026 · Correction by IOAI Training

Speed-up

This page gives the full approach. Read it once you have searched.

Metric
ROC AUC
Evaluated on
self-generated tests (official tests are hidden): 265 held-out train clips, half time-stretched by us
Our score
AUC 0.999 on our self-made test
Expected level
public LB best 1.000 (organisers and 3 teams)

We solved the problem ourselves: downloaded the data, ran the experiments and measured the score below. The statement and data belong to their organisers.

The problem in brief

A camp records a nightly storytelling hour read by about thirty regular readers. Someone has secretly sped up some of the clips so that they fit in the time slot, in a way that keeps the pitch of the voice almost unchanged. You get 865 clips of 7 seconds (16 kHz) that are all at normal speed, about 29 per reader, and 280 test clips by the same 30 readers, some of them sped up. For each test clip you output a score, higher meaning "more likely sped up". The only pretrained model allowed is a provided Whisper speech recogniser; we did not need it.

How we evaluate. The test labels are private and we never submitted anything. We therefore build our own labelled test: for each reader we set 10 training clips aside, speed up a random half of them ourselves, and measure how well the detector ranks them (265 clips, 120 sped up). This tells us how well we detect our speed-up; whether the organisers used the same kind of processing is checked separately on the real test clips (see the results).

How you are scored

The ROC AUC of the scores: the probability that a randomly chosen sped-up clip gets a higher score than a randomly chosen normal one,

AUC=Pr⁡(score(sped up)>score(normal)).\text{AUC} = \Pr\big(\text{score}(\text{sped up}) > \text{score}(\text{normal})\big).

Only the ranking matters, and 0.5 is chance. What to aim for: on the public leaderboard the organisers' solution and three teams reach 1.000; five entries sit at 0.643 to 0.644, which is presumably the Whisper-confidence baseline of the starter notebook.

First look at the data

  • Every clip lasts exactly 7.000 s, with no padding at the end, so the length gives nothing away: a sped-up clip was made from a longer stretch of reading.
  • "The pitch barely shifts" tells us how the clips were sped up. Playing audio faster by resampling raises the pitch by the same factor (the "chipmunk" effect). Keeping the pitch requires time-stretching, usually with a phase vocoder: cut the sound into overlapping frames, compute their spectra, read the frames faster (interpolating between neighbouring spectra), re-synthesise each frequency with a phase that advances at the right rate, and add the frames back.
  • Speakers differ a lot: a fast reader at normal speed talks faster than a slow reader sped up by 20 %. Any feature has to be compared with the same reader's normal clips.

A first look at the test set already shows something odd: compared with the training clips of the same readers, the test clips are on average quieter and have smoother spectra. That is a hint that a processing step left marks on part of them.

A first baseline

The starter notebook runs Whisper on every clip and uses its average token log-probability: sped-up speech is unusual for the recogniser, so it should be less confident. That idea works only a little (the cluster of leaderboard entries at 0.643 to 0.644 is presumably this baseline). A plausible reason: Whisper is trained to be robust to fast speakers and noisy recordings, so its confidence on a clip depends more on the text and the voice than on the speed. We did not run it ourselves (its checkpoint is a 1.6 GB pickle and our method does not need it).

The most natural hand-made baseline is the speaking rate: count energy peaks (roughly syllables) per second and compare with the reader's usual rate. On our self-made test this gives only AUC 0.66: the rate of one reader varies a lot from sentence to sentence, so a 10 to 20 % speed-up hides inside the natural variation.

The key idea

Do not detect "fast speech", detect "speech that went through a phase vocoder". Then learn that detector from examples you make yourself, by time-stretching the normal training clips.

A phase vocoder leaves fingerprints that are much more regular than the speaking rate:

  1. Smoother spectra over time. The stretched frames are interpolations between neighbouring original frames, so the spectrum changes less from one 8 ms step to the next. We measure the spectral flux, the root-mean-square change of the log-spectrum between consecutive frames, Φ=(log⁡Pt+1(f)−log⁡Pt(f))2‾\Phi = \sqrt{\overline{(\log P_{t+1}(f) - \log P_t(f))^2}}, taken as the median over the loud frames.
  2. Lower loudness. Re-synthesised phases no longer line up exactly across overlapping frames, so neighbouring frames partly cancel each other out ("phasiness"), and the overall RMS drops.
  3. A smeared energy envelope. Syllable onsets become less sharp, so the modulation spectrum of the energy envelope (its centroid between 1 and 16 Hz) moves down, even though the speech is faster. Fewer frames are cleanly periodic, so our pitch tracker finds fewer voiced frames.

To turn these clues into a detector we need labelled examples, and we can make them: for every training clip we create a time-stretched copy with our own phase vocoder (2048-sample frames, hop 512, the usual library defaults) at a random rate between 1.05 and 1.5. Every feature is then turned into a z-score within the reader, z=(x−μreader)/σreaderz = (x - \mu_{\text{reader}})/\sigma_{\text{reader}}, using that reader's normal training clips, and a logistic regression learns "original versus stretched" from 9 features: modulation centroid, energy-peak rate, mean length of loud stretches, median pitch, number of voiced frames, spectral centroid, spectral flux, RMS and crest factor.

On our self-made test the single features already tell the story:

feature (z-score within the reader)AUC alone
spectral flux (lower when stretched)0.968
RMS loudness (lower)0.948
modulation centroid of the envelope (lower)0.895
number of voiced frames (lower)0.834
spectral centroid (higher)0.707
energy-peak rate, i.e. speaking rate (higher)0.660
median pitch (higher)0.640

The artefacts of the processing beat the speed itself by a wide margin, and the combination is almost perfect.

The full solution

  1. For every clip compute the 9 features (band-pass 300 Hz to 4 kHz energy envelope at 100 Hz for the rate features, autocorrelation pitch on 40 ms frames, STFT with 512-sample frames for centroid and flux).
  2. Normalise each feature with the mean and standard deviation of the same reader's normal training clips.
  3. Positive examples: every training clip time-stretched at a random rate in [1.05,1.5][1.05, 1.5]; negative examples: the original clips.
  4. Logistic regression (C=1C = 1) on the 9 z-scores; the test score is its log-odds.
P = table(tr.audio_path, tr.speaker_id, rates)       # the same clips, time-stretched by us
X = np.vstack([speaker_z(A, A, cols), speaker_z(P, A, cols)])
clf = LogisticRegression(C=1).fit(X, np.r_[np.zeros(len(A)), np.ones(len(A))])
score = clf.decision_function(speaker_z(B, A, cols))

Everything runs on the CPU in about 2 minutes, without any pretrained model.

Results

approach (self-made test: 265 held-out clips, 120 stretched by us)AUC
Whisper confidence baseline (presumed, public leaderboard on real test clips)about 0.64
speaking rate alone (energy peaks per second, within reader)0.660
spectral flux alone0.968
gradient boosting on the 9 within-reader features0.998
logistic regression on the 9 within-reader features (final)0.999
same, only clips stretched by 5 to 15 %0.997

On our self-made test the detector reaches AUC 0.999, and it stays at 0.997 even for speed-ups of only 5 to 15 %.

Does it transfer to the organisers' speed-up? The real test labels are hidden, but the test clips answer the question themselves:

Test clips against normal and stretched training clips

In the left panel, the test clips (black circles) do not form one cloud around the normal clips: about half of them sit on the normal cloud and half on the cloud of clips we stretched ourselves. The scores of the final detector on the test clips (right panel) are strongly bimodal: 49 % of the clips are confidently called sped up, 51 % confidently called normal, with very few in between. A detector that missed the organisers' processing would give one blurred bump. This strongly suggests that the organisers also used a phase vocoder and that our ranking of the real test set is close to the 1.000 reached on the leaderboard, but we cannot verify that number without the labels.

What to remember

  • Ask how the data was made. "Faster but same pitch" means time-stretching, and time-stretching leaves its own artefacts. Detecting the tool is often easier than detecting its intended effect.
  • When you have no positives, manufacture them. Applying the suspected transformation to clean training data gives a labelled set for free; the method then becomes ordinary supervised learning.
  • Normalise per individual. Readers differ more than the effect you look for; z-scores within each reader remove that nuisance.
  • Check transfer without labels. A clean two-group structure of the scores on the real test set is good evidence that your synthetic positives resemble the real ones.
  • A big pretrained model is not always the answer. Whisper is built to ignore speed and recording quirks, which is exactly the information this task needs.
Full code (solution.py)
"""Speed-up (Tenerife camp 2026, day 9): rank 7 s speech clips by how likely they were sped up.
Metric: ROC AUC. Training clips are all normal speed; the same 30 speakers appear in the test set.

Files dataset/{train,test}.csv and dataset/{train,test}/*.wav from the Kaggle competition
tenerife-camp-contest-audio (the 1.6 GB Whisper checkpoint is not needed and not used).
Test labels are private, so we build our own labelled test: for 10 held-out clips per speaker we
speed up a random half ourselves and measure the AUC there (`python solution.py`), then train on all
clips and write submission.csv.

Idea: the clips were made faster without changing the pitch, i.e. by time-stretching. A phase
vocoder leaves fingerprints that are easier to see than the faster speech itself (smoother spectra
from frame to frame, lower loudness from phase interference, a smeared energy envelope). We
  1. describe every clip by 9 hand-made acoustic features,
  2. normalise each feature by the clip's speaker (mean and std of that speaker's training clips),
  3. create positive examples by time-stretching every training clip with a phase vocoder at a random
     rate in [1.05, 1.5], and train a logistic regression "original vs stretched".
"""
import numpy as np, pandas as pd, soundfile as sf, warnings
from scipy.signal import stft, butter, sosfiltfilt
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
warnings.filterwarnings("ignore")


SR = 16000
_sos = butter(4, [300, 4000], btype="band", fs=SR, output="sos")

def envelope_rate(x):
    """speaking-rate proxies from the 100 Hz energy envelope of the speech band"""
    y = sosfiltfilt(_sos, x)
    e = np.sqrt(np.convolve(y ** 2, np.ones(160) / 160, "same")[::160] + 1e-10)   # 10 ms frames
    le = np.log(e); le = le - le.mean()
    spec = np.abs(np.fft.rfft(le * np.hanning(len(le)))) ** 2
    f = np.fft.rfftfreq(len(le), 0.01)
    band = (f >= 1) & (f <= 16)
    centroid = (f[band] * spec[band]).sum() / spec[band].sum()
    # syllable-like peaks: local maxima of the smoothed envelope that rise 3 dB above neighbours
    s = np.convolve(le, np.ones(5) / 5, "same")
    pk = (s[1:-1] > s[:-2]) & (s[1:-1] >= s[2:]) & (s[1:-1] > np.quantile(s, 0.5))
    # duration of voiced/loud stretches: mean run length above median energy
    loud = s > np.median(s); runs = np.diff(np.flatnonzero(np.diff(np.r_[0, loud.astype(int), 0])))[::2]
    return centroid, pk.sum() / (len(le) * 0.01), runs.mean() * 0.01 if len(runs) else 0

def f0_median(x, fmin=60, fmax=400):
    """median pitch of the voiced frames, by normalised autocorrelation on 40 ms frames"""
    fr, hop = 640, 320; out = []
    for i in range(0, len(x) - fr, hop):
        w = x[i:i + fr] * np.hanning(fr)
        if (w ** 2).mean() < 1e-5: continue
        ac = np.fft.irfft(np.abs(np.fft.rfft(w, 2 * fr)) ** 2)[:fr]
        ac /= ac[0] + 1e-12
        lo, hi = SR // fmax, SR // fmin
        k = lo + np.argmax(ac[lo:hi])
        if ac[k] > 0.5: out.append(SR / k)
    return np.median(out) if out else np.nan, len(out)

def spectral(x):
    f, t, Z = stft(x, SR, nperseg=512, noverlap=384)
    P = np.abs(Z) ** 2 + 1e-12
    cen = (f[:, None] * P).sum(0) / P.sum(0)
    flux = np.sqrt((np.diff(np.log(P), axis=1) ** 2).mean(0))
    loud = P.sum(0) > np.median(P.sum(0))
    return np.median(cen[loud]), np.median(flux[loud[1:]])

def features(x):
    c, r, run = envelope_rate(x)
    f0, nv = f0_median(x)
    sc, flux = spectral(x)
    a = np.abs(x)
    return dict(mod_centroid=c, peak_rate=r, loud_run=run, f0=f0, voiced=nv, spec_centroid=sc, flux=flux,
                rms=np.sqrt((x ** 2).mean()), crest=a.max() / (np.sqrt((x ** 2).mean()) + 1e-9))
def phase_vocoder(x, rate, n_fft=2048, hop=512):
    """time-stretch by `rate` (>1 = faster) keeping the pitch, like librosa.effects.time_stretch"""
    win = np.hanning(n_fft)
    frames = np.lib.stride_tricks.sliding_window_view(np.pad(x, n_fft // 2, mode="reflect"), n_fft)[::hop] * win
    Z = np.fft.rfft(frames, axis=1).T                     # (F, T)
    steps = np.arange(0, Z.shape[1] - 1, rate)
    omega = 2 * np.pi * hop * np.arange(Z.shape[0]) / n_fft
    phase = np.angle(Z[:, 0]); out = np.zeros((Z.shape[0], len(steps)), complex)
    for i, s in enumerate(steps):
        a, b = Z[:, int(s)], Z[:, int(s) + 1]; frac = s - int(s)
        out[:, i] = ((1 - frac) * np.abs(a) + frac * np.abs(b)) * np.exp(1j * phase)
        dp = np.angle(b) - np.angle(a) - omega; dp -= 2 * np.pi * np.round(dp / (2 * np.pi))
        phase += omega + dp
    y = np.zeros(n_fft + hop * (len(steps) - 1)); wsum = np.zeros_like(y)
    fr = np.fft.irfft(out.T, n=n_fft, axis=1) * win
    for i in range(len(steps)):
        y[i * hop:i * hop + n_fft] += fr[i]; wsum[i * hop:i * hop + n_fft] += win ** 2
    y = y / np.maximum(wsum, 1e-8)
    return y[n_fft // 2: n_fft // 2 + int(round(len(x) / rate))]

D = "dataset"


def table(paths, spk, rates=None):
    rows = []
    for k, p in enumerate(paths):
        x = sf.read(f"{D}/{p}", dtype="float32")[0].astype(np.float64)
        if rates is not None: x = phase_vocoder(x, rates[k])
        rows.append(features(x))
    F = pd.DataFrame(rows); F["spk"] = list(spk)
    return F


def speaker_z(F, ref, cols):
    mu, sd = ref.groupby("spk")[cols].mean(), ref.groupby("spk")[cols].std()
    return ((F[cols] - mu.loc[F.spk].values) / sd.loc[F.spk].values).fillna(0).values


if __name__ == "__main__":
    tr, te = pd.read_csv(f"{D}/train.csv"), pd.read_csv(f"{D}/test.csv")
    rng = np.random.default_rng(1)
    rates = rng.uniform(1.05, 1.5, len(tr))
    A = table(tr.audio_path, tr.speaker_id)                    # originals
    P = table(tr.audio_path, tr.speaker_id, rates)             # the same clips, time-stretched
    cols = [c for c in A.columns if c != "spk"]

    # ---- self-generated test: clips 20+ of each speaker, a random half of them stretched ----
    ref = (A.groupby("spk").cumcount() < 20).values
    V = np.where(~ref)[0]; flip = np.random.default_rng(0).random(len(V)) < 0.5
    clf = LogisticRegression(C=1, max_iter=2000).fit(
        np.vstack([speaker_z(A[ref], A[ref], cols), speaker_z(P[ref], A[ref], cols)]), np.r_[np.zeros(ref.sum()), np.ones(ref.sum())])
    Xv = np.vstack([speaker_z(A.iloc[V[~flip]], A[ref], cols), speaker_z(P.iloc[V[flip]], A[ref], cols)])
    yv = np.r_[np.zeros((~flip).sum()), np.ones(flip.sum())]
    print("self-generated test AUC:", round(roc_auc_score(yv, clf.predict_proba(Xv)[:, 1]), 4), f"({len(yv)} clips)")

    # ---- final model on every training clip, applied to the real test clips ----
    B = table(te.audio_path, te.speaker_id)
    clf = LogisticRegression(C=1, max_iter=2000).fit(
        np.vstack([speaker_z(A, A, cols), speaker_z(P, A, cols)]), np.r_[np.zeros(len(A)), np.ones(len(A))])
    score = clf.decision_function(speaker_z(B, A, cols))
    pd.DataFrame({"audio_id": te.audio_id, "score": score}).to_csv("submission.csv", index=False)
    print("test clips predicted sped up:", round(float((score > 0).mean()), 3))