After a bit of reading up on the subject, I thought I would do some experimentation with an NCO, to see how it fares.
Twiddling some numbers in a spreadsheet determined that an output frequency range of 0 to 31,250 Hz should be achievable with a 16-bit accumulator and a base timer frequency of 62.5 kHz. The number say that output frequency error (of desired versus actual) should be less than 0.2% across the range, with the exception of down in the single-digit hertz, where it is a poor, but probably tolerable, 4.6%. But numbers can lie, so I wrote some code:
#define TIMER_FREQ_HZ 62500
volatile uint16_t nco_increment = 0;
void main(void) {
// Set up timer 1 to trigger interrupt at TIMER_FREQ_HZ rate, etc.
// nco_set_increment() called from main loop with desired output frequency according to user input.
}
void nco_set_increment(const uint16_t freq) {
float ratio = (float)(freq * 2) / TIMER_FREQ_HZ;
uint16_t incr = roundf(ratio * UINT16_MAX);
ATOMIC_BLOCK(ATOMIC_RESTORESTATE) {
nco_increment = incr;
}
}
void nco_update() {
static uint16_t nco_accumulator = 0;
if(nco_increment == 0) {
// Turn off output pin when frequency zero.
PORTB &= ~(_BV(PORTB5));
} else {
if(__builtin_uadd_overflow(nco_accumulator, nco_increment, &nco_accumulator)) {
// Toggle output pin.
PINB |= _BV(PINB5);
}
}
}
ISR(TIMER1_COMPA_vect) {
nco_update();
}
Glad I found GCC has the __builtin_uadd_overflow(), as that's really handy to be able to do the addition and determine if it overflowed in one function call.
Not sure about how efficient it is - the assembly it outputs is a couple of dozen lines.
The result is that it works, but the jitter is pretty terrible at frequencies above 10 kHz.
For example at 12 kHz, without any changes in user input, it's jittering by several hundred Hz.
This was on a spare Arduino Uno board I had to hand, and I remembered that by default the Arduino library runs a timer and interrupts for some background stuff (e.g. millis() timing, etc.), so I thought I would see what effect disabling every interrupt except my one would have. While the jitter was ever so slightly improved, it was nothing significant.
If it's pretty bad in a basic bare-bones experimental setup, I can imagine it would be even worse when there are numerous other regular and intermittent interrupts firing off, as my project's final implementation would have. So unless my experimental setup can be improved, I'm not sure about the merits of using an software-driven NCO.