From 55f6eeb9a8c60bb6f3c49e179ce1854112bb10f3 Mon Sep 17 00:00:00 2001 From: Chad Date: Sat, 20 Jun 2026 16:42:33 -0700 Subject: [PATCH 1/2] Fix NTSC burst phase: replace shift33 hack with calibrated subcarrier offset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old shift33 constant (83° in radians, treated as output samples by apply_offsets) had a unit mismatch that made the math opaque and produced ~139° median burst phase instead of the correct ~147°. Replaced with a direct formula: fsc_phase_deg=122.5 converted to input samples via the subcarrier period. Includes investigation notes documenting what was ruled out (filter phase, comb filter, sinc interpolation, burst locking damping, outlinelen rounding, etc). Co-Authored-By: Claude Opus 4.6 --- lddecode/core.py | 9 +++-- phase-offset-investigation.md | 70 +++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 phase-offset-investigation.md diff --git a/lddecode/core.py b/lddecode/core.py index f571fd0bb..502e369d6 100644 --- a/lddecode/core.py +++ b/lddecode/core.py @@ -3495,10 +3495,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 = 122.5 + shift_samples = (fsc_phase_deg / 360) / self.rf.SysParams["fsc_mhz"] * self.rf.freq + self.linelocs = np.array(self.linelocs4) - shift_samples class LDdecode: diff --git a/phase-offset-investigation.md b/phase-offset-investigation.md new file mode 100644 index 000000000..59b59b816 --- /dev/null +++ b/phase-offset-investigation.md @@ -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. From ca97e789c6fc8b1464e9c6febccfe18849a44110 Mon Sep 17 00:00:00 2001 From: Chad Date: Sat, 20 Jun 2026 21:44:29 -0700 Subject: [PATCH 2/2] Fix NTSC differential phase: split BPF, IEC 60857 group delay EQ, narrow audio notch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes to eliminate ~10° differential phase between burst (0 IRE) and 70 IRE color bar measurements: - Split RF BPF into independent HP order 2 / LP order 3 (was single BPF order 4), reducing sideband asymmetry on the low-frequency skirt - Generalize post-demod group delay equalizer for NTSC (IEC 60857 9.1.7) in addition to PAL (IEC 60856 9.1.6), correcting LPF undershoot across the chroma band - Narrow NTSC audio notch width from 350 kHz to 200 kHz (matching PAL), cutting the notch filters' group delay contribution to differential phase - Recalibrate fsc_phase_deg from 122.5° to 117.25° for the new filter chain Result on he010 color bar disc (30 frames): DP reduced from ~10° to 0.7°, with Phase (70 IRE bar) at 147.7° and MedPhase (burst) at 147.0°. Co-Authored-By: Claude Opus 4.6 --- lddecode/core.py | 80 ++++++++++++++++++++++++++++-------------------- 1 file changed, 46 insertions(+), 34 deletions(-) diff --git a/lddecode/core.py b/lddecode/core.py index 502e369d6..df7437b40 100644 --- a/lddecode/core.py +++ b/lddecode/core.py @@ -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. @@ -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. @@ -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) @@ -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, @@ -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 ) @@ -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. @@ -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 @@ -3497,7 +3509,7 @@ def process(self): # Subcarrier phase offset in degrees, calibrated for correct NTSC # burst phase (~147°) at the output. - fsc_phase_deg = 122.5 + 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