Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 50 additions & 37 deletions lddecode/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,12 +170,20 @@ def calclinelen(SysParams, mult, mhz):

FilterParams_NTSC = {
# The audio notch filters are important with DD v3.0+ boards
"audio_notchwidth": 350000,
"audio_notchwidth": 200000,
"audio_notchorder": 2,
"video_deemp": (120e-9, 320e-9),
# This BPF is similar but not *quite* identical to what Pioneer did
# NTSC builds its RF video filter as a split high-pass (low edge) + low-pass
# (high edge) cascade so the two skirts can be shaped independently
# (see computevideofilters). The low edge is kept gentle (order 2) to
# protect the lower chroma sideband (~4.5 MHz at blanking) and its group
# delay; the high edge is sharper to reject HF noise while keeping the
# upper chroma sideband (~11.7 MHz) flat.
"video_bpf_low": 3700000,
"video_bpf_low_order": 2,
"video_bpf_high": 13800000,
"video_bpf_high_order": 3,
# video_bpf_order is retained for the --lowband override.
"video_bpf_order": 4,
# This can easily be pushed up to 4.5mhz or even a bit higher on most disks.
# A sharp 4.8-5.0 is probably the maximum before the audio carriers bleed into 0IRE.
Expand Down Expand Up @@ -489,18 +497,19 @@ def notchrange(self, f, notchwidth, hz = False):
]

def build_groupdelay_equalizer(self, lpf_fft):
"""All-pass equaliser matching the IEC 60856 sub-clause 9.1.6 video
group-delay pre-distortion (PAL).
"""All-pass equaliser matching the IEC video group-delay pre-distortion.

PAL: IEC 60856 sub-clause 9.1.6
NTSC: IEC 60857 sub-clause 9.1.7

The disc is recorded with its video group delay pre-distorted so that
the playback low-pass filter brings the overall group delay flat across
the chroma band. ld-decode's Butterworth video LPF undershoots that
target (only ~+99 ns at 4.43 MHz where the spec wants +135 ns, and
~+128 vs +200 ns at 4.8 MHz), leaving the chroma sidebands sloped, which
smears colour.
the chroma band. ld-decode's Butterworth video LPF undershoots the
target, leaving the chroma sidebands sloped, which smears colour and
contributes to differential phase.

This returns a unit-magnitude (all-pass) FFT-domain filter whose group
delay equals target - LPF, so that LPF * equaliser reproduces the 9.1.6
delay equals target - LPF, so that LPF * equaliser reproduces the spec
curve. De-emphasis is deliberately left out of the basis: its group
delay is cancelled end-to-end by the disc's (inverse) pre-emphasis, so
only the LPF's deviation needs correcting.
Expand All @@ -509,11 +518,18 @@ def build_groupdelay_equalizer(self, lpf_fft):
fs = self.freq_hz
binfreq = np.abs(np.fft.fftfreq(blocklen, 1.0 / fs))

# IEC 60856 9.1.6 target group delay relative to 0.5 MHz, in seconds
# (the spec tabulates pre-distortion of -10/-35/-85/-135/-200 ns; the
# playback chain must supply the inverse, held flat above 4.8 MHz).
gd_f = np.array([0.0, 0.5e6, 2.0e6, 3.0e6, 4.0e6, 4.4336e6, 4.8e6, 5.5e6])
gd_t = np.array([0.0, 0.0, 10e-9, 35e-9, 85e-9, 135e-9, 200e-9, 200e-9])
if self.system == "PAL":
# IEC 60856 9.1.6 target group delay relative to 0.5 MHz, in seconds
# (the spec tabulates pre-distortion of -10/-35/-85/-135/-200 ns; the
# playback chain must supply the inverse, held flat above 4.8 MHz).
gd_f = np.array([0.0, 0.5e6, 2.0e6, 3.0e6, 4.0e6, 4.4336e6, 4.8e6, 5.5e6])
gd_t = np.array([0.0, 0.0, 10e-9, 35e-9, 85e-9, 135e-9, 200e-9, 200e-9])
else:
# IEC 60857 9.1.7 target group delay relative to 0.5 MHz, in seconds
# (the spec tabulates pre-distortion of -15/-45/-80/-135/-200 ns; the
# playback chain must supply the inverse, held flat above 4.2 MHz).
gd_f = np.array([0.0, 0.5e6, 2.0e6, 3.0e6, 3.58e6, 4.0e6, 4.2e6, 4.8e6])
gd_t = np.array([0.0, 0.0, 15e-9, 45e-9, 80e-9, 135e-9, 200e-9, 200e-9])
target = np.interp(binfreq, gd_f, gd_t)

# actual LPF group delay = -d(phase)/d(omega)
Expand Down Expand Up @@ -570,15 +586,12 @@ def computevideofilters(self):
MTF = sps.zpk2tf([], [to_z(MTF_polef_lo), to_z(MTF_polef_hi)], 1)
SF["MTF"] = filtfft(MTF, self.blocklen)

# The BPF filter, defined for each system in DecoderParams
if self.system == "PAL":
# PAL: build the RF band-pass as a separate high-pass (low edge) and
# low-pass (high edge) so each skirt can be ordered independently.
# The low edge stays gentle to avoid loading the lower chroma
# sideband (~2.67 MHz) with group delay; the high edge is sharper to
# trim HF noise and the folded upper-J2 product without touching the
# chroma sidebands. Behaviour on the low edge is identical to the
# previous order-2 band-pass.
# The BPF filter, defined for each system in DecoderParams.
# When split skirt parameters are available, build as independent
# high-pass (low edge) + low-pass (high edge) so each can be ordered
# separately — gentler low edge protects the lower chroma sideband and
# its group delay, sharper high edge rejects HF noise.
if "video_bpf_low_order" in DP:
filt_rfvideo_hp = sps.butter(
DP["video_bpf_low_order"],
DP["video_bpf_low"] / self.freq_hz_half,
Expand All @@ -589,7 +602,6 @@ def computevideofilters(self):
DP["video_bpf_high"] / self.freq_hz_half,
btype="lowpass",
)
# Start building up the combined FFT filter using the split BPF
SF["RFVideo"] = filtfft(filt_rfvideo_hp, self.blocklen) * filtfft(
filt_rfvideo_lp, self.blocklen
)
Expand All @@ -599,7 +611,6 @@ def computevideofilters(self):
self.freqrange(DP["video_bpf_low"], DP["video_bpf_high"]),
btype="bandpass",
)
# Start building up the combined FFT filter using the BPF
SF["RFVideo"] = filtfft(filt_rfvideo, self.blocklen)

# Notch filters for analog audio RF. DdD captures on NTSC need this.
Expand Down Expand Up @@ -650,14 +661,15 @@ def computevideofilters(self):
# Post processing: lowpass filter + deemp
SF["FVideo"] = SF["Fvideo_lpf"] * (SF["Fdeemp"] ** DP['video_deemp_strength'])

# PAL: correct the post-demod video group delay to the IEC 60856 9.1.6
# curve the disc was pre-distorted against (the Butterworth LPF alone
# undershoots it across the chroma band, smearing colour). This is a
# pure all-pass, so |FVideo| is unchanged; only the output video path is
# equalised (the burst/pilot/sync reference paths are left as-is).
if self.system == "PAL":
SF["FVideoGD"] = self.build_groupdelay_equalizer(SF["Fvideo_lpf"])
SF["FVideo"] = SF["FVideo"] * SF["FVideoGD"]
# Correct the post-demod video group delay to the IEC spec curve the
# disc was pre-distorted against (PAL: IEC 60856 9.1.6, NTSC: IEC 60857
# 9.1.7). The Butterworth LPF alone undershoots the target across the
# chroma band, smearing colour and contributing to differential phase.
# This is a pure all-pass, so |FVideo| is unchanged; only the output
# video path is equalised (the burst/pilot/sync reference paths are
# left as-is).
SF["FVideoGD"] = self.build_groupdelay_equalizer(SF["Fvideo_lpf"])
SF["FVideo"] = SF["FVideo"] * SF["FVideoGD"]

# additional filters: 0.5mhz and color burst
# Using an FIR filter here to get a known delay
Expand Down Expand Up @@ -3495,10 +3507,11 @@ def process(self):

self.burstmedian = self.calc_burstmedian()

# Now adjust the phase to get the downscaled image onto I/Q color axis
# (This should be 33 but this is what makes it line up)
shift33 = 83 * (np.pi / 180)
self.linelocs = self.apply_offsets(self.linelocs4, -shift33 - 0)
# Subcarrier phase offset in degrees, calibrated for correct NTSC
# burst phase (~147°) at the output.
fsc_phase_deg = 117.25
shift_samples = (fsc_phase_deg / 360) / self.rf.SysParams["fsc_mhz"] * self.rf.freq
self.linelocs = np.array(self.linelocs4) - shift_samples


class LDdecode:
Expand Down
70 changes: 70 additions & 0 deletions phase-offset-investigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# NTSC Burst Phase Offset Investigation (2026-06-20)

## Problem

Median burst phase across all TBC output lines consistently measured ~139° instead of the ideal ~147° (NTSC standard burst phase). Consistent across different source discs — a decoder-side systematic bias.

## Root Cause

The `shift33` constant in `FieldNTSC.process()` (core.py ~line 3034). The old code:

```python
shift33 = 83 * (np.pi / 180)
self.linelocs = self.apply_offsets(self.linelocs4, -shift33 - 0)
```

had a unit mismatch: `apply_offsets` treats `phaseoffset` as output samples (converting via `* freq / outfreq`), but `shift33` was passed in radians. Each degree of the constant produced ~pi/2 degrees of actual subcarrier shift, not 1:1. The comment "This should be 33 but this is what makes it line up" acknowledged the confusion.

## Fix Applied

Replaced with a clean formula using degrees of subcarrier directly:

```python
fsc_phase_deg = 122.5
shift_samples = (fsc_phase_deg / 360) / self.rf.SysParams["fsc_mhz"] * self.rf.freq
self.linelocs = np.array(self.linelocs4) - shift_samples
```

The constant 122.5° is the actual subcarrier phase offset, equivalent to the old `shift33 = 78 * pi/180` (78 * pi/2 = 122.52°). Measured median burst phase after fix: ~147.5°.

## Calibration Math

- Old constant 83 → measured 139.6°
- Each degree of the old constant shifted subcarrier by pi/2 ≈ 1.5708°
- To correct by +7.8°: delta = 7.8 / (pi/2) ≈ 5
- New old-style constant = 83 - 5 = 78
- In clean units: 78 * pi/2 = 122.5° of subcarrier

**Direction:** Increasing the phase shift constant moves burst phase DOWN (tested: 83→88 dropped 139→131). Decreasing moves it UP (83→78 raised 139→147.5).

## What Was Investigated and Ruled Out

These are all potential sources of phase offset that were checked and found NOT to contribute:

### 1. `outlinelen` rounding
Exactly 910 samples = 227.5 * 4, zero fractional error. No contribution. (core.py `calclinelen`, SysParams_NTSC)

### 2. IIR filter phase at fSC
The `Fvideo_lpf` and `Fdeemp` filters have significant phase response at fSC (~155° combined), but this affects both `demod_burst` (used for burst locking) and `demod` (used for output) equally, so it cancels out. Would only matter if burst locking used a different signal path than the output.

### 3. FIR burst filter compensation
The 81-tap FIR with delay 40 samples is correctly compensated via a frequency-domain shift (core.py ~line 719). No residual offset.

### 4. 1D comb filter in CombNTSC
At fSC with 4*fSC sampling, `(data[n-2] + data[n+2])/2 - data[n]` has gain = -2 and phase = exactly 180°. This is a property of the symmetric ±2 tap structure at quarter-cycle spacing. No fractional phase contribution. (metrics.py `buildCBuffer`)

### 5. Sinc interpolation in `scale_field`
16-tap symmetric kernel, centered at tap index 7. No half-sample offset that could introduce phase. (core.py `scale_field`)

### 6. `computewow_scaled`
Output pixel 0 of each line maps exactly to `linelocs[line]`, no additional bias introduced.

### 7. Burst locking half-correction damping
The `/2` damping factor in `refine_linelocs_burst` (~line 2933) is compensated by feeding the full value back via `phase_adjust_median`. No systematic bias — just convergence rate.

### 8. `getlinephase` correctness
The NTSC 4-field color phase sequence logic in metrics.py correctly models the relationship between fieldPhaseID, line parity, and I/Q sign patterns.

## Key Takeaway

When debugging phase issues in the NTSC pipeline, start at the `fsc_phase_deg` constant and the burst locking path (`refine_linelocs_burst`). The filter chain, comb filter, and resampling are phase-neutral by design/verification.
Loading