Author Topic: WFM Stereo Broadcasting Standards - OIRT vs CCIR  (Read 18591 times)

0 Members and 4 Guests are viewing this topic.

Offline radiolistenerTopic starter

  • Super Contributor
  • ***
  • Posts: 5745
  • Country: Earth
Re: WFM Stereo Broadcasting Standards - OIRT vs CCIR
« Reply #50 on: April 18, 2026, 04:22:50 am »
After a long pause, I returned to the OIRT multiplex demodulator. I finally made a working version for both CCIR and OIRT, and it seems to work ok  :) for OIRT, the L/R channel separation is around 56 dB  8)

In GQRX it’s much worse — there are a few mistakes, and the PLL implementation in GNU Radio turns out to have insufficient step resolution, which leads to a fairly large phase error in carrier recovery. I had to rework the angular frequency integrator to use double precision, since float resolution wasn’t enough and it helps to keep pretty precise phase recovery.

The main issue with OIRT is the residual DC from the suppressed carrier after DSB-SC demodulation. In principle, it works fine on a synthetic signal even if I hardcode DC subtraction as 0.2, but in real stations the carrier level can drift slightly. So I added a DC estimator with an initial value of -0.2 and subtract the calculated DC level in real time. However, I don’t really like this approach — it may slightly cut low frequencies.  :-\

I’m wondering if there’s a better way to reliably eliminate DC offset from carrier without affecting the low-frequency content?
 

Offline radiolistenerTopic starter

  • Super Contributor
  • ***
  • Posts: 5745
  • Country: Earth
Re: WFM Stereo Broadcasting Standards - OIRT vs CCIR
« Reply #51 on: April 18, 2026, 04:33:13 am »
Can the gqrx stereo decoder demodulate Njk's recorderd composite w/o distortion?

In my new demodulator, @Njk’s recorded OIRT samples do play, but with some distortion. At the same time, my own synthetic examples (generated by me) reproduce cleanly.

This strongly suggests the issue is with the nonlinearity of the oscilloscope ADC used for the recording. The voice sample sounds reasonably good overall, just with some added noise/interference. The music sample, however, shows clear distortion — the L−R component is noticeably affected.

I don’t have other real off-air OIRT recordings to compare yet, so for now I’m mostly testing with my own synthetic signals.
 

Offline gf

  • Super Contributor
  • ***
  • Posts: 1832
  • Country: de
Re: WFM Stereo Broadcasting Standards - OIRT vs CCIR
« Reply #52 on: April 18, 2026, 01:22:35 pm »
The main issue with OIRT is the residual DC from the suppressed carrier after DSB-SC demodulation. In principle, it works fine on a synthetic signal even if I hardcode DC subtraction as 0.2, but in real stations the carrier level can drift slightly. So I added a DC estimator with an initial value of -0.2 and subtract the calculated DC level in real time. However, I don’t really like this approach — it may slightly cut low frequencies.  :-\

I’m wondering if there’s a better way to reliably eliminate DC offset from carrier without affecting the low-frequency content?

It's impossible to have a filter with a zero at DC without also affecting low frequencies near DC. However, when you talk about drift, it's not pure DC anyway, but rather low-frequency AC. Therefore, you actually want to remove these low frequencies as well. IMO, the only feasible solution is a high-pass filter. I think a cutoff of -0.1 dB at 20 Hz will be fine? The transmitted audio likely doesn't go down any further.

IIRs tend to become numerically unstable at such a large fs/fc ratio and they are not linear phase. FIR would need a large number of taps. So I'd tend to realize a complementary highpass as delay(signal) - lowpass(signal), where the lowpass is a simple 4th order moving average filter that can be realized with a CIC/RRS structure. With 1752-tap moving average (for each of the 4 stages), 3502 samples delay, and 48kSa/s, I get the attached magnitude response. That's a passband ripple of only ~0.02 dB and attenuation of 80 dB at 0.1 Hz (and linear phase, of course, so no phase distortion).
« Last Edit: April 19, 2026, 10:04:47 am by gf »
 
The following users thanked this post: radiolistener

Offline radiolistenerTopic starter

  • Super Contributor
  • ***
  • Posts: 5745
  • Country: Earth
Re: WFM Stereo Broadcasting Standards - OIRT vs CCIR
« Reply #53 on: April 21, 2026, 08:59:47 am »
1752 taps feels quite excessive for a real-time SDR chain. I’d prefer to keep the demodulator lightweight — that kind of delay and buffer size is overkill for what is essentially slow drift / near-DC.

From my experiments, I ran into a different issue with the PLL. After switching from my own implementation to liquid-dsp nco_crcf, lock became much faster (~5x times), but I started getting audible spurs. After investigating, I found tones around the carrier at about -60 dBc at the PLL output.

I suspect this is due to the fixed loop parameters in liquid-dsp — there’s no control over damping, and the internal NCO/PLL seems to introduce phase modulation artifacts.

Also, OIRT demodulation turned out to be very sensitive to the phase detector. Using atan2(Q,I) - phase works fine, but other detectors break stereo demodulation, even though the PLL still locks. The reason isn’t fully clear yet for me.

So for now I’d prefer a simple/cheap DC removal and focus on getting a cleaner PLL/LO.
 

Offline radiolistenerTopic starter

  • Super Contributor
  • ***
  • Posts: 5745
  • Country: Earth
Re: WFM Stereo Broadcasting Standards - OIRT vs CCIR
« Reply #54 on: April 21, 2026, 09:22:02 am »
here is my attempt to implement PLL for Octave with using liquid-dsp nco_crcf in MEX file.

compile it with
Code: [Select]
mkoctfile --mex pll_process2_mex.cpp -lliquid
pll_process2_mex.cpp
Code: [Select]
// linux: mkoctfile --mex pll_process2_mex.cpp -lliquid
#include "mex.h"
#include <cmath>
#include <complex>
#include <liquid/liquid.h>


typedef std::complex<float> cf;

typedef struct {
    nco_crcf nco;
    float    err;
    float    min_freq;
    float    max_freq;
} pll_t;

static inline float mod_2pi(float x) {
         if (x >  M_PI) return x - (2.0 * M_PI);
    else if (x < -M_PI) return x + (2.0 * M_PI);
    else return x;
}

static void pll_init(pll_t &pll, double loop_bw, double min_freq, double max_freq) {
    if (loop_bw <= 0) {
        throw std::out_of_range("pll_init: invalid bandwidth. Must be >= 0.");
    }   
    pll.nco = nco_crcf_create(LIQUID_NCO);
    nco_crcf_pll_set_bandwidth(pll.nco, loop_bw);
    nco_crcf_set_frequency(pll.nco, 0.0f);

    pll.err   = 0.0;
    pll.max_freq = max_freq;
    pll.min_freq = min_freq;
}

static inline void pll_update(pll_t &pll, cf input) {
    // phase detector
    float phase = atan2f(input.imag(), input.real());
    pll.err = mod_2pi( phase - nco_crcf_get_phase(pll.nco));

    // update PLL
    nco_crcf_pll_step(pll.nco, pll.err);

    float freq = nco_crcf_get_frequency(pll.nco);
    if (freq > pll.max_freq) freq = pll.max_freq;
    if (freq < pll.min_freq) freq = pll.min_freq;
    nco_crcf_set_frequency(pll.nco, freq);

    nco_crcf_step(pll.nco);   
}

//----------------------------------------------------------------------

void mexFunction(int nlhs, mxArray *plhs[], int nrhs, const mxArray *prhs[]) {
    if (nrhs != 4)
        mexErrMsgTxt("Usage: [output, err] = pll_process2_mex(input, loop_bw, min_freq, max_freq)");

    const mxArray *in = prhs[0];

    double *x_re = mxGetPr(in);
    double *x_im = mxGetPi(in);

    if (!x_im)
        mexErrMsgTxt("Input must be complex");

    double loop_bw  = mxGetScalar(prhs[1]);
    double min_freq = mxGetScalar(prhs[2]);
    double max_freq = mxGetScalar(prhs[3]);

    mwSize N = mxGetNumberOfElements(in);

    // Create output arrays
    plhs[0] = mxCreateDoubleMatrix(N, 1, mxCOMPLEX);
    double *y_re = mxGetPr(plhs[0]);                  // output
    double *y_im = mxGetPi(plhs[0]);
   
    plhs[1] = mxCreateDoubleMatrix(N, 1, mxREAL);     // err
    double *err_re = mxGetPr(plhs[1]);   

    // init
    pll_t pll;
    pll_init(pll, loop_bw, min_freq, max_freq);

    for (mwSize n = 0; n < N; n++) {
        cf lo;
        nco_crcf_cexpf(pll.nco, &lo);

        y_re[n] = lo.real();
        y_im[n] = lo.imag();
        err_re[n] = pll.err;

        pll_update(pll, cf(x_re[n], x_im[n]));
    }
}

it requires to install liquid-dsp library: https://github.com/jgaeddert/liquid-dsp.git

Here is Octave test script:
Code: [Select]
pkg load signal;

fs = 384000;        % sample rate
f0 = 19000;         % CCIR pilot

N = fs/8;         % 0.1 sec test
t = (0:N-1)'/fs;

% --- test signal: pure pilot tone ---
phi0 = pi/3;       % initial phase of pilot in radians
pilot = cos(    (2*pi*f0*t + phi0));

% --- FIR LPF and complex BPF kernel calculator ---
function ntaps = compute_ntaps(fs, transition_width, max_atten)
    ntaps = (max_atten * fs) / (22.0 * transition_width);
    ntaps = floor(ntaps);
    % --- ensure odd length (linear-phase FIR requirement)
    if mod(ntaps, 2) == 0
        ntaps = ntaps + 1;
    end
end
function taps = low_pass(gain, fs, fc, tw)
    % construct the truncated ideal impulse response
    % [sin(x)/x for the low pass case]
    ntaps = compute_ntaps(fs, tw, 53);
    w = hamming(ntaps).';
    M = floor((ntaps - 1)/2);
    n = -M:M;
    wc = 2*pi*fc/fs;
    taps = zeros(1, ntaps);
    idx = (n == 0);
    taps(idx) = (wc/pi) * w(idx);
    idx = ~idx;
    taps(idx) = (sin(n(idx)*wc) ./ (n(idx)*pi)) .* w(idx);
    % normalize
    fmax = taps(M+1) + 2*sum(taps(M+2:end));
    gain = gain / fmax;
    taps = taps * gain;
end
function taps = complex_band_pass(gain, fs, f1, f2, tw)
    % low-pass prototype
    lptaps = low_pass(gain, fs, (f2-f1)/2, tw);
    ntaps = length(lptaps);
    n = 0:ntaps-1;
    fc = (f1 + f2)/2;
    w0 = 2*pi*fc/fs;
    % modulation
    taps = lptaps .* exp(1j * w0 * (n - (ntaps-1)/2));
end

% --- initialize PLL ---
pll_bw = 10;
pll_minf = f0-50;
pll_maxf = f0+50;
pilotc = complex(pilot*2.0, 0);

bpf = complex_band_pass(1.0, fs, pll_minf, pll_maxf, 5000.0);
bpf_delay = (length(bpf) - 1) / 2; % bpf_delay = mean(grpdelay(bpf));
fprintf('FIR BPF: %d taps, delay = %f samples\n', length(bpf), bpf_delay);
bpf_delay = floor(bpf_delay);

%plotFreqz(bpf, 1, fs, -fs/2, fs/2, -150, 0, 2^20, true);

pilotc = filter(bpf, 1, pilotc);
fprintf('bpf done\n');

[pll_lo, err] = pll_process2_mex(pilotc, pll_bw * 2.0*pi/fs, pll_minf * 2.0*pi/fs, pll_maxf * 2.0*pi/fs);
fprintf('pll done\n');

% --- align with BPF delay ---
pilotc(end+1:end+bpf_delay) = zeros(1, bpf_delay);
pll_lo(end+1:end+bpf_delay) = zeros(1, bpf_delay);
   err(end+1:end+bpf_delay) = zeros(1, bpf_delay);
pilot = [zeros(bpf_delay, 1); pilot];


% --- plot FFT Spectrum with Kaiser window ---
function plot_fft_kaiser(x, fs)
    Nfft = length(x);
    beta = 8.6; % ~80 dB sidelobes
    w = kaiser(Nfft, beta);
    xw = x(:) .* w; % apply window
    X = fftshift(fft(xw));
    % normalize (avoid log(0))
    mag = abs(X);
    mag = mag / max(mag + 1e-12);
    mag_db = 20 * log10(mag + 1e-12);
    f = (-Nfft/2:Nfft/2-1) * (fs / Nfft);
    figure;
    plot(f, mag_db);
    grid on; grid minor;
    set(gca, 'gridalpha', 0.2); set(gca, 'minorgridalpha', 0.05);
    xlabel('Frequency (Hz)');
    ylabel('Magnitude (dB)');
    xlim([-fs/2 fs/2]);
    ylim([-160 5]);
end

% skip unstable part
pilot_fft = pll_lo(1000:end);
pot = 2 ^ floor(log2(length(pilot_fft)));
pilot_fft = pilot_fft(1:pot);

plot_fft_kaiser(pilot_fft, fs);


% --- plot Waveform ---
N = length(pilotc);
t = 1:N;

figure;
hold on;
plot(t, real(pilotc),  'DisplayName', 'pilotc');
plot(t, real(pll_lo),  'DisplayName', 'pll_lo');
plot(t, err,           'DisplayName', 'err');
grid on; grid minor;
set (gca, 'gridalpha', 0.2); set(gca, 'minorgridalpha', 0.05);
xlim([min(t) max(t)]);
legend();

Is there a way to eliminate these spurs?
« Last Edit: April 21, 2026, 09:32:16 am by radiolistener »
 

Offline gf

  • Super Contributor
  • ***
  • Posts: 1832
  • Country: de
Re: WFM Stereo Broadcasting Standards - OIRT vs CCIR
« Reply #55 on: April 22, 2026, 05:13:53 pm »
Is there a way to eliminate these spurs?

Good question where they come from. Can you plot pll.err over time? Zoom in and check if it ripples quicky, like one sample up, one sample down, and so on? Does it help if you dcrease loop BW by factor 10 or maybe even 100?
 

Online KE5FX

  • Super Contributor
  • ***
  • Posts: 2659
  • Country: us
    • KE5FX.COM
Re: WFM Stereo Broadcasting Standards - OIRT vs CCIR
« Reply #56 on: April 22, 2026, 07:07:50 pm »
NCO spurs due to phase truncation, maybe.  What kind of precision do you get from the LiquidDSP oscillator implementation?
 

Offline radiolistenerTopic starter

  • Super Contributor
  • ***
  • Posts: 5745
  • Country: Earth
Re: WFM Stereo Broadcasting Standards - OIRT vs CCIR
« Reply #57 on: April 22, 2026, 08:58:47 pm »
Is there a way to eliminate these spurs?

Good question where they come from. Can you plot pll.err over time? Zoom in and check if it ripples quicky, like one sample up, one sample down, and so on? Does it help if you dcrease loop BW by factor 10 or maybe even 100?

I checked that. Reducing loop BW actually makes the ripple around the spurs more pronounced, but the spurs themselves don’t go away.

There is some ripple visible on pll.err, but it’s roughly at the same level as with my manual PLL implementation — and that version doesn’t produce any spurs.

So it doesn’t look like the issue is simply error signal noise or loop bandwidth. It seems more like something intrinsic to the NCO/PLL implementation rather than the loop bandwidth.

 

Offline radiolistenerTopic starter

  • Super Contributor
  • ***
  • Posts: 5745
  • Country: Earth
Re: WFM Stereo Broadcasting Standards - OIRT vs CCIR
« Reply #58 on: April 22, 2026, 09:09:53 pm »
NCO spurs due to phase truncation, maybe.  What kind of precision do you get from the LiquidDSP oscillator implementation?

From what I see in the liquid-dsp nco_crcf implementation, it uses a 32-bit integer phase accumulator, i.e. effectively a 2^32 phase resolution.

Code: [Select]
    // type == LIQUID_NCO, LIQUID_VCO_INTERP
    uint32_t        theta;          // 32-bit phase     [radians]
    uint32_t        d_theta;        // 32-bit frequency [radians/sample]

The output is then generated via a lookup table (or interpolated LUT depending on configuration).

Here is the source for pll_set and pll_step from liquid-dsp source: liquid-dsp/src/nco/src/nco.proto.c:
Code: [Select]
// set pll bandwidth
int NCO(_pll_set_bandwidth)(NCO() _q,
                            T     _bw)
{
    // ...
    _q->alpha = _bw;                // frequency proportion
    _q->beta  = SQRT(_q->alpha);    // phase proportion
    // ...
}

// advance pll phase
//  _q      :   nco object
//  _dphi   :   phase error
int NCO(_pll_step)(NCO() _q,
                   T     _dphi)
{
    // ...

    // increase frequency proportional to error
    NCO(_adjust_frequency)(_q, _dphi*_q->alpha);

    // TODO: ensure frequency doesn't run away

    // increase phase proportional to error
    NCO(_adjust_phase)(_q, _dphi*_q->beta);

    // constrain frequency
    //NCO(_constrain_frequency)(_q);
    // ...
}

// adjust frequency of nco object
int NCO(_adjust_frequency)(NCO() _q,
                           T     _df)
{
    // ...
    _q->d_theta += NCO(_constrain)(_df);
    // ...
}

// adjust phase of nco object, constraining phase
int NCO(_adjust_phase)(NCO() _q,
                       T     _dphi)
{
    // ...
    _q->theta += NCO(_constrain)(_dphi);
    // ...
}
« Last Edit: April 22, 2026, 09:20:01 pm by radiolistener »
 

Offline gf

  • Super Contributor
  • ***
  • Posts: 1832
  • Country: de
Re: WFM Stereo Broadcasting Standards - OIRT vs CCIR
« Reply #59 on: April 22, 2026, 09:58:23 pm »
KE5FX is right. I get the same phase truncation spurs when I calculate DDS (free-running, no PLL) manually, from only 1024 wavetable entries. 1024 entries with phase truncation are simply not enough if you want a higher SFDR. You need either more entries (larger NCO_STATIC_LUT_NBITS), or you interpolate, or both. Type LIQUID_VCO_INTERP seems to interpolate.

Code: [Select]
fs = 384000
fc = 19000
wt = single(exp(2i*pi*[0:1023]/1024));
fcw = round(fc/fs*2^32)
phi = floor([0:fs-1]*fcw/2^22);
sig = wt(1+floor(mod(phi,1024)));
w = flattopwin(length(sig),"periodic")';
plot(([0:fs-1]-fs/2)*0.001,20*log10(abs(fftshift(fft(sig.*w)/sum(w))))); grid on

Btw, I deleted my previous post. There was an off-by-one error in the calculation (and consequently, also in the conclusion).

EDIT: With a 64k wavetable and truncation, I get them down to -96 dBc, see figure 2.

EDIT: Seems that 1024 entries with linear interpolation are even more effective than 64k with truncation, see figure 3.

Code: [Select]
fs = 384000
fc = 19000
wt = single(exp(2i*pi*[0:1024]/1024));
phi = mod([0:fs-1]*fc/fs*1024,1024);
sig = interp1([0:1024],wt,phi);
w = flattopwin(length(sig),"periodic")';
plot(([0:fs-1]-fs/2)*0.001,20*log10(abs(fftshift(fft(sig.*w)/sum(w))))); grid on
« Last Edit: April 23, 2026, 06:13:23 pm by gf »
 
The following users thanked this post: radiolistener

Offline radiolistenerTopic starter

  • Super Contributor
  • ***
  • Posts: 5745
  • Country: Earth
Re: WFM Stereo Broadcasting Standards - OIRT vs CCIR
« Reply #60 on: April 24, 2026, 09:41:16 am »
That’s the interesting part — in my manual PLL implementation this code:
Code: [Select]
lo = cexpf(I * pll.phase);
does not produce visible spurs.

However, when I use the same oscillator approach with the liquid-dsp PLL phase state instead of nco_crcf_cexpf(pll.nco, &lo), spurs still appear, but their position shifts closer to the carrier and with less magnitude.
« Last Edit: April 24, 2026, 09:46:27 am by radiolistener »
 

Offline gf

  • Super Contributor
  • ***
  • Posts: 1832
  • Country: de
Re: WFM Stereo Broadcasting Standards - OIRT vs CCIR
« Reply #61 on: April 24, 2026, 09:55:58 am »
That’s the interesting part — in my manual PLL implementation this code:
Code: [Select]
lo = cexpf(I * pll.phase);does not produce visible spurs.

Code: [Select]
// get phase [radians]
T NCO(_get_phase)(NCO() _q)
{
    if (_q->type == LIQUID_VCO_DIRECT) {
        return liquid_error(LIQUID_EICONFIG,"error: nco_get_phase(), "
                       "cannot be used with object type == LIQUID_VCO_DIRECT");
    }
    return TIL(2)*TFL(M_PI)*(T)_q->theta / (T)(1LLU<<32);
}

Note that _q->theta is the phase at full 32-bit precision of the phase accumulaor, not yet truncated to its upper 10 bits, which act as index into the wavetable.
[ phase range [0...2*pi) maps to theta range [0...2^32) ]

Your cexpf(I * pll.phase) keeps ~24-bit precision (single precision float) for the phase, unlike the 10-bit table lookup.

Did you already try LIQUID_VCO_INTERP?
« Last Edit: April 24, 2026, 10:21:02 am by gf »
 

Offline radiolistenerTopic starter

  • Super Contributor
  • ***
  • Posts: 5745
  • Country: Earth
Re: WFM Stereo Broadcasting Standards - OIRT vs CCIR
« Reply #62 on: May 13, 2026, 01:02:04 pm »
Here is the current PLL behavior (my implementation) on a real OIRT signal previously posted by Njk in this thread.

Does this look like normal PLL operation, or is something wrong?

The large phase error fluctuations are caused by the presence of DSB from L-R components after applying the BPF to composite.

Parameters:
fs = 96 kHz
PLL loop bandwidth = 5 Hz

BPF 183 taps:
f0 = 31250 Hz
cutoff = ±50 Hz
transition width = 5000 Hz
stop-band attenuation = 60 dB

If the BPF transition width is reduced to 100 Hz, the phase error fluctuations disappear and the PLL produces a nice smooth curve. However, the BPF length then grows to about 9065 taps, which is impractical. Increasing the BPF from 183 to 9065 taps does not noticeably improve stereo separation — the only visible difference is that the lock metric becomes cleanly equal to 1.

The current demux implementation provides about 60 dB L/R channel separation for OIRT and about 100 dB for CCIR. However, I suspect the limitation may come from the de-emphasis response curve, due to slight phase/amplitude mismatch between the pre-emphasis and de-emphasis IIR filters. This still needs to be tested.
« Last Edit: May 13, 2026, 01:55:28 pm by radiolistener »
 

Offline gf

  • Super Contributor
  • ***
  • Posts: 1832
  • Country: de
Re: WFM Stereo Broadcasting Standards - OIRT vs CCIR
« Reply #63 on: May 13, 2026, 03:11:24 pm »
Do you still use atan phase detector? Then I guess this might be the problem. It needs a clean, pre-filtered pilot. A mulitiplying detector (mixer) should not need it -- the loop filter does the filtering.

It can't lock half vco frequency to pilot, though, so you may need an NCO that generates pilot sin (for pd) and cos (for stereo detection) and subcarrier cos waves simultaneously.

EDIT: The last sentence is, of course, not relevant for OIRT, where we don't have a separate pilot tone with half subcarrier frequency.
« Last Edit: May 14, 2026, 06:38:40 am by gf »
 

Offline radiolistenerTopic starter

  • Super Contributor
  • ***
  • Posts: 5745
  • Country: Earth
Re: WFM Stereo Broadcasting Standards - OIRT vs CCIR
« Reply #64 on: May 13, 2026, 05:39:37 pm »
I experimented with several different phase detectors. Some of them can work reasonably well for CCIR stereo, but OIRT turned out to be much more sensitive to accurate carrier phase recovery. Even relatively small phase errors noticeably degrade the L-R demodulation.

Because of that, I currently use a conjugate mixer to shift the pilot to DC and then calculate the phase error with atan2(). In my experiments this produced the best results for OIRT stability and stereo separation.

You may be right that I am missing some simpler or more lightweight approach, but so far all alternatives I tested gave worse results on real OIRT recordings, especially regarding phase accuracy.


Here is my current PLL implementation in C++ MEX for Octave.
In its current form, it only shows the PLL acquisition behavior for a clean synthetic sine wave. However, you can replace it with loading an audio file containing a real carrier signal for testing — examples of such file loading are already present in the code as commented sections.

pll_process_mex.cpp:
Code: [Select]
// linux: mkoctfile --mex pll_process_mex.cpp
#include "mex.h"
#include <cmath>
#include <complex>


typedef struct {
    float   phase;   // radians
    double  freq;    // radians/step
    float   err;
    std::complex<float>  lo;
    float   lock_avg;
    bool    is_lock;
   
    float   alpha;
    float   beta;

    double  min_freq;
    double  max_freq;
} pll_t;

static inline float mod_2pi(float x) {
         if (x >  M_PI) return x - (2.0 * M_PI);
    else if (x < -M_PI) return x + (2.0 * M_PI);
    else return x;
}

static void pll_init(pll_t &pll, double loop_bw, double min_freq, double max_freq) {
    if (loop_bw < 0) {
        throw std::out_of_range("pll_init: invalid bandwidth. Must be >= 0.");
    }
    pll.phase = 0.0f;
    pll.freq  = 0.0f;
    pll.err   = 0.0f;
    pll.lo    = std::exp(std::complex<float>(0.0f, pll.phase));
    pll.lock_avg = 0.0;
    pll.is_lock  = false;

    pll.max_freq = max_freq;
    pll.min_freq = min_freq;

    // update gains
    double damping = sqrt(2.0) / 2.0;
    double nfactor = (1.0 + 2.0 * damping * loop_bw + loop_bw * loop_bw);
    pll.alpha = (4.0 * damping * loop_bw) / nfactor;
    pll.beta  = (4.0 * loop_bw * loop_bw) / nfactor;
}

static inline void pll_update(pll_t &pll, std::complex<float> input) {
    // oscillator
    float sin, cos;
    sincosf(pll.phase, &sin, &cos);
    pll.lo = std::complex<float>(cos, sin);
   
    // phase detector
    std::complex<float> pdiff = input * std::conj(pll.lo);
    pll.err = atan2f(pdiff.imag(), pdiff.real());

    pll.freq += pll.beta * pll.err;             // freq update
    pll.freq = std::min(std::max(pll.freq, pll.min_freq), pll.max_freq);    // freq clamp

    pll.phase += pll.freq + pll.alpha * pll.err;// phase update
    pll.phase = mod_2pi(pll.phase);             // phase wrap

    // lock detect
    float detect = std::abs(pll.err);                       // abs(err)
    detect = 1.0f - std::min(detect, 1.0f);                 // clamp 0..1 and invert
    pll.lock_avg += pll.alpha * (detect - pll.lock_avg);    // IIR LPF
    if (pll.is_lock && pll.lock_avg < 0.4f) {
        pll.is_lock = false;
        fprintf(stderr, "PLL loss\n");
    } else if (!pll.is_lock && pll.lock_avg > 0.6f) {
        pll.is_lock = true;
        fprintf(stderr, "PLL lock\n");
    }
}

//----------------------------------------------------------------------

void mexFunction(int nlhs, mxArray *plhs[], int nrhs, const mxArray *prhs[]) {
    if (nrhs != 4) {
        mexErrMsgTxt("Usage: [output, err, lock] = pll_process_mex(input, loop_bw, min_freq, max_freq)");
    }
    const mxArray *in = prhs[0];

    double *x_re = mxGetPr(in);
    double *x_im = mxGetPi(in);
    if (!x_im) {
        mexErrMsgTxt("input must be complex");
    }

    double loop_bw  = mxGetScalar(prhs[1]);
    double min_freq = mxGetScalar(prhs[2]);
    double max_freq = mxGetScalar(prhs[3]);

    mwSize N = mxGetNumberOfElements(in);

    // Create output arrays
    plhs[0] = mxCreateDoubleMatrix(N, 1, mxCOMPLEX);
    double *y_re = mxGetPr(plhs[0]);                  // output
    double *y_im = mxGetPi(plhs[0]);
    plhs[1] = mxCreateDoubleMatrix(N, 1, mxREAL);     // err
    double *err_re = mxGetPr(plhs[1]);   
    plhs[2] = mxCreateDoubleMatrix(N, 1, mxREAL);     // lock
    double *lock_re = mxGetPr(plhs[2]);   

    // init
    pll_t pll;
    pll_init(pll, loop_bw, min_freq, max_freq);

    for (mwSize n = 0; n < N; n++) {
        pll_update(pll, std::complex<float>(x_re[n], x_im[n]));
        y_re[n]   = pll.lo.real();
        y_im[n]   = pll.lo.imag();
        err_re[n] = pll.err;
        //lock_re[n]= pll.is_lock;
        lock_re[n]= pll.lock_avg;
    }
}

And here is Octave test code for PLL:
Code: [Select]
pkg load signal;

fs = 384000;        % sample rate
f0 = 31250;         % OIRT pilot

N = fs;             % 1 sec test
t = (0:N-1)'/fs;

% --- test signal: pure pilot tone ---
phi0 = pi/3;       % initial phase of pilot in radians
pilot = cos(    (2*pi*f0*t + phi0));

%[pilot, fs] = audioread('/home/pi/tmp-oirt/50k/test-oirt-1-lpf96k.wav', [1 N]);      % read file
%[pilot, fs] = audioread('../../DATA/test-oirt-composite-50us.flac', [1 N]);          % read file
if size(pilot, 2) ~= 1
    error('Input file must be mono composite');
end
N = length(pilot);


%---UTILS---------------------------------------------------------------
% Kaiser beta estimator
function bta = firdes_kaiser_beta(A)
    % A - sidelobe suppression in dB
    if (A >= 50)
        bta = 0.1102 * (A - 8.7);
    elseif (A < 50 && A > 21)
        bta = 0.5842 * (A - 21)^0.4 + 0.07886 * (A - 21);
    else
        bta = 0;
    end
end
% FIR Kaiser ntaps estimator
function ntaps = firdes_kaiser_ntaps(fs, df, A)
    dw = 2*pi * df / fs;
    if dw <= 0
        error('df must be > 0 Hz');
    end
    ntaps = max(1, ceil((A-7.95)/(2.285*dw)));
end
% FIR real LPF (FIR Type I)
function taps = firdes_real_lpf(gain, fs, fc, tw, A)
    N = firdes_kaiser_ntaps(fs, tw, A);
    % force odd number of taps for Type I FIR
    % (odd length, symmetric impulse response, LPF/BPF)
    if mod(N,2)==0
        N = N + 1;
    end
    M = floor((N-1)/2);
    % ideal sinc LPF impulse response
    wc = 2*pi*fc/fs;
    taps = zeros(1, N);
    n=-M:M;
    idx = (n ~= 0);
    taps(idx)  = sin(wc*n(idx)) ./ (pi*n(idx)); % h(n) = sin(wc*n)/(pi*n)
    taps(~idx) = wc/pi;                         % h(0) = wc/pi
    % apply Kaiser window
    taps = taps .* kaiser(N, firdes_kaiser_beta(A)).';
    % normalize DC gain
    fmax = taps(M+1) + 2*sum(taps(M+2:end));
    gain = gain / fmax;
    taps = taps * gain;
end
% FIR complex BPF
function taps = firdes_complex_bpf(gain, fs, f1, f2, tw, A)
    % prototype lpf
    lpf_taps = firdes_real_lpf(gain, fs, (f2-f1)/2, tw, A);
    fc = (f1 + f2)/2;       % center freq [Hz]
    wc = 2*pi*fc/fs;        % center freq [rad/sample]
    N = length(lpf_taps);
    if mod(N,2)==0
        error('firdes_complex_bpf: odd taps LPF expected');
    end
    M = floor((N-1)/2);
    n = -M:M;
    taps = lpf_taps .* exp(1j * wc * n);    % shift to wc
end

% plot signal FFT with Kaiser window
% A - sidelobe suppression in dB
function plot_fft_kaiser(x, fs, A, text)
    N = length(x);
    % truncate to NPOT
    NPOT = 2^floor(log2(N));
    NPOT = min(NPOT, 2^22);
    if N ~= NPOT
        N = NPOT;
        x = x(1:N);
    end
    fprintf('FFT: %g kpts\n', N/1024);
    xw = x(:) .* kaiser(N, firdes_kaiser_beta(A)); % apply window
    x_fft = fftshift(fft(xw));
    mag = abs(x_fft);
    mag = mag / max(mag + eps);     % normalize
    mag_db = 20.0*log10(mag + eps);
    f = ((0:N-1) - floor(N/2)) * fs/N;
    % autoscale freq axis
    limits = [1e7 1e4 0]; scales = [1e6 1e3 1]; snames = {'M','k',''};
    i = find(max(abs(f)) >= limits, 1, 'first');
    if isempty(i), i = numel(scales); end
    f = f / scales(i);
    funit = sprintf('%sHz', snames{i});
    % plot
    figure;
    plot(f, mag_db);
    grid on; grid minor;
    set(gca, 'gridalpha', 0.2); set(gca, 'minorgridalpha', 0.05);
    xlabel(funit);
    ylabel('dB');
    xlim([min(f) max(f)]);
    ylim([min(mag_db)-6 max(mag_db)+6]);
    title(sprintf('%s\nFFT %g kpts, fs=%g kS/s', text, N/1024, fs/1000));
end

% Ensure MEX is built and up-to-date
function ensure_mex_uptodate(src_name)
    [pathstr, name, ext] = fileparts(src_name);
    if isempty(ext)
        error('Source file must include extension');
    end
    mex_name = [name, '.', mexext()]; %mex_name = fullfile(pathstr, [name, '.', mexext()]);   
    src = dir(src_name);
    mex = dir(mex_name);
    if isempty(src)
        error('Source file not found: %s', src_name);
    end
    if isempty(mex) || src.datenum > mex.datenum    % Rebuild if needed
        fprintf('Compile %s...\n', src_name);
        status = system(sprintf('mkoctfile --mex %s', src_name));
        if status ~= 0
            error('MEX compile failed');
        end
    end
end
%-----------------------------------------------------------------------


% --- initialize PLL/BPF ---
pll_bw   = 5;
pll_minf = f0-50;
pll_maxf = f0+50;
bpf = firdes_complex_bpf(1.0, fs, pll_minf, pll_maxf, 5000.0, 60);
bpf_delay = (length(bpf) - 1) / 2; % bpf_delay = mean(grpdelay(bpf));
fprintf('FIR BPF: %d taps, delay = %f samples\n', length(bpf), bpf_delay);
bpf_delay = floor(bpf_delay);

%freqz(bpf, 1, fs, 2^16);


% apply BPF
pilotc = complex(pilot*2.0, 0);         % to complex with amp compensation (mirror will be removed with BPF)
pilotc = filter(bpf, 1, pilotc);
fprintf('bpf done\n');

% fix for BPF delay
pilotc = [pilotc(1+bpf_delay:end); zeros(bpf_delay, 1)];


ensure_mex_uptodate('pll_process_mex.cpp');
[pll_lo, err, lock] = pll_process_mex(pilotc, pll_bw * 2.0*pi/fs, pll_minf * 2.0*pi/fs, pll_maxf * 2.0*pi/fs);
fprintf('pll done\n');


% plot FFT
lock_idx = find(lock > 0.6, 1);
if ~isempty(lock_idx)
    plot_fft_kaiser(pll_lo(lock_idx:end), fs, 100, ...
        sprintf('loop\\_bw=%g, minf=%g, maxf=%g', pll_bw, pll_minf, pll_maxf));
end


aerr = real(pilotc) - pilot;

% --- plot Waveform ---
%t = 1:length(pilotc);          % sample number
t = (1:length(pilotc)) / fs;    % second

figure;
hold on;
%plot(t, real(pilotc),  'DisplayName', 'pilotc');
%plot(t, real(pll_lo),  'DisplayName', 'pll\_lo');
plot(t, aerr,           'DisplayName', 'amplitude-err');
plot(t, err,            'DisplayName', 'phase-err');
plot(t, lock,           'DisplayName', 'lock');
grid on; grid minor;
set (gca, 'gridalpha', 0.2); set(gca, 'minorgridalpha', 0.05);
xlim([min(t) max(t)]);
%xlim([95830 95863]); ylim([-1.1 1.1]);
ylim([-0.6 2.6]);
xlabel('sec');
legend();
title(sprintf('loop\\_bw=%g, minf=%g, maxf=%g', pll_bw, pll_minf, pll_maxf));
« Last Edit: May 13, 2026, 06:09:41 pm by radiolistener »
 

Offline gf

  • Super Contributor
  • ***
  • Posts: 1832
  • Country: de
Re: WFM Stereo Broadcasting Standards - OIRT vs CCIR
« Reply #65 on: May 14, 2026, 10:21:25 am »
I experimented with several different phase detectors. Some of them can work reasonably well for CCIR stereo, but OIRT turned out to be much more sensitive to accurate carrier phase recovery.

Thinking more deeply about this, it seems CCIR has a solid ±4 kHz gap around the pilot tone, which is PLL-friendly. OIRT is much tighter: assuming audio goes down to 30 Hz, you only have a ±30 Hz gap on each side of the subcarrier before the L-R audio subbands start. Consequently, this requires narrow pre-filtering in front of the PLL and/or a tight PLL loop bandwidth. This does not pose a problem for slow frequency drift, but it reduces the system's tolerance for following FM-induced subcarrier jitter, and locking at larger initial frequency offsets.

« Last Edit: May 14, 2026, 10:29:17 am by gf »
 

Offline radiolistenerTopic starter

  • Super Contributor
  • ***
  • Posts: 5745
  • Country: Earth
Re: WFM Stereo Broadcasting Standards - OIRT vs CCIR
« Reply #66 on: May 14, 2026, 12:20:46 pm »
Yes, I see the same thing — in CCIR the carrier is recovered much more cleanly because of the wider spectral gap around the pilot.

But the interesting part is that with the atan2-based detector, OIRT still works even with a very wide BPF transition width of about 5 kHz, while other phase detectors fail in that case. So apparently the atan2 detector is much less sensitive to the DSB components present around the carrier.

The main issue is that a moderate reduction of the BPF transition width does not produce any noticeable improvement. Significant improvement only starts to appear when the transition width is narrowed to around 100 Hz or less. However, this increases the filter length to more than 9000 taps, which is extremely expensive from a performance standpoint.

From what I can see, this would suggest that a very narrow filter is required. But at the same time the system appears to work reasonably well even with a 5 kHz transition width when using the atan2-based phase detector. So at first glance this does not seem like a problem that justifies such a massive increase in computational cost.

Are there any alternative approaches to improve this behavior without requiring such an extremely long BPF?
 

Offline gf

  • Super Contributor
  • ***
  • Posts: 1832
  • Country: de
Re: WFM Stereo Broadcasting Standards - OIRT vs CCIR
« Reply #67 on: May 14, 2026, 12:56:16 pm »
Which composite signal? Can you repost the audio file, or refer to the msg where it was already atached?
 

Offline radiolistenerTopic starter

  • Super Contributor
  • ***
  • Posts: 5745
  • Country: Earth
Re: WFM Stereo Broadcasting Standards - OIRT vs CCIR
« Reply #68 on: May 14, 2026, 03:51:11 pm »
Which composite signal? Can you repost the audio file, or refer to the msg where it was already atached?

I can't attach the original samples in good quality due to the attachment size limit.
Even with FLAC 24-bit compression, the file is 14 MB, while the limit here is just 8 MB.

Here is a link to the file-sharing host. They say the file should be available for 10 days:
- test-oirt-1-lpf96k.wav: https://gofile.io/d/DKwfAp
- test-oirt-2-lpf96k.wav: https://gofile.io/d/1VsR6s

Unfortunately, the quality is not very good since the signal was captured using an oscilloscope, but these are currently the only real off-air OIRT composite samples I have available.

The second sample (music) demuxes to stereo, but with noticeable frequency distortions. The first sample (voice) demuxes quite well overall, although there is some slight background noise.
 
The following users thanked this post: gf

Offline radiolistenerTopic starter

  • Super Contributor
  • ***
  • Posts: 5745
  • Country: Earth
Re: WFM Stereo Broadcasting Standards - OIRT vs CCIR
« Reply #69 on: May 14, 2026, 05:19:50 pm »
Here is an additional my synthetic sample that I generated in Octave.

But note, during generation, a pre-emphasis IIR filter H(z) = 1 + k - k*z^-1; where k = Fs * tau; (as used in CCIR systems) was applied instead of the frequency-shaping formula specified in the OIRT standard. Therefore, this sample should not be used as a reference, but it works and demultiplexes in stereo.

 
The following users thanked this post: gf

Offline gf

  • Super Contributor
  • ***
  • Posts: 1832
  • Country: de
Re: WFM Stereo Broadcasting Standards - OIRT vs CCIR
« Reply #70 on: May 15, 2026, 06:09:51 pm »
Yes, I see the same thing — in CCIR the carrier is recovered much more cleanly because of the wider spectral gap around the pilot.

But the interesting part is that with the atan2-based detector, OIRT still works even with a very wide BPF transition width of about 5 kHz, while other phase detectors fail in that case. So apparently the atan2 detector is much less sensitive to the DSB components present around the carrier.

The main issue is that a moderate reduction of the BPF transition width does not produce any noticeable improvement. Significant improvement only starts to appear when the transition width is narrowed to around 100 Hz or less. However, this increases the filter length to more than 9000 taps, which is extremely expensive from a performance standpoint.

From what I can see, this would suggest that a very narrow filter is required. But at the same time the system appears to work reasonably well even with a 5 kHz transition width when using the atan2-based phase detector. So at first glance this does not seem like a problem that justifies such a massive increase in computational cost.

Just a few thoughts on this. Trying to approach it analytically, it seems that an atan2 phase detector is well-suited for an AM-modulated analytic signal

    (1 + modulating_sig(t)) · e^(i·(ω·t + φ))  [ where ω is the carrier frequency ]

because atan2 is amplitude-independent. As long as modulating_sig(t) > -1 is granted, the following applies:

    angle( (1 + modulating_sig(t)) · e^(i·(ω·t + φ)) · e^(-i·ω·t) ) = φ  [ independent of modulating_sig :) ]

However, if modulating_sig(t) > -1 is not met, the detector suffers from 180° phase jumps/inversions (i.e. discontinuities) in the phase_error(t) signal.
Thus, for the atan2 phase detector to work properly with an AM signal, three conditions are IMO required:

1. The signal must be analytic (no negative frequencies).
2. Extraneous components like L+R must be filtered out.
3. Over-modulation must be avoided.

Conditions (1) and (2) are achieved if your complex bandpass filter has sufficient attenuation for frequencies < 15 kHz. However, condition (3) does not automatically follow. Once the subcarrier is reduced, the phase detector suffers from over-modulation (wrt. the reduced subcarrier) unless L−R happens to be weak enough.

And now the ominous transfer function K(f) from the standard comes into play. It specifies how the carrier and the sidebands near the carrier are reduced. Consequently, applying the inverse transfer function should undo this reduction and fully restore the original AM signal — complete with its full carrier and free of over-modulation.

While K(f) is defined as a shelved high-pass filter in the baseband, which is applied before modulation, it could equivalently be frequency-translated to the subcarrier frequency and applied after regular AM modulation. The same applies to the inverse of K(f) — it can also be frequency-shifted to the subcarrier frequency and applied before demodulation.

Basically, I think that any bandpass filter whose peak-aligned frequency response stays below the curve of the frequency-translated inverse of K(f) should achieve the goal of eliminating over-modulation. This leads us to a maximum bandwidth of ~324 Hz for the bandpass filter, assuming a complex 1st-order shape. Then, the attenuation at 15kHz becomes ~40dB, which I think is sufficient as well.

Quote
Are there any alternative approaches to improve this behavior without requiring such an extremely long BPF?

What about a 1st order complex IIR bandpass?

At resonance, the phase is exactly 0°. Sure, a frequency offset of the subcarrier introduces a static phase shift, and any phase offset of the recovered subcarrier translates into a cosine amplitude error in the demodulated L-R path, which in turn limits channel separation. However, the impact is likely acceptable. Even with a 5° phase offset, the channel separation remains at over 54 dB. With a bandpass bandwidth of ~300 Hz, a 5-degree phase budget maps to a frequency tolerance of roughly ±450 to ±500 ppm.
 
The following users thanked this post: radiolistener

Offline gf

  • Super Contributor
  • ***
  • Posts: 1832
  • Country: de
Re: WFM Stereo Broadcasting Standards - OIRT vs CCIR
« Reply #71 on: May 16, 2026, 02:27:09 pm »
Are there any alternative approaches to improve this behavior without requiring such an extremely long BPF?

Yet another idea:

At (say) 10 Hz loop bandwidth, updating the PLL 384,000 times per second is overkill.
Doing 100–200 updates per second should be enough to sample the error signal properly.

Here is the proposed approach:

1. Multiply the (real) input stream with the conjugate of the complex NCO (down-mix).
2. Divide the down-mixed stream into blocks of 2048 samples each.
3. Apply a 2048-tap (periodic) Hann window to each block and sum the complex samples up.
4. Calculate the phase error = angle(sum) at the end of each block to update the PLL.

Assuming a loop bandwidth of 10 Hz, the additional intra-loop delay of approximately 2.7 ms (1024 samples) degrades the phase margin by ~9°. This reduction is IMO acceptable and should not induce loop oscillations. Although alternative window functions or FIR kernels could be used in step 3, I think that a Hann window is a good trade-off. It has a 60 dB/decade asymptotic roll-off, ensuring more than 100 dB attenuation at <= 15 kHz, despite a narrower equivalent noise bandwidth compared to other windows (except rectangular which is not an option anyway). Moreover, with 2048 taps, the resulting -3 dB bandwidth is ~270 Hz. Since this is narrower than the inverse of K(f), I think it will suffice to "over-undo" K(f) in order to eliminate over-modulation.

EDIT: Unlike an out-of-loop pre-filter, this filter is embedded inside the loop.
Consequently, the VCO remains in-phase with the input signal and suffers no filter time delay.

EDIT: Naturally, the PLL loop filter parameters must be adjusted to account for the lower fs/2048 update rate (~187.5 Hz) to maintain the target 10 Hz loop bandwidth.
« Last Edit: May 16, 2026, 03:13:28 pm by gf »
 
The following users thanked this post: radiolistener

Offline gf

  • Super Contributor
  • ***
  • Posts: 1832
  • Country: de
Re: WFM Stereo Broadcasting Standards - OIRT vs CCIR
« Reply #72 on: May 18, 2026, 10:29:36 am »
Here is an additional my synthetic sample that I generated in Octave.

My own PLL attempt can lock to your sample virtually perfectly. Residual ripple in the phase detector output is less than a millidegree (before loop filter).

The other two recorded samples also do lock-in and stay locked. I don't see phase jumps and inversions.
But ripple in phase detector output is up to 10-20° peak :(
I've no idea yet what specific "anomaly" of the signal is the root cause :-//

I have not yet tried to demodulate.

 

Offline radiolistenerTopic starter

  • Super Contributor
  • ***
  • Posts: 5745
  • Country: Earth
Re: WFM Stereo Broadcasting Standards - OIRT vs CCIR
« Reply #73 on: May 19, 2026, 03:34:21 am »
I found a synthetic recording which appears to have been generated by Stereo Tool, and it seems to produce an incorrect OIRT multiplex. I do not see the expected polar channel separation in the waveform.

However, the file is interesting for another reason. My decoder correctly demodulates the initial stereo fragments that are present only in the left or only in the right channel, i.e. when the L-R signal appears. But at the same time, the PLL lock detector suddenly collapses because the phase detector output starts producing extremely large swings, almost reaching min/max excursions.

I do not fully understand what could produce such huge phase error spikes. The file is clearly generated incorrectly in some way, but it is not obvious what exactly is wrong with it that causes the phase detector to behave so violently.

What is even more interesting is that if the BPF transition width is narrowed to about 100 Hz, the PLL locks cleanly and the phase error spikes disappear completely. So the issue is clearly related to the nearby SSB sidebands somehow severely disturbing the phase estimate.

It is interesting to understand why these sidebands are able to corrupt the detected phase so strongly in this particular signal.

As for this strange file, a look at the waveform clearly shows that there are issues with the carrier amplitude, and the characteristic polar channel separation is missing. That polar separation is one of the key features of the OIRT multiplex, so its absence strongly suggests that the signal was generated incorrectly. But the main question here is what exactly is happening inside the phase detector itself (mix-down + atan2). That is the part which currently looks the most puzzling.

You mentioned earlier that over-modulation could potentially cause this kind of behavior. Could this perhaps be exactly such a case?

As far as I understood from the Stereo Tool author’s comments, he attempted to implement the frequency-shaping formula described in the standard. Perhaps this is exactly where the issue originates. What do you think?
« Last Edit: May 19, 2026, 03:38:07 am by radiolistener »
 

Offline gf

  • Super Contributor
  • ***
  • Posts: 1832
  • Country: de
Re: WFM Stereo Broadcasting Standards - OIRT vs CCIR
« Reply #74 on: May 19, 2026, 10:11:47 am »
I found a synthetic recording which appears to have been generated by Stereo Tool, and it seems to produce an incorrect OIRT multiplex. I do not see the expected polar channel separation in the waveform.

However, the file is interesting for another reason. My decoder correctly demodulates the initial stereo fragments that are present only in the left or only in the right channel, i.e. when the L-R signal appears. But at the same time, the PLL lock detector suddenly collapses because the phase detector output starts producing extremely large swings, almost reaching min/max excursions.

I do not fully understand what could produce such huge phase error spikes. The file is clearly generated incorrectly in some way, but it is not obvious what exactly is wrong with it that causes the phase detector to behave so violently.

What is even more interesting is that if the BPF transition width is narrowed to about 100 Hz, the PLL locks cleanly and the phase error spikes disappear completely. So the issue is clearly related to the nearby SSB sidebands somehow severely disturbing the phase estimate.

It is interesting to understand why these sidebands are able to corrupt the detected phase so strongly in this particular signal.

These are clearly phase inversions that are still left over after filtering.

Are you sure that the K(f) filter, as specified in the standard, was correctly applied to the L-R signal prior to subcarrier modulation? Because K(f) is a shelved highpass filter that attenuates low frequencies by up to 14 dB, it naturally attenuates the close-in sidebands near the subcarrier correspondingly.

Once your bandpass filter becomes narrower than the lowest audio frequency in the recording, you end up eliminating the sidebands completely, which explains why this approach works. However, with proper transmitter-side K(f) filtering, I think that a ~300 Hz bandpass should generally suffice, independent of the lowest audio frequency. BTW, the bandpass response should be bell-shaped rather than flat-topped, as the latter is counterproductive for interaction with the K(f) frequency response.

EDIT: Deletet potentially wrong statement - need to re-think.

EDIT: Regarding my point that "a 300 Hz bandpass should generally suffice": it is not merely a matter of bandwidth, but also of the filter's shape. The condition for atan2 phase detector stability is | 5 * K(f) * HLP(f) | <= 1, for any frequency f, where HLP represents the frequency response of the lowpass prototype of the filter in front of the phase detector. When using a Hann window (what I do), the minimum number of taps required is 0.003974 * Fs (-> 763 @192 kSa/s). Prerequisite is, of course, that the transmitter does K(f) filtering.

The worst-case test signal is certainly one where R = -L applies, at 100% level, but it is not possible to specify a particular worst-case audio frequency, since for an arbitrary HLP(f), the condition | 5 * K(f) * HLP(f) | could become >= 1 at any frequency.

Here's what I assume on the TX side:

Code: [Select]
L = ...
R = ...

% TODO: pre-emphasis - not relevant for this consideration

% K(f) shelved highpass, reduces the subcarrier and close-in sidebands
tau = 4.6 / (2000 * pi);
[b,a] = bilinear([tau 1], [tau 5], 1/Fs);

composite = 0.8*(L+R)/2 + filter(b,a,1+0.8*(L-R)/2) .* cos(2*pi*fsub*t);
« Last Edit: May 19, 2026, 01:18:08 pm by gf »
 


Share me

Digg  Facebook  SlashDot  Delicious  Technorati  Twitter  Google  Yahoo
Smf

 

-->