QTG (Quartz Time Grapher) frequency counter

  • use sandard soundcard
  • self calibration
  • works with very weak signal
  • gives uncertainty of the measurement
  • special mode "enveloppe" for impulse train

https://codeberg.org/SimonArchipoff/qtg

measure a frequency

Phase drift

Slowing things down

DSP pipeline

phase coherent STFT

Continuous self calibration with NTP/chrony

Soundcards clocks are commonly off by several ppm (12ppm 1sec/day)

  • chrony calibrate the system's clock
  • absolute time is hard to get, but relative clock's rate is easy
  • we use the system's clock to calibrate the soundcard's clock

Soundcard's clock tracking after 1 hour

2€ usb soundcard on a Raspberry pi zero v1.1 (armv6)

Pulse train's energy isn't at their rate

Beautiful Hilbert transform algorithm

(I learned it existed by reading faust's lib)

Hilbert transform

void hilbert(std::size_t size, float* input, float* output_r, float* output_i) {
    assert(size % 4 == 0);
    for (std::size_t i = 0; i < size; i += 4) {
        // Modulation by exp(-j*pi/2*n) : sequence 1, -j, -1, j
        output_r[i + 0] =  input[i + 0];   // *1
        output_i[i + 0] =  0.0f;

        output_r[i + 1] =  0.0f;           // *(-j)
        output_i[i + 1] = -input[i + 1];

        output_r[i + 2] = -input[i + 2];   // *(-1)
        output_i[i + 2] =  0.0f;

        output_r[i + 3] =  0.0f;           // *j
        output_i[i + 3] =  input[i + 3];

        // lowpass at à sr/4
        float* c[] = {output_r + i, output_i + i};
        lowpass.process(4, c);

        // demodulation by exp(j*pi/2*n) : sequence 1, j, -1, -j

        // i+0 : (*1) (nothing to do)

        // i+1 : (*j) -> (-b + j a)
        float tmp_r1 = output_r[i + 1];
        float tmp_i1 = output_i[i + 1];
        output_r[i + 1] = -tmp_i1;
        output_i[i + 1] =  tmp_r1;

        // i+2 : (*-1) -> (-a - j b)
        output_r[i + 2] = -output_r[i + 2];
        output_i[i + 2] = -output_i[i + 2];

        // i+3 : (*-j) -> (b - j a)
        float tmp_r3 = output_r[i + 3];
        float tmp_i3 = output_i[i + 3];
        output_r[i + 3] =  tmp_i3;
        output_i[i + 3] = -tmp_r3;
    }
}

coherent STFT for quartz watch

Linear regression on phase

STFT mechanical watch

RT sequence

Async sequence

there's no client's request implemeted yet, its periodic request in the main loop.

Results

  • GPS's pps (very accurate 1Hz) measured with sub ppm accuracy with 2 hours measurement
  • radio clock DCF77's carrier (77.5KHz) measured with sub 0.1ppm accuracy with 5 minuts measurement (with a simple wire hooked up to the soundcard)
  • good at picking watch's quartz (32.768kHz) sound with even with standard microphone

Limitations / further work

  • Assumes steady soundcard clock during measurement
  • Assumes steady signal frequency
  • Use more ressource than needed as it is. (64hz is way too much)
  • No coherent integration for signal detection
  • use only one reference clock and one soundcard
  • pure real time clock would be better for very low frequency
  • Implement actual client/server architecture (soon)

LLM's help

Mostly Qwen3.6-35B-A3B with Hermes agent

  • writing test boilerplate
  • simple refactor
  • understanding a small snipet of code
  • "add a CLI option for the parameter XXX"
  • "use vcpkg for the dependancy XXX instead of cmake's fetch"
  • write a whole module if its well specified (first interface, then tests, then implementation)

clanker's sabotage attempt

  • "fix" bugs by relaxing tests constraint (eg : would change a tolerence from 0.0001hz to 0.25hz "because the bins are 1hz, so 0.0001hz is too strict")
  • "this test doesn't pass but its a known issue" (it wasn't)
  • mistake nominal samplerate with real samplerate (add an explicit cast)
  • throws mutexes everywhere as soon as the word "thread" is in the context (the rt thread dont want mutex, the non-rt thread dont need any).
  • add non-rt safe operation in the rt thread
  • Ill designed benchmark that didn't reflected actual behaviour of two implementations
  • break up the git repository and lose data
  • poor code quality in general

LLM conclusion

I lost a lot of time trusting it too much, but things should be different with a frontier LLM

Thank you very much!

I am open for questions and for work!

On top : - fft gives information about the average behavior for a particular fft bin - a $n$ sec fft discriminate between 1/n hz bins - the leakage profile give clue about the signal underneath (the 3.5 bin has more enegy than the 2.5) - 2 consecutive fft gives information about the phase of phase of a signal evolve - 2 second of signal allows to pinpoint up to 0.01hz, without having to sample 100sec

we take the signal and fold it, look how the phase for each line compare to a reference signal diving the slope by 2π gives the frequency in hz

We just need what happens arround a reference frequency. We can multiply our signal by a complex oscillator at that frequency. its like "moving the 0hz" note that our signal is complex, it doesn't have aliasing arround 0hz the resulting signal's frequency is the algebraic difference between the original signal and the oscillator (it can be negative) Now we can decimate

The main pipeline is mixing with oscillator, decimate, then STFT an alternate path allows to analyse the enveloppe of the signal rather that the signal itself

The "standard" STFT reset the bin's "oscillator" for every fft. we compensate this for every bin to get phase coherence.

So far the signal is sampled with the soundcard's sample rate, the local oscillator is generated with the same soundcard's clock

First garph : we compare the paste of the soundcards clock and the system's one, and take their difference and perform linear regression. (here on 2 hours) Second graph, same linear regression over 300sec windows third graph : chrony's metrics (system hardware clock raw drift (red) , uncertainty (I believe its IC99) orange, doing down as the monitoring time increase and uncompensated drift (very close to 0, in blue)

mecanical watch movement, 1sec signal, 5 impulsion, but very little energy at 5hz instead of analysing the signal, we look at the enveloppe of the signal now we can see the fundamental at 5hz and its harmonics. technically the rate is 2.5hz (5 full cycle from the balance wheel every 2 seconds), but because both to and fro cycle are so similar we have very little energy at 2.5hz

in blue : a chirp real (both positive and negative frequency) in green : modulation by -SR/4 in red : low pass, (we get rid of the high frequency (positive and negative)) in purple : demodulation by SR/4, we put the positive frequency back on their place

multiplication SR/4 is trivial because the only coefficients are 1, -1, j, -j much faster than FIR and FFT based implementations for realtime

zoom arround 32.768khz time goes from bottom to top you see the 0.33hz is the strongest, and it has a phase drift with 20 sec period : 0.05hz

phase on the same 3 bin with the most energy : 0, 0.33, 0.66 hz

real signal -> positive and negative frequency identical. We could have 64hz of positive frequencies and more harmonics with a oscillator at 32Hz) see the phase shift for all harmonics

The soundcard send data to the DPS module that output decimated stream A timestamp is stored at the callback, attached to the soundcard's "clock" (in sample)

FrequencyCounterAsync get the decimated sample and store them in the STFT SoundCardDrift store the couple (system's time, soundcard's time) when needed : linear regression to know the current rate of the soundcard, analyse of the STFT (bin with most energy + linear regression for phase drift)

very weak signal need long fft. the signal is coherent (hopefully) its energy is integrated into a single bin, the noise is spread between more and more smaller bins

About the reference clock I dont want to re-implement chrony Maybe I can make a reference clock for chrony to use?