Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
276 changes: 276 additions & 0 deletions ports/raspberrypi/boards/mtm_computer/module/DACOut.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
// This file is part of the CircuitPython project: https://circuitpython.org
//
// SPDX-FileCopyrightText: Copyright (c) 2026 Tod Kurt
//
// SPDX-License-Identifier: MIT
//
// MCP4822 dual-channel 12-bit SPI DAC driver for the MTM Workshop Computer.
// Uses PIO + DMA for non-blocking audio playback, mirroring audiobusio.I2SOut.

#include <stdint.h>
#include <string.h>

#include "mpconfigport.h"

#include "py/gc.h"
#include "py/mperrno.h"
#include "py/runtime.h"
#include "boards/mtm_computer/module/DACOut.h"
#include "shared-bindings/microcontroller/Pin.h"
#include "shared-module/audiocore/__init__.h"
#include "bindings/rp2pio/StateMachine.h"

// ─────────────────────────────────────────────────────────────────────────────
// PIO program for MCP4822 SPI DAC
// ─────────────────────────────────────────────────────────────────────────────
//
// Pin assignment:
// OUT pin (1) = MOSI — serial data out
// SET pins (N) = MOSI through CS — for CS control & command-bit injection
// SIDE-SET pin (1) = SCK — serial clock
//
// On the MTM Workshop Computer: MOSI=GP19, CS=GP21, SCK=GP18.
// The SET group spans GP19..GP21 (3 pins). GP20 is unused and driven low.
//
// SET PINS bit mapping (bit0=MOSI/GP19, bit1=GP20, bit2=CS/GP21):
// 0 = CS low, MOSI low 1 = CS low, MOSI high 4 = CS high, MOSI low
//
// SIDE-SET (1 pin, SCK): side 0 = SCK low, side 1 = SCK high
//
// MCP4822 16-bit command word:
// [15] channel (0=A, 1=B) [14] don't care [13] gain (1=1x)
// [12] output enable (1) [11:0] 12-bit data
//
// DMA feeds unsigned 16-bit audio samples. RP2040 narrow-write replication
// fills both halves of the 32-bit PIO FIFO entry with the same value,
// giving mono→stereo for free.
//
// The PIO pulls 32 bits, then sends two SPI transactions:
// Channel A: cmd nibble 0b0011, then all 16 sample bits from upper half-word
// Channel B: cmd nibble 0b1011, then all 16 sample bits from lower half-word
// The MCP4822 captures exactly 16 bits per CS frame (4 cmd + 12 data),
// so only the top 12 of the 16 sample bits become DAC data. The bottom
// 4 sample bits clock out harmlessly after the DAC has latched.
// This gives correct 16-bit → 12-bit scaling (effectively sample >> 4).
//
// PIO instruction encoding with .side_set 1 (no opt):
// [15:13] opcode [12] side-set [11:8] delay [7:0] operands
//
// Total: 26 instructions, 86 PIO clocks per audio sample.
// ─────────────────────────────────────────────────────────────────────────────

static const uint16_t mcp4822_pio_program[] = {
// side SCK
// 0: pull noblock side 0 ; Get 32 bits or keep X if FIFO empty
0x8080,
// 1: mov x, osr side 0 ; Save for pull-noblock fallback
0xA027,

// ── Channel A: command nibble 0b0011 ──────────────────────────────────
// Send 4 cmd bits via SET, then all 16 sample bits via OUT.
// MCP4822 captures exactly 16 bits per CS frame (4 cmd + 12 data);
// the extra 4 clocks shift out the LSBs which the DAC ignores.
// This gives correct 16→12 bit scaling (top 12 bits become DAC data).
// 2: set pins, 0 side 0 ; CS low, MOSI=0 (bit15=0: channel A)
0xE000,
// 3: nop side 1 ; SCK high — latch bit 15
0xB042,
// 4: set pins, 0 side 0 ; MOSI=0 (bit14=0: don't care)
0xE000,
// 5: nop side 1 ; SCK high
0xB042,
// 6: set pins, 1 side 0 ; MOSI=1 (bit13=1: gain 1x)
0xE001,
// 7: nop side 1 ; SCK high
0xB042,
// 8: set pins, 1 side 0 ; MOSI=1 (bit12=1: output active)
0xE001,
// 9: nop side 1 ; SCK high
0xB042,
// 10: set y, 15 side 0 ; Loop counter: 16 sample bits
0xE04F,
// 11: out pins, 1 side 0 ; Data bit → MOSI; SCK low (bitloopA)
0x6001,
// 12: jmp y--, 11 side 1 ; SCK high, loop back
0x108B,
// 13: set pins, 4 side 0 ; CS high — DAC A latches
0xE004,

// ── Channel B: command nibble 0b1011 ──────────────────────────────────
// 14: set pins, 1 side 0 ; CS low, MOSI=1 (bit15=1: channel B)
0xE001,
// 15: nop side 1 ; SCK high
0xB042,
// 16: set pins, 0 side 0 ; MOSI=0 (bit14=0)
0xE000,
// 17: nop side 1 ; SCK high
0xB042,
// 18: set pins, 1 side 0 ; MOSI=1 (bit13=1: gain 1x)
0xE001,
// 19: nop side 1 ; SCK high
0xB042,
// 20: set pins, 1 side 0 ; MOSI=1 (bit12=1: output active)
0xE001,
// 21: nop side 1 ; SCK high
0xB042,
// 22: set y, 15 side 0 ; Loop counter: 16 sample bits
0xE04F,
// 23: out pins, 1 side 0 ; Data bit → MOSI; SCK low (bitloopB)
0x6001,
// 24: jmp y--, 23 side 1 ; SCK high, loop back
0x1097,
// 25: set pins, 4 side 0 ; CS high — DAC B latches
0xE004,
};

// Clocks per sample: 2 (pull+mov) + 42 (chanA) + 42 (chanB) = 86
// Per channel: 8(4 cmd bits × 2 clks) + 1(set y) + 32(16 bits × 2 clks) + 1(cs high) = 42
#define MCP4822_CLOCKS_PER_SAMPLE 86


void common_hal_mtm_hardware_dacout_construct(mtm_hardware_dacout_obj_t *self,
const mcu_pin_obj_t *clock, const mcu_pin_obj_t *mosi,
const mcu_pin_obj_t *cs) {

// SET pins span from MOSI to CS. MOSI must have a lower GPIO number
// than CS, with at most 4 pins between them (SET count max is 5).
if (cs->number <= mosi->number || (cs->number - mosi->number) > 4) {
mp_raise_ValueError(
MP_COMPRESSED_ROM_TEXT("cs pin must be 1-4 positions above mosi pin"));
}

uint8_t set_count = cs->number - mosi->number + 1;

// Initial SET pin state: CS high (bit at CS position), others low
uint32_t cs_bit_position = cs->number - mosi->number;
pio_pinmask32_t initial_set_state = PIO_PINMASK32_FROM_VALUE(1u << cs_bit_position);
pio_pinmask32_t initial_set_dir = PIO_PINMASK32_FROM_VALUE((1u << set_count) - 1);

common_hal_rp2pio_statemachine_construct(
&self->state_machine,
mcp4822_pio_program, MP_ARRAY_SIZE(mcp4822_pio_program),
44100 * MCP4822_CLOCKS_PER_SAMPLE, // Initial frequency; play() adjusts
NULL, 0, // No init program
NULL, 0, // No may_exec
mosi, 1, // OUT: MOSI, 1 pin
PIO_PINMASK32_NONE, PIO_PINMASK32_ALL, // OUT state=low, dir=output
NULL, 0, // IN: none
PIO_PINMASK32_NONE, PIO_PINMASK32_NONE, // IN pulls: none
mosi, set_count, // SET: MOSI..CS
initial_set_state, initial_set_dir, // SET state (CS high), dir=output
clock, 1, false, // SIDE-SET: SCK, 1 pin, not pindirs
PIO_PINMASK32_NONE, // SIDE-SET state: SCK low
PIO_PINMASK32_FROM_VALUE(0x1), // SIDE-SET dir: output
false, // No sideset enable
NULL, PULL_NONE, // No jump pin
PIO_PINMASK_NONE, // No wait GPIO
true, // Exclusive pin use
false, 32, false, // OUT shift: no autopull, 32-bit, shift left
false, // Don't wait for txstall
false, 32, false, // IN shift (unused)
false, // Not user-interruptible
0, -1, // Wrap: whole program
PIO_ANY_OFFSET,
PIO_FIFO_TYPE_DEFAULT,
PIO_MOV_STATUS_DEFAULT,
PIO_MOV_N_DEFAULT
);

self->playing = false;
audio_dma_init(&self->dma);
}

bool common_hal_mtm_hardware_dacout_deinited(mtm_hardware_dacout_obj_t *self) {
return common_hal_rp2pio_statemachine_deinited(&self->state_machine);
}

void common_hal_mtm_hardware_dacout_deinit(mtm_hardware_dacout_obj_t *self) {
if (common_hal_mtm_hardware_dacout_deinited(self)) {
return;
}
if (common_hal_mtm_hardware_dacout_get_playing(self)) {
common_hal_mtm_hardware_dacout_stop(self);
}
common_hal_rp2pio_statemachine_deinit(&self->state_machine);
audio_dma_deinit(&self->dma);
}

void common_hal_mtm_hardware_dacout_play(mtm_hardware_dacout_obj_t *self,
mp_obj_t sample, bool loop) {

if (common_hal_mtm_hardware_dacout_get_playing(self)) {
common_hal_mtm_hardware_dacout_stop(self);
}

uint8_t bits_per_sample = audiosample_get_bits_per_sample(sample);
if (bits_per_sample < 16) {
bits_per_sample = 16;
}

uint32_t sample_rate = audiosample_get_sample_rate(sample);
uint8_t channel_count = audiosample_get_channel_count(sample);
if (channel_count > 2) {
mp_raise_ValueError(MP_COMPRESSED_ROM_TEXT("Too many channels in sample."));
}

// PIO clock = sample_rate × clocks_per_sample
common_hal_rp2pio_statemachine_set_frequency(
&self->state_machine,
(uint32_t)sample_rate * MCP4822_CLOCKS_PER_SAMPLE);
common_hal_rp2pio_statemachine_restart(&self->state_machine);

// DMA feeds unsigned 16-bit samples. The PIO discards the top 4 bits
// of each 16-bit half and uses the remaining 12 as DAC data.
// RP2040 narrow-write replication: 16-bit DMA write → same value in
// both 32-bit FIFO halves → mono-to-stereo for free.
audio_dma_result result = audio_dma_setup_playback(
&self->dma,
sample,
loop,
false, // single_channel_output
0, // audio_channel
false, // output_signed = false (unsigned for MCP4822)
bits_per_sample, // output_resolution
(uint32_t)&self->state_machine.pio->txf[self->state_machine.state_machine],
self->state_machine.tx_dreq,
false); // swap_channel

if (result == AUDIO_DMA_DMA_BUSY) {
common_hal_mtm_hardware_dacout_stop(self);
mp_raise_RuntimeError(MP_COMPRESSED_ROM_TEXT("No DMA channel found"));
} else if (result == AUDIO_DMA_MEMORY_ERROR) {
common_hal_mtm_hardware_dacout_stop(self);
mp_raise_RuntimeError(MP_COMPRESSED_ROM_TEXT("Unable to allocate buffers for signed conversion"));
} else if (result == AUDIO_DMA_SOURCE_ERROR) {
common_hal_mtm_hardware_dacout_stop(self);
mp_raise_RuntimeError(MP_COMPRESSED_ROM_TEXT("Audio source error"));
}

self->playing = true;
}

void common_hal_mtm_hardware_dacout_pause(mtm_hardware_dacout_obj_t *self) {
audio_dma_pause(&self->dma);
}

void common_hal_mtm_hardware_dacout_resume(mtm_hardware_dacout_obj_t *self) {
audio_dma_resume(&self->dma);
}

bool common_hal_mtm_hardware_dacout_get_paused(mtm_hardware_dacout_obj_t *self) {
return audio_dma_get_paused(&self->dma);
}

void common_hal_mtm_hardware_dacout_stop(mtm_hardware_dacout_obj_t *self) {
audio_dma_stop(&self->dma);
common_hal_rp2pio_statemachine_stop(&self->state_machine);
self->playing = false;
}

bool common_hal_mtm_hardware_dacout_get_playing(mtm_hardware_dacout_obj_t *self) {
bool playing = audio_dma_get_playing(&self->dma);
if (!playing && self->playing) {
common_hal_mtm_hardware_dacout_stop(self);
}
return playing;
}
38 changes: 38 additions & 0 deletions ports/raspberrypi/boards/mtm_computer/module/DACOut.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// This file is part of the CircuitPython project: https://circuitpython.org
//
// SPDX-FileCopyrightText: Copyright (c) 2026 Tod Kurt
//
// SPDX-License-Identifier: MIT

#pragma once

#include "common-hal/microcontroller/Pin.h"
#include "common-hal/rp2pio/StateMachine.h"

#include "audio_dma.h"
#include "py/obj.h"

typedef struct {
mp_obj_base_t base;
rp2pio_statemachine_obj_t state_machine;
audio_dma_t dma;
bool playing;
} mtm_hardware_dacout_obj_t;

void common_hal_mtm_hardware_dacout_construct(mtm_hardware_dacout_obj_t *self,
const mcu_pin_obj_t *clock, const mcu_pin_obj_t *mosi,
const mcu_pin_obj_t *cs);

void common_hal_mtm_hardware_dacout_deinit(mtm_hardware_dacout_obj_t *self);
bool common_hal_mtm_hardware_dacout_deinited(mtm_hardware_dacout_obj_t *self);

void common_hal_mtm_hardware_dacout_play(mtm_hardware_dacout_obj_t *self,
mp_obj_t sample, bool loop);
void common_hal_mtm_hardware_dacout_stop(mtm_hardware_dacout_obj_t *self);
bool common_hal_mtm_hardware_dacout_get_playing(mtm_hardware_dacout_obj_t *self);

void common_hal_mtm_hardware_dacout_pause(mtm_hardware_dacout_obj_t *self);
void common_hal_mtm_hardware_dacout_resume(mtm_hardware_dacout_obj_t *self);
bool common_hal_mtm_hardware_dacout_get_paused(mtm_hardware_dacout_obj_t *self);

extern const mp_obj_type_t mtm_hardware_dacout_type;
Loading
Loading