Skip to content

Technical Code Breakdowns

Harry Munday edited this page Nov 3, 2025 · 2 revisions

Technical Code Documentation

This document provides an end-to-end breakdown of the vhs-decode pipeline, from raw RF input through TBC output to final chroma decoding.


End-to-End Decode Pipeline Overview

High-Level Flow

The vhs-decode pipeline consists of two main stages:

  1. RF Decode Stage (vhs-decode): Raw RF samples → TBC files (.tbc + .tbc.json)
  2. 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

RF Decode Pipeline (vhs-decode)

1. Entry Point & Initialization

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 metadata

2. RF Input & Loading

Files: 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

3. RF Filtering & Processing

File: vhsdecode/process.py - VHSRFDecode.__init__() and _computevideofilters_b()

Filter Generation

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

4. FM Demodulation

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, env

5. Sync Detection & Level Detection

Files: vhsdecode/addons/resync.py, vhsdecode/addons/vsyncserration.py

Pulse Detection

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) tuples

Pulse Classification

Pulses 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)

6. Field Assembly & Line Location

File: vhsdecode/field.py - FieldShared.compute_linelocs()

The field assembly process:

  1. Find valid pulse sequence: HSYNC → EQPL → VSYNC → EQPL → HSYNC
  2. Determine field type: First field vs. second field based on pulse timing
  3. Calculate line locations: Map pulse positions to line start locations
  4. 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_offset

7. Time Base Correction (TBC)

Files: 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_line

Output Scaling

The 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)

8. Chroma Processing (Color-Under Formats)

Files: vhsdecode/chroma.py, vhsdecode/field.py

For color-under formats (VHS, Betamax, Sony 8mm, U-Matic):

Phase Detection (VHS/Video8)

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_phase

Chroma Upconversion

def 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 uphet

9. Dropout Detection

File: 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.outlinelen

10. TBC File Output

Files: vhsdecode/main.py, lddecode/core.py

Binary TBC Files

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)

JSON Metadata

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]
      }
    }
  ]
}

Chroma Decoder Pipeline (ld-chroma-decoder)

Overview

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

1. Input & Metadata Loading

// 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);
}

2. Decoder Thread Pool

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
    }
};

3. Decoder Thread Execution

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;
        }
    }
}

4. NTSC Chroma Decoding Theory

Files: tools/ld-chroma-decoder/ntscdecoder.cpp, tools/ld-chroma-decoder/comb.cpp

NTSC Color Encoding Principles

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.

1D Comb Filter (Notch Filter)

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

2D Comb Filter (Line Combing)

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);
}

3D Comb Filter (Temporal + Spatial)

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)

Chroma Demodulation (NTSC)

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);
        }
    }
}

5. PAL Chroma Decoding Theory

Files: tools/ld-chroma-decoder/paldecoder.cpp, tools/ld-chroma-decoder/palcolour.cpp

PAL Color Encoding Principles

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)

PAL 2D Decoder (PALcolour)

File: tools/ld-chroma-decoder/palcolour.cpp

PALcolour is a line-locked decoder using 2D FIR (Finite Impulse Response) filters.

2D Filter Design

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
    }
}

Burst Detection and Phase Compensation

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;
}

2D Filtering and Demodulation

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

Transform PAL Decoders (2D/3D)

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.

Theory: PAL Spectral Distribution

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.

2D Transform PAL

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;
    }
}

3D Transform PAL

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);
    }
}

Adaptive Thresholding

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

6. Decoder Algorithm Comparison

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

Choosing a Decoder

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

7. Output Conversion & Video Export Pipeline

Standard Workflow: tbc-video-export

Tool: tbc-video-export (Python script by JuniorIsAJitterbug)

The standard export pipeline uses tbc-video-export which automates:

  1. Running ld-chroma-decoder with appropriate settings
  2. Piping decoded YUV to FFmpeg
  3. 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.)

tbc-video-export Profiles

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

Internal Decoder Output

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.

8. Field Order Handling & Time Base Corrections

Understanding Field Order Issues

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

Terminal Messages During Decode

Normal Operation

File Frame 1234: VHS
File Frame 1235: VHS

No issues - fields decoded normally.


Critical VSync Detection Messages

"Unexpected vsync arbitrage"

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:

  1. Envelope Detection: Low-pass filters detect amplitude dips during vsync
  2. Harmonic Analysis: Bandpass filtering measures EQ pulse harmonics near line frequency
  3. 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 None

Causes:

  • 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 -f sample rate setting

"Unable to determine start of field - dropping field"

Note

Sources:

  • lddecode/core.py line 2214
  • vhsdecode/field.py line 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 HSYNCCRITICAL6. 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, retry

Common 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 diskLoc values
  • isDuplicateField: true flags

Field Order Discontinuity Detected

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

Field Drop Compensation

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

Progressive Content Detection

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

Field Order Detection Logic

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 flag

Field Order Action Modes

Command 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)

Time Base Location Tracking

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
    }
  ]
}

9. Audio Synchronization & Field Timing

Why Field Drops Cause Audio Sync Issues

The Problem: Video fields can be dropped or duplicated, but audio captures continuously.

When the decoder handles field issues:

  1. Field duplication: Adds ~16.7ms (NTSC) or ~20ms (PAL) of video time
  2. Field drop: Video timeline advances but no actual video data added
  3. Audio is continuous: Audio capture runs independently, unaffected by field handling
  4. Result: Cumulative A/V drift throughout the recording

Example Scenario

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!)

TBC JSON: The Source of Truth

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

Audio Alignment Solutions

Method 1: Manual Offset Calculation

# 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.tbc

Method 2: Automated with auto-audio-align

Primary 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.flac

What it does:

  • Reads diskLoc field 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)

Method 3: VBI Timecode Extraction

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-align

Note

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.

Best Practices

During Decode:

  • Monitor terminal for field error messages
  • More errors = more audio sync work needed
  • Use --field_order_confidence appropriately for tape condition

For Audio Sync:

  • Always check .tbc.json for isDuplicateField entries
  • 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.json

If you see high counts (>100 issues per hour), expect significant audio sync challenges.

10. Performance Optimization

Multi-threading Architecture

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)

Complete Pipeline Summary

End-to-End Data Flow

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)

Key Concepts Review

RF Decode Stage

  • 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

Chroma Decode Stage

  • 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

Typical Decode Command Examples

VHS NTSC Decode (Standard)

# 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)

VHS NTSC with Custom Profile

# 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

PAL LaserDisc (High Quality)

# 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)

Betamax NTSC to ProRes

# 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

Web/Streaming Output

# 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)

Advanced: Direct ld-chroma-decoder Use

For custom workflows, call ld-chroma-decoder directly & FFmpeg via piping.

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

Tools & Workflows

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.md in vhs-decode repository

Recommended Workflow:

  1. RF decode: vhs-decode or ld-decode → TBC files
  2. Video export: tbc-video-export → YUV 4:2:2 video (FFV1/ProRes/etc.)
  3. Post-processing: NLE (DaVinci Resolve, Premiere) or FFmpeg

References & Further Reading

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:


Core Components Overview

1. Format Parameter System

File: vhsdecode/formats.py

Central repository for all tape format parameters.

Format Detection

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, DecoderParams

Key Parameters

System 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

leveldetect.py

  • 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

process.py

  • 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_defs/

  • 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
}

resync.py

  • 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

2. Signal Processing Pipeline

Input Stage

  • Sample rate verification
  • Input level normalization
  • Pre-emphasis compensation

Demodulation Stage

  • FM demodulation
  • Carrier separation (color-under formats)
  • De-emphasis filtering

Sync Processing

  • 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

Video Recovery

  • Line reconstruction
  • Color processing
  • Output formatting

3. Format Support Details

Direct FM Formats

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

Color-Under Formats

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

Support Level Definitions

  • 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

Technical Notes

  • 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

4. Development Notes

Critical Parameters

  • Sample rates: 28-40 MSPS
  • FM carrier frequencies
  • Sync pulse thresholds
  • Color subcarrier frequencies

Debug Features

DEBUG_FLAGS = {
    'show_sync': False,    # Show sync detection
    'plot_levels': False,  # Plot level detection
    'dump_tbc': False,     # Save TBC data
}

Chroma Decoder

Overview

The chroma decoder handles color subcarrier recovery and demodulation for various video formats.

Components

Subcarrier Recovery

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
        }

Color System Processing

NTSC Processing
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
    """
    pass
PAL Processing
def 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-Specific Implementations

Color-Under Formats

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

Direct Composite

Format Processing Method Implementation Status
Quad Direct composite decode Supported
Type-C Direct composite decode Experimental

Key Features

Color Processing Pipeline

  1. Subcarrier Recovery

    • Burst detection
    • Phase lock
    • Reference generation
  2. Chroma Demodulation

    • Format-specific carriers
    • Cross-color suppression
    • Bandwidth limiting
  3. Color Space Conversion

    • YIQ/YUV detection
    • Matrix conversion
    • Color correction

Debug Options

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 System Processing

Supported Line Standards

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
    }
}

Debug Features

# 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 Support Status (Updated)

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 Processing

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
    }
}

Chroma Decoder

Overview

The chroma decoder handles color subcarrier recovery and demodulation for various video formats.

Components

Subcarrier Recovery

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-Specific Color Processing

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

Line System Context

System Color Encoding Subcarrier
525 NTSC M 3.579545 MHz
625 PAL B/G/I 4.43361875 MHz
625 SECAM FM encoded

Home

Starting & Contributing

Software Installation

Capture Hardware

Hardware Installation

Software Usage

VBI Data Extraction & Decoding

Tools & Scripts

Guides

Technical

Other Decoders

Support & Community

Clone this wiki locally