diff --git a/examples/spi_dac/README.md b/examples/spi_dac/README.md
index ff0c1a8e2128a86741e6bbcc7bf7dab072f7dc61..7693f5d14376784a6b91c4c74a94f52fc1892cc3 100644
--- a/examples/spi_dac/README.md
+++ b/examples/spi_dac/README.md
@@ -1,5 +1,51 @@
 # SPI DAC Demo
-This example shows how to set up the SPI port with circular DMA and IRQs to do
-continuous audio output through a DAC.
+This example shows how to set up the SPI port with a timer, circular DMA and
+IRQs to do continuous audio output through an inexpensive I2S DAC.
+
+![Scope image of sine and sawtooth waves](oscope.png) 
+
+## Theory
+The CH32V003 does not have an I2S port which is usually required for driving
+audio DACs, but the inexpensive PT8211 stereo audio DAC is very forgiving of
+the signal format used to drive it so we can approximate an I2S signal using
+one of the CH32V003 on-chip timers to generate the frame sync signal. The SPI
+port then provides the bit clock and serial data.
+
+### Timer setup
+TIM1 on the CH32V003 is set up to generate a 48kHz square wave which is output
+on GPIO pin PC4 and serves as the frame sync or WS. The timer is configured to
+run in center-aligned mode 3 which generates DMA requests on both rising and
+falling edges. Channel 4 is configured as the output and the threshold is set
+to 50%.
+
+### SPI setup
+SPI1 is configured for 16-bit TX with a bit clock of 48MHz/8 (3MHz) which is
+fast enough to clock out 16 bits of data between the edges of the frame sync
+signal. Transmit data arrives via DMA, but the SPI port does not control DMA -
+that is triggered from the timer above.
+
+### DMA setup
+DMA1 channel 4 is used because that channel is connected to TIM1 Chl 4 output
+and is set up in circular mode with both Half-transfer and Transfer-Complete
+interrupts. It continuously pulls data out of a 32-word buffer and sends it to
+the SPI port when the timer fires.
+
+### Interrupts
+The DMA TC and HT IRQs trigger execution of a buffer fill routine which simply
+indexes through a sinewave table at variable rates using fixed-point math to
+fill the buffer as requested. This process uses up only about 4% of the
+available CPU cycles so there's plenty leftover for foreground processing or
+more complex waveform calculations like interpolation or synthesis.
 
 ## Use
+Connect a PT8211 DAC as follows:
+* DAC pin 1 (BCK) - MCU pin 15 (PC5/SCK)
+* DAC pin 2 (WS)  - MCU pin 14 (PC4/T1CH4)
+* DAC pin 3 (DIN) - MCU pin 16 {PC6/MOSI)
+* DAC pin 4 (GND) - ground
+* DAC pin 5 (VCC) - 3.3V or 5V supply
+* DAC pin 6 (LCH) - left channel output
+* DAC pin 8 (RCH) - right channel output
+
+Connect an oscilloscope to the left and right channel outputs and observe
+sine and sawtooth waves at different frequencies.
diff --git a/examples/spi_dac/oscope.png b/examples/spi_dac/oscope.png
new file mode 100644
index 0000000000000000000000000000000000000000..5f1bb342e937763e11fb6447903d830a9d6ec603
Binary files /dev/null and b/examples/spi_dac/oscope.png differ
diff --git a/examples/spi_dac/spidac.h b/examples/spi_dac/spidac.h
index 1f2c253f3ad57fffa6e7fffa8ca38e5029990021..e8242b9321abaada92d2d6c7bcd513cc15de2c26 100644
--- a/examples/spi_dac/spidac.h
+++ b/examples/spi_dac/spidac.h
@@ -7,12 +7,13 @@
 #define _SPIDAC_H
 
 #include <stdint.h>
+#include "Sine16bit.h"
 
 // uncomment this to enable GPIO diag
 #define DAC_DIAG
 
 // uncomment this to fill the buffer with static test data
-#define DAC_STATIC
+//#define DAC_STATIC
 
 // uncomment this for timer-generated DMA
 #define DAC_TIMDMA
@@ -20,6 +21,7 @@
 #define DACBUFSZ 32
 
 uint16_t dac_buffer[DACBUFSZ];
+uint32_t osc_phs[2], osc_frq[2];
 
 /*
  * initialize SPI and DMA
@@ -27,13 +29,20 @@ uint16_t dac_buffer[DACBUFSZ];
 void spidac_init( void )
 {
 #ifdef DAC_STATIC
+	// fill output buffer with diag data
 	uint16_t data = 0xffff;
 	for(int i=0;i<DACBUFSZ;i++)
 	{
 		/* just a full-scale ramp for now */
 		dac_buffer[i] = data;
 		data >>= 1;
-	}	
+	}
+#else
+	// init two oscillators
+	osc_phs[0] = 0;
+	osc_phs[1] = 0;
+	osc_frq[0] = 0x01000000;
+	osc_frq[1] = 0x00400000;
 #endif
 	
 	// Enable DMA + Peripherals
@@ -61,36 +70,11 @@ void spidac_init( void )
 	SPI1->CTLR1 = 
 		SPI_NSS_Soft | SPI_CPHA_1Edge | SPI_CPOL_Low | SPI_DataSize_16b |
 		SPI_Mode_Master | SPI_Direction_1Line_Tx |
-		SPI_BaudRatePrescaler_32;
-
-#ifndef DAC_TIMDMA
-	// SPI generates DMA Req
-	SPI1->CTLR2 = SPI_CTLR2_TXDMAEN;
-	//SPI1->HSCR = 1;
+		SPI_BaudRatePrescaler_16;
 
 	// enable SPI port
 	SPI1->CTLR1 |= CTLR1_SPE_Set;
 
-	//DMA1_Channel3 is for SPI1TX
-	DMA1_Channel3->PADDR = (uint32_t)&SPI1->DATAR;
-	DMA1_Channel3->MADDR = (uint32_t)dac_buffer;
-	DMA1_Channel3->CNTR  = DACBUFSZ;
-	DMA1_Channel3->CFGR  =
-		DMA_M2M_Disable |		 
-		DMA_Priority_VeryHigh |
-		DMA_MemoryDataSize_HalfWord |
-		DMA_PeripheralDataSize_HalfWord |
-		DMA_MemoryInc_Enable |
-		DMA_Mode_Circular |
-		DMA_DIR_PeripheralDST |
-		DMA_IT_TC | DMA_IT_HT;
-
-	NVIC_EnableIRQ( DMA1_Channel3_IRQn );
-	DMA1_Channel3->CFGR |= DMA_CFGR1_EN;
-#else
-	// enable SPI port
-	SPI1->CTLR1 |= CTLR1_SPE_Set;
-
 	// TIM1 generates DMA Req and external signal
 	// Enable TIM1
 	RCC->APB2PCENR |= RCC_APB2Periph_TIM1;
@@ -112,7 +96,7 @@ void spidac_init( void )
 	TIM1->PSC = 0x0000;
 	
 	// Auto Reload - sets period to ~47kHz
-	TIM1->ATRLR = 1023;
+	TIM1->ATRLR = 499;
 	
 	// Reload immediately
 	TIM1->SWEVGR |= TIM_UG;
@@ -124,7 +108,7 @@ void spidac_init( void )
 	TIM1->CHCTLR2 |= TIM_OC4M_2 | TIM_OC4M_1;
 	
 	// Set the Capture Compare Register value to 50% initially
-	TIM1->CH4CVR = 512;
+	TIM1->CH4CVR = 256;
 	
 	// Enable TIM1 outputs
 	TIM1->BDTR |= TIM_MOE;
@@ -151,7 +135,6 @@ void spidac_init( void )
 
 	NVIC_EnableIRQ( DMA1_Channel4_IRQn );
 	DMA1_Channel4->CFGR |= DMA_CFGR1_EN;
-#endif
 }
 
 /*
@@ -159,53 +142,22 @@ void spidac_init( void )
  */
 void dac_update(uint16_t *buffer)
 {
-}
-
-#ifndef DAC_TIMDMA
-/*
- * SPI DMA IRQ Handler
- */
-void DMA1_Channel3_IRQHandler( void ) __attribute__((interrupt));
-void DMA1_Channel3_IRQHandler( void ) 
-{
-#ifdef DAC_DIAG
-	GPIOD->BSHR = 1;
-#endif
+	int i;
 	
-	// why is this needed? Can't just direct compare the reg in tests below
-	volatile uint16_t intfr = DMA1->INTFR;
-
-	if( intfr & DMA1_IT_TC3 )
+	// fill the buffer with stereo data
+	for(i=0;i<DACBUFSZ/2;i+=2)
 	{
-		// Transfer complete - update 2nd half
-		dac_update(dac_buffer+DACBUFSZ/2);
-		
-		// clear TC IRQ
-		DMA1->INTFCR = DMA1_IT_TC3;
-		
-		GPIOC->BSHR = (1<<1); // NSS 1
-	}
-	
-	if( intfr & DMA1_IT_HT3 )
-	{
-		// Half transfer - update first half
-		dac_update(dac_buffer);
-		
-		// clear HT IRQ
-		DMA1->INTFCR = DMA1_IT_HT3;
+		// right chl
+		*buffer++ = Sine16bit[osc_phs[0]>>24];
+		osc_phs[0] += osc_frq[0];
 		
-		GPIOC->BSHR = (1<<(1+16)); // NSS 0
+		// left chl
+		//*buffer++ = Sine16bit[osc_phs[1]>>24];
+		*buffer++ = osc_phs[1]>>16;
+		osc_phs[1] += osc_frq[1];
 	}
-
-	// clear the Global IRQ
-	DMA1->INTFCR = DMA1_IT_GL3;
-	
-#ifdef DAC_DIAG
-	GPIOD->BSHR = 1<<16;
-#endif
 }
 
-#else
 /*
  * TIM1CH4 DMA IRQ Handler
  */
@@ -248,8 +200,4 @@ void DMA1_Channel4_IRQHandler( void )
 	GPIOD->BSHR = 1<<16;
 #endif
 }
-
-#endif
-
-
 #endif