Skip to content

Blind playback-rate listening test

A watch party that catches up a lagging viewer by raising playback speed needs to stay imperceptible. If your friends can hear the speed change, the fix is worse than the drift. This guide runs a blind listening test - 16 trials at 2-3% speed changes, half ramped and half instant, with catch trials - to measure whether those rates are audible through a browser’s HTMLMediaElement.playbackRate and through mpv’s speed with the scaletempo2 pitch-preserving filter.

Prerequisites: a browser (Firefox or Chromium), optionally mpv for the audio-path test, and content with dialogue and steady music. Headphones are the hard case - they expose rate artefacts that speakers mask.

An informal single-listener run found that speeding up was heard more readily than slowing down (the listener “could sense when it got faster”). No formal results exist yet; this guide documents the test rig. Once results are in, they set the cap on catch-up rate, the ramp profile, and whether slow-down-only is necessary. For the broader context on far-viewer sync and the architecture this test feeds into, see the companion references on synchronized playback with far-away viewers and the lockstep watch-party sync architecture.

The same schedule runs in the browser and in mpv. Four conditions, two modes, two repetitions: 16 trials in random order.

ParameterValue
Conditions1.02, 1.03, 0.98, 1.0 (catch)
Modesramp (2 s, 8 discrete steps) and step (instant)
Trials16 (4 conditions x 2 modes x 2 reps)
Gap between changes30-75 s at 1.00 (uniform random)
Hold at changed rate25-50 s (uniform random)
Hit window15 s after onset

Design choices:

  • Catch trials: 4 of the 16 trials make no change at all. These detect false-alarm responders. Without them, a positive response rate is uninterpretable.
  • Ramp vs step: a continuous ramp may mask a change that a step exposes. Both are tested.
  • Randomised gaps: a fixed interval trains the listener to expect changes at predictable times.
  • Clock only runs while playing: pausing, seeking, or buffering does not advance the trial timer. Settle in and watch normally.
  • Generous hit window: 15 s after onset gives time to notice and react. Each press is matched to at most one transition (onset or offset) within the window. A press that matches no change is a false alarm.
  • Either-direction reporting: one key for “I heard something change” - no forced choice between faster and slower. That reduces cognitive load and eliminates direction-guessing as a confound.

How thresholds work: the just-noticeable difference (JND) for speech rate is about 5.3%1; for video speed it is wider, about 9-18%2. A 2-3% change is below both, which is why the test matters - published thresholds say it should be inaudible, but those are population averages measured in lab conditions with clean stimuli, not with film dialogue and a stereo mix.

Both paths keep pitch constant. Browsers default preservesPitch to true3; mpv defaults --audio-pitch-correction=yes, which inserts the scaletempo2 filter whenever speed is not 1.045.

Open your Jellyfin web UI in Firefox or Chromium and start an episode or film. Do not join a lockstep room - the room’s playback corrector owns playbackRate and would override the test’s changes.

Open devtools (F12), switch to the Console tab, and paste the script below. Firefox requires typing allow pasting into the console before the first paste.

Press H (for “heard”) whenever you think the speed changed - faster, slower, or returning to normal. A counter in the bottom-left corner of the page shows how many times you have pressed. At the end the result prints in the console and copies to the clipboard.

// lockstep#65 blind listening test: can you hear a small playback-rate
// change? Paste into the browser devtools console on a jellyfin-web tab
// that is playing something with dialogue (NOT inside a lockstep room - the
// room's corrector owns playbackRate there). Then watch normally and press
// H (for "heard") whenever you think the speed changed (either way: speeding
// up, slowing down, or going back to normal). Nothing on screen tells you.
//
// Each trial: 30-75 s at 1.00, then a change (1.02, 1.03, 0.98, or a
// "catch" trial that changes nothing) held 25-50 s, then back to 1.00.
// Half the changes ramp over 2 s, half switch in one step. The clock only
// runs while the video plays, so pausing is fine.
//
// Results print to the console at the end (or call lockstepRateTest.report()
// any time) and are copied to the clipboard as JSON: paste them back.
// lockstepRateTest.stop() aborts and restores 1.00.
(() => {
'use strict';
const prev = window.lockstepRateTest;
if (prev) prev.stop();
const CONDITIONS = [1.02, 1.03, 0.98, 1.0]; // 1.0 = catch trial
const MODES = ['ramp', 'step'];
const TRIALS = 16; // every condition x mode twice
const GAP_S = [30, 75];
const HOLD_S = [25, 50];
const RAMP_S = 2;
const RAMP_STEPS = 8;
const HIT_WINDOW_S = 15;
const TICK_MS = 100;
const rnd = (lo, hi) => lo + Math.random() * (hi - lo);
const shuffle = (a) => {
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
};
const trials = [];
for (let k = 0; k < TRIALS / (CONDITIONS.length * MODES.length); k++) {
for (const rate of CONDITIONS) for (const mode of MODES) trials.push({ rate, mode });
}
shuffle(trials).forEach((t) => {
t.gap = rnd(...GAP_S);
t.hold = rnd(...HOLD_S);
});
// Timeline in "playing seconds" (only advances while the video plays).
let t = 0;
let idx = 0;
let phase = 'gap'; // gap -> hold -> (next trial) ... -> done
let phaseStart = 0;
const transitions = []; // {at, kind: 'onset'|'offset', rate, mode, trial}
const presses = []; // {at}
const glitches = []; // {at, what}
const rateReadback = []; // {set, got}
let lastWall = performance.now();
let target = 1.0;
let rampFrom = 1.0;
let rampAt = 0;
let rampMode = 'step';
const video = () => document.querySelector('video');
function applyRate(r) {
const v = video();
if (!v) return;
v.playbackRate = r;
if (rateReadback.length < 50 && Math.abs(v.playbackRate - r) > 1e-6) {
rateReadback.push({ set: r, got: v.playbackRate });
}
}
function setTarget(r, mode) {
rampFrom = currentRate();
target = r;
rampAt = t;
rampMode = mode;
if (mode === 'step') applyRate(r);
}
function currentRate() {
const v = video();
return v ? v.playbackRate : 1.0;
}
function stepRamp() {
if (rampMode !== 'ramp') return;
const frac = Math.min(1, (t - rampAt) / RAMP_S);
const steps = Math.ceil(frac * RAMP_STEPS);
const r = rampFrom + ((target - rampFrom) * steps) / RAMP_STEPS;
if (Math.abs(currentRate() - r) > 1e-6) applyRate(r);
}
function playing(v) {
return v && !v.paused && !v.seeking && !v.ended && v.readyState >= 3;
}
const onWaiting = () => glitches.push({ at: +t.toFixed(2), what: 'waiting' });
let boundTo = null;
function bind(v) {
if (boundTo === v) return;
if (boundTo) boundTo.removeEventListener('waiting', onWaiting);
boundTo = v;
if (v) v.addEventListener('waiting', onWaiting);
}
function tick() {
const now = performance.now();
const dt = (now - lastWall) / 1000;
lastWall = now;
const v = video();
bind(v);
if (!playing(v)) return;
t += dt;
stepRamp();
const trial = trials[idx];
if (phase === 'gap' && t - phaseStart >= trial.gap) {
phase = 'hold';
phaseStart = t;
transitions.push({ at: +t.toFixed(2), kind: 'onset', rate: trial.rate, mode: trial.mode, trial: idx });
setTarget(trial.rate, trial.mode);
} else if (phase === 'hold' && t - phaseStart >= trial.hold) {
transitions.push({ at: +t.toFixed(2), kind: 'offset', rate: trial.rate, mode: trial.mode, trial: idx });
setTarget(1.0, trial.mode);
idx += 1;
phaseStart = t;
phase = idx < trials.length ? 'gap' : 'done';
if (phase === 'done') setTimeout(finish, (RAMP_S + HIT_WINDOW_S) * 1000);
}
}
function onKey(e) {
if (e.code !== 'KeyH') return;
e.preventDefault();
e.stopPropagation();
presses.push({ at: +t.toFixed(2) });
badge.textContent = `rate test: ${presses.length}`;
}
function report() {
const used = new Set();
const rows = transitions.map((tr) => {
const p = presses.find((x, i) => !used.has(i) && x.at >= tr.at && x.at <= tr.at + HIT_WINDOW_S);
if (p) used.add(presses.indexOf(p));
return { ...tr, heard: !!p, latency_s: p ? +(p.at - tr.at).toFixed(1) : null };
});
const falseAlarms = presses.filter((_, i) => !used.has(i)).length;
const summary = {};
for (const r of rows) {
const key = r.rate === 1.0 ? 'catch(1.00)' : `${r.rate.toFixed(2)} ${r.mode} ${r.kind}`;
summary[key] ??= { heard: 0, of: 0 };
summary[key].of += 1;
if (r.heard) summary[key].heard += 1;
}
const out = {
test: 'lockstep#65 rate-test (web)',
ua: navigator.userAgent,
done_trials: idx,
playing_s: +t.toFixed(1),
summary,
false_alarms: falseAlarms,
presses: presses.length,
glitches,
rate_readback_mismatch: rateReadback,
rows,
};
console.table(summary);
console.log(JSON.stringify(out));
navigator.clipboard?.writeText(JSON.stringify(out, null, 1)).catch(() => {});
return out;
}
function teardown() {
clearInterval(timer);
document.removeEventListener('keydown', onKey, true);
bind(null);
badge.remove();
applyRate(1.0);
delete window.lockstepRateTest;
}
function finish() {
const out = report();
teardown();
console.log('lockstep rate test finished; JSON copied to clipboard (if allowed).');
return out;
}
const badge = document.createElement('div');
badge.textContent = 'rate test: 0';
badge.style.cssText =
'position:fixed;left:4px;bottom:4px;z-index:2147483647;font:11px monospace;color:#888;opacity:.6;pointer-events:none';
document.body.appendChild(badge);
document.addEventListener('keydown', onKey, true);
const timer = setInterval(tick, TICK_MS);
window.lockstepRateTest = {
report,
stop: () => {
const out = report();
teardown();
return out;
},
};
console.log(`lockstep rate test started: ${TRIALS} trials, about ${Math.round(trials.reduce((s, x) => s + x.gap + x.hold, 0) / 60)} min of playback. Press H when you hear a speed change.`);
})();

lockstepRateTest.report() prints the JSON early; lockstepRateTest.stop() aborts. Run the test in each browser your viewers use. Test Safari separately - it rounds playbackRate to the nearest 0.01, which rate_readback_mismatch captures as {set, got} pairs in the output.

Fladder’s default player on every platform except Android TV is the MPV backend. Its setSpeed sets mpv’s speed, and mpv’s default --audio-pitch-correction=yes inserts scaletempo25 - the same filter a standalone mpv uses. A standalone mpv test exercises the same audio path without Fladder in the loop.

Install mpv (winget install mpv-player.mpv-CI.MSVC on Windows), save the script below as rate-test.lua, then run:

mpv --script=rate-test.lua "<local file or Jellyfin stream URL>"

Press F12 whenever you hear a change. Press Shift+F12 to end early. The JSON prints in the terminal and writes to %USERPROFILE%\Downloads\lockstep-rate-test-<time>.json.

-- lockstep#65 blind listening test for the Fladder audio path. Fladder's
-- default player (MPV backend, media_kit) sets mpv's `speed`; with mpv's
-- default --audio-pitch-correction=yes that inserts scaletempo2, the same
-- filter this script exercises in a standalone mpv.
--
-- Run: mpv --script=rate-test.lua <file or stream URL with dialogue>
-- Watch normally and press F12 whenever you think the speed changed
-- (speeding up, slowing down, or going back to normal). Nothing on screen
-- tells you. Same schedule as rate-test.js: 16 trials, each 30-75 s at 1.00
-- then 1.02 / 1.03 / 0.98 / catch (no change) held 25-50 s, half ramped
-- over 2 s and half in one step. The clock only runs while playing.
--
-- Results: printed to the terminal and written to
-- %USERPROFILE%\Downloads\lockstep-rate-test-<time>.json (or the current
-- directory) when the test ends, or when you press Shift+F12 / quit.
local mp = require 'mp'
local utils = require 'mp.utils'
local CONDITIONS = { 1.02, 1.03, 0.98, 1.0 } -- 1.0 = catch trial
local MODES = { 'ramp', 'step' }
local TRIALS = 16
local GAP_S = { 30, 75 }
local HOLD_S = { 25, 50 }
local RAMP_S = 2
local RAMP_STEPS = 8
local HIT_WINDOW_S = 15
local TICK_S = 0.1
math.randomseed(os.time())
local function rnd(lo, hi) return lo + math.random() * (hi - lo) end
local trials = {}
for _ = 1, TRIALS / (#CONDITIONS * #MODES) do
for _, rate in ipairs(CONDITIONS) do
for _, mode in ipairs(MODES) do
trials[#trials + 1] = { rate = rate, mode = mode }
end
end
end
for i = #trials, 2, -1 do
local j = math.random(i)
trials[i], trials[j] = trials[j], trials[i]
end
for _, tr in ipairs(trials) do
tr.gap = rnd(GAP_S[1], GAP_S[2])
tr.hold = rnd(HOLD_S[1], HOLD_S[2])
end
local t, idx, phase, phase_start = 0, 1, 'gap', 0
local transitions, presses, glitches = {}, {}, {}
local target, ramp_from, ramp_at, ramp_mode = 1.0, 1.0, 0, 'step'
local last_wall = mp.get_time()
local finished = false
local function round(x, n) local m = 10 ^ (n or 2); return math.floor(x * m + 0.5) / m end
local function set_speed(r) mp.set_property_number('speed', r) end
local function speed() return mp.get_property_number('speed', 1.0) end
local function set_target(r, mode)
ramp_from, target, ramp_at, ramp_mode = speed(), r, t, mode
if mode == 'step' then set_speed(r) end
end
local function step_ramp()
if ramp_mode ~= 'ramp' then return end
local frac = math.min(1, (t - ramp_at) / RAMP_S)
local steps = math.ceil(frac * RAMP_STEPS)
local r = ramp_from + (target - ramp_from) * steps / RAMP_STEPS
if math.abs(speed() - r) > 1e-6 then set_speed(r) end
end
mp.observe_property('paused-for-cache', 'bool', function(_, v)
if v then glitches[#glitches + 1] = { at = round(t), what = 'paused-for-cache' } end
end)
local function report()
local used, rows, summary = {}, {}, {}
for _, tr in ipairs(transitions) do
local hit
for i, p in ipairs(presses) do
if not used[i] and p.at >= tr.at and p.at <= tr.at + HIT_WINDOW_S then hit = i; break end
end
if hit then used[hit] = true end
local row = { at = tr.at, kind = tr.kind, rate = tr.rate, mode = tr.mode, trial = tr.trial,
heard = hit ~= nil, latency_s = hit and round(presses[hit].at - tr.at, 1) or nil }
rows[#rows + 1] = row
local key = tr.rate == 1.0 and 'catch(1.00)' or string.format('%.2f %s %s', tr.rate, tr.mode, tr.kind)
summary[key] = summary[key] or { heard = 0, of = 0 }
summary[key].of = summary[key].of + 1
if row.heard then summary[key].heard = summary[key].heard + 1 end
end
local fa = 0
for i = 1, #presses do if not used[i] then fa = fa + 1 end end
local out = {
test = 'lockstep#65 rate-test (mpv)',
mpv = mp.get_property('mpv-version'),
af = mp.get_property('af'),
done_trials = idx - 1,
playing_s = round(t, 1),
summary = summary,
false_alarms = fa,
presses = #presses,
glitches = glitches,
rows = rows,
}
local json = utils.format_json(out)
print(json)
local dir = os.getenv('USERPROFILE')
dir = dir and (dir .. '\\Downloads') or '.'
local path = utils.join_path(dir, 'lockstep-rate-test-' .. os.date('%Y%m%d-%H%M%S') .. '.json')
local f = io.open(path, 'w')
if not f then
path = 'lockstep-rate-test-' .. os.date('%Y%m%d-%H%M%S') .. '.json'
f = io.open(path, 'w')
end
if f then f:write(json); f:close(); print('lockstep rate test: wrote ' .. path) end
return out
end
local timer
local function finish()
if finished then return end
finished = true
if timer then timer:kill() end
set_speed(1.0)
report()
mp.osd_message('lockstep rate test finished', 3)
end
timer = mp.add_periodic_timer(TICK_S, function()
local now = mp.get_time()
local dt = now - last_wall
last_wall = now
if phase == 'done' or mp.get_property_bool('core-idle', true) or mp.get_property_bool('seeking', false) then return end
t = t + dt
step_ramp()
local tr = trials[idx]
if phase == 'gap' and t - phase_start >= tr.gap then
phase, phase_start = 'hold', t
transitions[#transitions + 1] = { at = round(t), kind = 'onset', rate = tr.rate, mode = tr.mode, trial = idx }
set_target(tr.rate, tr.mode)
elseif phase == 'hold' and t - phase_start >= tr.hold then
transitions[#transitions + 1] = { at = round(t), kind = 'offset', rate = tr.rate, mode = tr.mode, trial = idx }
set_target(1.0, tr.mode)
idx, phase_start = idx + 1, t
if idx > #trials then
phase = 'done'
-- let the last ramp finish and leave time to answer the last change
local done_at = t
local w
w = mp.add_periodic_timer(TICK_S, function()
step_ramp()
t = t + TICK_S
if t - done_at >= RAMP_S + HIT_WINDOW_S then w:kill(); finish() end
end)
else
phase = 'gap'
end
end
end)
mp.add_forced_key_binding('F12', 'lockstep-rate-press', function()
presses[#presses + 1] = { at = round(t) }
end)
mp.add_forced_key_binding('Shift+F12', 'lockstep-rate-stop', finish)
mp.register_event('shutdown', function() if not finished then finished = true; report() end end)
local total = 0
for _, tr in ipairs(trials) do total = total + tr.gap + tr.hold end
print(string.format('lockstep rate test: %d trials, about %d min of playback. Press F12 when you hear a speed change.',
#trials, math.floor(total / 60 + 0.5)))

Both scripts emit JSON with the same structure. The fields:

FieldMeaning
summaryPer-condition heard/of counts. catch(1.00) is the false-positive baseline - a catch trial that was “heard” is a false alarm.
false_alarmsPresses that matched no change.
glitchesPlayback interruptions during the test (stalls, rebuffering). A trial that lands on a glitch is still scored, but note the confound.
rate_readback_mismatchIn the browser test: an array of {set, got} pairs where the engine’s actual rate differed from the requested rate. Safari rounds playbackRate and will fill this. The mpv test does not emit this field.
rowsTrial-by-trial: whether each change was heard, and the latency in seconds.

No formal results exist yet. The output fields above are the format; fill them with your own data.

If 1.02 is reliably heard: the catch-up band cannot include 1.02. Slow-down-only (under 1.00) might fare better if slowing down is less audible - the informal run suggested this asymmetry.

If 1.02 is heard on step but not on ramp: a ramp masks the change. Catch-up should ramp into its rate over at least 2 s rather than switching instantly.

If catch trials get responses: the listener is guessing or the test content has natural tempo changes (dialogue speeding up, music shifting). Re-run with different content.

If 1.03 is inaudible across listeners: the catch-up band can be 1.00-1.03, which matches the dash.js default (0.96-1.04)6. A 3% drift correction closes 1 second of lag in about 33 seconds of playback.

If Safari rounds rates: rate-based catch-up on Safari will be quantised to full percentage points. The correction should target values Safari can actually hold.

  • MDK backend. Fladder offers an MDK player option in its settings. MDK sets speed through its own audio pipeline, not mpv’s scaletempo2.
  • Android TV native player. Fladder in lean-back mode uses the Android native player, which ignores setSpeed entirely. Rate-based catch-up cannot work there; the room keeps waiting.
  • In-app integration. The test exercises the raw audio path (browser playbackRate or mpv speed). It does not test whether Fladder’s setSpeed call reaches the player at the right time or whether the UI hides the change.
  • Pausing or seeking pauses the trial timer. The clock only runs while the video plays.
  • A buffering stall during a trial is recorded as a glitch. The trial is still scored, but the stall itself is an audio artefact that may be mistaken for a rate change.

The scripts live in a private repository (lockstep, dev/rate-test/). The full versions are reproduced above:

FilePurpose
rate-test.jsBrowser test (paste into devtools console).
rate-test.luampv test (run with mpv --script=).

No other files are needed. The scripts are self-contained.

  1. L. J. Stifelman, “A Study of Rate Discrimination of Time-Compressed Speech,” AVIOS Journal, vol. 14, pp. 29-36, 1994. https://www.media.mit.edu/speech/papers/1994/Stifelman_AVIOSJ94_rate_discrimination.pdf ↩

  2. C. de’Sperati and S. Baud-Bovy, “Low perceptual sensitivity to altered video speed in viewing a soccer match,” Scientific Reports, vol. 7, article 15379, 2017. https://www.nature.com/articles/s41598-017-15619-8 ↩

  3. MDN, “HTMLMediaElement: preservesPitch property,” MDN Web Docs. https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/preservesPitch ↩

  4. mpv, “OPTIONS - Playback Control,” mpv.io. --speed option: “Slow down or speed up playback by the factor given as parameter. If --audio-pitch-correction (on by default) is used, playing with a speed higher than normal automatically inserts the scaletempo2 audio filter.” https://raw.githubusercontent.com/mpv-player/mpv/master/DOCS/man/options.rst ↩

  5. mpv, “OPTIONS - Audio,” mpv.io. --audio-pitch-correction: “If this is enabled (default), playing with a speed different from normal automatically inserts the scaletempo2 audio filter.” https://raw.githubusercontent.com/mpv-player/mpv/master/DOCS/man/options.rst ↩ ↩2

  6. Dash-Industry-Forum, “Proposal: use playbackRate to adjust synchronization,” dash.js issue #2600, 2018. “Small increases in playback rate are usually not noticeable.” https://github.com/Dash-Industry-Forum/dash.js/issues/2600 ↩