The dsp-G1 synth on the ATtiny85

The dsp-G1 synth chip

The dsp-G1 was one great piece of virtual analog synthesizer in a single 8-pin DIP package.

It was running on the NXP LPC-810 ARM MCU which is now EOL and has been replaced by the second generation DSP synthesizer chip, dsp-G2.

As promised the dsp-G1 will be fully open-sourced and for you to be able to tinker with it the code has been transferred to the ATtiny85 AVR MCU.

Still supporting TTL-MIDI and running great on MIDI ghost-power!

Why is the code moved to AVR?

Firstly the form factor is the same, 8-pin DIP.

Secondly the code in this project will be easy to understand and port to other platforms if you choose.

Building blocks

There are 2 major basic components needed for transferring the dsp-G1 code to the ATtiny (or any other MCU):

  • A D/A converter for the audio
  • A serial UART input for the MIDI

The ATtiny does not have a DAC so we create one using PWM.

And its also lacking a UART but we can use the USI component for that one.

The GPL license

The code is free for personal use but the GPL license needs to be intact.

// (*) All in the spirit of open-source and open-hardware 
// Janost 2019 Sweden  
// The dsp-G1 virtual analog synthesizer on ATtiny85
// http://blog.dspsynth.eu/the-dsp-g1-synth-on-the-attiny85/
// Copyright 2019 DSP Synthesizers Sweden. 
// 
// Author: Jan Ostman 
// 
// This program is free software: you can redistribute it and/or modify 
//it under the terms of the GNU General Public License as published by 
// the Free Software Foundation, either version 3 of the License, or 
// (at your option) any later version. 
// This program is distributed in the hope that it will be useful, 
// but WITHOUT ANY WARRANTY; without even the implied warranty of 
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 
// GNU General Public License for more details.

First we need to setup the chip. This code takes care of setting up the PWM and USI.

The fuses are important as we want to run the Tiny on a internal 16MHz clock.

//Set Fuses to E1 DD FE for PLLCLK 16MHz

//Global variables for the MIDI handler
volatile uint8_t MIDISTATE=0;
volatile uint8_t MIDIRUNNINGSTATUS=0;
volatile uint8_t MIDINOTE;
volatile uint8_t MIDIVEL;

void setup() {
  
  // Enable 64 MHz PLL and use as source for Timer1
  PLLCSR = 1<<PCKE | 1<<PLLE;     

  // Set up Timer/Counter1 for PWM output
  TIMSK = 0;                     // Timer interrupts OFF
  TCCR1 = 1<<PWM1A | 2<<COM1A0 | 1<<CS10; // PWM A, clear on match, 1:1 prescale
  OCR1A = 128;               // 50% duty at start

  pinMode(1, OUTPUT); // Enable PWM output pin
  pinMode(0, INPUT);  // Enable USI input pin

  //Setup the USI
  USICR = 0;          // Disable USI.
  GIFR = 1<<PCIF;     // Clear pin change interrupt flag.
  GIMSK |= 1<<PCIE;   // Enable pin change interrupts
  PCMSK |= 1<<PCINT0; // Enable pin change on pin 0

  uint16_t dummy=analogRead(0); //Setup the ADC
  sbi(ADCSRA, ADSC); //start next conversation
}

Next we need a pinchange interrupt to handle the start bit in the MIDI serial input.

MIDI is serial data at 31250bits/s so one bit time is 32 microseconds.

The start of a byte causes a pin-change interrupt. In the pin-change interrupt service routine we check that it’s a falling edge, and if so we set up Timer/Counter0 in CTC mode. We want to set up a delay of half a bit, to get into the middle of the start bit. The closest we can get to that is a prescaler of 8 and a compare match of 32. Finally we clear and enable the output compare interrupt

ISR (PCINT0_vect) {
  if (!(PINB & 1<<PINB0)) {       // Ignore if DI is high
    GIMSK &= ~(1<<PCIE);          // Disable pin change interrupts
    TCCR0A = 2<<WGM00;            // CTC mode
    TCCR0B = 0<<WGM02 | 2<<CS00;  // Set prescaler to /8
    TCNT0 = 0;                    // Count up from 0
    OCR0A = 31;                   // Delay (31+1)*8 cycles
    TIFR |= 1<<OCF0A;             // Clear output compare flag
    TIMSK |= 1<<OCIE0A;           // Enable output compare interrupt
  }
}

The compare match interrupt occurs in the middle of the start bit. In the compare match interrupt service routine we reset the compare match to the duration of one bit, 64 (32uS), enable the USI to start shifting in the data bits on the next compare match, and enable the USI overflow interrupt.

ISR (TIMER0_COMPA_vect) {
  TIMSK &= ~(1<<OCIE0A);          // Disable COMPA interrupt
  TCNT0 = 0;                      // Count up from 0
  OCR0A = 63;                    // Shift every (63+1)*8 cycles 32uS
  // Enable USI OVF interrupt, and select Timer0 compare match as USI Clock source:
  USICR = 1<<USIOIE | 0<<USIWM0 | 1<<USICS0;
  USISR = 1<<USIOIF | 8;          // Clear USI OVF flag, and set counter
}

Note that we set the Wire Mode to 0 with 0<<USIWM0. This ensures that the output of the USI shift register won’t affect the audio output pin, PB1.

When 8 bits have been shifted in the USI overflow interrupt occurs. The interrupt service routine disables the USI, reads the USI shift register, and enables the pin change interrupt ready for the next byte.

ISR (USI_OVF_vect) {
  uint8_t MIDIRX;
  USICR = 0;                      // Disable USI         
  MIDIRX = USIDR;
  GIFR = 1<<PCIF;                 // Clear pin change interrupt flag.
  GIMSK |= 1<<PCIE; // Enable pin change interrupts again

  //Wrong bit order so swap it
  MIDIRX = ((MIDIRX >> 1) & 0x55) | ((MIDIRX  << 1) & 0xaa);
  MIDIRX = ((MIDIRX >> 2) & 0x33) | ((MIDIRX  << 2) & 0xcc);
  MIDIRX = ((MIDIRX >> 4) & 0x0f) | ((MIDIRX  << 4) & 0xf0);
  
  //Parse MIDI data
  if ((MIDIRX>0xBF)&&(MIDIRX<0xF8)) {
    MIDIRUNNINGSTATUS=0;
    MIDISTATE=0;
    return;
  }
  if (MIDIRX>0xF7) return;
  if (MIDIRX & 0x80) {
    MIDIRUNNINGSTATUS=MIDIRX;
    MIDISTATE=1;
    return;
  }
  if (MIDIRX < 0x80) {
    if (!MIDIRUNNINGSTATUS) return;
    if (MIDISTATE==1) {
    MIDINOTE=MIDIRX;
    MIDISTATE++;
    return;
  }
  if (MIDISTATE==2) {
    MIDIVEL=MIDIRX;
    MIDISTATE=1;
    if ((MIDIRUNNINGSTATUS==0x80)||(MIDIRUNNINGSTATUS==0x90)) handleMIDINOTE(MIDIRUNNINGSTATUS,MIDINOTE,MIDIVEL);
    if (MIDIRUNNINGSTATUS==0xB0) handleMIDICC(MIDINOTE,MIDIVEL);
    return;
  }
}

The UART sends the bits LSB first, whereas the USI assumes that the MSB is first, so we need to reverse the order of the bits after reception.

The MIDI parser is also included here.

Running status is what 99% of all MIDI synths and sequencers do today and it means that if the status byte is the same as the previous one its not transmitted which results in less bytes on the line.

Handling “Running status”
1. Buffer is cleared (ie, set to 0) at power up.
2. Buffer stores the status when a Voice Category Status (ie, 0x80 to 0xEF) is received.
3. Buffer is cleared when a System Common Category Status (ie, 0xF0 to 0xF7) is received.
4. Nothing is done to the buffer when a RealTime Category message is received.
5. Any data bytes are ignored when the buffer is 0.

The parser handles running status and calls the noteon/noteoff and CC handlers.

The 0x80, 0x90 and 0xB0 bytes here is MIDI status for Note-on, Note-off and MIDI-CC at channel #01. If you want some other receive channel, say channel #02, you change the lower nibble to 0x01, i.e 0x91.

Also a majority of transmitters use Note-on with zero velocity for Note-off.

The code above is a skeleton that can be used for running any MIDI synthesizer on the ATtiny85 as long as it fits in the 8KB flash.

Now that we have our main blocks next up is the sample engine.

Sample engine

By now we have used up the 2 timers that the ATtiny85 have.
So how can we time the samples?

The ADC converter on the chip always completes 13 CPU clock cycles from when it’s triggered.

By starting the next conversation when one is finished we have a timing that is CPUclock/ADCprescaler/13. Default that is 9615Hz but can be increased.

Also we are going to use a ringbuffer to feed the samples to the DAC. That way the rest of the code can compute ahead of the buffer and keep up.

//——— Ringbuf parameters ———-
uint8_t Ringbuffer[32]; //Use a sample FIFO of 32 bytes
uint8_t RingWrite=0; //FIFO head
uint8_t RingRead=0;  //FIFO tail
uint8_t RingCount=0; //Unread bytes in FIFO
//—————————————–

void loop() { //Synth engine main loop
  //Keep FIFO sample accurate
  if (!(ADCSRA & 64)) { //Wait for ADC to complete
    sbi(ADCSRA, ADSC); //start next conversation
    if (RingCount) { //If entry in FIFO
      OCR1A = Ringbuffer[(RingRead++)]; //Output byte to 8-bit DAC
      RingCount–; //Drop byte in FIFO
      RingRead&=31; //Keep pointer in bounds
    }
  }
  
  //Do the synthesizer stuff
  if (RingCount<31) { //if space in ringbuffer
  
    //Here goes the synthesizer sample calculator

    Ringbuffer[RingWrite]=DAC; //Write the sample
    RingWrite++;
    RingCount++; //Add byte in FIFO
    RingWrite&=31; //Keep pointer in bounds
  }
}

Our sample engine uses a ringbuffer of 32 bytes to time the samples.

With the FIFO our synth engine can calculate up to 32 samples ahead of time and at busy times still be sample accurate.

The comment “//Here goes the synthesizer sample calculator” is where we actually add our synthesizer code for calculating the next sample.

The latency of the sampler (time from hitting a note and hearing it) is at 9615sps about 3.3mS, pretty good.

The dsp-G1 synth engine