I hope this post belongs here, if not feel free to move it to a more appropriate place.
I did notice that the MicroPython documentation for the DAC class states that the interface is subject to change so this is my contribution to a (possibly already ongoing) discussion about that future DAC interface.
I propose to implement an arbitrary waveform generator within the limits of the hardware. It is a hybrid software/hardware DDS solution.
- The user provides an external reconstruction filter at the DAC output pin(s) (OpAmp-based, ...).
- The DAC(s) are configured in double buffer mode with DMA support. DAC(s) run at 12bit resolution.
- At the begining we populate both buffers.
- Then we register a callback to be triggered by the DMA controller to replenish one of the used buffers with the next batch of DAC values.
Code: Select all
import DAC
dac = DAC(pin=...,
waveform=array.array('H', [...]),
f_DAC=..., # I think maximum would be 1MHz
outputBufferSize=...)
dac.frequency(...) # works both with DAC running or stopped
dac.phaseOffset(...) # works both with DAC running or stopped
dac.start()
dac.stop()
Implementing this on a lower level like built-in DAC would make it less taxing to the CPU. I did not yet make any attempts to initialize the DAC/DMA to operate in double buffer mode. The DAC class currently exposed in MicroPython would limit us to 8bit values anyways. So there is still plenty of work to do ...
Partial Proof of Concept:
Code: Select all
import array
from math import floor, sin, pi
import gc
class AWG:
N = 24 # width of phase_accu and tuning_word
M = 14 # number phase_accu's MSBs used to
# index into PATTERN: len(PATTERN) = 2**M
R = 12 # MicroPython's DAC resolution is 12bit
PATTERN = array.array('H', (0 for _ in range(2**M)))
# PATTERN stores one full period of the
# desired wave form, the default wave
for i in range(2**M): # form pattern is a sine wave
PATTERN[i] = int(
floor((2**R-1)*
(1.0+sin(pi*i/(2**(M-1)))) / 2.0))
gc.collect() # PATTERN init is taxing on free memory
def __init__(self, f_clk):
super().__init__()
self.f_clk = f_clk # frequency at which *individual* values
# are processed by DAC
self.state = array.array('L', [0, 0])
# phase_accu (24bit)
# tuning_word(24bit)
self.output = array.array('H', [0]*64)
def f_out(self, new_frequency):
new_frequency = float(new_frequency)
# can't go over Nyquist frequency
assert(new_frequency < self.f_clk / 2.0)
# negative frequencies are not possible either
assert(not(new_frequency < 0))
# just adapt the tuning word, phase_accu is untouched
self.state[1] = int(new_frequency / self.f_clk * 2**AWG.N)
@micropython.asm_thumb
def next_64_values(r0, r1, r2):
# r0 = state = [phase_accu, tuning_word]
# r1 = PATTERN
# r2 = output[64]
ldr(r3, [r0, 0]) # r3 = phase_accu
ldr(r4, [r0, 4]) # r4 = tuning_word
movw(r5, 64) # r5 = loop index
# r6 = tmp'
# r7 = tmp
label(loop)
add(r7, r3, r4) # tmp = phase_accu + tuning_word
movwt(r6, 0xffffff) # tmp' = 2**n - 1
and_(r7, r6) # tmp = tmp % tmp'
mov(r3, r7) # phase_accu = tmp
mov(r6, 10) # tmp = phase_accu >> (n - m)
lsr(r7, r6)
mov(r6, 1) # tmp *= 2 because PATTERN is
lsl(r7, r6) # organized in 2 Byte words
add(r6, r1, r7) # output[i] = PATTERN[tmp]
ldrh(r7, [r6, 0])
strh(r7, [r2, 0])
add(r2, r2, 2) # output organized in half words
sub(r5, 1) # i--
cmp(r5, 0) # repeat until
bne(loop) # r5 is zero
str(r3, [r0, 0]) # phase_accu = tmp
s = AWG(100000.0) # DAC runs at f_DAC = 100kHz
s.f_out(1000.0) # reproduces PATTERN at 1kHz
# can't be faster than 50kHz
# unless you really know what
# you do.
def main():
st = s.state
sp = s.PATTERN
so = s.output
# each value in timings[] is the time to fill the buffer
# with the next 64 values which the DAC then is supposed
# to output at f_DAC = 100kHz.
# In other words: At a rate of 100kHz / 64 = 1562.5Hz
# the DAC (or DMA controller?) would interrupt us and
# ask to fill the next buffer of 64 values. On my board
# it takes 23us to do that. 1562.5Hz means one interrupt
# every 640us, then we block regular executions for 23us.
# At this rate the CPU should have plenty of time to do
# other tasks. Also the time to generate the next batch
# of values is dominated by the overhead of the function
# call. E.g. if I increase the buffer to 4*64 = 256 values
# then the time to refill the buffer is just a little less
# than 51us and not 4*23us = 92us.
timings = []
for _ in range(256):
t1 = pyb.micros()
next_64_values(st, sp, so)
t2 = pyb.micros()
timings.append(t2-t1)
return timings