Skip to content

Commit 3b9eaf9

Browse files
committed
mtm_computer: Add DAC audio out module
1 parent 039ebae commit 3b9eaf9

3 files changed

Lines changed: 318 additions & 0 deletions

File tree

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
// This file is part of the CircuitPython project: https://circuitpython.org
2+
//
3+
// SPDX-FileCopyrightText: Copyright (c) 2026 Tod Kurt
4+
//
5+
// SPDX-License-Identifier: MIT
6+
//
7+
// MCP4822 dual-channel 12-bit SPI DAC driver for the MTM Workshop Computer.
8+
// Uses PIO + DMA for non-blocking audio playback, mirroring audiobusio.I2SOut.
9+
10+
#include <stdint.h>
11+
#include <string.h>
12+
13+
#include "mpconfigport.h"
14+
15+
#include "py/gc.h"
16+
#include "py/mperrno.h"
17+
#include "py/runtime.h"
18+
#include "boards/mtm_computer/module/DACOut.h"
19+
#include "shared-bindings/microcontroller/Pin.h"
20+
#include "shared-module/audiocore/__init__.h"
21+
#include "bindings/rp2pio/StateMachine.h"
22+
23+
// ─────────────────────────────────────────────────────────────────────────────
24+
// PIO program for MCP4822 SPI DAC
25+
// ─────────────────────────────────────────────────────────────────────────────
26+
//
27+
// Pin assignment:
28+
// OUT pin (1) = MOSI — serial data out
29+
// SET pins (N) = MOSI through CS — for CS control & command-bit injection
30+
// SIDE-SET pin (1) = SCK — serial clock
31+
//
32+
// On the MTM Workshop Computer: MOSI=GP19, CS=GP21, SCK=GP18.
33+
// The SET group spans GP19..GP21 (3 pins). GP20 is unused and driven low.
34+
//
35+
// SET PINS bit mapping (bit0=MOSI/GP19, bit1=GP20, bit2=CS/GP21):
36+
// 0 = CS low, MOSI low 1 = CS low, MOSI high 4 = CS high, MOSI low
37+
//
38+
// SIDE-SET (1 pin, SCK): side 0 = SCK low, side 1 = SCK high
39+
//
40+
// MCP4822 16-bit command word:
41+
// [15] channel (0=A, 1=B) [14] don't care [13] gain (1=1x)
42+
// [12] output enable (1) [11:0] 12-bit data
43+
//
44+
// DMA feeds unsigned 16-bit audio samples. RP2040 narrow-write replication
45+
// fills both halves of the 32-bit PIO FIFO entry with the same value,
46+
// giving mono→stereo for free.
47+
//
48+
// The PIO pulls 32 bits, then sends two SPI transactions:
49+
// Channel A: cmd nibble 0b0011, then all 16 sample bits from upper half-word
50+
// Channel B: cmd nibble 0b1011, then all 16 sample bits from lower half-word
51+
// The MCP4822 captures exactly 16 bits per CS frame (4 cmd + 12 data),
52+
// so only the top 12 of the 16 sample bits become DAC data. The bottom
53+
// 4 sample bits clock out harmlessly after the DAC has latched.
54+
// This gives correct 16-bit → 12-bit scaling (effectively sample >> 4).
55+
//
56+
// PIO instruction encoding with .side_set 1 (no opt):
57+
// [15:13] opcode [12] side-set [11:8] delay [7:0] operands
58+
//
59+
// Total: 26 instructions, 86 PIO clocks per audio sample.
60+
// ─────────────────────────────────────────────────────────────────────────────
61+
62+
static const uint16_t mcp4822_pio_program[] = {
63+
// side SCK
64+
// 0: pull noblock side 0 ; Get 32 bits or keep X if FIFO empty
65+
0x8080,
66+
// 1: mov x, osr side 0 ; Save for pull-noblock fallback
67+
0xA027,
68+
69+
// ── Channel A: command nibble 0b0011 ──────────────────────────────────
70+
// Send 4 cmd bits via SET, then all 16 sample bits via OUT.
71+
// MCP4822 captures exactly 16 bits per CS frame (4 cmd + 12 data);
72+
// the extra 4 clocks shift out the LSBs which the DAC ignores.
73+
// This gives correct 16→12 bit scaling (top 12 bits become DAC data).
74+
// 2: set pins, 0 side 0 ; CS low, MOSI=0 (bit15=0: channel A)
75+
0xE000,
76+
// 3: nop side 1 ; SCK high — latch bit 15
77+
0xB042,
78+
// 4: set pins, 0 side 0 ; MOSI=0 (bit14=0: don't care)
79+
0xE000,
80+
// 5: nop side 1 ; SCK high
81+
0xB042,
82+
// 6: set pins, 1 side 0 ; MOSI=1 (bit13=1: gain 1x)
83+
0xE001,
84+
// 7: nop side 1 ; SCK high
85+
0xB042,
86+
// 8: set pins, 1 side 0 ; MOSI=1 (bit12=1: output active)
87+
0xE001,
88+
// 9: nop side 1 ; SCK high
89+
0xB042,
90+
// 10: set y, 15 side 0 ; Loop counter: 16 sample bits
91+
0xE04F,
92+
// 11: out pins, 1 side 0 ; Data bit → MOSI; SCK low (bitloopA)
93+
0x6001,
94+
// 12: jmp y--, 11 side 1 ; SCK high, loop back
95+
0x108B,
96+
// 13: set pins, 4 side 0 ; CS high — DAC A latches
97+
0xE004,
98+
99+
// ── Channel B: command nibble 0b1011 ──────────────────────────────────
100+
// 14: set pins, 1 side 0 ; CS low, MOSI=1 (bit15=1: channel B)
101+
0xE001,
102+
// 15: nop side 1 ; SCK high
103+
0xB042,
104+
// 16: set pins, 0 side 0 ; MOSI=0 (bit14=0)
105+
0xE000,
106+
// 17: nop side 1 ; SCK high
107+
0xB042,
108+
// 18: set pins, 1 side 0 ; MOSI=1 (bit13=1: gain 1x)
109+
0xE001,
110+
// 19: nop side 1 ; SCK high
111+
0xB042,
112+
// 20: set pins, 1 side 0 ; MOSI=1 (bit12=1: output active)
113+
0xE001,
114+
// 21: nop side 1 ; SCK high
115+
0xB042,
116+
// 22: set y, 15 side 0 ; Loop counter: 16 sample bits
117+
0xE04F,
118+
// 23: out pins, 1 side 0 ; Data bit → MOSI; SCK low (bitloopB)
119+
0x6001,
120+
// 24: jmp y--, 23 side 1 ; SCK high, loop back
121+
0x1097,
122+
// 25: set pins, 4 side 0 ; CS high — DAC B latches
123+
0xE004,
124+
};
125+
126+
// Clocks per sample: 2 (pull+mov) + 42 (chanA) + 42 (chanB) = 86
127+
// Per channel: 8(4 cmd bits × 2 clks) + 1(set y) + 32(16 bits × 2 clks) + 1(cs high) = 42
128+
#define MCP4822_CLOCKS_PER_SAMPLE 86
129+
130+
131+
void common_hal_mtm_hardware_dacout_construct(mtm_hardware_dacout_obj_t *self,
132+
const mcu_pin_obj_t *clock, const mcu_pin_obj_t *mosi,
133+
const mcu_pin_obj_t *cs) {
134+
135+
// SET pins span from MOSI to CS. MOSI must have a lower GPIO number
136+
// than CS, with at most 4 pins between them (SET count max is 5).
137+
if (cs->number <= mosi->number || (cs->number - mosi->number) > 4) {
138+
mp_raise_ValueError(
139+
MP_COMPRESSED_ROM_TEXT("cs pin must be 1-4 positions above mosi pin"));
140+
}
141+
142+
uint8_t set_count = cs->number - mosi->number + 1;
143+
144+
// Initial SET pin state: CS high (bit at CS position), others low
145+
uint32_t cs_bit_position = cs->number - mosi->number;
146+
pio_pinmask32_t initial_set_state = PIO_PINMASK32_FROM_VALUE(1u << cs_bit_position);
147+
pio_pinmask32_t initial_set_dir = PIO_PINMASK32_FROM_VALUE((1u << set_count) - 1);
148+
149+
common_hal_rp2pio_statemachine_construct(
150+
&self->state_machine,
151+
mcp4822_pio_program, MP_ARRAY_SIZE(mcp4822_pio_program),
152+
44100 * MCP4822_CLOCKS_PER_SAMPLE, // Initial frequency; play() adjusts
153+
NULL, 0, // No init program
154+
NULL, 0, // No may_exec
155+
mosi, 1, // OUT: MOSI, 1 pin
156+
PIO_PINMASK32_NONE, PIO_PINMASK32_ALL, // OUT state=low, dir=output
157+
NULL, 0, // IN: none
158+
PIO_PINMASK32_NONE, PIO_PINMASK32_NONE, // IN pulls: none
159+
mosi, set_count, // SET: MOSI..CS
160+
initial_set_state, initial_set_dir, // SET state (CS high), dir=output
161+
clock, 1, false, // SIDE-SET: SCK, 1 pin, not pindirs
162+
PIO_PINMASK32_NONE, // SIDE-SET state: SCK low
163+
PIO_PINMASK32_FROM_VALUE(0x1), // SIDE-SET dir: output
164+
false, // No sideset enable
165+
NULL, PULL_NONE, // No jump pin
166+
PIO_PINMASK_NONE, // No wait GPIO
167+
true, // Exclusive pin use
168+
false, 32, false, // OUT shift: no autopull, 32-bit, shift left
169+
false, // Don't wait for txstall
170+
false, 32, false, // IN shift (unused)
171+
false, // Not user-interruptible
172+
0, -1, // Wrap: whole program
173+
PIO_ANY_OFFSET,
174+
PIO_FIFO_TYPE_DEFAULT,
175+
PIO_MOV_STATUS_DEFAULT,
176+
PIO_MOV_N_DEFAULT
177+
);
178+
179+
self->playing = false;
180+
audio_dma_init(&self->dma);
181+
}
182+
183+
bool common_hal_mtm_hardware_dacout_deinited(mtm_hardware_dacout_obj_t *self) {
184+
return common_hal_rp2pio_statemachine_deinited(&self->state_machine);
185+
}
186+
187+
void common_hal_mtm_hardware_dacout_deinit(mtm_hardware_dacout_obj_t *self) {
188+
if (common_hal_mtm_hardware_dacout_deinited(self)) {
189+
return;
190+
}
191+
if (common_hal_mtm_hardware_dacout_get_playing(self)) {
192+
common_hal_mtm_hardware_dacout_stop(self);
193+
}
194+
common_hal_rp2pio_statemachine_deinit(&self->state_machine);
195+
audio_dma_deinit(&self->dma);
196+
}
197+
198+
void common_hal_mtm_hardware_dacout_play(mtm_hardware_dacout_obj_t *self,
199+
mp_obj_t sample, bool loop) {
200+
201+
if (common_hal_mtm_hardware_dacout_get_playing(self)) {
202+
common_hal_mtm_hardware_dacout_stop(self);
203+
}
204+
205+
uint8_t bits_per_sample = audiosample_get_bits_per_sample(sample);
206+
if (bits_per_sample < 16) {
207+
bits_per_sample = 16;
208+
}
209+
210+
uint32_t sample_rate = audiosample_get_sample_rate(sample);
211+
uint8_t channel_count = audiosample_get_channel_count(sample);
212+
if (channel_count > 2) {
213+
mp_raise_ValueError(MP_COMPRESSED_ROM_TEXT("Too many channels in sample."));
214+
}
215+
216+
// PIO clock = sample_rate × clocks_per_sample
217+
common_hal_rp2pio_statemachine_set_frequency(
218+
&self->state_machine,
219+
(uint32_t)sample_rate * MCP4822_CLOCKS_PER_SAMPLE);
220+
common_hal_rp2pio_statemachine_restart(&self->state_machine);
221+
222+
// DMA feeds unsigned 16-bit samples. The PIO discards the top 4 bits
223+
// of each 16-bit half and uses the remaining 12 as DAC data.
224+
// RP2040 narrow-write replication: 16-bit DMA write → same value in
225+
// both 32-bit FIFO halves → mono-to-stereo for free.
226+
audio_dma_result result = audio_dma_setup_playback(
227+
&self->dma,
228+
sample,
229+
loop,
230+
false, // single_channel_output
231+
0, // audio_channel
232+
false, // output_signed = false (unsigned for MCP4822)
233+
bits_per_sample, // output_resolution
234+
(uint32_t)&self->state_machine.pio->txf[self->state_machine.state_machine],
235+
self->state_machine.tx_dreq,
236+
false); // swap_channel
237+
238+
if (result == AUDIO_DMA_DMA_BUSY) {
239+
common_hal_mtm_hardware_dacout_stop(self);
240+
mp_raise_RuntimeError(MP_COMPRESSED_ROM_TEXT("No DMA channel found"));
241+
} else if (result == AUDIO_DMA_MEMORY_ERROR) {
242+
common_hal_mtm_hardware_dacout_stop(self);
243+
mp_raise_RuntimeError(MP_COMPRESSED_ROM_TEXT("Unable to allocate buffers for signed conversion"));
244+
} else if (result == AUDIO_DMA_SOURCE_ERROR) {
245+
common_hal_mtm_hardware_dacout_stop(self);
246+
mp_raise_RuntimeError(MP_COMPRESSED_ROM_TEXT("Audio source error"));
247+
}
248+
249+
self->playing = true;
250+
}
251+
252+
void common_hal_mtm_hardware_dacout_pause(mtm_hardware_dacout_obj_t *self) {
253+
audio_dma_pause(&self->dma);
254+
}
255+
256+
void common_hal_mtm_hardware_dacout_resume(mtm_hardware_dacout_obj_t *self) {
257+
audio_dma_resume(&self->dma);
258+
}
259+
260+
bool common_hal_mtm_hardware_dacout_get_paused(mtm_hardware_dacout_obj_t *self) {
261+
return audio_dma_get_paused(&self->dma);
262+
}
263+
264+
void common_hal_mtm_hardware_dacout_stop(mtm_hardware_dacout_obj_t *self) {
265+
audio_dma_stop(&self->dma);
266+
common_hal_rp2pio_statemachine_stop(&self->state_machine);
267+
self->playing = false;
268+
}
269+
270+
bool common_hal_mtm_hardware_dacout_get_playing(mtm_hardware_dacout_obj_t *self) {
271+
bool playing = audio_dma_get_playing(&self->dma);
272+
if (!playing && self->playing) {
273+
common_hal_mtm_hardware_dacout_stop(self);
274+
}
275+
return playing;
276+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// This file is part of the CircuitPython project: https://circuitpython.org
2+
//
3+
// SPDX-FileCopyrightText: Copyright (c) 2026 Tod Kurt
4+
//
5+
// SPDX-License-Identifier: MIT
6+
7+
#pragma once
8+
9+
#include "common-hal/microcontroller/Pin.h"
10+
#include "common-hal/rp2pio/StateMachine.h"
11+
12+
#include "audio_dma.h"
13+
#include "py/obj.h"
14+
15+
typedef struct {
16+
mp_obj_base_t base;
17+
rp2pio_statemachine_obj_t state_machine;
18+
audio_dma_t dma;
19+
bool playing;
20+
} mtm_hardware_dacout_obj_t;
21+
22+
void common_hal_mtm_hardware_dacout_construct(mtm_hardware_dacout_obj_t *self,
23+
const mcu_pin_obj_t *clock, const mcu_pin_obj_t *mosi,
24+
const mcu_pin_obj_t *cs);
25+
26+
void common_hal_mtm_hardware_dacout_deinit(mtm_hardware_dacout_obj_t *self);
27+
bool common_hal_mtm_hardware_dacout_deinited(mtm_hardware_dacout_obj_t *self);
28+
29+
void common_hal_mtm_hardware_dacout_play(mtm_hardware_dacout_obj_t *self,
30+
mp_obj_t sample, bool loop);
31+
void common_hal_mtm_hardware_dacout_stop(mtm_hardware_dacout_obj_t *self);
32+
bool common_hal_mtm_hardware_dacout_get_playing(mtm_hardware_dacout_obj_t *self);
33+
34+
void common_hal_mtm_hardware_dacout_pause(mtm_hardware_dacout_obj_t *self);
35+
void common_hal_mtm_hardware_dacout_resume(mtm_hardware_dacout_obj_t *self);
36+
bool common_hal_mtm_hardware_dacout_get_paused(mtm_hardware_dacout_obj_t *self);
37+
38+
extern const mp_obj_type_t mtm_hardware_dacout_type;

ports/raspberrypi/boards/mtm_computer/mpconfigboard.mk

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,7 @@ EXTERNAL_FLASH_DEVICES = "W25Q16JVxQ"
1111
CIRCUITPY_AUDIOEFFECTS = 1
1212
CIRCUITPY_IMAGECAPTURE = 0
1313
CIRCUITPY_PICODVI = 0
14+
15+
SRC_C += \
16+
boards/$(BOARD)/module/mtm_hardware.c \
17+
boards/$(BOARD)/module/DACOut.c

0 commit comments

Comments
 (0)