A .NET 11 rewrite of the decode-facing parts of
oyvindln/vhs-decode, targeting
upstream release v0.4.0 at commit
43155200da87c0d49eb37d8ec09b1372075ee8e4.
The current .NET port release is v0.4.0-2.10.0 (application version 2.10.0).
Important
This remains a compatibility work in progress. The top-level decode paths are implemented and heavily tested, but every real capture and rare option combination has not yet been certified byte-for-byte.
Read the detailed English reference for the full compatibility matrix, implementation notes, historical benchmarks, validation evidence, and remaining gaps.
- Decode-only scope: VHS, CVBS, LaserDisc, and HiFi.
- Release 4.0 command names, options, aliases, defaults, diagnostics, and output lifecycle are the compatibility target.
- VHS-family routing includes VHS/S-VHS, Betamax, Video8/Hi8, U-matic, Type C, EIAJ, and supported PAL/NTSC variants.
- TBC utility tools, the double-click GUI, and developer plotting windows are intentionally out of scope.
- The Visual Studio 2026
.slnxsolution has 1,615 standard xUnit v3 tests that are visible in Test Explorer and runnable withdotnet test.
Download the current binary-only Windows x64 package from
GitHub Releases.
The package is built as a single-file decode.exe.
decode.exe vhs [upstream options] input.lds output
decode.exe cvbs [upstream options] input.lds output
decode.exe ld [upstream options] input.lds output
decode.exe hifi [upstream options] input.lds output.wavStandalone command aliases such as vhs-decode.exe and ld-decode.exe are
also supported. Use decode.exe <command> --help for the complete compatible
option set.
The release workflow also builds a self-contained, multi-file glibc
linux-x64 tar. It supports only the portable exact backend and bundles the
Linux SQLite, libsndfile, and libsoxr native assets; Windows IPP/CUDA DLLs are
excluded. After installing FFmpeg and the documented OS libraries:
tar -xzf vhs-decode-dotnet-linux-x64.tar.gz
cd vhs-decode-dotnet-linux-x64
./vhs-decode --pal --dsp-backend exact input.lds outputSee Linux x64 release for the Ubuntu 22.04/glibc 2.35 baseline, runtime packages, checksums, build provenance, and release gates.
VHS and LaserDisc can expose a local, seekable HTTP preview without an output base name:
Run decode.exe vhs --preview-server --pal input.lds for tape RF, or
decode.exe ld --preview-server --pal input.ldf for LaserDisc RF.
The command prints a loopback player URL and a standard HLS/fMP4 playlist URL.
The default address starts at 127.0.0.1:8080; if occupied, startup increments
the port through 8180 until one binds. An explicit --preview-port is strict,
while --preview-port 0 asks the operating system for a dynamic port. Preview mode creates no TBC, JSON,
SQLite, EFM, audio, or decoder log artifacts.
This is intentionally a low-accuracy navigation mode. It retains colour through
a cheap 4fSC one-dimensional demodulator, derives the PAL V-switch from
neighbouring burst lines to avoid four-field hue flicker, applies lightweight dropout
concealment, skips audio and the expensive export comb/repair stages, and
decodes the full continuous frame count for every two-second preview window.
The muted web player starts automatically and keeps two windows of lookahead
buffered. Top-field-first source fields are deinterlaced at field rate: NTSC is
served as progressive 640x480 at 60000/1001 fps and PAL as progressive 768x576
at 50 fps. At startup the preview validates complete fMP4 pipelines in this
order: NVENC with CUDA YADIF, QSV with advanced VPP deinterlacing, AMF with CPU
YADIF, then libx264 with CPU YADIF. --preview-crf accepts 0 through 51 and
defaults to 31; hardware encoders map it to their closest quality/QP control,
so bitrate is not identical across backends. Eligible native-rate 40 MSPS
PAL/NTSC VHS preview first performs a lightweight CUDA-driver/device preflight;
this does not load cuFFT, create a CUDA context, or initialize NVENC. A passing
device then gets one full CUDA/cuFFT/NVENC initialization attempt. If preflight
or full startup is unavailable, preview falls back to ipp-fast when available,
then to the portable managed backend. Other preview inputs start with that same
IPP-to-managed CPU order. Standard 40 MSPS VHS preview also applies a fixed anti-alias filter and
decodes its internal RF stream at 20 MSPS. Native 20 MSPS VHS input stays at
20 MSPS. In other words, supported VHS preview routes force the same behavior as
the full-decode --decode-at-20msps switch. Full VHS decode can opt into that
switch with ipp-fast, cuda-fast, or approx-fast; Exact, S-VHS, other tape formats, and
LaserDisc retain their existing sample-rate behavior. Startup reports the selected video pipeline, IPP-FAST initialization,
active decoder thread count, and separate in-place window-ID and real-time-FPS
lines. A matching
FFmpeg build is required on PATH; VHSDECODE_FFMPEG and VHSDECODE_FFPROBE
can select explicit binaries.
Native-rate 40 MSPS PAL/NTSC VHS therefore selects the independent GPU preview path automatically on a compatible machine. The same path can be pinned explicitly:
decode.exe vhs --preview-server --dsp-backend cuda-fast --pal input.ldfThis keeps one CUDA context across windows, performs the anti-aliased 40-to-20
MSPS reduction, sync, FM/chroma/dropout processing, NV12 bob rendering, and
NVENC H.264 encoding on the GPU. The renderer writes a block-linear NV12 CUDA
array that NVENC registers directly, avoiding its pitch-linear conversion. Each bounded RF batch is uploaded once, while full luma, chroma, and NV12 frames are
never downloaded; only small sync/field-order control metadata and compressed
H.264 packets cross the host/device boundary. FFmpeg only copy-muxes the H.264
into HLS/fMP4. An explicit --dsp-backend cuda-fast request requires a compatible
NVIDIA GPU and never falls back to the CPU preview or another encoder. Automatic
default selection falls back only if GPU startup fails; it never changes backend
after a preview session has started. The existing GPU bob
deinterlacer is unchanged. Preview-only cross-field dropout substitution uses a
clean opposite-parity field when one exists in the bounded batch, and a
one-field 75/25 current/previous chroma blend resets at every seek window.
A sustained local resource matrix on 2026-08-20 used the same real 40 MSPS PAL
capture, an Intel Core Ultra 7 265K (20 logical processors), and an RTX 4070.
The first five rows came from source commit 41bfd92; the corrected IPP
preview row came from 1fb1455. Each row is the mean of two independent
process launches; ranges are the two observed source-frame rates.
| Path | Source fps (range) | decode.exe CPU |
Whole-system CPU | GPU SM avg/peak | NVENC avg/peak | Peak GPU FB |
|---|---|---|---|---|---|---|
| Full, CUDA 40 MSPS | 35.30 (35.28-35.33) | 10.81% / 2.16 cores | 33.89% | 32.44% / 72% | 0% / 0% | 7,038 MiB |
| Full, IPP 40 MSPS | 23.79 (23.71-23.87) | 22.15% / 4.43 cores | 28.76% | 0.10% / 4% | 0% / 0% | 3,102 MiB |
| Full, CUDA 20 MSPS | 35.80 (35.60-35.99) | 10.92% / 2.18 cores | 34.74% | 27.67% / 54% | 0% / 0% | 5,288 MiB |
| Full, IPP 20 MSPS | 26.86 (26.25-27.48) | 24.19% / 4.84 cores | 48.39% | 0% / 0% | 0% / 0% | 3,102 MiB |
| Preview, CUDA 20 MSPS | 47.03 (46.59-47.48) | 4.06% / 0.81 cores | 24.75% | 26.91% / 61% | 3.12% / 8% | 4,795 MiB |
| Preview, IPP 20 MSPS | 34.33 (34.05-34.61) | 24.66% / 4.93 cores | 43.85% | 1.52% / 9% | 1.48% / 4% | 3,292 MiB |
Full runs requested 500 source frames and verified exactly 1,000 output fields. Preview runs requested 20 distinct two-second windows, or 1,000 source frames, after a separate cold W5. Source fps does not count the two output fields/bob frames as two source frames. Process CPU is normalized against all 20 logical processors; whole-system CPU includes FFmpeg, drivers, the sampler, and other machine work. GPU values are global 100 ms NVML samples. The first five rows' idle baseline was 5.05% system CPU, 0% GPU SM, 0% NVENC, and 3,103 MiB GPU FB; the separately corrected IPP preview row used 6.85% system CPU and 3,110 MiB GPU FB. CUDA delivered 1.48x/1.33x/1.37x the IPP source throughput in full-40/full-20/preview-20 respectively. See the detailed method and evidence.
The final-source five-window quality recheck used corrected IPP coordinates. After trimming the first IPP output frame for one-field alignment, default CUDA preview averaged SSIM Y/U/V/All of 0.914657/0.957361/0.966698/0.930448. The forced line-phase guard averaged 0.926692 combined and disabling cross-field dropout plus chroma stabilization averaged 0.922357; default was higher on every tested window. These are capture- and hardware-specific preview results, not Exact-equivalence claims.
--compat-version selects upstream behavior:
| Value | Meaning |
|---|---|
v0.4.0 |
Default. Targets the pinned Python release behavior. |
current |
Opt-in staged behavior from upstream PR 341, including newer VHS sync and color-under processing. |
The strict compatibility oracle is Python v0.4.0 commit g4315520 with
--threads 0. Python output hashes are not stable across its worker counts, so
multithreaded Python runs are used for speed measurements only.
--dsp-backend selects the DSP implementation:
| Value | Meaning |
|---|---|
exact |
Default managed path for compatibility-sensitive decoding. |
ipp-fast |
Experimental Windows x64 VHS and LaserDisc real-RF paths using Intel IPP. It can change floating-point bits and never silently falls back to exact. |
cuda-fast |
Experimental Windows x64 NVIDIA CUDA 13 full-signal VHS path. It has an independent numerical contract, supports PAL/NTSC VHS at 40 MSPS normally or GPU 40-to-20/native-20 MSPS with --decode-at-20msps, and never silently falls back to a CPU backend. |
approx-fast |
Experimental VHS-only FP32 path with selectable TBC resampler and precision contract. The default balanced precision keeps the existing v1/v2 behavior; explicit aggressive selects the faster, deliberately lossy field-FP32/burst-IQ-prefix/chroma-FFT v6 contract. Nonzero RF high boost is not supported. |
decode.exe vhs --compat-version current --dsp-backend ipp-fast `
--threads 20 input.lds output
decode.exe vhs --dsp-backend ipp-fast --decode-at-20msps `
--pal input.lds output-20msps
decode.exe vhs --dsp-backend cuda-fast --pal `
--decode-at-20msps --start 100 --length 20 input.ldf output
decode.exe vhs --dsp-backend approx-fast `
--pal input.lds output-approx-balanced
decode.exe vhs --dsp-backend approx-fast `
--approx-resampler catmull-rom4 --pal input.lds output-approx-catmull
decode.exe vhs --compat-version current --dsp-backend approx-fast `
--approx-resampler catmull-rom4 `
--approx-precision aggressive --pal input.lds output-approx-aggressive--approx-resampler sinc16|catmull-rom4 and
--approx-precision balanced|aggressive apply only to approx-fast; using any
of them with another backend is rejected rather than ignored. Approx is a
separate numerical contract from both exact and ipp-fast; use exact when
release-compatible or byte-sensitive output is required.
If --approx-precision is omitted, balanced is selected. Balanced behavior is
unchanged: omitted or explicit sinc16 retains vhs-rf-transform-f32-v1, while
explicit catmull-rom4 selects vhs-rf-transform-f32-catmull-rom4-v2 and uses
four-tap float32 Catmull-Rom for final luma/chroma TBC resampling; sync and
phase-analysis prefix resampling remain sinc-based.
Explicit aggressive selects
vhs-rf-transform-field-f32-catmull-rom4-burst-iq-prefix-chroma-fft-v6. It
requires --compat-version current, --approx-resampler catmull-rom4, and a
40 MSPS decode. The route keeps field-sync VideoLowPass, its nine-tap boxcar,
and the edge scan in FP32. It also skips current's 32-iteration nonlinear
chroma-burst Tune, directly uses float32-derived I/Q phase, amplitude, and DC
initial estimates, keeps the nominal burst frequency, and resamples chroma
phase-analysis line prefixes with four-tap Catmull-Rom instead of the 16-tap
sinc kernel.
The v6 chroma stage reuses the resident input RFFT, multiplies its half-spectrum
by the zero-phase ChromaBurst |H|² response, and runs an IRFFT directly into
the float32 chroma buffer. Relative to the older SOS forward/backward filtfilt
path, this is an intentionally lossy numerical contract, including different
FP32 rounding and block-edge behavior. Exact and Balanced remain unchanged.
AFC/--cafc, chroma or video notches, --export_raw_tbc, non-color-under
formats, configurations without the standard chroma filter, and other options
that require the older chroma path fail closed; no partial fallback to that
older path is performed. Aggressive precision does not disable CTI:
--cti_mix 0 is a separate option. No fixed speedup or visual-equivalence claim
is implied; compare complete same-input balanced/aggressive decodes, including
TBC/chroma/JSON, ordered fields, and fileLoc, with the intended thread count.
--decode-at-20msps is a VHS preview-quality mode, not an Exact-equivalence
mode. A 40 MSPS source is anti-alias filtered and decoded internally at 20
MSPS; native 20 MSPS input is decoded without another reduction. TBC metadata
fileLoc remains in original input-sample coordinates.
This is a preview-quality rate choice, not a universal throughput switch. In
the current startup-inclusive 500-frame gate it raised CUDA throughput by
1.40% and IPP throughput by 12.91%. An earlier 100-frame gate showed IPP 6.83%
slower because fixed startup and reduction costs dominated that short request;
benchmark the intended capture and run length before selecting it for full
decode.
The default Windows release includes the small CUDA-fast bridge but does not
embed the 271 MiB cuFFT DLL. An explicit --dsp-backend cuda-fast request, or
an eligible automatic VHS preview after its lightweight driver/device preflight
passes, searches for a compatible CUDA 13/cuFFT 12 installation. If none is available,
it verifies the NVIDIA driver first, downloads the pinned 202.2 MiB NVIDIA
redistributable, validates both the archive and DLL with SHA-256, and installs
it once under %LOCALAPPDATA%\vhs-decode-dotnet\cuda\cufft. A failed lightweight
preflight never enters the resolver or accesses the network. Exact, IPP, and
preview inputs outside the automatic CUDA support surface also remain offline.
Set VHSDECODE_CUDA_RUNTIME_PATH for an
offline/system runtime, VHSDECODE_CUDA_CACHE_PATH for a different cache root,
or VHSDECODE_CUDA_AUTO_DOWNLOAD=0 to disable automatic downloads.
LaserDisc now routes its video, EFM, and analog-audio full-complex FFT stages
through IPP. CVBS and HiFi still reject ipp-fast; use exact whenever
release-compatible behavior is required. See the
detailed backend notes before using IPP
or CUDA for compatibility-sensitive work. On the tested RTX 4070 and one real
PAL capture, the quality-corrected FP32 CUDA-full path is now visually much
closer to Exact. In the sustained matrix above, CUDA measured 35.30 source fps
versus IPP's 23.79 (1.48x) at 40 MSPS, and 35.80 versus 26.86 (1.33x) at
20 MSPS. A separate short same-source session measured materially higher CUDA
throughput, so these figures are descriptive snapshots rather than evidence
that later code alone reversed the older CUDA/IPP result. Each variant's
two luma/chroma/JSON output sets were byte-identical within that variant.
An aligned 79-frame lossless comparison with Exact using the default
export-side dropout correction measured SSIM Y/U/V/All of
0.954905/0.988109/0.991285/0.972301 and PSNR Y/U/V/average of
33.196867/41.243137/43.586266/35.699053 dB. Manual inspection retained closely
matching scene content, colour, and motion, while numerical equality is not
claimed. This narrow result is hardware- and capture-specific; cuda-fast
remains experimental and does not share the CPU numerical contract.
This startup-inclusive --start 100 --length 160 snapshot uses one fixed private
local 40 MHz PAL VHS .ldf fixture; its filename is intentionally not published.
It retains 30 fixed Python reference measurements from 2026-08-12. All 60 .NET
measurements were refreshed together on 2026-08-26 with one self-contained .NET
11 Preview 7 candidate based on main commit b893bf7 plus the current RF chroma
pass optimization described below. Every cell has three complete runs.
Compatibility is evaluated separately from speed.
| CLI mode (workers) | Python v0.4.0 | Python PR341 | Exact + v0.4.0 | Exact + current | IPP-fast + v0.4.0 | IPP-fast + current |
|---|---|---|---|---|---|---|
| default (5) | 52.811 s | 54.243 s | 11.598 s / 4.553x | 11.398 s / 4.759x | 10.230 s / 5.162x | 7.997 s / 6.783x |
--threads 1 |
57.067 s | 56.762 s | 32.969 s / 1.731x | 37.599 s / 1.510x | 22.629 s / 2.522x | 24.415 s / 2.325x |
--threads 5 |
52.920 s | 55.722 s | 11.773 s / 4.495x | 11.100 s / 5.020x | 10.101 s / 5.239x | 7.971 s / 6.991x |
--threads 10 |
52.965 s | 54.949 s | 9.040 s / 5.859x | 8.122 s / 6.765x | 8.391 s / 6.312x | 5.813 s / 9.453x |
--threads 20 |
53.555 s | 54.842 s | 7.310 s / 7.327x | 7.076 s / 7.751x | 6.919 s / 7.741x | 4.868 s / 11.265x |
Each .NET cell shows median wall time and speedup versus its profile-matched Python column. The default is 5 workers; three-run ranges are in the detailed performance notes. A ratio moves when either the Python numerator or .NET denominator moves, and historical tables using another fixture or window are not directly comparable. Same-moment .NET revision A/B runs, rather than old ratio cells, determine causal regressions.
The current candidate fuses the first float32 quantization into the existing
current RF chroma roll/DC pass and vectorizes its final centering conversion.
The 32,768-sample kernel fell from 28.025 to 16.166 ms over 1,000 calls (42.317%).
Three opposite-order 1,000-frame Exact current --threads 20 pairs retained all
eight compatibility surfaces; paired median wall and CPU gains were 0.5385% and
0.6860%. Memory stayed bounded, but no reduction is claimed.
The refreshed 60-run Exact/IPP-fast matrix retained one hash for luma, chroma,
raw JSON, stdout, normalized stderr/logs, and ordered fileLoc in every cell and
across worker counts. The latest standard xUnit v3 1,615-test suite passed
with 1,612 successes and 3 expected environment skips.
Every .NET profile/thread cell was deterministic across its three refreshed
runs. Merged Python PR341 was deterministic in its pinned reference set; Python
v0.4.0 produced 15 distinct luma, chroma, JSON, and normalized-log hashes in 15
runs, so the strict oracle remains Python v0.4.0 g4315520 --threads 0.
Commands, ranges, binary hashes, memory bounds, and historical measurements are in the
detailed performance reference.
The main decode pipelines, streaming outputs, recovery behavior, and CLI
surface are implemented. Focused tests and real-RF gates cover luma, chroma,
JSON, ordered fileLoc, stdout, normalized stderr/logs, determinism, and
bounded memory. Rare captures and uncommon option interactions remain ongoing
work, so a successful build or equal file size alone is not treated as proof
of compatibility.
TBC, chroma, JSON, and log files are opened for concurrent reading while a decode is running, allowing compatible preview tools to inspect partial output without blocking the writer.
On native-input routes, direct raw fLaC .ldf/.flac inputs that are 40 kHz
mono PCM16 and contain at most Int32.MaxValue samples use the bundled
libsndfile reader. Ordinary parallel VHS decode can also use libsndfile for a
narrowly gated oversized fixed-block raw FLAC without a seek table; integer
mapping reproduces the pinned FFmpeg/PyAV frame starts and rewind/restart
boundaries, with one-way fallback at the same logical sample on any failure.
--threads 0/1, debug-plot and GNU Radio AFE modes, nonzero --sharpness,
other command families, default VHS .flac, CVBS, Ogg/FLAC, stereo, PCM24,
other sample rates, and unfinished or ineligible headers retain FFmpeg.
The pinned SDK is .NET 11.0.100-preview.7.26381.103.
dotnet restore VHSDecodeDotNet.slnx
dotnet build VHSDecodeDotNet.slnx -c Release --no-restore
dotnet test --solution VHSDecodeDotNet.slnx -c Release `
--no-build --no-restore --minimum-expected-tests 1615Open VHSDecodeDotNet.slnx in Visual Studio 2026 to build, debug, and run the
xUnit v3 suite through Test Explorer.
On Ubuntu 22.04 x64, the complete native-build, test, multi-file publish, reproducible-tar, and final extracted-tar smoke pipeline is:
pwsh ./tools/build-linux-x64-release.ps1GPL-3.0. See LICENSE.