New Teensy 4.1 DIY Synthesizer

Hello there..
It's been a while since the last post. In the meantime, there have been some firmware updates for the
Jeannie (see link). Among other things, Jeannie can now also do wavetables on both oscillators. To achieve this, I implemented part of the oscillator engine from the Braids
Synthesizer (Mutable Instrument).

Jeannie blog:
https://www.sequencer.de/synthesizer/threads/jeannie-polyphonic-diy-synthesizer.160564/

Video:

Video:
 
Last edited:
Interested in building a Jeannie, but TubeOhm‘s decided to stop the hw business in beginning of 2025 is an unfortunate roadblock for Jeannie prospects like me… (temporary ?)
Is there alternative hw-sources / kit ‘manufacturers’ in the works?
or has the hw project reached a dead end?

In the lack of available kits at the moment I am pondering about making my own remake of the PCB’s. ( I hope that is acceptable and don’t anger anyone, if kits were available i would have bought them)

One issue that i have identified is the contents of the fx module eeprom. Tubeohm website indicates that this one is delivered pre-programmed in the kits…. Is this content available openly?

Br.. Klas
 
The Jeannie synthesizer kit is sold by exploding-shed. If you only need the circuit boards, you can get them from me.

Link: https://www.exploding-shed.com/tubeohm-jeannie/100352
Jeannie Case here: https://www.voltmusicstore.com/de/e...5VVx65sN6XjYwQTZV81FxZfx6fK6fxJhoCa2QQAvD_BwE

and here: https://www.ericasynths.lv/shop/enclosures/studio/tubeohm-instruments-jeannie-case/

A programming adapter is required for the EEPROM in the FX module. The software is free. I have an EEPROM and display and can supply them.

Link: https://www.amazon.de/DollaTek-EEPR...pcontext&ref_=fplfs&psc=1&smid=A3SCFTIO8CSK1X

My new project "Degenerator 2" https://forum.pjrc.com/index.php?threads/new-polyhonic-teensy-diy-sampler.76219/page-2
 
Last edited:
Hallo everybody :)
The new DIY synthesizer "Jeannie 2" is in development. Its bigger, it will feature a 3.5-inch touchscreen display and a variety of waldorf encoders, knobs, and buttons for operation. Jeannie 2 will be eight-voice polyphonic, with three oscillators per voice, one of which can sample. It will include a 64-step polyphonic sequencer and arpeggiator. New digital Fx Modul with many sound function.
A wide range of oscillator synthesis options will be available, such as VA, Vowel, SuperSaw, Csaw, Vosim, wavetable synthesis with 128 different wavetables and adjustable interpolation, and more...

20260305_161401.jpg
 
Last edited:
Jeannie 2

Key function..
Boost = Bass boost on/off
Level = Master volume
HP Filter = High-pass filter on/off
HP CUT = High-pass filter cutoff setting
HP RES = High-pass filter resonance setting
Panic = All notes off
Shift = Toggles parameter or menu functions
Load = Load a patch
Save = Save a patch
Patch = Menu page for loading, saving, initializing, and modifying patches
OSC = Menu page for oscillator settings and functions
Mixer = Adjust oscillator volume, panning, and FX mix
Filter = Menu page for filter settings
ENV = Envelopes menu page
LFO = Menu page for LFOs
FX = Effects settings
SEQ/ARP = Menu page for sequencer, arpeggiator, and blue buttons
Mtune = Parameter drift
Red marker = Encoder for parameter selection and menu

Jeannie 2 Keys.jpg
 
Hello friends :)

The Jeannie 2 is equipped with a total of seven high-quality encoders. These encoders are used for parameter input via the menu and for modulation.

The Jeannie 2 is equipped with a total of seven high-quality encoders. These are used for parameter input in the menu and for modulation. Unfortunately, the Teensy 4.1 microcontroller in the Jeannie 2 doesn't have enough port pins to connect all the encoders. Therefore, I used an MCP23017 16-bit I/O expander for the encoder readings. This communicates with the Teensy 4.1 microcontroller via an I²C bus at a speed of 400 kHz.

The MCP23017 is also available with a faster SPI interface. Unfortunately, the two available SPI interfaces of the Teensy 4.1 are already in use. To still ensure sufficiently fast encoder polling, I opted for interrupt-driven polling of the MCP23017. As soon as an encoder is moved, the MCP23017 signals via its interrupt pin that the data is ready for retrieval.

The Teensy microcontroller then reads the 16-bit GPIO register of the MCP23017 and processes the encoder data. To reduce the number of read and write cycles to the MCP23017 via the I²C bus and thus increase processing speed, I read the 16-bit GPIO register of the MCP23017 only once and extract the current encoder data from it.

Soundwell encoders (also used in Waldorf Blofeld)

Screenshot 2026-02-27 233929.png
Jeannie2_KEY_Panel.jpg

C:
// Abfrage für den Seven-Encoder auf Teensy und MCP23017
//

#include <Arduino.h>
#include <Wire.h>
#include <avr/interrupt.h>
#include <Adafruit_MCP23X17.h>

Adafruit_MCP23X17 mcp0; // Tasten 1-16
Adafruit_MCP23X17 mcp1; // Encoder

// Encoder-Variable
volatile uint16_t current_GPIO1 = 0;
uint16_t old_GPIO1 = 0;
int16_t Enc_Pos[7]= {0,0,0,0,0,0,0};
uint16_t currentClkState[7] = {0,0,0,0,0,0,0};
uint8_t Enc_B[7] = {0,0,0,0,0,0,0};
uint16_t lastClkState[7] = {0,0,0,0,0,0,0};

// Key interrupt ----------------------------------------------
void Key_Interrupt()
{
    key_action = true;
}

// Encoder-Interrupt ----------------------------------------------
void Encoder_Interrupt()
{
    enc_action = true;
    current_GPIO1 = mcp1.readGPIOAB();
}

// read Encoder  ------------------------------------------------
void read_enc(void)
{
    if (enc_action)
    {
        uint8_t encID = 0;
        enc_action = false;
        // Check MCP23017 GPIO for activity
        uint16_t changesBits = old_GPIO1 ^ current_GPIO1;
        old_GPIO1 = current_GPIO1;
            // Encoder1 Clk Pin status
        if (changesBits == 256)
        {
            currentClkState[0] = bitRead(current_GPIO1, 8);
            encID = 0;
        }   // Encode1 DT Pin status
        else if (changesBits == 512)
        {
            Enc_B[0] = bitRead(current_GPIO1, 9);
            encID = 0;
        }
            // Encoder1 Clk Pin status
        else if (changesBits == 1024)
        {
            currentClkState[1] = bitRead(current_GPIO1, 10);
            encID = 1;
        }   // Encode2 DT Pin status
        else if (changesBits == 2048)
        {
            Enc_B[1] = bitRead(current_GPIO1, 11);
            encID = 1;
        }
        // print encoder values
        if (currentClkState[encID] != lastClkState[encID])
        {
            if (Enc_B[encID] != currentClkState[encID])
            {
                Enc_Pos[encID]++;
            }
            else
            {
                Enc_Pos[encID]--;
            }
            tft.setTextColor(ST7735_WHITE);
            tft.setFont(OpenSans_16);
            if (encID == 0)
            {
                tft.fillRect(90, 100, 60, 20, ST7735_RED);
                tft.setCursor(100, 101);
            }
            else
            {
                tft.fillRect(90, 150, 60, 20, ST7735_BLUE);
                tft.setCursor(100, 151);
            }
            tft.print(Enc_Pos[encID]);
            tft.updateScreenAsyncT4();
        }
        lastClkState[encID] = currentClkState[encID];
        old_GPIO1 = current_GPIO1;
    }
}

// Setup  -------------------------------------------------------
void setup(void)
{
    Serial.begin(9600);

    // Initializing the key/encoder MCP23017 I/O expander
    mcp0.begin_I2C(0x21, &Wire1); // Adresse 0x20-0x27
    mcp1.begin_I2C(0x22, &Wire1);

    // Initialize Wire1 for encoder
    Wire1.begin();
    Wire1.setClock(400000UL); // I2C-Speed 400 kHz

    // MPC23017(1-3) Expander for encoders & buttons
    mcp0.setupInterrupts(true, false, LOW);
    for (size_t i = 0; i < 16; i++)
    {
        mcp0.pinMode(i, INPUT_PULLUP);
        mcp0.setupInterruptPin(i, CHANGE);
    }
    mcp0.getCapturedInterrupt();
    mcp0.readGPIOAB();
    mcp0.clearInterrupts();

    mcp1.setupInterrupts(true, false, LOW);
    for (size_t i = 0; i < 16; i++)
    {
        mcp1.pinMode(i, INPUT_PULLUP);
        mcp1.setupInterruptPin(i, CHANGE);
    }
    mcp1.getCapturedInterrupt();
    mcp1.readGPIOAB();
    mcp1.clearInterrupts();
 
    // init Key Interrupt
    attachInterrupt(digitalPinToInterrupt(PIN_KEY_INT), Key_Interrupt, FALLING);

    // init Encoder-Interrupt
    attachInterrupt(digitalPinToInterrupt(PIN_ENC_INT), Encoder_Interrupt, FALLING);
 
}


void loop()
{
    // Here are high-priority items
 
   // lower priority here
    if (loopCount++ > 15)  // counter
    {
        //read_keys();
        //read_ts();
        read_enc();
        loopCount = 0;
    }
}
 
Last edited:
The Jeannie 2 is equipped with a total of seven high-quality encoders. These are used for parameter input in the menu and for modulation. Unfortunately, the Teensy 4.1 microcontroller in the Jeannie 2 doesn't have enough port pins to connect all the encoders. Therefore, I used an MCP23017 16-bit I/O expander for the encoder readings. This communicates with the Teensy 4.1 microcontroller via an I²C bus at a speed of 400 kHz.

Thank you for posting this example.

For anyone who wants to run the example code, I had to make some small changes for the code to compile
  • add a few macros and variables that were not defined
  • comment out calls related to tft display

Code:
// Abfrage für den Seven-Encoder auf Teensy und MCP23017
//

#include <Arduino.h>
#include <Wire.h>
#include <avr/interrupt.h>
#include <Adafruit_MCP23X17.h>

Adafruit_MCP23X17 mcp0; // Tasten 1-16
Adafruit_MCP23X17 mcp1; // Encoder

// Encoder-Variable
volatile uint16_t current_GPIO1 = 0;
uint16_t old_GPIO1 = 0;
int16_t Enc_Pos[7]= {0,0,0,0,0,0,0};
uint16_t currentClkState[7] = {0,0,0,0,0,0,0};
uint8_t Enc_B[7] = {0,0,0,0,0,0,0};
uint16_t lastClkState[7] = {0,0,0,0,0,0,0};

// JWP added these macros and variables
#define PIN_KEY_INT (1)
#define PIN_ENC_INT (2)
bool key_action;
bool enc_action;
int loopCount;

// Key interrupt ----------------------------------------------
void Key_Interrupt()
{
    key_action = true;
}

// Encoder-Interrupt ----------------------------------------------
void Encoder_Interrupt()
{
    enc_action = true;
    current_GPIO1 = mcp1.readGPIOAB();
}

// read Encoder  ------------------------------------------------
void read_enc(void)
{
    if (enc_action)
    {
        uint8_t encID = 0;
        enc_action = false;
        // Check MCP23017 GPIO for activity
        uint16_t changesBits = old_GPIO1 ^ current_GPIO1;
        old_GPIO1 = current_GPIO1;
            // Encoder1 Clk Pin status
        if (changesBits == 256)
        {
            currentClkState[0] = bitRead(current_GPIO1, 8);
            encID = 0;
        }   // Encode1 DT Pin status
        else if (changesBits == 512)
        {
            Enc_B[0] = bitRead(current_GPIO1, 9);
            encID = 0;
        }
            // Encoder1 Clk Pin status
        else if (changesBits == 1024)
        {
            currentClkState[1] = bitRead(current_GPIO1, 10);
            encID = 1;
        }   // Encode2 DT Pin status
        else if (changesBits == 2048)
        {
            Enc_B[1] = bitRead(current_GPIO1, 11);
            encID = 1;
        }
        // print encoder values
        if (currentClkState[encID] != lastClkState[encID])
        {
            if (Enc_B[encID] != currentClkState[encID])
            {
                Enc_Pos[encID]++;
            }
            else
            {
                Enc_Pos[encID]--;
            }
            // JWP // tft.setTextColor(ST7735_WHITE);
            // JWP // tft.setFont(OpenSans_16);
            if (encID == 0)
            {
                // JWP // tft.fillRect(90, 100, 60, 20, ST7735_RED);
                // JWP // tft.setCursor(100, 101);
            }
            else
            {
                // JWP // tft.fillRect(90, 150, 60, 20, ST7735_BLUE);
                // JWP // tft.setCursor(100, 151);
            }
            // JWP // tft.print(Enc_Pos[encID]);
            // JWP // tft.updateScreenAsyncT4();
        }
        lastClkState[encID] = currentClkState[encID];
        old_GPIO1 = current_GPIO1;
    }
}

// Setup  -------------------------------------------------------
void setup(void)
{
    Serial.begin(9600);

    // Initializing the key/encoder MCP23017 I/O expander
    mcp0.begin_I2C(0x21, &Wire1); // Adresse 0x20-0x27
    mcp1.begin_I2C(0x22, &Wire1);

    // Initialize Wire1 for encoder
    Wire1.begin();
    Wire1.setClock(400000UL); // I2C-Speed 400 kHz

    // MPC23017(1-3) Expander for encoders & buttons
    mcp0.setupInterrupts(true, false, LOW);
    for (size_t i = 0; i < 16; i++)
    {
        mcp0.pinMode(i, INPUT_PULLUP);
        mcp0.setupInterruptPin(i, CHANGE);
    }
    mcp0.getCapturedInterrupt();
    mcp0.readGPIOAB();
    mcp0.clearInterrupts();

    mcp1.setupInterrupts(true, false, LOW);
    for (size_t i = 0; i < 16; i++)
    {
        mcp1.pinMode(i, INPUT_PULLUP);
        mcp1.setupInterruptPin(i, CHANGE);
    }
    mcp1.getCapturedInterrupt();
    mcp1.readGPIOAB();
    mcp1.clearInterrupts();
 
    // init Key Interrupt
    attachInterrupt(digitalPinToInterrupt(PIN_KEY_INT), Key_Interrupt, FALLING);

    // init Encoder-Interrupt
    attachInterrupt(digitalPinToInterrupt(PIN_ENC_INT), Encoder_Interrupt, FALLING);
}


void loop()
{
    // Here are high-priority items
 
   // lower priority here
    if (loopCount++ > 15)  // counter
    {
        //read_keys();
        //read_ts();
        read_enc();
        loopCount = 0;
    }
}
 
Programming Encoder acceleration..

C:
// MCP23017 Encoder query with acceleration
//

#include <Arduino.h>
#include <Wire.h>
#include <avr/interrupt.h>
#include <Adafruit_MCP23X17.h>

Adafruit_MCP23X17 mcp0; // Tasten 1-16
Adafruit_MCP23X17 mcp1; // Encoder

// Encoder-Variable
volatile uint16_t current_GPIO1 = 0;
uint16_t old_GPIO1 = 0;
int16_t Enc_Pos[7]= {0,0,0,0,0,0,0};
uint16_t currentClkState[7] = {0,0,0,0,0,0,0};
uint8_t Enc_B[7] = {0,0,0,0,0,0,0};
uint16_t lastClkState[7] = {0,0,0,0,0,0,0};

// macros and variables
#define PIN_KEY_INT (1)
#define PIN_ENC_INT (2)
bool key_action;
bool enc_action;
int loopCount;

// U10 MCP23017 3x Encoder Pin
#define enc1_A 256      // GPB0
#define enc1_B 512      // GPB1
#define enc2_A 1024     // GPB2
#define enc2_B 2048     // GPB3
#define enc3_A 4096     // GPB4
#define enc3_B 8192     // GPB5

// 7x Encoder
volatile uint16_t current_GPIO1 = 0;
uint16_t old_GPIO1 = 0;
int16_t Enc_Pos[7]= {0,0,0,0,0,0,0};
uint16_t currentClkState[7] = {0,0,0,0,0,0,0};
uint8_t Enc_B[7] = {0,0,0,0,0,0,0};
uint16_t lastClkState[7] = {0,0,0,0,0,0,0};
unsigned long lastInterruptTime = 0;
int accelerationFactor = 1;


// Key interrupt ----------------------------------------------
void Key_Interrupt()
{
    key_action = true;
}

// Encoder-Interrupt ----------------------------------------------
void Encoder_Interrupt()
{
    enc_action = true;
    current_GPIO1 = mcp1.readGPIOAB();
}


// read Encoder ------------------------------------------------
void read_enc(void)
{
    if (enc_action)
    {
        uint8_t encID = 0;
        unsigned long interruptTime = millis();
        unsigned long timeDiff = interruptTime - lastInterruptTime;

        enc_action = false;

        // Encoder Acceleration
        // If the rotation is fast (< 50 ms), accelerate
        if (timeDiff < 25)
        {
            accelerationFactor = 20;
        }
        else if (timeDiff < 50)
        {
            accelerationFactor = 5;
        }
        else
        {
            accelerationFactor = 1;
        }

        // Check MCP23017 GPIO for activity
        uint16_t changesBits = old_GPIO1 ^ current_GPIO1;

        // Encoder1 Clk Pin status
        if (changesBits == enc1_A)
        {
            currentClkState[0] = bitRead(current_GPIO1, 8);
            encID = 0;
        } // Encode1 DT Pin status
        else if (changesBits == enc1_B)
        {
            Enc_B[0] = bitRead(current_GPIO1, 9);
            encID = 0;
        }
        // Encoder2 Clk Pin status
        else if (changesBits == enc2_A)
        {
            currentClkState[1] = bitRead(current_GPIO1, 10);
            encID = 1;
        } // Encode2 DT Pin status
        else if (changesBits == enc2_B)
        {
            Enc_B[1] = bitRead(current_GPIO1, 11);
            encID = 1;
        }
        // Encoder3 Clk Pin status
        else if (changesBits == enc3_A)
        {
            currentClkState[2] = bitRead(current_GPIO1, 12);
            encID = 2;
        } // Encode3 DT Pin status
        else if (changesBits == enc3_B)
        {
            Enc_B[2] = bitRead(current_GPIO1, 13);
            encID = 2;
        }

        // print encoder values
        if (currentClkState[encID] != lastClkState[encID])
        {
            if (Enc_B[encID] != currentClkState[encID])
            {
                Enc_Pos[encID] += accelerationFactor;
                if (Enc_Pos[encID] >= 1023)
                {
                    Enc_Pos[encID] = 1023;
                }
               
            }
            else
            {
                Enc_Pos[encID] -= accelerationFactor;
                if (Enc_Pos[encID] <= 0)
                {
                    Enc_Pos[encID] = 0;
                }
            }

            // tft.setTextColor(ST7735_WHITE);
            // tft.setFont(OpenSans_16);

            if (encID == 0)
            {
                // tft.fillRect(90, 100, 60, 20, ST7735_RED);
                // tft.setCursor(100, 101);
            }
            else if (encID == 1)
            {
                // tft.fillRect(90, 130, 60, 20, ST7735_BLUE);
                // tft.setCursor(100, 131);
            }
            else if (encID == 2)
            {
                // tft.fillRect(90, 160, 60, 20, tab_color_green);
                // tft.setCursor(100, 161);
            }

            // tft.print(Enc_Pos[encID]);
            // tft.updateScreenAsyncT4();
        }
        lastClkState[encID] = currentClkState[encID];
        old_GPIO1 = current_GPIO1;
        lastInterruptTime = interruptTime;
    }
}

// Setup  -------------------------------------------------------
void setup(void)
{
    Serial.begin(9600);

    // Initializing the key/encoder MCP23017 I/O expander
    mcp0.begin_I2C(0x21, &Wire1); // Adresse 0x20-0x27
    mcp1.begin_I2C(0x22, &Wire1);

    // Initialize Wire1 for encoder
    Wire1.begin();
    Wire1.setClock(400000UL); // I2C-Speed 400 kHz

    // MPC23017(1-3) Expander for encoders & buttons
    mcp0.setupInterrupts(true, false, LOW);
    for (size_t i = 0; i < 16; i++)
    {
        mcp0.pinMode(i, INPUT_PULLUP);
        mcp0.setupInterruptPin(i, CHANGE);
    }
    mcp0.getCapturedInterrupt();
    mcp0.readGPIOAB();
    mcp0.clearInterrupts();

    mcp1.setupInterrupts(true, false, LOW);
    for (size_t i = 0; i < 16; i++)
    {
        mcp1.pinMode(i, INPUT_PULLUP);
        mcp1.setupInterruptPin(i, CHANGE);
    }
    mcp1.getCapturedInterrupt();
    mcp1.readGPIOAB();
    mcp1.clearInterrupts();
 
    // init Key Interrupt
    attachInterrupt(digitalPinToInterrupt(PIN_KEY_INT), Key_Interrupt, FALLING);

    // init Encoder-Interrupt
    attachInterrupt(digitalPinToInterrupt(PIN_ENC_INT), Encoder_Interrupt, FALLING);
}


void loop()
{
    // Here are high-priority items
 
   // lower priority here
    if (loopCount++ > 15)  // counter
    {
        //read_keys();
        //read_ts();
        read_enc();
        loopCount = 0;
    }
}

Video
 
A few observations…
  • key_action and enc_action should be volatile
  • your code will not work reliably if a user turns two encoders at the same time
  • as it stands, extending from 3 to 7 encoders will involve a lot of copy/pasted code, which will be hard to maintain
  • also, all encoders have to have the same, compiled-in acceleration factors; these may not suit all use cases or users
 
Why is there a header file named <avr/interrupt/h> if the design is based on Teensy 4 (ARM)? Is this a remnant of the porting to ARM?
 
Hello everyone...

I've had some time again and have continued programming the encoder query and graphics. With such a large display of 480 x 320 pixels, the graphics transmission time is a significant issue. A single-core processor like the Teensy 4.1 I'm using is fast and powerful, but it doesn't have any special functions for driving graphics cards or displays quickly and efficiently. Therefore, good and proven techniques, such as a frame buffer with DMA transmission, have to be used.

ADSR Functions
Delay: Delayed start for the envelope (orange bar graph)
Attack - Release: Basic envelope function

Touch Buttons at the bottom of the screen
AMP - VCF - MOD: Switch between the different envelopes. AMP and VCF are fixed, and Mod is the modulation source.
Curve: Switch between linear and exponential envelopes
Reset: Resets the settings to default values.

 
Small update to the envelope menu. You can now set the curve of each envelope to linear or exponential. The Init button resets the envelope to a default value.

Video
 
Hi Rolfdegen,
Could you tell me which libraries and drivers you use for the Wave Share screen?

Thank you very much.
 
Back
Top