A simple Pulse Density modulation for e.g. SPI output (using only the MOSI/DO line, no CS/SCK/MISO/DI) is actually very simple:
Let STATE be an unsigned integer type state variable
Let RATE be the duty cycle, also an unsigned integer
Loop: Add RATE to STATE Carry flag reflects the output bit state
For PDM'ing an arbitrary waveform, the
RATE reflects the signal amplitude for each output bit.
Limiting
RATE to 87.5% of the available dynamic range (6.25% to 93.75%) means that the output stays low or high at most 16 cycles consecutively.
Limiting
RATE to 75% of the available dynamic range (12.5% to 87.5%) means the output stays low or high at most 8 cycles consecutively.
Reducing the signal dynamic range this way can make it easier to use a passive low-pass filter and still get good output signal.
It is interesting to note that when using a sine wave within 0..1, \$y = (1 - \cos \pi x) / 2\$, the cubic curve \$y = 3 x^2 - 2 x^3\$ matches the rising part to within \$\pm 0.01001\$; the absolute error is about 1% maximum. This means that a simple cubic function can be used to approximate the sinusoidal amplitude. Technically, a single period of a sinusoidal wave with period \$2 X\$ (\$x = 0 \dots 2 X\$) and dynamic range \$Y\$ (amplitude varying between \$0\$ and \$Y\$) can be written as
$$y(x) = \begin{cases}
3 Y \frac{x^2}{X^2} - 2 Y \frac{x^3}{X^3}, & x \le X \\
- 4 Y + 12 Y \frac{x}{X} - 9 Y \frac{x^2}{X^2} + 2 Y \frac{x^3}{X^3}, & x \ge X \\
\end{cases}$$
Because this is a third degree polynomial, we can discretize (for consecutive integer
x) using
RATE = RATE + DELTA1 DELTA1 = DELTA1 + DELTA2 DELTA2 = DELTA2 + DELTA3where
DELTA3 is a constant that only changes sign at the beginning (\$x = 0\$) and at the middle (\$x = X\$) of each period. All three
DELTA variables do need to be set up for a specific dynamic range and period used.
This means that emitting a pulse density modulated signal representing a cubic approximation to a pure sinusoidal wave only needs four additions per output bit (found in the carry flag). Using native register size on most microcontroller, this means that an unrolled loop (generating say one full register of bits) takes four additions and one shift-via-carry per bit; or five additions and one shift. This is surprisingly lightweight.
It is also possible to keep the rate constant for a specific number of bits, and only update the rate once per "word", so word cost would be three additions plus either one addition and one shift-through carry or two additions and one shift, per bit. This basically replaces the cubic curve with a polyline (linear segments) along the same curve, with each line being "word" bits long along the time axis (\$x\$).
If we furthermore limit the dynamic range to 87.5% or 75%, the generated pulse stream quantization noise is pretty much all at the high end of the spectrum, so it should be much easier to filter out using a passive lowpass filter.
Now, although I do not have a spectrum analyzer, I can simulate this. I'd only need to know the bit depth of the registers used (8, 16, or 32 bits), and the rate at which the microcontroller can generate (and emit via SPI) those bits. A simple program can produce the exact bitstream, and fftw3 library can calculate its exact discrete Fourier transform, giving its spectrum; only excluding practical effects like output pin slew rate and stray inductances and capacitances.
(In the Raspberry Pi Pico thread I mentioned it would be so nice if the peripheral could do this; but it cannot, the instruction set is too limited. But I believe an FPGA implementation along these lines – but acknowledging the output is not a true sine wave, but a 1% absolute error cubic approximation of one – would be pretty nifty as a free-to-use IP block, eh?

)