Tiny Ensemble - an Attiny85 powered chorus effect

Started by Ksander, October 27, 2023, 04:33:25 PM

Previous topic - Next topic

Ksander

Demo: https://drive.google.com/file/d/1_1SSOmZFlLFsfo6QO0Em526tlwOML5Ju/view?usp=sharing

I have previously built a pitch shifter using an attiny85, and figured the code might be suitable for a chorus with a few modifications. In the adapted code, the attiny85 can produce a delay up to 14-something ms, and this delay is varied between the max value and 0ms over a period of about 7 seconds; following a triangular waveform. The output is then combined with the (buffered) input signal to yield a chorus effect. It works, and is indeed chorusy! But, there is still some noise -- consider this a first version.

Attached are the code and schematic. Input to improve the code and/or schematic would be much appreciated (also the schematic may be wrong, it is drawn from memory). Considering how complicated chorus effects typically are, I figure this has some serious DIY potential.


/* Attiny85 Chorus by Ksander de Winkel 27th October 2023

   8-Bit Variable delay for audio (guitar/microphone) signals.
   Combining the (scaled down) output with the original input signal yields a chorus effect.
   The maximum delay is 14.3ms. The amount can be scaled by changing the value of Shift.
   The period of the variation can be changed by setting the wrapping value of Counter.
   Note that these variables affect each other.
 
   Based on Attiny85 pitch shifter by David Johnson-Davies - www.technoblogy.com - 11th February 2017
   ATtiny85 @ 8MHz (internal oscillator; BOD disabled)
   
   CC BY 4.0
   Licensed under a Creative Commons Attribution 4.0 International license:
   http://creativecommons.org/licenses/by/4.0/

*/

volatile uint8_t Buffer[256];               // Circular buffer
volatile uint8_t WritePtr, LastPtr, New, Ptr, Counter, Shift;
volatile bool Direction = true;
// Everything done by interrupts

// Write to buffer when ADC conversion is complete
ISR (ADC_vect) {

  Buffer[LastPtr] = New;
  New = ADCH + 128;
  Buffer[WritePtr] = (Buffer[WritePtr] + New)>>1;
  LastPtr = WritePtr;
  WritePtr = (WritePtr + 1) & 0xFF;

}

// Read from buffer and output to DAC
ISR (TIMER0_COMPA_vect) {

  Counter = (Counter + 1) & 0xFF;

  if (Counter == 0) {
   
    if (Direction) {
      Shift = (Shift + 1) & 0xFF;
    } else {
      Shift = (Shift - 1) & 0xFF;
    }

    if (Shift == 255 | Shift == 0) Direction = !Direction;
   
  }
 
  Ptr = (LastPtr - Shift) & 0xFF;

  OCR1A = Buffer[Ptr];

}

// Setup

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 OCR1A, clear on match, 1:1 prescale
  OCR1A = 128;
  pinMode(1, OUTPUT);                     // Enable OC1A PWM output pin

  // Set up Timer/Counter0 to generate 20kHz interrupt
  TCCR0A = 2<<WGM00;                      // CTC mode
  TCCR0B = 2<<CS00;                       // /8 prescaler
  OCR0A = 55;                             // 17.9kHz interrupt (1M/56)
  TIMSK = TIMSK | 1<<OCIE0A;              // Enable interrupt
 
  // Set up ADC
  ADMUX = 6<<REFS0 | 1<<ADLAR | 7<<MUX0;  // Internal 2.56V ref, ADC2 vs ADC3, x20
  // Enable, auto trigger, interrupt, 250kHz ADC clock:
  ADCSRA = 1<<ADEN | 1<<ADSC | 1<<ADATE | 1<<ADIE | 5<<ADPS0; 
  ADCSRB = 1<<7 | 0<<ADTS0;               // Bipolar, free-running

}

void loop () {
}

updated schematic:


Ksander

Tiny improvement: add

pinMode(#,INPUT_PULLUP);
with #=0 and another line with #=2, to the setup. This defines the levels on pin pb0 and pb2, and might reduce some noise as well(?).

ElectricDruid

#2
This is a very interesting bit of work, Ksander. Nice one!

Looking at the hardware for a minute, one reason it's so simple is because it leaves out any filtering. A BBD-based chorus (or *any* sampling system, really) would usually include a filter ahead of the delay to eliminate any frequencies that would cause aliasing when sampled. There would also be another filter after the delay to smooth the output and turn the digital output back into an analogue signal.
Both of these filters are designed to remove noise of certain type - whether that's what your circuit has a problem with, I can't say. With a PWM output, proper filtering to remove the PWM clock would be especially important.

I've got some questions too.

It looks from the code like the sample rate is 17.9KHz. And since everything seems to be done with bytes, it looks like we've got 8 bit ADC input? Is that right?
The output is then done with PWM module. What frequency is this? again, it's 8-bit presumably?

There's a couple of things I don't get in the code too.

The ADC set up says "bipolar" which I take to mean "signed -128 to +127", but then the data we use everywhere is unsigned 0-255, and at one point we even add an offset to remove the negative values (is that what it's doing?):

  New = ADCH + 128;
Would it be better to set the ADC up for unipolar values instead?

This line in the ADC ISR doesn't seem to do anything:

  Buffer[LastPtr] = New;
because it is followed by this:

  New = ADCH + 128;
Finally, the way the LFO is produced is interesting. First it uses the counter variable to effectively divide the samaple rate by 256, so 17900/256 = 69.92Hz. It then uses this slower rate to count up from 0 to 255 and then back down from 255 to 0 again. That's 512 steps, and 69.92/512 = 0.1365 Hz = 7.3seconds per wave.
One improvement would be to use the 8 bits of the Counter variable to produce interpolation between one sample and the next. At the moment the code outputs the same sample 256 times before then jumping to the next one. That abrupt jump could be smoothed by doing a linear interpolation across the 256 samples instead - crossfading from one to the other, essentially. This would reduce some noise.

There's a theoretical noise floor due to the 8-bit sampling which isn't very low, so we're never going to be able to do better than that, but it might be nice to get as close as we can!


Ksander

#3
Thanks for the elaborate reply!

Actually, there is (or should be) some filtering; there is an 100pF cap to ground after the 220k resistor of the output voltage divider, which is intended as a ~ 8kHz LP filter, and then there is a 2.2nF cap at the junction of the buffered input signal and attiny output, which both have a 10kOhm resistor in front of them; also intended as an 8kHz LP filter. I'm however not quite certain whether it is implemented correctly, so suggestions are also very welcome in this respect!

EDIT: and there is a LP filter before the Attiny85 input with Fc ~ 16kHz (10k/1nF), which I forgot to draw (now updated in the schematic). I guess the Fc could be lowered.

To answer your questions (as best I can):
* The PWM output is updated at the same rate as the ADC; the IC runs at 8mHz, there is a factor 8 prescaler and then a division by 56.

* Considering the implementation of the ADC - I'm not sure about the bipolar part either. This comes from the original code from technoblogy. The ADC uses two pins, one is connected to ground, the other is the signal. Maybe that is what is bipolar. Further processing is indeed done on unsigned chars, and the +128 indeed gets the signal in that range. I have previously tried using just a single ADC input (unipolar?) with an internal reference, but this was a lot more noisy.

*The Buffer[LastPtr] = New; is also directly from the old code. I believe it is functional however; the way it is explained on technoblogy indicates that the code is to average two consecutive samples.

*The LFO is my contribution. It indeed works as you describe. I'm not sure what you mean by linear interpolation however; the LFO gives varies the delay by changing the read-index relative to the write-index. Since the buffer is only 256 samples, smaller steps than 1 (once every 256 iterations of the ISR) are not possible?

mzy12

This is very interesting! Something I wonder about on a lot of digital choruses/delays - I wonder if it could be improved with companding and emphasis/de-emphasis filters? In general, the chips people use for guitar effects don't have a lot of headroom and although they are (or at least can be) a lot less noisy than the old BBD chips, I still think there's something to be said for the methods those pedals used to get more out those chips. If you look at boss schematics, their old digital delays used companders and they sounded excellent. I'm pretty sure their modern digital delay line CE-5 and CH-1 pedals still use the companding and emphasis & de-emphasis filters of the older BBD equiqed revisions and they still sound great for it.

Ksander

#5
Quote from: mzy12 on October 28, 2023, 10:17:14 AMThis is very interesting! Something I wonder about on a lot of digital choruses/delays - I wonder if it could be improved with companding and emphasis/de-emphasis filters? In general, the chips people use for guitar effects don't have a lot of headroom and although they are (or at least can be) a lot less noisy than the old BBD chips, I still think there's something to be said for the methods those pedals used to get more out those chips. If you look at boss schematics, their old digital delays used companders and they sounded excellent. I'm pretty sure their modern digital delay line CE-5 and CH-1 pedals still use the companding and emphasis & de-emphasis filters of the older BBD equiqed revisions and they still sound great for it.

A BBD chorus was the inspiration to try this. however, those IC's are too expensive imo. Attiny's cost only a fraction of the price!

Regarding the companding, I'm not sure how to do that; at least not in a simple way. One thing I thought would reduce noise is that the signal is amplified in the ADC by a factor 20, and then the Attiny85's output is scaled down back to input signal level using a voltage divider. I figured this would also reduce noise by a factor 20. Not sure if the thinking is correct though...

niektb

Quote from: ElectricDruid on October 28, 2023, 06:43:20 AM[...]

The ADC set up says "bipolar" which I take to mean "signed -128 to +127", but then the data we use everywhere is unsigned 0-255, and at one point we even add an offset to remove the negative values (is that what it's doing?):

  New = ADCH + 128;
Would it be better to set the ADC up for unipolar values instead?

This line in the ADC ISR doesn't seem to do anything:

  Buffer[LastPtr] = New;
because it is followed by this:

  New = ADCH + 128;
[...]
I tihnk the ADC inside the ATTiny85 is 10-bit so I'm not sure what this +128 is about. Is that because you're using 1.1V as Vref? I'm not super familiar with the ADC inside the Tiny but what I would suggest is a unipolar operation with 5V Vref so you get values between 0 and 1024 but then you truncate the 2 LSBs to fit it inside the buffer, makes it a bit more easy to understand I'd say.

What you could also try (if memory allows) is to raise the buffer type to uint16_t and use a Dual PWM output (where one port outputs the least significant bits and the other the most):

Should raise the output resolution to about 14-bits and effectively reduce the noise-floor somewhat.

mzy12

Quote from: Ksander on October 28, 2023, 10:23:42 AMRegarding the companding, I'm not sure how to do that; at least not in a simple way. One thing I thought would reduce noise is that the signal is amplified in the ADC by a factor 20, and then the Attiny85's output is scaled down back to input signal level using a voltage divider. I figured this would also reduce noise by a factor 20. Not sure if the thinking is correct though...
The problem here is that there is no way that the Attiny85 has enough headroom to deal with that. A typical guitar pickup has, at it's most extreme, a peak to peak voltage of around 2V. Scaling that up by a factor of 20 would mean that the Attiny85 would have to have the capability of handling a 40V peak to peak signal at it's ADC and also have a >40Vdc supply, which it doesn't and you definitely can't respectively. A compander could allow you to limit the incoming signal THEN bring it up to max Vin of the ADC (which I can't actually find any definitive information on, but the peak to peak of said signal physically can't be more than the supply voltage.), which would give you a better S/N ratio. Don't forget the expanding part of the equation!

Will be following your progress on this  :D

Ksander

Quote from: mzy12 on October 28, 2023, 12:20:30 PM
Quote from: Ksander on October 28, 2023, 10:23:42 AMRegarding the companding, I'm not sure how to do that; at least not in a simple way. One thing I thought would reduce noise is that the signal is amplified in the ADC by a factor 20, and then the Attiny85's output is scaled down back to input signal level using a voltage divider. I figured this would also reduce noise by a factor 20. Not sure if the thinking is correct though...
The problem here is that there is no way that the Attiny85 has enough headroom to deal with that. A typical guitar pickup has, at it's most extreme, a peak to peak voltage of around 2V. Scaling that up by a factor of 20 would mean that the Attiny85 would have to have the capability of handling a 40V peak to peak signal at it's ADC and also have a >40Vdc supply, which it doesn't and you definitely can't respectively. A compander could allow you to limit the incoming signal THEN bring it up to max Vin of the ADC (which I can't actually find any definitive information on, but the peak to peak of said signal physically can't be more than the supply voltage.), which would give you a better S/N ratio. Don't forget the expanding part of the equation!

Will be following your progress on this  :D

That explains the clipping when I strum hard!

mzy12

Yep, that would do it! I think the Attiny ADC max Vin is about half the supply voltage from what I understand?

Ksander

#10
Quote from: niektb on October 28, 2023, 12:13:24 PM[...]

I tihnk the ADC inside the ATTiny85 is 10-bit so I'm not sure what this +128 is about. Is that because you're using 1.1V as Vref? I'm not super familiar with the ADC inside the Tiny but what I would suggest is a unipolar operation with 5V Vref so you get values between 0 and 1024 but then you truncate the 2 LSBs to fit it inside the buffer, makes it a bit more easy to understand I'd say.

What you could also try (if memory allows) is to raise the buffer type to uint16_t and use a Dual PWM output (where one port outputs the least significant bits and the other the most):

Should raise the output resolution to about 14-bits and effectively reduce the noise-floor somewhat.


The ADC is indeed 10 bits, but only the 8 most significant bits are read out when calling ADCH. I have tried to work with a 10 bit reading in the pitch shifter, but gave up. It is interesting though - maybe it can work with two PWM outputs, but I don't know if there are enough PWM pins and also not if there are enough timers available for the ADC, and two PWM. Can two PWM run on the same timer? Let's dive back into the datasheet...

EDIT: it looks like PB0 and PB1 can be both run from timer0, but I realized that there is not enough memory for a 256 sample 16-bit buffer. Maybe there is another way - storing part of a sample in the 6 unused bits of a 16 bit variable? This gets complicated!
EDIT 2: The waveform is probably not a triangle... I need to reconsider the code!
EDIT 3: The code has been updated in the opening post for the triangle waveform and to set the ADC reference to 2.56V.

cordobes

You have to use oversampling and decimation. I have tried taking the ADC to 1MHz, and it already compromises the third least significant bit. The best compromise solution is 500 kHz, take the ADH data and store it, then gain 2 bits of dynamic range through 16 samples. You would have a bandwidth of 15.6 kHz, and 10 effective bits, which you could take to a dual PWM DAC scheme.
Because a chorus does not do too much processing, with a 64 MHz clock it is perfectly feasible to do between samples.
There are other solutions to gain more dynamic range, but they involve other techniques and different hardware, which this little micro unfortunately does not have.
I hope you find it useful
And all our yesterdays have lighted fools the way to dusty death.
Out, out, brief candle! Life's but a walking shadow.

niektb

I was thinking similar as Cordobas, what you could do is run your buffer at a lower sample rate but at 16-bit. You could for example sum 2 adjacent adc samples together (the sum of 2 10-bit numbers fit inside 16-bit) and store that single number in the buffer. On the output you use interpolation to bump up to the higher sample rate.

Not sure if lower sample-rate storage but higher resolution improves the end result compared to 8-bit all the way :)

cordobes

#13
Definitely any improvement is always welcome, the result of gaining 2 bits of dynamic range by adding 16 samples (oversampling) and corresponding right shift (decimation), is appreciable, and the implementation is trivial. And it is what makes the difference with other kind of developments with basic HW.
If you add only 2 samples you get half a bit.
One thing that has not been brought into play in this design is the use of pre and de-emphasis.
And another issue is that it does not make sense, as has been discussed before, is to use 2's complement or other data handling like IQ, when you are only going to move through a circular buffer, and you are modulating the pointers.
And all our yesterdays have lighted fools the way to dusty death.
Out, out, brief candle! Life's but a walking shadow.

mzy12

Quote from: cordobes on October 28, 2023, 06:27:46 PMOne thing that has not been brought into play in this design is the use of pre and de-emphasis.
How much would that affect the ADC input of an Attiny85? Does it perform better at some frequencies than others? Maybe I'm on the barking up the wrong tree here, but I do think companding would be a sure fire way to increase S/N ratio. If pre and de-emphasis filters also help, that would be great  :)

cordobes

I haven't tried it, but I find no reason to replace a 1024 stage BBD with an attiny processing z^-1024 in a circuit like a ce-2, and not take advantage of its analog design. It's different headroom, but nothing that a gain recalculation can't optimize.
Companding would add additional stages, but it shouldn't be ruled out either.
And all our yesterdays have lighted fools the way to dusty death.
Out, out, brief candle! Life's but a walking shadow.

mzy12

I must admit my knowledge here mostly ends at the analogue domain. I've not tried programming a microcontroller like this for a pedal, and my suggestions come from understanding how the operate in the realm of analogue voltages. You sound like you know what you're doing in relation to the digital stuff so I most definitely will defer to you on that end!

One question, when you say this

Quote from: cordobes on October 28, 2023, 07:56:59 PMand not take advantage of its analog design

are you referring to your own pre/de-emphasis filter suggestion here? Or some other aspect of the Attiny85?

niektb

Honestly, I think compansion somewhat defeats the purpose of this pedal because of the added complexity. At that point I would first invest in a 'bigger' MCU which has the memory for higher resolution and Sample rate (you know 16-bit and >32kHz) and maybe even a true DAC pin.

Another thing I would try is try sticking a capacitance multiplier in front of the LDO, and add a 100nF as close as possible to VCC pin of the Attiny.

Ksander

Quote from: cordobes on October 28, 2023, 06:27:46 PMDefinitely any improvement is always welcome, the result of gaining 2 bits of dynamic range by adding 16 samples (oversampling) and corresponding right shift (decimation), is appreciable, and the implementation is trivial. And it is what makes the difference with other kind of developments with basic HW.
If you add only 2 samples you get half a bit.
One thing that has not been brought into play in this design is the use of pre and de-emphasis.
And another issue is that it does not make sense, as has been discussed before, is to use 2's complement or other data handling like IQ, when you are only going to move through a circular buffer, and you are modulating the pointers.

I'm sorry, but it is not trivial to me - I don't know much about programming nor hardware and generally take the empirical approach when building stuff  :icon_wink:

As I understand, writing to and reading from the buffer have to be done at the same speed (otherwise you get pitch shifting; what the original code was for). Provided that these speeds can both be reduced to 15.6kHz, the buffer could still not be much larger than 128 samples when it has to hold 16 bit values, which works out to some 8ms of delay. Or do you mean something along the lines of sampling at a higher rate, but averaging multiple samples before writing them to a buffer? Could you provide some example code?

niektb

#19
I'm not super well-versed with the Attiny register nor do I have the setup to test it so here is some pseudo-code. I think something like this could work.
// Pseudo-code, not functional!

volatile uint16_t intermediateSum = 0;
volatile uint16_t New;
volatile uint8_t adc_counter = 0;
volatile k = 0;

#define BUFFER_SIZE 256  // Adjust as needed
#define INTERPOLATION_FACTOR 4

/*
We know the ADC input is 10-bits. We can sum together 64 10-bit values in a 16-bit variable before it overflows.
Therefore we want to run the ADC at 64 times the speed of the main flow but only write to the buffer every 64th time,
storing the sum of the input values. Then we reset the intermediate values at 0 and start over.
Say the sample rate of the buffer is 8kHz, then the ADC sample rate should be 512kHz.
*/
ISR(ADC_vect) {
  intermediateSum = intermediateSum + New;
  adc_counter++;

  if (adc_counter == 64) {
    Buffer[write_pointer] = intermediateSum;
    intermediateSum = 0;
    adc_counter = 0;
    write_pointer = (write_pointer + 1) % BUFFER_SIZE;
  }
}

/*
Mow because the buffer is fairly slow, we want to output DAC values faster to move the PWM carrier out of the audible range.
For example 4 times. We use linear interpolation to determine the intermediate samples.
*/

ISR(TIMER0_COMPA_vect) {

  /* do the LFO-thingy to determine the new read pointer position */

  // k keeps track of the location between samples. so we can use it to determine the weights of this sample and the next sample. We can probably write a more efficient form of the equation below.
  uint16_t outputVal = (Buffer[read_pointer] / INTERPOLATION_FACTOR) * (INTERPOLATION_FACTOR - k) + (Buffer[(read_pointer + 1) % BUFFER_SIZE] / INTERPOLATION_FACTOR) * (k);
  // i'm not sure if it should be read_pointer + 1 or read_pointer - 1, I'm inclined to think the first. Maybe we need to use floats for the intermediate values and then cast to int to avoid losing data.

  /* Set OutputVal to ports */

  k++; // modulo operator or something similar is probably more elegant
  if (k == INTERPOLATION_FACTOR) {
    k = 0;
  }
}