FM DDS: First Light Hardware

Some Barely Viable Prototype hardware for a frequency modulated DDS to replace Channel Elements requiring now-unobtainable crystals:

FM DDS - First Light layout
FM DDS – First Light layout

The heatsink (surely harvested from a PC, then salvaged from a box o’ goodies) runs about 25 °C above ambient while dropping a 12 V input to 5 V at 180 mA, so it’s good for maybe 2°C/W. It carries a KA278RA05C LDO regulator; you’d probably want something fancier in real life.

The AD9851 DDS requires a 5 V supply to run at 180 MHz from the 30 MHz oscillator on its PCB, with the side effect of putting its minimum Logic 1 Voltage threshold at 3.5 V. Because the Teensy 3.6 runs at 3.3 V from its own on-board linear regulator, the DIP 74AHCT125 level shifter between the two boosts the Teensy’s LVCMOS SPI signals to good old TTL.

The sticker on the CPU reminds me of the jumper cut between the USB +5 V line and the VIN pin, thus putting the Teensy on the better-regulated local supply for the benefit of its ADC reference:

Teensy 3.6 Back - VIN to VUSB jumper
Teensy 3.6 Back – VIN to VUSB jumper

The picture comes from PJRC’s exceedingly helpful Teensy 3.6 reference cards.

I ran header pins along both sides of the Teensy to simplify attaching scope probes and suchlike; the dangling gray wire brings the scope’s Arbitrary Function generator signal to the Teensy’s A9 input.

The FMDDS Mock 3 firmware lit right up, albeit with the faceplant of sending the SPI bytes in the wrong order and the wrong bit direction, which was easily fixed after a bit of puzzling:

FM DDS 10 MHz - SPI 16 MHz LSB
FM DDS 10 MHz – SPI 16 MHz LSB

Just a typo, could happen to anyone …

FM DDS: SPI Mock 3

Running some serial I/O in the background adds jitter to the timer interrupt pacing the ADC samples and as-yet-unwired DDS updates. For reference, an overview of the process showing the procession from the IRQ on the left to the SPI outputs near the middle and another IRQ on the far right:

DDS Mock - 0 VAC - SPI
DDS Mock – 0 VAC – SPI

Now, speed up the sweep and delay the trace by 25 μs to put the triggering pulse off-screen to the left and the second pulse at the center division:

ADC Sample IRQ jitter
ADC Sample IRQ jitter

The orange smear in the middle should be a tidy pulse, but it isn’t.

The  25 μs timer interrupt now has the highest priority on the front burner:

IntervalTimer AudioSampler;

... snippage ...

  AudioSampler.priority(0);
  if (!AudioSampler.begin(AudioSamplerIRQ, SamplePeriod)) {
    Serial.printf("Timer start failed\n");
    while (true) {
      FlipPin(BUILTIN_LED);
      delay(75);
    }
  }

Although nothing can interrupt it, other code / handlers may disable interrupts around their own critical sections and delay the tick. If the triggering tick (the off-screen one starting the trace) is delayed, then the on-screen pulse will appear “too soon”, to the left of center. If the triggering tick is on time, but the on-screen pulse is delayed, it’ll appear “too late” on the right.

The blur is (roughly) symmetric around the center graticule line, so the handwaving seems about right.

In round numbers, the jitter moves the interrupt ±325 ns on either side of its nominal position, with most of the pulses within ±100 ns. I doubt the jitter distribution is Gaussian, but vigorous handwaving says the RMS jitter might amount to 75 ns.

At the 4 kHz audio band limit, a 75 ns sampling error a phase error of 0.1°, so the maximum amplitude jitter would be sin(0.1°) = 0.002 = -55 dB, which might suffice for amateur-radio audio.

I think, anyhow.

Teensy 3.6 USB Serial Startup

The Arduino Serial doc says the USB hardware on the (now obsolescent) Leonardo requires a test-for-open before using the serial port:

  Serial.begin(9600);
  while (!Serial) {
    ; // wait for serial port to connect. Needed for native USB
  }
}

As it happens, you must also use that test on the ARM-based Teensy 3.6.

The gotcha happens when the USB port doesn’t become available, in which case the conditional remains true and the loop continues forever, which is precisely what happened when I powered the Teensy from a USB battery pack on the Squidwrench Operating Table.

After some flailing around, this startup snippet falls through after ahem awhile:

#define BUILTIN_LED 13

... snippage ...

Serial.begin(115200);

int waited = 0;
while (!Serial && waited < 3000) {
  delay(1);
  waited++;
  if (! (waited % 50))
    FlipPin(BUILTIN_LED);
}

... snippage ...

Serial.printf(" serial wait: %d ms\n\n",waited);

The serial startup delay seems to vary unpredictably between 800 and 1800 ms, so 3000 ms may be too short:

serial wait: 1033 ms
serial wait: 899 ms
serial wait: 907 ms

The ARM Teensy connects the board's built-in LED to the same SPI clock as on the AVR Arduinos, so it's only useful during startup, but having some hint will come in handy the next time it jams for another reason.

FM DDS: SPI Mock 2

Doing the DDS calculations in full-frontal double floating point turns out to be maybe fast enough:

DDS Mock - 0 VAC - SPI
DDS Mock – 0 VAC – SPI

I set the ADC to HIGH_SPEED conversion and sampling, reducing the time between the start of conversion (first pulse in D1) and the ADC end-of-conversion interrupt (rising edge in D2) from 4.7 μs to 2.6 μs, more-or-less, kinda-sorta.

The ADC hardware can return the average of several sample taken in quick succession, so I set it to average four samples. The vertical cursors show the combination of fast conversion and averaging requires 7 μs (-ish) from start to finish: long enough to justify separating the two by an interrupt and short enough to allow calculations after fetching the result.

The purple trace shows the analog input voltage hovering close to a constant VCC/2 (about 1.6+ V), rather than the sine-wave I used earlier, again courtesy of the scope’s arbitrary function generator. The loop() dumps the min and max ADC values (minus half the ADC range (4096/2= 2048):

    -4 to     2
    -3 to     2
    -3 to     2

A span of half a dozen counts = 3 bits means the 12 bit ADC really delivers 9 bits = 0.2% resolution = 54 dB dynamic range = probably not good enough. However, the “circuit” is an open-air hairball on the bench, driven from the scope’s arbitrary waveform generator in high-Z mode, so things can only get better with more any attention to detail.

The 1.9 μs gap between the first and second burst of SPI clocks contains all the floating-point calculations required to convert an ADC sample to DDS delta-phase bits:

void adc0_isr(void) {

  int Audio;

  digitalWriteFast(PIN_ANALOG,HIGH);

  AnalogSample = adc->readSingle();             	  // fetch just-finished sample
  Audio = AnalogSample - 2048;                      // convert to AC signal

  DDSBuffer.Phase = 0;

  SPI.beginTransaction(SPISettings(8000000, MSBFIRST, SPI_MODE0));
  digitalWriteFast(PIN_DDS_FQUD, LOW);

  SPI.transfer(DDSBuffer.Phase);

  DDSBuffer.DeltaPhase = (uint32_t)((((double)Audio / 2048.0) * Deviation + Crystal) * CountPerHertz);

  SPI.transfer((uint8_t)(DDSBuffer.DeltaPhase >> 24));      // MSB first!

  if (Audio > AudioMax)                                     // ignore race conditions
    AudioMax = Audio;
  if (Audio < AudioMin) AudioMin = Audio; SPI.transfer((uint8_t)(DDSBuffer.DeltaPhase >> 16));

  SPI.transfer((uint8_t)(DDSBuffer.DeltaPhase >>  8));
  SPI.transfer((uint8_t)DDSBuffer.DeltaPhase);

  SPI.endTransaction();                         // do not raise FQ_UD until next timer tick!

  digitalWriteFast(PIN_ANALOG,LOW);
}

A closer look lets the scope decode and present the SPI data:

DDS Mock - 0 VAC - SPI detail
DDS Mock – 0 VAC – SPI detail

The program calculates and displays various “constants” I set for convenience:

FM Modulated DDS
Ed Nisley KE4ZNU
 serial wait: 890 ms

DDS clock:     180000000.000 Hz
CountPerHertz:        23.861 ct
HertzPerCount:         0.042 Hz

Crystal:    20000000.000 Hz
Deviation:      5000.000 Hz

You can confirm the SPI data by working backwards with a calculator:

  • DDS delta-phase register bytes: 1C 71 C6 E2 = 477218530 decimal
  • Multiply by 180 MHz / 2^32 to get frequency: 1999997.5506 Hz
  • Subtract nominal 20.0 MHz crystal to get modulation: -2.4494 Hz
  • Divide by nominal 5.0 kHz deviation to get fractional modulation: -4.89.9e-6
  • Multiply by half the ADC range (4096/2) to get ADC counts: -1.003
  • Add 2048 to get the actual ADC sample: 2047

Nicely inside the range of values reported by the main loop, whew.

Which means I can avoid screwing around with fixed-point arithmetic until such time as clawing back a few microseconds makes a meaningful difference.

Now, to begin paying attention to those pesky hardware details …

The TeensyDuino source code as a GitHub Gist:

// FM DDS
// Ed Nisley - KE4ZNU
// 2017-04-19 Demo 1
#include <IntervalTimer.h>
#include <ADC.h>
#include <SPI.h>
#define PIN_HEART 14
#define PIN_TIMER 15
#define PIN_ANALOG 16
#define PIN_GLITCH 17
#define PIN_AUDIO A9
#define PIN_DDS_FQUD 10
// data to DDS MOSI0 11
// no data from DDS MISO0 12
// DDS clock on SCK0 13 -- also LED
#define BUILTIN_LED 13
//---------------------
// Useful constants
int SamplePeriod = 25; // microseconds per analog sample
//---------------------
// Globals
ADC *adc = new ADC();
IntervalTimer timer;
volatile int AnalogSample;
volatile int AudioMax = -4096;
volatile int AudioMin = 4096;
typedef struct {
uint8_t Phase;
uint32_t DeltaPhase; // DDS expects MSB first!
} DDS;
DDS DDSBuffer;
double DDSClock = 180.0e6; // nominal DDS oscillator
double CountPerHertz, HertzPerCount; // DDS delta-phase increments
double Crystal = 20.0e6; // nominal DDS frequency
double Deviation = 5.0e3; // nominal FM signal deviation (one-sided)
double TestFreq;
//---------------------
// Handy routines
void FlipPin(int pin) {
digitalWriteFast(pin,!digitalRead(pin));
}
void PulsePin(int p) {
FlipPin(p);
FlipPin(p);
}
//---------------------
// Timer handler
void timer_callback(void) {
digitalWriteFast(PIN_TIMER,HIGH);
digitalWriteFast(PIN_DDS_FQUD,HIGH); // latch previously shifted bits
adc->startSingleRead(PIN_AUDIO, ADC_0); // start ADC conversion
analogWriteDAC0(AnalogSample); // show previous audio sample
digitalWriteFast(PIN_TIMER,LOW);
}
//---------------------
// Analog read handler
void adc0_isr(void) {
int Audio;
digitalWriteFast(PIN_ANALOG,HIGH);
AnalogSample = adc->readSingle(); // fetch just-finished sample
Audio = AnalogSample - 2048; // convert to AC signal
DDSBuffer.Phase = 0;
SPI.beginTransaction(SPISettings(8000000, MSBFIRST, SPI_MODE0));
digitalWriteFast(PIN_DDS_FQUD, LOW);
SPI.transfer(DDSBuffer.Phase);
DDSBuffer.DeltaPhase = (uint32_t)((((double)Audio / 2048.0) * Deviation + Crystal) * CountPerHertz);
SPI.transfer((uint8_t)(DDSBuffer.DeltaPhase >> 24)); // MSB first!
if (Audio > AudioMax) // ignore race conditions
AudioMax = Audio;
if (Audio < AudioMin)
AudioMin = Audio;
SPI.transfer((uint8_t)(DDSBuffer.DeltaPhase >> 16));
SPI.transfer((uint8_t)(DDSBuffer.DeltaPhase >> 8));
SPI.transfer((uint8_t)DDSBuffer.DeltaPhase);
SPI.endTransaction(); // do not raise FQ_UD until next timer tick!
digitalWriteFast(PIN_ANALOG,LOW);
}
//---------------------
// Hardware setup
void setup(void) {
pinMode(BUILTIN_LED,OUTPUT); // will eventually become SCK0
pinMode(PIN_HEART, OUTPUT); // show we arrived
digitalWrite(PIN_HEART,LOW);
PulsePin(PIN_HEART);
PulsePin(PIN_HEART);
pinMode(PIN_TIMER,OUTPUT);
digitalWrite(PIN_TIMER,LOW);
pinMode(PIN_GLITCH,OUTPUT);
digitalWrite(PIN_GLITCH,LOW);
pinMode(PIN_ANALOG,OUTPUT);
digitalWrite(PIN_ANALOG,LOW);
pinMode(PIN_AUDIO,INPUT);
pinMode(PIN_DDS_FQUD,OUTPUT);
digitalWriteFast(PIN_DDS_FQUD,HIGH);
Serial.begin(115200);
int waited = 0;
while (!Serial && waited < 3000) { // fall out after a few seconds
delay(1);
waited++;
if (! (waited % 50))
FlipPin(BUILTIN_LED);
}
Serial.printf("FM Modulated DDS\nEd Nisley KE4ZNU\n");
Serial.printf(" serial wait: %d ms\n\n",waited);
SPI.begin();
SPI.usingInterrupt(255); // attached through analog IRQs
adc->setAveraging(4); // choices: 0, 4, 8, 16, 32
adc->setResolution(12); // choices: 8, 10, 12, 16
adc->setConversionSpeed(ADC_CONVERSION_SPEED::HIGH_SPEED);
adc->setSamplingSpeed(ADC_SAMPLING_SPEED::HIGH_SPEED);
adc->enableInterrupts(ADC_0);
if (!timer.begin(timer_callback, SamplePeriod)) {
Serial.printf("Timer start failed\n");
while (true) {
FlipPin(BUILTIN_LED);
delay(75);
}
}
CountPerHertz = (1LL << 32) / DDSClock;
HertzPerCount = 1.0 / CountPerHertz;
Serial.printf("DDS clock: %13.3f Hz\n",DDSClock);
Serial.printf("CountPerHertz: %13.3f ct\n",CountPerHertz);
Serial.printf("HertzPerCount: %13.3f Hz\n\n",HertzPerCount);
TestFreq = Crystal;
Serial.printf("Crystal: %13.3f Hz\n",Crystal);
Serial.printf("Deviation: %13.3f Hz\n",Deviation);
Serial.printf("\nSetup done\n");
}
//---------------------
// Do things forever
void loop(void) {
digitalWrite(PIN_HEART,HIGH);
Serial.printf(" %5d to %5d\n",AudioMin,AudioMax);
AudioMax = 99*AudioMax/100; // ignore race conditions
AudioMin = 99*AudioMin/100;
digitalWrite(PIN_HEART,LOW);
delay(500);
}
view raw FMDDS.ino hosted with ❤ by GitHub

FM DDS: Floating Point Timing

Inserting a few simple floating point operations between the SPI transfers provides a quick-n-dirty look at the timings:

Math timing - double ops
Math timing – double ops

The corresponding code runs in the ADC end-of-conversion handler:

void adc0_isr(void) {

  digitalWriteFast(ANALOG_PIN,HIGH);

  AnalogSample = adc->readSingle();                     // fetch just-finished sample

  SPI.beginTransaction(SPISettings(8000000, MSBFIRST, SPI_MODE0));
  digitalWriteFast(DDS_FQUD_PIN, LOW);

  SPI.transfer(DDSBuffer.Phase);                // interleave with FM calculations
  FlipPin(GLITCH_PIN);
  TestFreq += DDSStepFreq;
  FlipPin(GLITCH_PIN);
  SPI.transfer(DDSBuffer.Bits31_24);
  TestFreq -= DDSStepFreq;
  SPI.transfer(DDSBuffer.Bits23_16);
  TestFreq *= DDSStepFreq;
  SPI.transfer(DDSBuffer.Bits15_8);
  FlipPin(GLITCH_PIN);
  TestFreq /= DDSStepFreq;
  FlipPin(GLITCH_PIN);
  SPI.transfer(DDSBuffer.Bits7_0);
  SPI.endTransaction();                         // do not raise FQ_UD until next timer tick!

  digitalWriteFast(ANALOG_PIN,LOW);
}

The FlipPin() function twiddling the output bit takes a surprising amount of time, as shown by the first two gaps in the blocks of SPI clocks (D4). Some cursor fiddling on a zoomed scale says 300 ns = 50-ish cycles for each call. In round numbers, actual code doing useful work will take longer than that.

Double precision floating add / subtract / multiply seem to take about 600 ns. That’s entirely survivable if you don’t get carried away.

Double precision division, on the other paw, eats up 3 μs = 3000 ns, so it’s not something you want to casually plunk into an interrupt handler required to finish before the next audio sample arrives in 20 μs.

Overall, the CPU utilization seems way too high for comfort, mostly due to the SPI transfers, even without any computation. I must study the SPI-by-DMA examples to see if it’s a win.

Frequency Modulated DDS: SPI Mock 1

The general idea is to frequency modulate the sine wave coming from a DDS, thereby generating a signal suitable for upconverting in amateur repeaters now tied to unobtainable crystals. The crystals run from 4-ish to 20-ish MHz, with frequency multiplication from 3 to 36 producing RF outputs from 30-ish MHz through 900-ish MHz; more details as I work through the choices.

The demo code runs on a bare Teensy 3.6 as a dipstick test for the overall timing and functionality:

FM DDS - Teensy 3.6 SPI demo
FM DDS – Teensy 3.6 SPI demo

The fugliest thing you’ve seen in a while, eh?

An overview of the results:

Analog 4 kHz @ 40 kHz - SPI demo overview
Analog 4 kHz @ 40 kHz – SPI demo overview

The pulses in D1 (orange digital) mark timer ticks at a 40 kHz pace, grossly oversampling the 4 kHz audio bandwidth in the hope of trivializing the antialiasing filters. The timer tick raises the DDS latch pin (D6, top trace) to change the DDS frequency, fires off another ADC conversion, and (for now) copies the previous ADC value to the DAC output:

void timer_callback(void) {
  digitalWriteFast(TIMER_PIN,HIGH);
  digitalWriteFast(DDS_FQUD_PIN,HIGH);                // latch previously shifted bits
  adc->startSingleRead(AUDIO_PIN, ADC_0);             // start ADC conversion
  analogWriteDAC0(AnalogSample);                      // show previous audio sample
  digitalWriteFast(TIMER_PIN,LOW);
}

The purple analog trace is the input sine wave at 4 kHz. The yellow analog stairstep comes from the DAC, with no hint of a reconstruction filter knocking off the sharp edges.

The X1 cursor (bold vertical dots) marks the start of the ADC read. I hope triggering it from the timer tick eliminates most of the jitter.

The Y1 cursor (upper dotted line, intersecting X1 just left of the purple curve) shows the ADC sample apparently happens just slightly after the conversion. The analog scales may be slightly off, so I wouldn’t leap to any conclusions.

The pulses in D2 mark the ADC end-of-conversion interrupts:

void adc0_isr(void) {
  digitalWriteFast(ANALOG_PIN,HIGH);
  AnalogSample = adc->readSingle();                     // fetch just-finished sample
  SPI.beginTransaction(SPISettings(8000000, MSBFIRST, SPI_MODE0));
  digitalWriteFast(DDS_FQUD_PIN, LOW);
  SPI.transfer(DDSBuffer.Phase);                // interleave with FM calculations
  SPI.transfer(DDSBuffer.Bits31_24);
  SPI.transfer(DDSBuffer.Bits23_16);
  SPI.transfer(DDSBuffer.Bits15_8);
  SPI.transfer(DDSBuffer.Bits7_0);
  SPI.endTransaction();                         // do not raise FQ_UD until next timer tick!
  digitalWriteFast(ANALOG_PIN,LOW);
}

The real FM code will multiply the ADC reading by the amplitude-to-frequency-deviation factor, add it to the nominal “crystal” frequency, convert the sum to the DDS delta-phase register value, then send it to the DDS through the SPI port. For now, I just send five constant bytes to get an idea of the minimum timing with the SPI clock ticking along at 8 MHz.

The tidy blurs in D4 show the SPI clock, with the corresponding data in D5.

D6 (top trace) shows the DDS FQ_UD (pronounced “frequency update”) signal dropping just before the SPI data transfer begins. Basically, FQ_UD is the DDS Latch Clock: low during the delta-phase value transfer, with the low-to-high transition latching all 40 control + data bits into the DDS to trigger the new frequency.

A closer look at the sample and transfer:

Analog 4 kHz @ 40 kHz - SPI demo detail
Analog 4 kHz @ 40 kHz – SPI demo detail

For reference, the digital players from bottom to top:

  • D0 – unused here, shows pulses marking main loop
  • D1 – 40 kHz timer ticks = ADC start conversion
  • D2 – ADC end of conversion,”FM calculation”, send DDS data
  • D3 – unused here, shows error conditions
  • D4 – SPI clock = rising edge active
  • D5 – SPI MOSI data to DDS = MSB first
  • D6 – SPI CS = FQ_UD = DDS latch

Remember, the yellow analog stairstepped trace is just a comfort signal showing the ADC actually samples the intended input.

The ARM CPU has floating-point hardware, but I suspect fixed-point arithmetic will once again win out over double-precision multiplies & divides.

Dropping the sampling to 20 kHz would likely work just as well and double the time available for calculations. At least now I can measure what’s going on.

All in all, it looks feasible.

And, yes, the scope is a shiny new Siglent SDS2304X with the MSO logic-analyzer option. It has some grievous UX warts & omissions suggesting an architectural botch job, but it’s mostly Good Enough for what I need. More later.

The TeensyDuino source code as a GitHub Gist:

// FM DDS
// Ed Nisley - KE4ZNU
// 2017-04-19 Demo 1
#include <IntervalTimer.h>
#include <ADC.h>
#include <SPI.h>
#define HEART_PIN 14
#define TIMER_PIN 15
#define ANALOG_PIN 16
#define GLITCH_PIN 17
#define AUDIO_PIN A9
#define DDS_FQUD_PIN 10
// data to DDS MOSI0 11
// no data from DDS MISO0 12
// DDS clock on SCK0 13 -- also LED
#define BUILTIN_LED 13
//---------------------
// Useful constants
int SamplePeriod = 25; // microseconds per analog sample
//---------------------
// Globals
ADC *adc = new ADC();
IntervalTimer timer;
volatile unsigned int AnalogSample;
typedef struct {
uint8_t Phase;
uint8_t Bits31_24;
uint8_t Bits23_16;
uint8_t Bits15_8;
uint8_t Bits7_0;
} DDS;
DDS DDSBuffer = {0x01,0x02,0x04,0x08,0x10};
double DDSFreq, EpsilonFreq, DDSStepFreq;
double CenterFreq, TestFreq;
//---------------------
// Handy routines
void FlipPin(int pin) {
digitalWriteFast(pin,!digitalRead(pin));
}
void PulsePin(int p) {
FlipPin(p);
FlipPin(p);
}
//---------------------
// Timer handler
void timer_callback(void) {
digitalWriteFast(TIMER_PIN,HIGH);
digitalWriteFast(DDS_FQUD_PIN,HIGH); // latch previously shifted bits
adc->startSingleRead(AUDIO_PIN, ADC_0); // start ADC conversion
analogWriteDAC0(AnalogSample); // show previous audio sample
digitalWriteFast(TIMER_PIN,LOW);
}
//---------------------
// Analog read handler
void adc0_isr(void) {
digitalWriteFast(ANALOG_PIN,HIGH);
AnalogSample = adc->readSingle(); // fetch just-finished sample
SPI.beginTransaction(SPISettings(8000000, MSBFIRST, SPI_MODE0));
digitalWriteFast(DDS_FQUD_PIN, LOW);
SPI.transfer(DDSBuffer.Phase); // interleave with FM calculations
SPI.transfer(DDSBuffer.Bits31_24);
SPI.transfer(DDSBuffer.Bits23_16);
SPI.transfer(DDSBuffer.Bits15_8);
SPI.transfer(DDSBuffer.Bits7_0);
SPI.endTransaction(); // do not raise FQ_UD until next timer tick!
digitalWriteFast(ANALOG_PIN,LOW);
}
//---------------------
// Hardware setup
void setup(void) {
pinMode(BUILTIN_LED,OUTPUT); // will eventually become SCK0
pinMode(HEART_PIN, OUTPUT); // show we arrived
digitalWrite(HEART_PIN,LOW);
PulsePin(HEART_PIN);
PulsePin(HEART_PIN);
pinMode(TIMER_PIN,OUTPUT);
digitalWrite(TIMER_PIN,LOW);
pinMode(GLITCH_PIN,OUTPUT);
digitalWrite(GLITCH_PIN,LOW);
pinMode(ANALOG_PIN,OUTPUT);
digitalWrite(ANALOG_PIN,LOW);
pinMode(AUDIO_PIN,INPUT);
pinMode(DDS_FQUD_PIN,OUTPUT);
digitalWriteFast(DDS_FQUD_PIN,HIGH);
Serial.begin(115200);
int waited = 0;
while (!Serial && waited < 3000) { // fall out after a few seconds
delay(1);
waited++;
if (! (waited % 50))
FlipPin(BUILTIN_LED);
}
Serial.printf("FM Modulated DDS\nEd Nisley KE4ZNU\n");
Serial.printf(" serial wait: %d ms\n\n",waited);
SPI.begin();
SPI.usingInterrupt(255); // attached through analog IRQs
adc->setAveraging(0);
adc->setResolution(12);
adc->setConversionSpeed(ADC_CONVERSION_SPEED::MED_SPEED);
adc->setSamplingSpeed(ADC_SAMPLING_SPEED::MED_SPEED);
adc->enableInterrupts(ADC_0);
if (!timer.begin(timer_callback, SamplePeriod)) {
Serial.printf("Timer start failed\n");
while (true) {
FlipPin(BUILTIN_LED);
delay(50);
}
}
DDSFreq = 180.0e6;
EpsilonFreq = 1.0e-5;
DDSStepFreq = DDSFreq / (1LL << 32);
Serial.printf("DDS frequency: %18.7f Hz\n",DDSFreq);
Serial.printf(" epsilon: %18.7f Hz\n",EpsilonFreq);
Serial.printf(" step: %18.7f Hz\n\n",DDSStepFreq);
CenterFreq = 146520000.0;
TestFreq = CenterFreq;
Serial.printf("Center frequency: %18.7f Hz\n",CenterFreq);
Serial.printf("Setup done\n");
}
//---------------------
// Do things forever
void loop(void) {
digitalWrite(HEART_PIN,HIGH);
if (TestFreq < (CenterFreq + 100*EpsilonFreq))
TestFreq += EpsilonFreq;
else
TestFreq += DDSStepFreq;
Serial.printf(" %18.7f Hz\n",TestFreq);
digitalWrite(HEART_PIN,LOW);
delay(500);
}
view raw FMDDS.ino hosted with ❤ by GitHub

Teensy 3.6 Double Precision Floats

Having spent a bit of effort wringing enough precision from an Arduino to make the 60 kHz quartz resonator tester, this came as a relief:

DDS frequency:  180000000.0000000 Hz
      epsilon:          0.0000001 Hz
         step:          0.0419095 Hz

Center frequency:  146520000.0000000 Hz
  146520000.0000001 Hz
  146520000.0000002 Hz
  146520000.0000003 Hz
  146520000.0000004 Hz
  146520000.0000004 Hz
  146520000.0000005 Hz
  146520000.0000006 Hz
  146520000.0000007 Hz
  146520000.0000008 Hz
  146520000.0000009 Hz
  146520000.0000010 Hz

... snippage ...

  146520000.0000099 Hz
  146520000.0000100 Hz
  146520000.0419195 Hz
  146520000.0838290 Hz
  146520000.1257386 Hz
  146520000.1676481 Hz
  146520000.2095576 Hz
  146520000.2514671 Hz
  146520000.2933766 Hz
  146520000.3352861 Hz
  146520000.3771957 Hz
  146520000.4191052 Hz
  146520000.4610147 Hz
  146520000.5029242 Hz
  146520000.5448337 Hz
  146520000.5867432 Hz
  146520000.6286528 Hz
  146520000.6705623 Hz
  146520000.7124718 Hz
  146520000.7543813 Hz
  146520000.7962908 Hz
  146520000.8382003 Hz
  146520000.8801098 Hz
  146520000.9220194 Hz
  146520000.9639289 Hz
  146520001.0058384 Hz
  146520001.0477479 Hz
  146520001.0896574 Hz
  146520001.1315669 Hz
  146520001.1734765 Hz

Which comes from a PJRC Teensy 3.6 running this code:

double DDSFreq, EpsilonFreq, DDSStepFreq;
double CenterFreq, TestFreq;

... in setup() ...

  DDSFreq = 180.0e6;
  EpsilonFreq = 1.0e-7;
  DDSStepFreq = DDSFreq / (1LL << 32);
  Serial.printf("DDS frequency: %18.7f Hz\n",DDSFreq);
  Serial.printf("      epsilon: %18.7f Hz\n",EpsilonFreq);
  Serial.printf("         step: %18.7f Hz\n\n",DDSStepFreq);

  CenterFreq = 146520000.0;
  TestFreq = CenterFreq;
  Serial.printf("Center frequency: %18.7f Hz\n",CenterFreq);

... in loop() ...

  if (TestFreq < (CenterFreq + 100*EpsilonFreq))
    TestFreq += EpsilonFreq;
  else
    TestFreq += DDSStepFreq;

  Serial.printf(" %18.7f Hz\n",TestFreq);

The IEEE-754 spec says a double floating-point variable carries about 15.9 decimal digits, which agrees with the 9 integer + 7 fraction digits. The highlight lowlight (gray bar) in the first figure shows the slight stumble where adding 1e-7 changes the sum, but not quite enough to affect the displayed fraction.

In round numbers, an increment of 1e-5 would work just fine:

  
  146520000.0000100 Hz
  146520000.0000200 Hz
  146520000.0000300 Hz
  146520000.0000401 Hz
  146520000.0000501 Hz
  146520000.0000601 Hz
  146520000.0000701 Hz
  146520000.0000801 Hz
  146520000.0000901 Hz
  146520000.0001001 Hz
  146520000.0001101 Hz
  146520000.0001202 Hz
  146520000.0001302 Hz
  146520000.0001402 Hz
  146520000.0001502 Hz
  146520000.0001602 Hz
  146520000.0001702 Hz
  146520000.0001802 Hz
  146520000.0001903 Hz
  146520000.0002003 Hz
  146520000.0002103 Hz
  146520000.0002203 Hz
  146520000.0002303 Hz

You’d use the “smallest of all” epsilon in a multiplied increment, perhaps to tick a value based on a knob or some such. Fine-tuning a VHF frequency with millihertz steps probably doesn’t make much practical sense.

The DDS frequency increment works out to 41.9095 mHz, slightly larger than with the Arduino, because it’s fot a cheap DDS eBay module with an AD9851 running a 180 MHz (6 × 30 MHz ) clock.