-
Notifications
You must be signed in to change notification settings - Fork 78
Technical Code Breakdowns
This document provides an end-to-end breakdown of the vhs-decode pipeline, from raw RF input through TBC output to final chroma decoding.
The vhs-decode pipeline consists of two main stages:
-
RF Decode Stage (
vhs-decode): Raw RF samples → TBC files (.tbc + .tbc.json) -
Chroma Decode Stage (
ld-chroma-decoder): TBC files → RGB/YUV video
RF PCM Samples (.u8/.s16/.flac/.lds/.ldf)
↓
[RF Input & Resampling]
↓
[RF Filtering & Demodulation]
↓
[Sync Detection & Field Assembly]
↓
[Time Base Correction (TBC)]
↓
[Dropout Detection]
↓
Luma TBC (.tbc) + Chroma TBC (_chroma.tbc) + Metadata (.tbc.json)
↓
[Dropout Correction]
↓
[ld-chroma-decoder]
↓
[tbc-video-export]
↓
YUV Video File Output
File: vhsdecode/main.py
The main entry point handles:
- Command-line argument parsing (tape format, system, sample rate, etc.)
- Tape format validation (VHS, SVHS, Betamax, Video8, U-matic, etc.)
- System selection (NTSC, PAL, SECAM, MPAL, etc.)
- Sample rate configuration (typically resampled to 40 MSPS)
# Simplified flow from main.py
def main():
# Parse arguments: tape format, system, RF options
args = parser.parse_args()
# Create file loader for RF input
loader = lddu.make_loader(filename, loader_input_freq)
# Initialize VHSDecode object
vhsd = VHSDecode(
filename, outname, loader, logger,
system=system,
tape_format=tape_format,
doDOD=not args.nodod,
threads=args.threads,
inputfreq=sample_freq,
rf_options=rf_options,
extra_options=extra_options
)
# Main decode loop
while not done:
f = vhsd.readfield() # Read and decode one field
jsondumper.write() # Write metadataFiles: lddecode/utils.py, vhsdecode/main.py
Supported input formats:
-
.lds- Domesday Duplicator packed format (10-bit) -
.ldf- Domesday Duplicator flac format (16-bit) via ld-compress workflow -
.u8/.u16- Raw unsigned & signed 8~16-bit samples (decode is bit-depth agnostic) - .flac - Standard FLAC compressed 6~16-bit files
- FFmpeg-compatible formats via
LoadFFmpeg
The loader:
- Reads RF samples from disk
- Resamples to target frequency (typically 40 MSPS) if needed
- Provides data in chunks (blocks) for processing
File: vhsdecode/process.py - VHSRFDecode.__init__() and _computevideofilters_b()
The RF decoder creates several FFT-based filters:
a) RF Bandpass Filter (RFVideo)
- Isolates the FM-modulated video signal
- Frequency range depends on tape format:
- VHS NTSC: ~3.4 MHz ±1.0 MHz deviation
- VHS PAL: ~3.8 MHz ±1.0 MHz deviation
- Betamax: ~3.5-4.4 MHz
- Video8: ~4.2-5.4 MHz
b) High-Boost Filter (RFTop)
- Enhances high-frequency components in weak signal areas
- Reduces zero-crossing detection errors
c) FM Audio Notch Filters (optional)
- Removes interference from FM HiFi audio carriers
- VHS: ~1.3 MHz and ~1.7 MHz
d) De-emphasis Filters (FDeemp, FVideo)
- Compensates for pre-emphasis applied during recording
- Video8 formats use additional chroma de-emphasis
File: vhsdecode/process.py - VHSRFDecode.demodblock()
The demodulation process:
def demodblock(data):
# 1. Apply RF filters to input
indata_fft = npfft.fft(data)
indata_fft *= self.Filters["RFVideo"]
# 2. Calculate envelope for dropout detection
raw_filtered = npfft.ifft(indata_fft * self.Filters["hilbert"]).real
env = sosfiltfilt_rust(self.Filters["FEnvPost"], np.abs(raw_filtered))
# 3. High-frequency boost in weak areas
if env_mean > 0:
high_part = sosfiltfilt_rust(self.Filters["RFTop"], data)
high_part *= (env_mean / env) * boost_multiplier
indata_fft += npfft.fft(high_part)
# 4. FM demodulate using Hilbert transform
hilbert = npfft.ifft(indata_fft * self.Filters["hilbert"])
demod = unwrap_hilbert(hilbert, self.freq_hz)
# 5. Spike replacement (diff demod)
if max(demod) > threshold:
demod_b = unwrap_hilbert(np.ediff1d(hilbert), self.freq_hz)
demod = replace_spikes(demod, demod_b, threshold)
# 6. Apply de-emphasis and video LPF
demod_fft = npfft.rfft(demod)
out_video = npfft.irfft(demod_fft * self.Filters["FVideo"])
# 7. Extract chroma (for color-under formats)
out_chroma = demod_chroma_filt(data, self.Filters["FVideoBurst"])
return out_video, out_video05, out_chroma, envFiles: vhsdecode/addons/resync.py, vhsdecode/addons/vsyncserration.py
The Resync class detects sync pulses:
class Resync:
def get_pulses(self, field, check_levels):
# 1. Detect sync/blank levels if needed
if check_levels:
vsync_ire, blank_ire = self.detect_levels(field)
# 2. Find sync pulses using threshold detection
threshold = self.iretohz(vsync_ire / 2)
pulses = self.findpulses(field.data, threshold)
return pulses
def findpulses(self, data, threshold):
# Detect where signal crosses below threshold
# Returns list of Pulse(start, length) tuplesPulses are classified into:
- HSYNC: Horizontal sync (~4.7 µs)
- EQPL: Equalization pulses (~2.3 µs)
- VSYNC: Vertical sync pulses (~27 µs for 525, ~30 µs for 625)
File: vhsdecode/field.py - FieldShared.compute_linelocs()
The field assembly process:
- Find valid pulse sequence: HSYNC → EQPL → VSYNC → EQPL → HSYNC
- Determine field type: First field vs. second field based on pulse timing
- Calculate line locations: Map pulse positions to line start locations
- Refine sync positions: Fine-tune using hsync edges
def compute_linelocs(self):
# Get pulses from RF data
res = self._try_get_pulses(do_level_detect)
# Classify pulses and run state machine
validpulses = self.refinepulses()
# Calculate mean line length
meanlinelen = self.computeLineLen(validpulses)
# Find first hsync and determine field type
line0loc, first_hsync_loc = sync.get_first_hsync_loc(
validpulses, meanlinelen, system, field_lines
)
# Generate line location array
linelocs = sync.valid_pulses_to_linelocs(
validpulses, first_hsync_loc, meanlinelen, proclines
)
return linelocs, nextfield_offsetFiles: lddecode/core.py, vhsdecode/field.py
TBC resamples video data to correct timing errors:
def downscale(self, field):
# For each output line:
for line_num in range(self.outlinecount):
# Get input line location (from linelocs array)
input_loc = self.linelocs[line_num]
# Resample from variable input rate to fixed output rate
# Uses scipy interpolation
output_line = scipy.signal.resample(
input_data[input_loc:input_loc+input_len],
self.outlinelen
)
# Store in output buffer
output[line_num * outlinelen:(line_num + 1) * outlinelen] = output_lineThe hz_to_output() method converts from Hz to 16-bit output values:
def hz_to_output(self, input_hz):
# Convert Hz to IRE
ire = (input_hz - ire0) / hz_ire
# Subtract sync level
ire -= vsync_ire # typically -40 IRE
# Scale to 16-bit (0-65535)
output = (ire * out_scale) + outputZero
return np.clip(output, 0, 65535).astype(np.uint16)Files: vhsdecode/chroma.py, vhsdecode/field.py
For color-under formats (VHS, Betamax, Sony 8mm, U-Matic):
def try_detect_track_ntsc(field):
# Upconvert chroma with both possible phase rotations
uphet_phase0 = process_chroma(field, track_phase=0)
uphet_phase1 = process_chroma(field, track_phase=1)
# Check which phase has correct burst cancellation
burst_sum_0 = mean_of_burst_sums(uphet_phase0)
burst_sum_1 = mean_of_burst_sums(uphet_phase1)
# Lower sum = correct phase (bursts cancel on adjacent lines)
track_phase = 0 if burst_sum_0 < burst_sum_1 else 1
return track_phasedef process_chroma(field, track_phase):
# 1. TBC the chroma signal
chroma = field.downscale(channel="demod_burst")
# 2. Burst de-emphasis (NTSC)
if system == "NTSC":
chroma = burst_deemphasis(chroma)
# 3. Upconvert using heterodyne with phase rotation
uphet = upconvert_chroma(
chroma,
chroma_heterodyne, # fsc + color_under frequency
phase_rotation, # 0° or -90° per line
)
# 4. Filter to remove upper sideband
uphet = sosfiltfilt_rust(Filters["FChromaFinal"], uphet)
# 5. Comb filter to reduce crosstalk
uphet = comb_c_ntsc(uphet) # or comb_c_pal()
# 6. Automatic Chroma Control (ACC)
uphet = acc(uphet, burst_abs_ref)
return uphetFile: vhsdecode/doc.py
Dropout detection uses RF envelope:
def detect_dropouts_rf(field, dod_options):
envelope = field.data["video"]["envelope"]
# Calculate threshold
if dod_threshold_p:
threshold = np.mean(envelope) * dod_threshold_p
else:
threshold = dod_threshold_a
# Apply hysteresis
dropout_regions = find_regions_below_threshold(
envelope, threshold, threshold * dod_hysteresis
)
# Convert to line/pixel coordinates
for region in dropout_regions:
line = region.start // field.outlinelen
startx = region.start % field.outlinelen
endx = region.end % field.outlinelenFiles: vhsdecode/main.py, lddecode/core.py
Two binary files are written:
-
output.tbc: Luma data, 16-bit unsigned per pixel -
output_chroma.tbc: Chroma data (color-under formats only)
def writeout(dataset):
f, fi, (picturey, picturec), audio, efm = dataset
# Write luma
self.outfile_video.write(picturey) # Raw 16-bit array
# Write chroma (if present)
if self.rf.options.write_chroma:
self.outfile_chroma.write(picturec)
# Store field metadata
self.fieldinfo.append(fi)File: output.tbc.json
Contains per-field metadata:
{
"videoParameters": {
"isSourcePal": true,
"system": "PAL",
"tapeFormat": "VHS",
"fieldWidth": 1135,
"fieldHeight": 626,
"sampleRate": 17734472,
"black16bIre": 0,
"white16bIre": 65535
},
"fields": [
{
"seqNo": 1,
"isFirstField": true,
"syncConf": 95,
"diskLoc": 0.0,
"fileLoc": 0,
"fieldPhaseID": 0,
"dropOuts": {
"fieldLine": [45, 67],
"startx": [100, 250],
"endx": [150, 300]
}
}
]
}The chroma decoder (ld-chroma-decoder) is a multi-threaded (real-time on modern CPUs) C++/Qt application that separates luminance (Y) and chrominance (C) from composite video signals stored in TBC files, then produces RGB or YUV output.
Key Features:
- Multiple decoder algorithms (1D, 2D, 3D comb filters, Transform PAL)
- Multi-threaded processing for performance
- Support for NTSC, PAL, and monochrome sources
- Configurable chroma gain, phase, and filtering
Main Files:
-
tools/ld-chroma-decoder/main.cpp- Entry point and argument parsing -
tools/ld-chroma-decoder/decoder.cpp- Abstract base decoder class -
tools/ld-chroma-decoder/decoderpool.cpp- Thread pool and frame queuing -
tools/ld-chroma-decoder/comb.cpp- NTSC comb filter implementation -
tools/ld-chroma-decoder/palcolour.cpp- PAL 2D line-locked decoder -
tools/ld-chroma-decoder/transformpal*.cpp- Transform PAL 2D/3D decoders -
tools/ld-chroma-decoder/sourcefield.cpp- TBC file reading -
tools/ld-chroma-decoder/outputwriter.cpp- Output format conversion
// main.cpp
int main(int argc, char *argv[]) {
// Parse command line arguments
// --decoder: pal2d, transform2d, ntsc2d, etc.
// --output-format: rgb, yuv, y4m
// Load TBC metadata
LdDecodeMetaData metaData;
metaData.read(inputJsonFilename);
// Open TBC files
SourceVideo sourceVideo(inputTbcFilename, metaData);
// Select decoder based on video system
Decoder *decoder = selectDecoder(videoParameters);
// Create decoder pool
DecoderPool decoderPool(decoder, sourceVideo, outputWriter);
decoderPool.decode(startFrame, length);
}File: tools/ld-chroma-decoder/decoderpool.cpp
Multiple decoder threads process frames in parallel:
class DecoderPool {
void decode(qint32 startFrame, qint32 length) {
// Create worker threads
for (int i = 0; i < numThreads; i++) {
threads.append(decoder->makeThread(abort, *this));
threads[i]->start();
}
// Wait for completion
for (auto thread : threads) {
thread->wait();
}
}
// Thread-safe input queue
bool getInputFrames(qint32& frameNumber,
QVector<SourceField>& fields) {
QMutexLocker locker(&inputMutex);
// Return next batch of fields to process
}
// Thread-safe output queue
bool putOutputFrames(qint32 frameNumber,
QVector<OutputFrame>& frames) {
QMutexLocker locker(&outputMutex);
// Queue frames for writing
}
};File: tools/ld-chroma-decoder/decoder.cpp
void DecoderThread::run() {
QVector<SourceField> inputFields;
QVector<ComponentFrame> componentFrames;
QVector<OutputFrame> outputFrames;
while (!abort) {
// Get input fields from pool
if (!decoderPool.getInputFrames(startFrame, inputFields,
startIndex, endIndex)) {
break; // No more frames
}
// Decode fields to component (YUV) frames
decodeFrames(inputFields, startIndex, endIndex,
componentFrames);
// Convert to output format (RGB/YUV)
for (int i = 0; i < numFrames; i++) {
outputWriter.convert(componentFrames[i], outputFrames[i]);
}
// Write to output
if (!decoderPool.putOutputFrames(startFrame, outputFrames)) {
abort = true;
break;
}
}
}Files: tools/ld-chroma-decoder/ntscdecoder.cpp, tools/ld-chroma-decoder/comb.cpp
NTSC uses quadrature amplitude modulation (QAM) of a 3.579545 MHz color subcarrier:
- Color subcarrier (fsc): 3.579545 MHz (exactly 227.5 × line frequency)
- Phase relationship: Subcarrier phase inverts on each line (180° per line)
- Sample rate: Typically 4×fsc (14.318180 MHz) for easy digital processing
Key Property for Y/C Separation:
Because the subcarrier inverts phase on adjacent lines:
- Adding adjacent lines: Chroma cancels, leaving luma
- Subtracting adjacent lines: Luma cancels, leaving chroma
This is the basis for comb filtering.
Simplest approach - filters chroma out of luma in frequency domain:
void split1D() {
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
// At 4×fsc, subcarrier samples as: 1, 0, -1, 0, 1, 0, -1, 0...
// Chroma component
chroma[y][x] = composite[y][x] * sin4fsc(x);
// Luma - notch filter removes chroma band
luma[y][x] = notchFilter(composite[y][x]);
}
}
}Limitations:
- Luma detail in chroma band is removed
- Chroma bleeding on vertical edges
- "Dot crawl" artifacts
Uses spatial information from adjacent lines:
void Ntsc2DDecoder::decodeFrame(const SourceField& firstField,
const SourceField& secondField,
ComponentFrame& componentFrame) {
// 1. Assemble composite frame from two fields
CompositeFrame composite = assembleFrame(firstField, secondField);
// 2. Y/C separation using 2D comb filter
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
// Get current pixel and pixel from line above/below
double current = composite[y][x];
double above = (y > 0) ? composite[y-1][x] : current;
double below = (y < height-1) ? composite[y+1][x] : current;
// Luma is average (chroma cancels)
luma[y][x] = (above + 2*current + below) / 4;
// Chroma is difference
chroma[y][x] = current - luma[y][x];
}
}
// 3. Demodulate chroma
demodulateChroma(chroma, componentFrame);
}File: tools/ld-chroma-decoder/comb.cpp
Adds temporal dimension - analyzes motion between frames:
void FrameBuffer::split3D(const FrameBuffer& prevFrame,
const FrameBuffer& nextFrame) {
// For each pixel, evaluate multiple candidate Y/C separations
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
double candidates[NUM_CANDIDATES];
double errors[NUM_CANDIDATES];
// Spatial candidates (from 2D comb)
candidates[CAND_LEFT] = chromaFrom2D[y][x-1];
candidates[CAND_RIGHT] = chromaFrom2D[y][x+1];
candidates[CAND_UP] = chromaFrom2D[y-1][x];
candidates[CAND_DOWN] = chromaFrom2D[y+1][x];
// Temporal candidates
candidates[CAND_PREV_FIELD] = prevFrame.chromaFrom2D[y][x];
candidates[CAND_NEXT_FIELD] = nextFrame.chromaFrom2D[y][x];
candidates[CAND_PREV_FRAME] = prevFrame.chromaFrom2D[y][x];
candidates[CAND_NEXT_FRAME] = nextFrame.chromaFrom2D[y][x];
// Calculate error for each candidate
for (int c = 0; c < NUM_CANDIDATES; c++) {
// Error = how different this chroma is from its neighbors
// Lower error = more consistent with surrounding area
errors[c] = calculateConsistencyError(candidates[c], x, y);
}
// Select candidate with lowest error
int bestCandidate = findMinIndex(errors, NUM_CANDIDATES);
chroma[y][x] = candidates[bestCandidate];
// Luma is composite minus chroma
luma[y][x] = composite[y][x] - chroma[y][x];
}
}
}Adaptive Mode:
- Analyzes 8 different Y/C separation candidates
- Chooses the one that's most consistent with neighbors
- Automatically adapts to motion, edges, and detail
Benefits:
- Best quality for static areas (uses temporal info)
- Gracefully handles motion (falls back to spatial)
- Minimal artifacts on edges and diagonal patterns
Trade-offs:
- Requires 3 frames in memory (look-behind and look-ahead)
- Higher computational cost
- Slight lag (1 frame delay)
File: tools/ld-chroma-decoder/comb.cpp - splitIQ() and splitIQlocked()
After Y/C separation, chroma must be demodulated to extract color components:
void FrameBuffer::splitIQ() {
// At 4×fsc sampling, subcarrier samples as perfect sine/cosine
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
// Quadrature demodulation at 4×fsc is trivial:
// I component (in-phase)
i_raw[y][x] = chroma[y][x] * cos4fsc(x);
// Q component (quadrature - 90° phase shift)
q_raw[y][x] = chroma[y][x] * sin4fsc(x);
}
}
}
void FrameBuffer::filterIQ() {
// Low-pass filter I and Q to remove high-frequency artifacts
// Typical bandwidth: ~0.5-1.5 MHz
for (int y = 0; y < height; y++) {
i_filtered[y] = lowPassFilter(i_raw[y], chromaBandwidth);
q_filtered[y] = lowPassFilter(q_raw[y], chromaBandwidth);
}
// Convert I/Q to U/V (YUV color space)
// NTSC burst is at 180° (negated U axis), I/Q are rotated 33°
const double angle = 33.0 * M_PI / 180.0;
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
double i = i_filtered[y][x];
double q = q_filtered[y][x];
// Rotate I/Q to U/V
output.u[y][x] = i * cos(angle) - q * sin(angle);
output.v[y][x] = i * sin(angle) + q * cos(angle);
}
}
}Phase-Locked Mode (splitIQlocked):
Optionally compensates for phase errors by analyzing the color burst:
void FrameBuffer::splitIQlocked() {
for (int y = 0; y < height; y++) {
// Measure actual burst phase on this line
double burstPhase = detectBurstPhase(y);
// Adjust demodulation phase per-line
for (int x = 0; x < width; x++) {
double phase = 2.0 * M_PI * x / 4.0 + burstPhase;
i_raw[y][x] = chroma[y][x] * cos(phase);
q_raw[y][x] = chroma[y][x] * sin(phase);
}
}
}Files: tools/ld-chroma-decoder/paldecoder.cpp, tools/ld-chroma-decoder/palcolour.cpp
PAL (Phase Alternating Line) uses a clever trick to cancel phase errors:
- Color subcarrier (fsc): 4.43361875 MHz (exactly 283.75 × line frequency + 25 Hz)
- V-phase alternation: V component inverts on each line, U does not
- Burst phase: -U axis, alternates ±90° on each line
- Sample rate: Typically 4×fsc (17.734475 MHz)
PAL Line Structure:
Line N: Burst = -U-V, Signal = U + V
Line N+1: Burst = -U+V, Signal = U - V
Key Advantages:
- Phase errors average out over two lines
- More robust to signal distortion than NTSC
- Slight vertical color resolution loss (Hannover bars on fast changes)
File: tools/ld-chroma-decoder/palcolour.cpp
PALcolour is a line-locked decoder using 2D FIR (Finite Impulse Response) filters.
void PalColour::buildLookUpTables() {
// Generate reference sine/cosine at subcarrier frequency
for (int x = 0; x < fieldWidth; x++) {
double rad = 2.0 * M_PI * x * fSC / sampleRate;
sine[x] = sin(rad);
cosine[x] = cos(rad);
}
// Build 2D filter coefficients
// Chroma bandwidth: ~1.1 MHz
const double chromaBandwidth = 1100000.0 / 0.93;
const double ca = 0.5 * sampleRate / chromaBandwidth;
for (int f = 0; f <= FILTER_SIZE; f++) {
// Raised-cosine filter for U/V
// Uses lines n-3, n-2, n-1, n, n+1, n+2, n+3
cfilt[f][0] = (1 + cos(M_PI * f / ca)) / d; // Line n
cfilt[f][1] = (1 + cos(M_PI * sqrt(f*f + 4*4) / ca)) / d; // Lines n±2
cfilt[f][2] = (1 + cos(M_PI * sqrt(f*f + 2*2) / ca)) / d; // Lines n±1
cfilt[f][3] = (1 + cos(M_PI * sqrt(f*f + 6*6) / ca)) / d; // Lines n±3
// Y filter - only uses lines n and n±2
// This prevents castellation on horizontal color boundaries
yfilt[f][0] = (1 + cos(M_PI * f / ca)) / d; // Line n
yfilt[f][1] = 0.2 * (1 + cos(M_PI * sqrt(f*f + 4*4) / ca)) / d; // Lines n±2
}
}void PalColour::detectBurst(LineInfo& line, const quint16* compPtr) {
// Extract burst area (typically ~5.6-7.85 µs after sync)
const int burstStart = videoParameters.colourBurstStart;
const int burstEnd = videoParameters.colourBurstEnd;
// Detect burst on current line and neighbors
// Uses algorithm from BBC Research Department Report 1986/02
double bp = 0, bq = 0; // Burst phase components
double bpo = 0, bqo = 0; // Old (adjacent line) components
// Product-detect the burst
for (int i = burstStart; i < burstEnd; i++) {
double sample = compPtr[i];
// Demodulate against reference carrier
bp += sample * sine[i];
bq += sample * cosine[i];
}
// Normalize
int burstLength = burstEnd - burstStart;
bp /= burstLength;
bq /= burstLength;
// Detect V-switch state (PAL phase alternation)
// If burst vectors from adjacent lines are similar -> vsw = +1
// If opposite -> vsw = -1
if ((bp - bpo)*(bp - bpo) + (bq - bqo)*(bq - bqo)
< (bp*bp + bq*bq) * 2) {
line.vsw = 1; // Same V phase as previous
} else {
line.vsw = -1; // Opposite V phase
}
// Extract average -U phase
line.bp = (bp - bqo) / 2; // U component
line.bq = (bq + bpo) / 2; // V component
// Normalize to unit vector
double magnitude = sqrt(line.bp * line.bp + line.bq * line.bq);
line.bp /= magnitude;
line.bq /= magnitude;
}void PalColour::decodeField(const SourceField& field,
const double* chromaData,
ComponentFrame& output) {
const quint16* compPtr = field.data.data();
for (int y = firstActiveLine; y < lastActiveLine; y++) {
LineInfo line(y);
// Detect burst phase for this line
detectBurst(line, compPtr);
// Apply user adjustments (gain, phase)
double bp = line.bp * chromaGain * cos(chromaPhase);
double bq = line.bq * chromaGain * sin(chromaPhase);
// Apply 2D filter to extract Y and C
for (int x = activeVideoStart; x < activeVideoEnd; x++) {
double y_sum = 0, c_sum = 0;
// Apply horizontal filter taps
for (int h = -FILTER_SIZE; h <= FILTER_SIZE; h++) {
int xp = clamp(x + h, 0, width - 1);
// Apply vertical filter taps
for (int v = 0; v < 4; v++) {
int yp = getVerticalTapLine(y, v);
double sample = compPtr[yp * width + xp];
y_sum += sample * yfilt[abs(h)][v];
c_sum += sample * cfilt[abs(h)][v];
}
}
output.y[y][x] = y_sum;
// Demodulate chroma using detected burst phase
double chroma = c_sum;
output.u[y][x] = chroma * (bp * cosine[x] + bq * sine[x]) * 2;
output.v[y][x] = chroma * (bp * sine[x] - bq * cosine[x]) * 2 * line.vsw;
}
}
}Key Features:
- Separate optimized filters for Y and C
- Per-line burst phase detection and compensation
- Automatic V-switch detection
- Minimal dot-crawl and cross-color artifacts
Files: tools/ld-chroma-decoder/transformpal2d.cpp, tools/ld-chroma-decoder/transformpal3d.cpp
Transform PAL is an advanced frequency-domain decoder based on work by Jim Easterbrook (pyctools-pal). It analyzes the video signal in the frequency domain to optimally separate Y and C.
In the frequency domain, PAL signals have distinct Y and C energy distribution:
Frequency Domain (vertical axis = line frequency):
│
│ Y energy: concentrated near DC and horizontal multiples of fH
│ C energy: concentrated near fsc ± multiples of fH/2
│
│ Y C Y C
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐
│ │ │ │ │ │ │ │ │
──┼──┴────┴───┴────┴───┴────┴───┴────┴────► Frequency (horizontal)
0 fsc/2 fsc 3fsc/2
By analyzing these patterns, Transform PAL can separate Y and C more effectively than spatial filtering alone.
void TransformPal2D::filterFields(const QVector<SourceField>& inputFields,
qint32 startIndex, qint32 endIndex,
QVector<const double*>& outputFields) {
for (int fieldIdx = startIndex; fieldIdx < endIndex; fieldIdx++) {
// 1. Take 2D FFT (horizontal × vertical)
fftw_execute_dft_r2c(fftPlan, inputField, fftData);
// 2. Analyze frequency components
for (int fy = 0; fy < fftHeight; fy++) {
for (int fx = 0; fx < fftWidth; fx++) {
double frequency = sqrt(fx*fx + fy*fy);
// Distance from chroma carrier frequency
double chromaDist = abs(frequency - fsc);
// Energy at this frequency
double energy = magnitude(fftData[fy][fx]);
// Is this frequency Y or C?
// Compare with thresholds learned from typical signals
if (chromaDist < threshold) {
// Near subcarrier = chroma
chromaFFT[fy][fx] = fftData[fy][fx];
lumaFFT[fy][fx] = 0;
} else {
// Away from subcarrier = luma
lumaFFT[fy][fx] = fftData[fy][fx];
chromaFFT[fy][fx] = 0;
}
}
}
// 3. Inverse FFT to get separated Y and C
fftw_execute_dft_c2r(ifftPlan, chromaFFT, chromaOutput);
outputFields[fieldIdx] = chromaOutput;
}
}Extends to temporal dimension for even better separation:
void TransformPal3D::filterFields(const QVector<SourceField>& inputFields,
qint32 startIndex, qint32 endIndex,
QVector<const double*>& outputFields) {
// Requires look-behind and look-ahead frames
const int lookBehind = 1;
const int lookAhead = 1;
for (int fieldIdx = startIndex; fieldIdx < endIndex; fieldIdx++) {
// Load 3D block: horizontal × vertical × temporal
load3DBlock(inputFields, fieldIdx - lookBehind*2,
fieldIdx + lookAhead*2, blockData);
// 3D FFT
fftw_execute_dft_r2c(fft3DPlan, blockData, fft3DData);
// Analyze 3D frequency space
for (int fz = 0; fz < fftDepth; fz++) { // Temporal freq
for (int fy = 0; fy < fftHeight; fy++) { // Vertical freq
for (int fx = 0; fx < fftWidth; fx++) { // Horizontal freq
// 3D distance from expected chroma location
double dist3D = calculate3DChromaDistance(fx, fy, fz);
// Adaptive thresholding based on local energy
double threshold = adaptiveThreshold(fx, fy, fz);
if (dist3D < threshold) {
chromaFFT3D[fz][fy][fx] = fft3DData[fz][fy][fx];
} else {
chromaFFT3D[fz][fy][fx] = 0;
}
}
}
}
// Inverse 3D FFT
fftw_execute_dft_c2r(ifft3DPlan, chromaFFT3D, chromaBlock);
// Extract center field from 3D block
outputFields[fieldIdx] = extractCenterField(chromaBlock);
}
}Transform PAL uses adaptive thresholds based on signal characteristics:
double TransformPal::adaptiveThreshold(int fx, int fy, int fz) {
// Base threshold from configuration
double threshold = config.threshold;
// Look at local energy distribution
double lumaEnergy = 0, chromaEnergy = 0;
for (int dy = -1; dy <= 1; dy++) {
for (int dx = -1; dx <= 1; dx++) {
double energy = magnitude(fftData[fy+dy][fx+dx]);
if (isNearChromaCarrier(fx+dx, fy+dy)) {
chromaEnergy += energy;
} else {
lumaEnergy += energy;
}
}
}
// If chroma energy is weak, be more selective
if (chromaEnergy < lumaEnergy * 0.1) {
threshold *= 0.5; // Stricter threshold
}
return threshold;
}Transform PAL Advantages:
- Excellent Y/C separation with minimal cross-contamination
- Adapts to varying signal quality
- Very clean output on high-quality sources
- Handles edges and detail better than fixed 2D filters
Trade-offs:
- High computational cost (FFT operations)
- Requires careful threshold tuning
- 3D mode requires multiple frames in memory
- May introduce ringing on very noisy sources
| Decoder | Dimensions | Quality | Speed | Best For |
|---|---|---|---|---|
| NTSC 1D | Frequency only | Basic | Very Fast | Preview, low-quality sources |
| NTSC 2D | Spatial (lines) | Good | Fast | General use, good balance |
| NTSC 3D | Spatial + Temporal | Excellent | Medium | High-quality sources, archival |
| NTSC 3D Adaptive | Adaptive 3D | Best | Slower | Best possible quality |
| PAL 2D (PALcolour) | Spatial (2D FIR) | Very Good | Fast | General PAL decoding |
| Transform PAL 2D | Frequency (2D FFT) | Excellent | Medium | Clean PAL sources |
| Transform PAL 3D | Frequency (3D FFT) | Best | Slow | Archival PAL, best quality |
| Mono | N/A | N/A | Very Fast | Black & white sources |
For NTSC:
- Start with 2D comb for good balance
- Use 3D adaptive for best quality if source is good
- Use 1D only for previews or very noisy tapes
For PAL:
- PALcolour (2D) is excellent for most sources
- Transform 2D for very clean sources (LaserDisc, broadcast)
- Transform 3D for archival work where quality is paramount
General Guidelines:
- Better source quality → use more advanced decoder
- Motion-heavy content → 2D decoders may be better
- Static content → 3D decoders excel
- CPU-limited → use simpler decoders
Tool: tbc-video-export (Python script by JuniorIsAJitterbug)
The standard export pipeline uses tbc-video-export which automates:
- Running
ld-chroma-decoderwith appropriate settings - Piping decoded YUV to FFmpeg
- Encoding to production-ready formats (FFV1/ProRes HQ etc.)
Standard Output Format: YUV 4:2:2 (10-bit)
This is the industry standard for professional video work:
- Preserves luma resolution (full quality)
- Chroma subsampled horizontally (sufficient for broadcast)
- Compatible with all major NLEs (DaVinci Resolve, Premiere, etc.)
Files: README_tbc_video_export.md, tbc-video-export.json
| Profile | Codec | Bit-Depth | Chroma | Audio | Use Case | Bitrate |
|---|---|---|---|---|---|---|
| ffv1 (default) | FFV1 | 10-bit | 4:2:2 | FLAC | Archival, lossless | 70-100 Mbps |
| ffv1_8bit | FFV1 | 8-bit | 4:2:2 | FLAC | Smaller archival | 40-60 Mbps |
| ffv1_pcm | FFV1 | 10-bit | 4:2:2 | PCM | Editing, NLE | 70-100 Mbps |
| prores_hq | ProRes HQ | 10-bit | 4:2:2 | PCM | Professional editing | 55-70 Mbps |
| prores_4444xq | ProRes 4444XQ | 10-bit | 4:4:4 | PCM | High-end post | 80-110 Mbps |
| v210 | V210 | 10-bit | 4:2:2 | PCM | Uncompressed editing | 200 Mbps |
| v410 | V410 | 10-bit | 4:4:4 | PCM | Uncompressed 4:4:4 | 400 Mbps |
| x264_web | H.264 | 8-bit | 4:2:0 | AAC | Web/streaming | 8 Mbps |
| x265_web | H.265 | 8-bit | 4:2:0 | AAC | Web/streaming | 8 Mbps |
File: tools/ld-chroma-decoder/outputwriter.cpp
The chroma decoder internally outputs component video:
void OutputWriter::convert(const ComponentFrame& component,
OutputFrame& output) {
// Convert from internal YUV representation to output format
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
double Y = component.y[y][x];
double U = component.u[y][x] * chromaGain;
double V = component.v[y][x] * chromaGain;
// Output as YUV (standard)
output.y[y][x] = clamp(Y, 0, 65535);
output.u[y][x] = clamp(U, -32768, 32767);
output.v[y][x] = clamp(V, -32768, 32767);
}
}
}
void OutputWriter::writeFrame(const OutputFrame& frame) {
// Write raw YUV data (typically piped to FFmpeg)
outputFile.write(frame.data, frame.size);
}Raw Output Modes (for advanced use):
- YUV444P16: 16-bit YUV 4:4:4 planar (full chroma resolution)
- RGB48: 16-bit RGB (for specific workflows)
- Y4M: YUV4MPEG2 container with frame headers
- GRAY16: Luma-only (for black & white sources)
Note: Most users should use tbc-video-export rather than calling ld-chroma-decoder directly as it automates handling and the most accurate starting configs.
Files: vhsdecode/process.py - buildmetadata(), vhsdecode/main.py
Interlaced video requires strict alternation of first/second fields (top/bottom). Tape playback can disrupt this:
- Head switches: Transition between video heads can cause timing glitches
- Tape damage: Dropouts can cause fields to be lost
- Home recordings: Pauses during recording create discontinuities
- Tracking errors: Poor tape tracking causes field timing shifts
File Frame 1234: VHS
File Frame 1235: VHS
No issues - fields decoded normally.
Note
Source: vhsdecode/addons/vsyncserration.py line 363
The vertical sync serration detection system failed to locate valid VBI equalizing pulses. The "arbitrage" algorithm that validates vsync candidates by correlating envelope minima with harmonic peaks returned no valid results.
Technical Process:
The VsyncSerration class uses a three-stage detection:
- Envelope Detection: Low-pass filters detect amplitude dips during vsync
- Harmonic Analysis: Bandpass filtering measures EQ pulse harmonics near line frequency
- Arbitrage: Correlates envelope minima positions with harmonic peaks to validate
# From vsyncserration.py _vsync_arbitrage()
if len(where_min) > 0:
# Valid vsync candidates found
for w_min in where_min:
self.executor.submit(self._search_eq_pulses, data, w_min)
else:
ldd.logger.warning("Unexpected vsync arbitrage") # FAILED
return NoneCauses:
- Severe RF dropout in VBI region
- Head clogs causing unstable envelope
- Non-standard sync from special recording modes
- Sample rate mismatch
- VCR servo timing issues
Impact:
- Decoder falls back to cached sync/blanking levels from moving average buffer
- Reduced sync detection accuracy
- Potential field order errors
- Does NOT cause field drops (field still processed)
Mitigation:
- Clean VCR heads thoroughly
- Set Tracking manually, or adjust guide posts
- Verify correct
-fsample rate setting
Note
Sources:
-
lddecode/core.pyline 2214 -
vhsdecode/field.pyline 1327
After detecting vsync, the decoder could not locate the first horizontal sync pulse (first_hsync_loc). Without this anchor point, line locations cannot be calculated, forcing the field to be dropped entirely.
Field Assembly Sequence:
1. getpulses() → Detect sync pulses
2. refinepulses() → Classify HSYNC/VSYNC/EQPL
3. computeLineLen() → Calculate mean line length
4. getLine0() → Find field start reference
5. get_first_hsync_loc() → Locate first HSYNC ★ CRITICAL ★
6. valid_pulses_to_linelocs() → Generate line array
# If step 5 fails:
if first_hsync_loc is None:
logger.error("Unable to determine start of field - dropping field")
return None, None, self.inlinelen * 100 # Skip ahead, retryCommon Causes:
| Cause | Technical Detail | Typical Scenario |
|---|---|---|
| RF Dropout | Complete signal loss >20 lines | Oxide shedding, tape fold |
| Deadspace | No recorded signal in the samples at the current file location | Player Paused, Deadspace at start, Deadspace between tapes, End of signal on tape |
Immediate Impact:
- One field (half-frame) missing: ~16.7ms NTSC or ~20ms PAL gap
- Visible judder/stutter in playback
- Field order state corrupted (top/bottom alternation broken)
JSON Detection:
{
"fields": [
{"seqNo": 100, "syncConf": 95, "isFirstField": true},
{"seqNo": 101, "syncConf": 0, "dropOuts": {...}}, // ← Dropped
{"seqNo": 102, "diskLoc": 102.2, "isDuplicateField": true} // ← Gap
]
}Indicators:
syncConf: 0- Full-field dropouts
- Non-sequential
diskLocvalues -
isDuplicateField: trueflags
ERROR - Possibly skipped field (Two fields with same isFirstField in a row),
duplicating the last field to compensate...
What happened: Expected field order was Top → Bottom → Top, but decoder saw Top → Top
Cause: Missing field due to dropout, head switch issue, or recording gap
Action taken: Duplicate previous field to maintain field count
ERROR - Possibly skipped field (Two fields with same isFirstField in a row),
dropping the last field to compensate...
What happened: Detected duplicate field that should be removed
Action taken: Drop the field to restore proper field order
ERROR - Detected progressive video content..., manually flipping the field
order to compensate
What happened: Three+ fields with same order detected (impossible in interlaced)
Likely cause: Video game footage, computer output, or OSD (on-screen display)
Action taken: Manually flip field order to restore cadence
def buildmetadata(self, f):
# Check if this field's order matches the previous field
if prevfi["isFirstField"] == fi["isFirstField"]:
# Two fields with same order in a row - problem!
# Calculate distance between fields
distance = fi["diskLoc"] - prevfi["diskLoc"]
if field_order_action == "detect":
if distance > 1.1:
# Gap > 1.1 fields: missing field, duplicate to fill
duplicate_prev_field = True
action = "DUPLICATE"
elif distance < 0.9:
# Gap < 0.9 fields: overlapping, drop field
duplicate_prev_field = False
action = "DROP"
else:
# Normal distance: alternate action to minimize artifacts
duplicate_prev_field = not duplicate_prev_field
# Mark in JSON for downstream tools
fi["isDuplicateField"] = duplicate_prev_field
fi["decodeFaults"] |= 4 # Field order fault flagCommand line option: --field_order_action
| Mode | Behavior | Use Case |
|---|---|---|
| detect (default) | Analyze field distance, choose duplicate or drop | General use, adapts to situation |
| duplicate | Always duplicate previous field | Prefer smooth motion, accept judder |
| drop | Always drop the duplicate field | Prefer temporal accuracy, accept stutter |
| none | Just flip field order, don't compensate | Debug only, will cause sync issues |
Field Order Confidence: --field_order_confidence (0-100)
- 100 (default): Very strict, requires all pulses to match before changing cadence
- 50: Moderate, half of pulses must match (good for damaged tapes)
- 0: Permissive, any pulse can change cadence (use with caution)
Each field records its position in the RF data:
{
"fields": [
{
"seqNo": 1234,
"diskLoc": 617.0, // Position in "field units" from start
"fileLoc": 12583936, // Byte position in RF file
"isFirstField": true,
"isDuplicateField": false
},
{
"seqNo": 1235,
"diskLoc": 618.0,
"fileLoc": 12604416,
"isFirstField": false,
"isDuplicateField": false
},
{
"seqNo": 1236,
"diskLoc": 619.2, // Gap! Should be 619.0
"fileLoc": 12625152,
"isFirstField": true, // Same as field 1234 - ERROR!
"isDuplicateField": true // Marked as duplicate
}
]
}The Problem: Video fields can be dropped or duplicated, but audio captures continuously.
When the decoder handles field issues:
- Field duplication: Adds ~16.7ms (NTSC) or ~20ms (PAL) of video time
- Field drop: Video timeline advances but no actual video data added
- Audio is continuous: Audio capture runs independently, unaffected by field handling
- Result: Cumulative A/V drift throughout the recording
Tape playback:
t=0s: Perfect sync
t=10s: Head switch causes field drop → +1 duplicated field
t=15s: Tracking error → +1 duplicated field
t=30s: Recording pause → +3 duplicated fields
Result at t=30s:
Video: 30.0833s (5 extra fields × 16.67ms)
Audio: 30.0000s (unchanged)
Drift: +83ms (noticeable!)
The .tbc.json file records every field compensation:
{
"fields": [
{"seqNo": 100, "diskLoc": 50.0, "isDuplicateField": false},
{"seqNo": 101, "diskLoc": 51.0, "isDuplicateField": false},
{"seqNo": 102, "diskLoc": 52.1, "isDuplicateField": true}, // DUPLICATE
{"seqNo": 103, "diskLoc": 53.1, "isDuplicateField": false},
...
]
}By analyzing diskLoc and isDuplicateField, tools can:
- Calculate exact video duration (accounting for duplicates/drops)
- Generate audio offset curves
- Automatically stretch/compress audio to match
# Count field drops from decode log
grep -c "dropping field" decode.log
# Output: 25
# Calculate offset (NTSC)
offset_ms=$(echo "25 * 16.68" | bc)
# Result: 417 ms
# Apply with tbc-video-export
tbc-video-export --ffmpeg-audio-file audio.flac \
--ffmpeg-audio-offset 00:00:00.417 \
output.tbcPrimary Tool: auto-audio-align (located in tools/auto-audio-align/)
This tool automatically analyzes .tbc.json field timing and applies dynamic audio correction:
# Automatic audio alignment based on JSON field data
auto-audio-align input.tbc.json input_audio.flac output_audio_synced.flacWhat it does:
- Reads
diskLocfield positions from JSON - Detects field drops, duplications, and timing gaps
- Applies dynamic audio stretching/compression
- Handles non-linear drift throughout recording
- Outputs corrected audio file ready for muxing
Use Cases:
- Tapes with many field drops (>10 per minute)
- Non-uniform drift throughout recording
- Clockgen-synchronized captures (RF + Audio + HiFi on separate cards)
Tool: ld-process-vbi
For professional recordings with embedded VITC/LTC timecode:
# Extract VBI data and update JSON
ld-process-vbi input.tbc
# Timecode is written back to JSON metadata
# Use as reference for verification or with auto-audio-alignNote
ld-process-vbi extracts timecode but does not perform audio sync directly. Use extracted timecode data for manual verification or as input to auto-audio-align.
During Decode:
- Monitor terminal for field error messages
- More errors = more audio sync work needed
- Use
--field_order_confidenceappropriately for tape condition
For Audio Sync:
-
Always check
.tbc.jsonforisDuplicateFieldentries - Count total duplicates/drops to estimate drift
- Use VITC timecode when available (professional recordings)
- Test audio sync at multiple points in long recordings
- Consider dynamic sync for heavily damaged tapes
Quality Check:
# Count field issues in decoded output
jq '[.fields[] | select(.isDuplicateField == true)] | length' output.tbc.json
jq '[.fields[] | select(.decodeFaults != null)] | length' output.tbc.jsonIf you see high counts (>100 issues per hour), expect significant audio sync challenges.
The decoder uses a producer-consumer pattern:
[Main Thread]
│
├── Load TBC files
│
├── [Input Queue] ──────────┐
│ │
│ ┌────────────────────────┘
│ │
│ ├── [Worker Thread 1] ── Decode frames
│ ├── [Worker Thread 2] ── Decode frames
│ ├── [Worker Thread 3] ── Decode frames
│ └── [Worker Thread N] ── Decode frames
│ │
├── [Output Queue] ──────────┘
│
└── Write output (in order)
Key Points:
- Each thread processes independent frames
- Output queue ensures frames written in order
- Scales well with CPU cores
- 3D decoders need careful synchronization (look-ahead/behind)
1. RF Samples (Raw ADC data, 8-16 bit, 28-40 MSPS)
↓
[vhs-decode: RF Decode]
↓
├─ Resample to 40 MSPS (or use native input rate)
├─ RF bandpass filter (isolate FM video)
├─ FM demodulate (Hilbert transform)
├─ De-emphasis filtering
├─ Sync detection & field assembly
├─ Time base correction (TBC)
├─ Chroma heterodyne (color-under formats)
└─ Dropout detection
↓
2. TBC Files (16-bit per pixel, line-locked)
- output.tbc (luma)
- output_chroma.tbc (chroma, if color-under)
- output.tbc.json (metadata)
↓
[tbc-video-export: Automated Pipeline]
↓
├─ Launch ld-chroma-decoder
│ ├─ Load TBC + metadata
│ ├─ Assemble fields into frames
│ ├─ Y/C separation (comb/transform filters)
│ ├─ Chroma demodulation (I/Q or U/V)
│ └─ Output raw YUV 4:2:2 (16-bit)
│
└─ Pipe to FFmpeg
├─ Encode video (FFV1/ProRes/H.265/etc.)
├─ Mux audio (FLAC/PCM/AAC)
├─ Set metadata (timecode, aspect ratio)
└─ Write container (MKV/MP4/MXF)
↓
3. Video Output (Standard: YUV 4:2:2 10-bit)
- FFV1 MKV (lossless compressed archival)
- ProRes MOV (editing)
- V210 MOV (uncompressed)
- H.265 MP4 (web/streaming)
- FM Demodulation: Converts frequency-modulated RF to baseband video
- Time Base Correction: Fixes timing errors from tape playback
- Color-Under: VHS/Betamax/Video8 downconvert chroma to avoid crosstalk
- Dropout Detection: RF envelope analysis identifies signal loss
- Y/C Separation: Separating brightness and color from composite signal
- Comb Filtering: Exploits subcarrier phase relationships between lines
- Transform Decoding: Frequency-domain analysis for optimal separation
- Burst Phase: Reference signal used to demodulate chroma correctly
# Step 1: RF decode to TBC
vhs-decode --tf VHS --system NTSC input.flac output
# Step 2: Video export (default: FFV1 10-bit 4:2:2 MKV)
tbc-video-export output.tbc
# Result: output.mkv (FFV1 lossless, interlaced, with FLAC audio)# Export to ProRes HQ for editing
tbc-video-export --ffmpeg-profile prores_hq output.tbc
# Export with specific chroma decoder
tbc-video-export --chroma-decoder ntsc3d --ffmpeg-profile ffv1_pcm output.tbc# Step 1: RF decode
ld-decode --system PAL input.lds output
# Step 2: Export with Transform PAL 3D (best quality)
tbc-video-export --chroma-decoder transform3d output.tbc
# Result: output.mkv (Transform 3D decoded, FFV1 10-bit 4:2:2)# Step 1: RF decode
vhs-decode --tf BETAMAX -f 40 --system NTSC input.flac output
# Step 2: Export to ProRes 4444 XQ with 3D comb
tbc-video-export --chroma-decoder ntsc3d \
--ffmpeg-profile prores_4444xq \
--chroma-gain 2.0 \
output.tbc# Create deinterlaced HEVC/H.265 for web use
tbc-video-export --ffmpeg-profile x265_web output.tbc
# Result: output.mov (H.265 8-bit 4:2:0, deinterlaced, AAC audio)FFV1 (4:2:2) (Lossless Compressed)
NTSC
ld-chroma-decoder --decoder ntsc3d -p y4m -q INPUT.tbc| ffmpeg -i - -c:v ffv1 -level 3 -coder 1 -context 1 -g 60 -slicecrc 1 -vf setfield=tff -flags +ilme+ildct -color_primaries smpte170m -color_trc bt709 -colorspace smpte170m -color_range tv -pix_fmt yuv422p10le -vf setdar=4/3,setfield=tff OUTPUT.mkv
PAL
ld-chroma-decoder --decoder transform3d -p y4m -q INPUT.tbc| ffmpeg -i - -c:v ffv1 -level 3 -coder 1 -context 1 -g 60 -slicecrc 1 -vf setfield=tff -flags +ilme+ildct -color_primaries bt470bg -color_trc bt709 -colorspace bt470bg -color_range tv -pix_fmt yuv422p10le -vf setdar=4/3,setfield=tff OUTPUT.mkv
Primary Tools:
- vhs-decode: RF decode for VHS, Betamax, Video8, U-matic, etc.
- ld-decode: RF decode for LaserDisc and other direct FM formats
- ld-chroma-decoder: Chroma decoding (NTSC/PAL comb filters, Transform PAL)
-
tbc-video-export: Automated video export pipeline (by JuniorIsAJitterbug)
- Handles chroma decoding, FFmpeg encoding, and metadata
- Standard tool for converting TBC → production video
- See
README_tbc_video_export.mdin vhs-decode repository
Recommended Workflow:
- RF decode:
vhs-decodeorld-decode→ TBC files - Video export:
tbc-video-export→ YUV 4:2:2 video (FFV1/ProRes/etc.) - Post-processing: NLE (DaVinci Resolve, Premiere) or FFmpeg
Technical Standards:
- ITU-R BT.470: Conventional Television Systems
- ITU-R BT.601: Studio Encoding Parameters
- SMPTE 170M: Composite Analog Video Signal (NTSC)
- ITU-R BT.656: Interface for Digital Component Video
Research Papers:
- BBC RD 1986/02: "Colour encoding and decoding techniques for line-locked sampled PAL and NTSC"
- BBC RD 1988/11: "PAL decoding: Multi-dimensional filter design"
Source Code & Documentation:
- vhs-decode: https://github.com/oyvindln/vhs-decode
- ld-decode: https://github.com/happycube/ld-decode
- pyctools-pal: https://github.com/jim-easterbrook/pyctools-pal
- tbc-video-export: Included in vhs-decode repository
- vhs-decode Wiki: https://github.com/oyvindln/vhs-decode/wiki
File: vhsdecode/formats.py
Central repository for all tape format parameters.
def get_format_params(system, tape_format, tape_speed, logger):
"""Returns SysParams and DecoderParams for the specified format"""
# Get base system parameters (625-line PAL vs 525-line NTSC)
if system == "PAL" or system == "SECAM":
SysParams = SysParams_PAL.copy()
else:
SysParams = SysParams_NTSC.copy()
# Get format-specific decoder parameters
if tape_format == "VHS":
DecoderParams = get_vhs_params(system, tape_speed)
elif tape_format == "BETAMAX":
DecoderParams = get_betamax_params(system)
# ... etc for other formats
return SysParams, DecoderParamsSystem Parameters (SysParams):
-
fsc_mhz: Color subcarrier frequency (3.58 MHz NTSC, 4.43 MHz PAL) -
frame_lines: 525 (NTSC) or 625 (PAL) -
field_lines: Lines per field -
line_period: Microseconds per line -
FPS: Frames per second
Decoder Parameters (DecoderParams):
-
video_bpf_low/high: RF bandpass filter range -
video_lpf_freq: Video lowpass cutoff -
color_under_carrier: Chroma carrier frequency (if color-under) -
boost_bpf_mult: High-frequency boost multiplier -
deemp_*: De-emphasis filter parameters
- Sync level detection system
- Blanking level analysis
- AGC compensation
class LevelDetect:
def __init__(self):
self.sync_threshold = -40 # IRE
self.blank_threshold = 0 # IRE
def analyze_levels(self, signal):
# Level detection logic
pass- Time Base Correction (TBC)
- Field/frame assembly
- Signal normalization
class TBCProcessor:
def __init__(self):
self.line_length = 63.5556 # microseconds
self.tbc_buffer_size = 8 # fields
def process_field(self, field_data):
# TBC processing logic
pass- Format specifications
VHS_NTSC = {
'fm_carrier': 3.4, # MHz
'deviation': ±1.0, # MHz
'emphasis': 100, # ns
'chroma_freq': 629, # kHz
}
QUAD_NTSC = {
'fm_carrier': 7.06, # MHz
'deviation': ±2.0, # MHz
'emphasis': 100, # ns
}- Sync detection and tracking
class Resync:
def __init__(self, fs, sysparams):
self.sample_rate = fs
self.line_length = sysparams.line_length
self.sync_threshold = sysparams.sync_threshold
def find_pulses(self, signal):
"""Detect sync pulses in signal
Args:
signal: Input video signal
Returns:
List of sync pulse locations
"""
pass
def analyze_levels(self, field, pulses):
"""Analyze sync and blanking levels
Args:
field: Video field data
pulses: Detected sync pulses
Returns:
(sync_level, blank_level)
"""
pass- Sample rate verification
- Input level normalization
- Pre-emphasis compensation
- FM demodulation
- Carrier separation (color-under formats)
- De-emphasis filtering
- Horizontal sync detection
- Vertical interval analysis
- Field identification
def process_sync(signal):
"""
Process sync information from video signal
Args:
signal: Input video signal
Returns:
Dict containing sync timing info
"""
pass- Line reconstruction
- Color processing
- Output formatting
| Format | Carrier (MHz) | Deviation | Implementation Status |
|---|---|---|---|
| 2" Quad | 7.06-10.5 | ±2.0 MHz | Supported - NTSC/PAL |
| 1" Type C | 7.06-8.1 | ±2.0 MHz | Experimental |
| 1" Type B | 7.06-7.9 | ±2.0 MHz | Experimental |
| EIAJ-1 | 7.06 | ±1.5 MHz | Experimental |
| V2000 | 3.3 | ±1.0 MHz | Limited - PAL only |
| Format | Luma (MHz) | Chroma (kHz) | Implementation Status |
|---|---|---|---|
| VHS | 3.4 | 629/627 | Full - NTSC/PAL |
| S-VHS | 5.4 | 629/627 | Full - NTSC/PAL |
| Betamax | 3.5 | 688/689 | Full - NTSC/PAL |
| SuperBeta | 4.4 | 688/689 | Partial |
| Video8 | 4.2 | 732/743 | Full - NTSC/PAL |
| Hi8 | 5.4 | 732/743 | Full - NTSC/PAL |
| U-matic | 3.8 | 688/689 | Full - NTSC/PAL |
| U-matic SP | 5.6 | 688/689 | Partial |
- Full: Format implemented and tested at or better then hardware quality.
- Partial: Basic implementation, may need additional testing
- Experimental: May require custom parameters
- Limited: Basic support only
- All frequencies are nominal center frequencies
- Actual frequencies may vary by region/model
- Some formats require specific parameters
- Higher bandwidth variants (S-VHS, Hi8) need higher sample rates
- Sample rates: 28-40 MSPS
- FM carrier frequencies
- Sync pulse thresholds
- Color subcarrier frequencies
DEBUG_FLAGS = {
'show_sync': False, # Show sync detection
'plot_levels': False, # Plot level detection
'dump_tbc': False, # Save TBC data
}The chroma decoder handles color subcarrier recovery and demodulation for various video formats.
class SubcarrierRecovery:
def __init__(self, format_type):
"""Initialize subcarrier recovery
Args:
format_type: Format identifier (NTSC/PAL/SECAM)
"""
self.frequencies = {
'NTSC': 3.579545, # MHz
'PAL': 4.43361875, # MHz
'SECAM': None # SECAM uses FM for chrominance
}def ntsc_chroma_decode(signal, burst_start, burst_end):
"""NTSC color burst detection and phase correction
Args:
signal: Video line data
burst_start: Color burst start sample
burst_end: Color burst end sample
Returns:
Tuple of (I, Q) signals
"""
passdef pal_chroma_decode(signal, burst_start, burst_end):
"""PAL color burst detection with phase alternation
Args:
signal: Video line data
burst_start: Color burst start sample
burst_end: Color burst end sample
Returns:
Tuple of (U, V) signals
"""
pass| Format | Chroma Processing | Notes |
|---|---|---|
| VHS | Cross-color reduction, comb filtering | Uses 629kHz carrier (NTSC) |
| Beta | Advanced comb filter | 688kHz carrier with phase correction |
| Video8 | Adaptive comb filtering | 732/743kHz with noise reduction |
| Format | Processing Method | Implementation Status |
|---|---|---|
| Quad | Direct composite decode | Supported |
| Type-C | Direct composite decode | Experimental |
-
Subcarrier Recovery
- Burst detection
- Phase lock
- Reference generation
-
Chroma Demodulation
- Format-specific carriers
- Cross-color suppression
- Bandwidth limiting
-
Color Space Conversion
- YIQ/YUV detection
- Matrix conversion
- Color correction
CHROMA_DEBUG = {
'show_burst': False, # Display burst detection
'plot_vectors': False, # Show color vectors
'save_raw': False, # Save raw chroma data
'noise_reduce': True, # Enable noise reduction
}LINE_STANDARDS = {
'525': {
'total_lines': 525,
'active_lines': 486,
'field_rate': 59.94,
'line_time': 63.5556, # microseconds
'sync_width': 4.7, # microseconds
'burst_start': 5.3, # microseconds
'active_video_start': 9.4, # microseconds
'color_burst_width': 2.5 # microseconds
},
'625': {
'total_lines': 625,
'active_lines': 576,
'field_rate': 50,
'line_time': 64, # microseconds
'sync_width': 4.7, # microseconds
'burst_start': 5.6, # microseconds
'active_video_start': 10.5, # microseconds
'color_burst_width': 2.25 # microseconds
}
}# Command line arguments instead of DEBUG_FLAGS
parser.add_argument('--debug-sync', action='store_true')
parser.add_argument('--debug-levels', action='store_true')
parser.add_argument('--debug-tbc', action='store_true')
parser.add_argument('--save-raw', action='store_true')| Format | Implementation | Notes |
|---|---|---|
| VHS | Full | NTSC/PAL with color-under |
| S-VHS | Full | Higher bandwidth variant |
| U-matic | Full | Low/High band supported |
| Quad | Experimental | Basic NTSC/PAL support |
| Type-C | Experimental | Limited testing |
COLOR_SYSTEMS = {
'NTSC': {
'subcarrier': 3.579545, # MHz
'burst_length': 8, # cycles
'phase_comp': True # Uses phase compensation
},
'PAL': {
'subcarrier': 4.43361875,
'burst_length': 10,
'phase_alt': True # Phase alternation
}
}The chroma decoder handles color subcarrier recovery and demodulation for various video formats.
class SubcarrierRecovery:
def __init__(self, format_type):
"""Initialize subcarrier recovery
Args:
format_type: Format identifier (NTSC/PAL/SECAM)
"""
self.frequencies = {
'NTSC': 3.579545, # MHz
'PAL': 4.43361875, # MHz
'SECAM': None # SECAM uses FM for chrominance
}| Format | Processing Method | Notes |
|---|---|---|
| VHS | Cross-color reduction | 629kHz carrier (NTSC) |
| Beta | Comb filter | 688kHz with phase correction |
| Video8 | Adaptive comb | 732/743kHz noise reduction |
| Quad | Direct composite | Standard subcarrier |
| System | Color Encoding | Subcarrier |
|---|---|---|
| 525 | NTSC M | 3.579545 MHz |
| 625 | PAL B/G/I | 4.43361875 MHz |
| 625 | SECAM | FM encoded |
- FAQ - Frequently Asked Questions
- Diagram Breakdowns
- Visual-Comparisons
- VCR Reports / RF Tap Examples
- Download & Contribute Data
- Speed Testing
- Capture Setup Guide
- MISRC
- CX Cards & CXADC
- CX Cards - Clockgen Mod
- DdD - Domesday Duplicator
- RTL-SDR
- Hardware Installation Guide
- Finding RF Tap Locations
- Amplifier Setup Guide
- The Tap List Example VCR's
- Visual VBI Data Guide
- Closed Captioning
- Teletext
- WSS Wide - Screen Signalling
- VITC Timecode
- VITS Signals
- XDS Data (PBS)
- Video ID IEC 61880
- Auto Audio Align
- Vapoursynth TBC Median Stacking Guide
- Ruxpin-Decode & TV Teddy Tapes
- Tony's GNU Radio For Dummies Guide
- Tony's GNU Radio Scripts
- DomesDay Duplicator Utilities
- ld-decode Utilities