Language-Agnostic Playback Synchronization Engine
Automatically fixes subtitle sync in your media library. Detects how far off your subtitles are and corrects them, including linear drift caused by framerate mismatches between the video and the subtitle file.
A Jellyfin plugin is available at r-stisen/lapse-jellyfin-plugin for direct integration with your media server.
The lapse binary is a C++ engine built on FFmpeg, libfvad and FFTW3, and it syncs one file at a time. The Docker image wraps that engine with a Python watcher that scans your library, matches subtitles to video and keeps a SQLite record so nothing gets processed twice. Run the binary by hand for a single file, or run the container to keep a whole library synced on its own.
docs/benchmarks.md covers testing against alass and ffsubsync on 39 feature films picked to be hard to sync, with methodology and per-film results. Passed 36 of 39 in normal mode, 32 of 39 with splitting on, and median sync time on a cached film is 0.66 seconds.
Every release ships a self-contained archive per platform. Unpack it and run the binary, there is nothing to install:
| Platform | Archive | Runs on |
|---|---|---|
| Linux x86-64 | lapse-linux-amd64.tar.gz |
glibc 2.31 and newer (Ubuntu 20.04+, Debian 11+) |
| Linux ARM64 | lapse-linux-arm64.tar.gz |
Raspberry Pi 4/5, ARM servers, Apple silicon VMs |
| macOS Apple silicon | lapse-macos-arm64.tar.gz |
macOS 11 Big Sur and newer |
| macOS Intel | lapse-macos-x86_64.tar.gz |
macOS 10.15 Catalina and newer |
| Windows x64 | lapse-windows-x64.zip |
Windows 10 and newer |
Each archive holds the binary, libonnxruntime (onnxruntime.dll on Windows), silero_vad.onnx and the licence. FFmpeg, libfvad and FFTW3 are linked into the binary, so nothing else has to be on the machine. SHA256SUMS covers every asset.
On Linux and macOS the binary needs the executable bit after unpacking:
tar xf lapse-linux-amd64.tar.gz
cd lapse-linux-amd64
chmod +x lapse
./lapse --vad--vad prints which detector it found (silero or libfvad) and is the quickest way to tell that the archive landed intact.
The Windows x64 build also runs on Windows on ARM under emulation if the ARM64 one gives you trouble. Loading onnxruntime.dll on Windows needs the Microsoft Visual C++ 2015-2022 Redistributable, which most machines already have. Without it LAPSE says so and carries on with libfvad.
cmake -B build
cmake --build build -jThe binary lands in build/lapse. FFmpeg has to come from the system, everything else is used if it is installed and built inside build/ if it is not, so on a machine that only has FFmpeg the two commands above are the whole story. Nothing is written outside the build directory.
| macOS | brew install cmake ffmpeg pkg-config |
| Debian, Ubuntu | sudo apt install cmake pkg-config libavformat-dev libavcodec-dev libavutil-dev libswresample-dev |
| Fedora | sudo dnf install cmake pkgconf-pkg-config ffmpeg-devel |
| Arch | sudo pacman -S cmake ffmpeg pkgconf |
| MSYS2 | pacman -S $MINGW_PACKAGE_PREFIX-{cmake,ffmpeg,pkgconf,toolchain} |
Installing libfftw3 and libfvad as well is fine and slightly faster, the build picks them up instead. cmake --build build prints where each dependency came from. ctest --test-dir build runs the same smoke test CI runs, and cmake --install build puts the binary in /usr/local/bin and nothing else anywhere.
Useful options: -DLAPSE_FETCH_MISSING=OFF to fail instead of downloading anything, -DLAPSE_STATIC_DEPS=ON to link the dependencies statically, -DLAPSE_DEPS_PREFIX=<prefix> to build against a prefix from the scripts below, -DCMAKE_BUILD_TYPE=Debug for a build worth stepping through.
To reproduce a release build instead, where FFmpeg, FFTW3 and libfvad are all static and minimal and the binary depends on nothing outside the platform itself:
.github/scripts/build-ffmpeg.sh "$HOME/deps"
.github/scripts/build-fftw.sh "$HOME/deps"
.github/scripts/build-libfvad.sh "$HOME/deps"
cmake -B build-release -DLAPSE_DEPS_PREFIX="$HOME/deps"
cmake --build build-release -jThat is what CI does, on Linux, on macOS with the Xcode command line tools, and on Windows inside an MSYS2 MINGW64 shell. Set MACOSX_DEPLOYMENT_TARGET on macOS to pick the oldest system the binary should run on. $HOME/deps is a throwaway prefix -- delete it when the build is done.
ONNX Runtime is not needed to build. LAPSE looks for it when it runs (LAPSE_ONNXRUNTIME env var, then next to the binary, then the usual library paths) and falls back to libfvad if it is missing.
The Docker image includes the C++ binary and the Python file watcher. Point it at your media library and it will scan on startup then watch for new files.
services:
lapse:
image: ghcr.io/r-stisen/lapse:latest
restart: always
volumes:
- ./data:/data
- /your/media:/media
environment:
- MEDIA_ROOT=/media
- DB_PATH=/data/lapse.db
- PUID=1000
- PGID=1000docker compose up -dEverything you mount gets scanned. To use more than one library, add another volume and list both paths:
volumes:
- /your/movies:/movies
- /your/tv:/tv
environment:
- MEDIA_ROOT=/movies,/tvSet PUID and PGID to the user that owns your media. Without them the container runs as root and the files it writes end up owned by root.
| Variable | Default | What it does |
|---|---|---|
MEDIA_ROOT |
/media |
Where to look. Comma separated for more than one library |
DB_PATH |
/data/lapse.db |
Where the record of finished work is kept |
PUID / PGID |
unset | Run as this user and group |
MODE |
nosplit |
nosplit, ols or split |
PENALTY |
6 |
Split penalty, only used in split mode |
SCAN_INTERVAL |
900 |
Seconds between full rescans. 0 turns them off |
MIN_CONFIDENCE |
0 |
Put the original back when a result scores below this. 0 keeps everything |
MAX_ATTEMPTS |
3 |
How many times a failing pair is retried before it is left alone |
TIMEOUT |
1800 |
Seconds a single sync may take |
POLLING |
0 |
Set to 1 on network shares where file events do not arrive |
Subtitles are matched to video by filename inside the same folder. If the subtitles sit in their own folder, like the Subs folder a lot of releases ship with, the folder above is checked as well.
Language and tag words such as en, danish, forced and sdh are ignored when names are compared, so a video can have several subtitle files matched to it at once. Episode numbers have to agree, so S01E01 never picks up the subtitle for S01E02. When a folder holds one video, any subtitle in it belongs to that video.
New files are picked up as they arrive. A file is left alone until it stops changing, so a download that is still being written is not touched. Full rescans catch anything the file events missed, which is what SCAN_INTERVAL and POLLING are for on network storage.
Every sync is written to the database, so the same file is never done twice. Replacing a subtitle file with a new one gets it synced again.
The first run is worth watching with docker compose logs -f lapse to check that the matches look right.
Sync subtitles to a video file (no-split, default):
./lapse video.mkv subtitles.srtSync subtitles to another subtitle file (faster, more accurate):
./lapse reference.srt subtitles.srtSync with OLS mode for gradual framerate drift:
./lapse video.mkv subtitles.srt olsSync with split mode for director's cuts or ad-break versions:
./lapse video.mkv subtitles.srt split 6The fourth argument is the split penalty. A value of 6 is a good default. Higher values produce fewer splits. Leave the mode off and LAPSE decides for itself whether the file needs shifting, drifting, splitting, or some combination (auto mode)
By default LAPSE overwrites the subtitle file it was given and leaves a .bak next to it. Two flags change that:
--output <path> write the corrected subtitle to <path> instead of overwriting the input
--no-backup do not create the .bak file
--no-embedded ignore subtitle tracks inside the video and use the audio
--full-scan accepted and ignored, the whole film is always listened to
--no-sidecar write nothing at all rather than an unsure file beside the original
--no-cache do not read or write the saved speech profile
--force overwrite the input whatever the verdict is
--strict refuse instead of writing an unsure result beside the original
--dry-run work out the answer but write nothing
--confidence N how far the answer has to stand out before the original is overwritten, default 8
--json one machine readable line on stdout, everything else on stderr
--quiet say nothing but errors
--audio-track N use the Nth audio track instead of the default one
--sub-track N use the Nth embedded subtitle track as the reference
--undo <subtitle> puts the .bak back and removes it.
Two flags answer a question and exit instead of syncing anything. --formats lists the subtitle extensions the engine reads and writes, and --vad prints silero or libfvad depending on which detector this copy can reach, exiting non-zero on the fallback.
Together they cover the four ways a caller may want the output handled:
| Result | Flags |
|---|---|
| Overwrite, keep a backup (default) | neither flag |
| Overwrite, no backup | --no-backup |
| Write a sidecar, leave the original alone | --output out.srt --no-backup |
| Write a sidecar and back up the original | --output out.srt |
./lapse reference.srt danish.srt --output danish.synced.srt --no-backupThe flags may appear anywhere on the command line. An existing .bak is never overwritten, so the first backup stays the untouched original no matter how many times you run LAPSE on a file.
Every run reports a verdict, and it decides what happens to your file:
| Verdict | What it means | What happens | Exit |
|---|---|---|---|
solid |
the answer clears the bar | the subtitle is overwritten, .bak kept |
0 |
unsure |
there is an answer, it is just not proven | original untouched, answer written to name.lapse-unsure.srt |
3 |
nothing |
the audio does not support any offset | original untouched, best guess written to name.lapse-unsure.srt |
3 |
LAPSE never simply refuses. If it cannot prove an answer it still writes one, it just puts it in a sidecar file and leaves yours alone. --no-sidecar writes nothing at all instead. --confidence moves the bar for what counts as solid; --force overwrites regardless; --strict writes nothing rather than a sidecar.
--json puts one line on stdout and moves every other message to stderr:
{"mode":"auto/shifted","reference":"vad","offset_ms":22,"ratio":1,"confidence":0.455,
"margin":0.12,"sigma":12.3,"agreement":0.75,"verdict":"solid","coverage":1,
"cues":1578,"ignored_cues":1,"parts":1,"written":true,"output":"...","splits":[]}mode says what LAPSE decided the file needed. ols, nosplit and split mean you asked for that yourself. Everything under auto is what it worked out on its own:
mode |
What it found | What moved |
|---|---|---|
auto/shifted |
one offset fits the whole file | offset_ms |
auto/shifted+split |
one offset nearly fits, with cuts in it | offset_ms, then splits |
auto/drifting |
the file drifts, usually a framerate mismatch | ratio and offset_ms |
auto/drifting+split |
it drifts and was cut about as well | ratio, then splits |
auto/recut |
the offset changes throughout | splits |
auto/joined |
two parts in one video | splits |
auto/restart |
the subtitle starts over partway through | splits |
parts is how many pieces the file ended up in and splits holds the cue index each new piece starts at, so parts is always splits plus one. ratio is 1 unless the file was stretched.
lapse/
├── engine/ C++ source for the CLI binary
├── docker/ Python orchestrator, Dockerfile, compose file
├── docs/ Benchmarks and other reference docs
└── .github/ CI workflows, and the scripts that build a release binary
Video: All formats supported by FFmpeg (.mp4, .mkv, .avi, .mov, .ts, .webm and more)
Subtitles: .srt, .ass, .ssa, .vtt
Planned to support: .sup, .idx/.sub, .sub, .smi, .ttml/.dfxp, .sbv, embedded MKV/MP4 tracks via mkvmerge/ffmpeg.
Run lapse --formats to print the subtitle formats your binary can read, one per line
Licensed under the GNU General Public License v3.0 -- see LICENSE for details