Fourays: Updating Firmware & Debating Hardware Design

Table of contents

Firmware

I've done quite a bit more work on the firmware now, and I believe it to be mostly functionally complete for producing music as I now have complete control over the AY chips. There is still some more work to do on the configuration management and screen user interface though.

AY Control

Up to this point not all of the AY's features have been made available via config or MIDI control. We had config control over the noise mixer, but to change this during performance using config is impractical, some other means would be better.

A common method to enable or disable synth features during a performance is to use MIDI key switches. These switches are normally mapped to the lowest MIDI notes, which are out of range of the instrument. Using this method, we can implement the remaining features of the AY chips in a way which can be controlled during performance. In this instance I'll use a Note On message with velocity > 63 to turn a feature on and a Note On message with a velocity < 64 to turn a feature off.

Envelope Generator

The AY's envelope generator has 3 mix switches, 4 mode switches and a period parameter. When enabled for an oscillator, that oscillator's level will be affected by the envelope generator according to its mode and period.

All 7 switches are mapped to MIDI key switches:

MIDI Note NumberMIDI Note NameSwitch
1C#-1Enable for oscillator A
2D-1Enable for oscillator B
3D#-1Enable for oscillator C
4E-1Hold mode enable
5F-1Alternate mode enable
6F#-1Attack mode enable
7G-1Continue mode enable

The period is controlled by CC messages, and the CC can be changed in the configuration. However, the longest period is probably too long for practical purposes, so the CC range is currently hard-coded to be mapped to the lowest 20% of the period range.

The various modes can be combined to make the envelope generator behave in different ways:

From the AY-3-8910 data sheet; Figure 1.

Noise Generator

The noise generator period is set via CC messages, and the CC for each noise generator can be set in the configuration. The noise mixer configurations have been moved to key switches.

MIDI key switch mapping:

MIDI Note NumberMIDI Note NameSwitch
8G#-1Mix to output A enable
9A-1Mix to output B enable
10A#-1Mix to output C enable

Pitchbend, Transpose and Detune

Each MIDI note is mapped to an oscillator divisor value, which causes the oscillator to sound at something very close to the required note frequency.

The actual oscillator frequency is calculated as:

Frequency = Clock / 16 / Divisor

As such, it is not perfectly possible to match every note frequency across the entire MIDI note range because of the integer division.

We are driving the Clock at 2.0 MHz, which means that the lowest 23 MIDI notes are unplayable. This is because these notes would require a Divisor value greater than would fit in the 12-bit divisor control register (4095). The available Divisor resolution also decreases as the MIDI note increases, which causes the frequency error to also increase as we go up the scale. We peak at about 4% frequency error in the very highest notes, but nobody wants to listen to 5kHz square waves anyway.

To give the performer some control over the exact frequency output of the oscillators we can implement some CC messages to affect the frequency.

The final Divisor is therefore calculated as:

Divisor = divisor_note_map[Note + Transpose] + (divisor_delta_map[Note + Transpose] * Pitchbend) + Detune

Polyphony

Using the Fourays synth with each oscillator mapped to different MIDI channels is cumbersome, and is not the way most MIDI music is written. It is more useful to be able to map multiple oscillators to the same MIDI channel write our music polyphonically, and have the firmware allocate Note On events to available oscillators.

This was surprisingly straightforward to achieve, given the event driven architecture of the firmware using signals. In the monophonic firmware, we connected the incoming MIDI Note On and Note Off messages for each channel directly to the oscillator control handler.

I'll illustrate this using simplified excerpts of the firmware C++ (ish) code. This code probably isn't strictly valid, I'm just trying to demonstrate the connections and the algorithm.

// Create two arrays of Note message signals, to cover all the MIDI channels.
Signal<int note, int velocity>[16] channel_signals_note_on;
Signal<int note, int velocity>[16] channel_signals_note_off;

// Re-configure all the oscillators
for (auto &oscillator : oscillators) {
    channel_signals_note_on[oscillator.config.channel].connect(
        [](note, velocity) {
            oscillator.note_on(note, velocity);
        }
    );
    channel_signals_note_off[oscillator.config.channel].connect(
        [](note, velocity) {
            oscillator.note_off(note, velocity);
        }
    );
}

// When we receive any MIDI Note On message, dispatch it to the correct channel
midi_input.note_on.connect(
    [](channel, note, velocity) {
        channel_signals_note_on[channel].emit(note, velocity);
    }
);

// When we receive any MIDI Note Off message, dispatch it to the correct channel
midi_input.note_off.connect(
    [](channel, note, velocity) {
        channel_signals_note_off[channel].emit(note, velocity);
    }
);

To implement polyphony, we need to upgrade the channel signals array to an array of some objects which know how many oscillators are available per channel and keeps track of which oscillators are playing which notes:

using NoteSlot = std::function<void(int note, int velocity)>;

// This object replaces the Channel signals,
// and now holds references to the oscillator control functions.
class ChannelManager
{
public:
    // Add the oscillator on/off handlers to this manager
    void connect(NoteSlot on_slot, NoteSlot off_slot)
    {
        m_slots.push_back({ .on=on_slot, .off=off_slot });
    }

    // Decide what to do with each incoming note on message;
    // Find an available oscillator and activate it.
    void note_on(int note, int velocity)
    {
        if (m_active_notes.contains(note))
        {
            return; // note is already on
        }

        // find an available slot
        for (size_t i = 0; i < m_slots.size(); ++i)
        {
            // if this slot is not in use
            if (!m_active_slots.contains(i))
            {
                m_active_slots[i] = note;      // allocate the oscillator
                m_active_notes[note] = i;      // allocate the note
                m_slots[i].on(note, velocity); // forward the note to the oscillator
                return;
            }
        }

        // If we reach here ... we can consider this later ...
    }

    // Decide what to do with each incoming note off message;
    // If an oscillator is playing the note then end it and make
    // both the oscillator and note available for use again.
    void note_off(int note, int velocity)
    {
        if (!m_active_notes.contains(note))
        {
            return; // note is not on
        }

        // find which oscillator is playing this note
        const auto i = m_active_notes[note];

        m_active_slots.erase(i);        // free the oscillator
        m_active_notes.erase(note);     // free the note
        m_slots[i].off(note, velocity); // forward the note to the oscillator
    }

private:
    // Array of available oscillator control functions
    std::vector<struct { NoteSlot on; NoteSlot off; }> m_slots;

    // Somewhere to keep track of which oscillators are in use;
    // Useful for when we want to turn a note on
    std::unordered_map<uint8_t, uint8_t> m_active_slots; // maps <slot index, note>

    // Somewhere to keep track of which notes are currently on;
    // Useful for when we want to turn a note off
    std::unordered_map<uint8_t, uint8_t> m_active_notes; // maps <note, slot index>
};

ChannelManager[16] channel_managers;

// Re-configure all the oscillators
for (auto &oscillator : oscillators) {
    channel_managers[oscillator.config.channel].connect(
        [](note, velocity) {
            oscillator.note_on(note, velocity);
        },
        [](note, velocity) {
            oscillator.note_off(note, velocity);
        }
    )
}

// When we receive any MIDI Note On message, dispatch it to the correct manager
midi_input.note_on.connect(
    [](channel, note, velocity) {
        channel_managers[channel].note_on(note, velocity);
    }
);

// When we receive any MIDI Note Off message, dispatch it to the correct manager
midi_input.note_off.connect(
    [](channel, note, velocity) {
        channel_managers[channel].note_off(note, velocity);
    }
);

All of the polyphony handling was done in the middle layer without changing either the MIDI input driver or the oscillator controllers.

There's further work we could do here as well, as indicated by the final comment in the ChannelManager::note_on method. At the moment, if there are not enough available oscillator slots for the next Note On message, then the message is ignored. We could however do something else. We could for example "steal" an oscillator and make it play the new note in place of some other note which was currently playing. There's quite a few different strategies we could employ to do this; either steal from the oldest or newest playing note, or the highest, or lowest, or something in the middle. I'm not going to implement any of these for the time being though.

Finally, I think I will need to add a polyphony on/off configuration somewhere, since it is at odds some of the other options available, particularly Transpose. By which I mean that if two AYs are on the same MIDI channel with polyphony enabled, you can just write your music in octaves, you don't need to transpose one of the oscillators. However, with polyphony turned off it makes sense, you can then double up the oscillators in octaves from a single note on one channel. I think that adds value to the firmware, and lets the performer decide how they want to use the oscillators.

Configuration

Now that we support all of the AY functions and polyphony, the synth configuration needed attention to ensure that all of the note channels and control channels can be set up as desired. It's also worth at this point to explain also how the device manages configuration.

Because of the limited number of available input pins on the ESP32-S3, I've minimised the number of button or touch inputs to just three:

We will use these inputs as events in a simple state machine:

  flowchart LR
A[NAVIGATE STATE]
B[EDIT STATE]

    A -- LEFT <br>/ RIGHT --> A
    B -- LEFT <br>/ RIGHT --> B

    A -- SELECT --> B
    B -- SELECT --> A

Whilst we are in NAVIGATE state, pressing LEFT or RIGHT will update the state machine's context to cycle through the available configuration items. Pressing SELECT captures the current item for editing and enters EDIT state whereby the LEFT and RIGHT inputs will cycle through the item's values. Pressing SELECT again releases the item from editing and moves the state machine back to NAVIGATE state.

For example, lets say we have a configuration consisting of three items:

  1. "Oscillator A channel" = 5
  2. "Oscillator B channel" = 8
  3. "Oscillator C channel" = 14

Each of these items can hold a value from 1 to 16.

At startup, the configuration state machine is in NAVIGATE state and the first item is selected. Lets say we want to change the "Oscillator C channel" to 1. We would input:

  1. RIGHT - moves selection to "Oscillator B channel" item
  2. RIGHT - moves selection to "Oscillator C channel" item
  3. SELECT - selects "Oscillator C channel" for editing
  4. RIGHT - increments "Oscillator C channel" to 15
  5. RIGHT - increments "Oscillator C channel" to 16
  6. RIGHT - wraps "Oscillator C channel" back to 1
  7. SELECT - de-selects "Oscillator C channel"

Navigation also wraps around, so instead of initially going RIGHT twice, we could have input LEFT just once.

This probably sounds a lot more complicated than it is, but it's much the same as operating any simple electronic widget with a limited number of buttons. Showing the configuration items on screen with select and edit state markers also helps. But, I haven't completed the screen rendering code yet (it's quite slow and tedious to write, I'm seriously considering writing a screen/UI emulator for it to develop and test the config control code locally without using the ESP32-S3 to do it).

The actual Fourays configuration consists of 20 config items per AY chip:

Global Control Channel
Envelope Period CC
Noise Control Mode
Noise Note Channel
Noise Period CC

Oscillator A Note Channel               Oscillator B Note Channel               Oscillator C Note Channel
Oscillator A Control Channel            Oscillator B Control Channel            Oscillator C Control Channel
Oscillator A Filter Frequency CC        Oscillator B Filter Frequency CC        Oscillator C Filter Frequency CC
Oscillator A Transpose CC               Oscillator B Transpose CC               Oscillator C Transpose CC
Oscillator A Detune CC                  Oscillator B Detune CC                  Oscillator C Detune CC

You might have noticed that each oscillator can have different note and control channels. This is so that if you set up multiple oscillators per channel, so as to enable polyphony, that you can still allocate the CC controllers to distinct channels and control the oscillators' transpose, detune and filter frequency independently.

The final part of the configuration implementation will be some means to save and load presets both on the device itself and by reading and dumping MIDI SysEx messages. To do this, we need a way to specify the "identity" of a configuration item. It is actually these ID objects that the configuration controller state machine iterates through for selection.

The actual way I've identified config items in Fourays is using a struct called ConfigItemId:

enum class ConfigItemType
{
    PSG,
    PSGN,
    ENVELOPE,
    PSGV,
};

enum class ConfigItemPropType
{
    NONE,
    NOTECHANNEL,
    CTRLCHANNEL,
    MODE,
    CC0,
    CC1,
    CC2,
};

struct ConfigItemId
{
    ConfigItemType type;
    int8_t majorIdx = -1;
    int8_t minorIdx = -1;
    ConfigItemPropType property = ConfigItemPropType::NONE;
};

Lets break down why this is necessary:

So for example to construct the configuration items for all of the oscillator note channels looks like this:

for (int8_t i = 0; i < 4; ++i) // chips
{
    for (int8_t k = 0; k < 3; ++k) // oscillators
    {
        config_item_ids.push_back(ConfigItemId{
            .type = ConfigItemType::PSGV,
            .majorIdx = i,
            .minorIdx = k,
            .property = ConfigItemPropType::NOTECHANNEL,
        });
    }
}

The act of changing the configuration values from the user inputs is delegated separately to the configuration manager object. It looks something like this:

  flowchart TB

A[Config IDs Array]
B[Config Controller]
C[Config Manager]
D[Config Values]
E[User]

A -.-o B
A -.-o C

E -- INPUT<br>EVENTS --> B
B -- EDIT<br>EVENTS --> C

D -.-o C

The INPUT EVENTS are the same LEFT/RIGHT/SELECT that we met before. The EDIT EVENTS are actually calls to a method on the configuration manager which contains only the configuration ID and the direction of the edit (i.e. increment or decrement) since we are dealing only with numeric values. At no time does the configuration controller actually know the configuration values.

config_controller->edit().connect(
    /**
      Two parameters supplied by the state machine:
      - ConfigItemId: is explained below;
      - dir: -1 for decrement, 1 for increment

      nextVal(in, dir, min, max) is a simple helper function
      which implements the increment/decrement with wrapping
     */
    [](const ConfigItemId &id, const int dir)
    {
        const auto current = config.get_value(id);
        auto next = current;
        switch (id.property)
        {
            case ConfigItemPropType::NOTECHANNEL:
            case ConfigItemPropType::CTRLCHANNEL:
                next = nextVal(current, dir, 1, 16);
                break;
            case ConfigItemPropType::CC0:
            case ConfigItemPropType::CC1:
            case ConfigItemPropType::CC2:
                next = nextVal(current, dir, 1, 127);
                break;
            case ConfigItemPropType::MODE:
                next = nextVal(current, dir, 0, NUM_MODES);
                break;
            default:
            break;
        }
        config.put_value(id, next);
    });

Using an array of ConfigItemId works in favour of saving and loading the configuration. All we need to do to save is output a list of (ID, Value) pairs, and the reverse for loading. Saving or loading the configuration to file then is almost trivial, we don't have to know in the save or load code much at all about what configuration items even exist:

// Just dump the config in ID order to a file, fixed width 5 bytes per item.
void save_config()
{
    auto f = open_file("my.cfg");
    for (auto &item_id : config_item_ids)
    {
        const auto value = config.get_value(item_id);
        file.write(
            "%d%d%d%d%d",
            item_id.type, item_id.majorIdx, item_id.minorIdx, item_id.property,
            value
        );
    }
}

void load_config()
{
    auto f = open_file("my.cfg");
    while(f.available())
    {
        const auto id_type  = f.read();
        const auto id_major = f.read();
        const auto id_minor = f.read();
        const auto id_prop  = f.read();
        const auto value    = f.read();
        config.put_value(
            ConfigItemId{
                .type     = id_type,
                .majorIdx = id_major,
                .minorIdx = id_minor,
                .property = id_prop
            },
            value
        );
    }
}

Hardware

New Chips Shipment

On the hardware side, I did receive another shipment of chips from China. This time I ordered 20 Yamaha YM2149F chips. These are electrically identical to AY-3-8910, they were just made by Yamaha rather than GI or Microchip. All of these arrived in a somewhat dirty, but original state. These new chips have not been re-labelled, all of them read "YAMAHA JAPAN / YM2149F / 0517 GABG" on the top side. And yes, some of them are faulty. As per the previous post on chip testing, here's the results of what I got:

Bottom
Print
Bottom
Mark Left
Bottom
Mark Right
TestNotes
-5 E1TAIWANPASS
B7734M80 / 6766B09 S3SIPASSCropped pins 1-20
--TN H2FAILCropped pins / Buzzes
-I1I1PASSCropped pin 40
-5 D1TAIWANPASS
C8833662C-TN M4FAILCropped pins / DEAD
-Z 5CJPASS
AA1KN51C-TN E2PASS
-H 37D4FAILCropped pins 1-20 / DEAD
-M 37L5FAILCropped pins / Buzzes
-5 G5TAIWANFAILA, B DEAD; C Buzzes only
AH1UE1.1M37I1PASSPIN 20 DAMAGED
AA1KN51C-TN C3PASS
A93TRK1C-TN K4PASS
-5 L2TAIWANPASS
---PASS
AF4126.17 F5TAIWANPASS
-5 T3TAIWANFAILOnly buzzes
-5 M5TAIWANPASS
A8A8B11C-TN H3PASSCropped pins, hard to keep in socket

Curiously, some of the chips bottom case markings match what is on some of the other re-labelled AY-3-8910 chips from the first batch 🤔

Anyhow, I now have 24 working chips, which is enough to build 6 Fourays synths.

PCB Layouts

I've done a lot of PCB layout clean up and made the boards a little smaller. I'm also still playing around with what to include in the Fourays synth. There's the choice that it is somewhat fully-featured and standalone (with filters) or it's a bare multi-oscillator module (without filters).

With Filters

The op-amps have been switched to surface mount. It's also just occurred to me that the large BC547 and BC557 transistors can also be converted to surface mount. Also still to do is change the footprints of the trim potentiometers, now that I have an idea of which ones I would use and these have 3 pins in-line rather than offset.

Each Fourays synth needs four of the left hand AY board and one of the right hand control board. That's five boards to assemble together which roughly fits in a desktop enclosure about 25 cm x 14 cm x 6 cm. I've done some preliminary mock ups for this form factor, but haven't settled on anything I like enough to show off yet.

The filters in this design still need a +/- 12V supply. I've bought some modules which apparently convert from 5V to +/- 12V for this purpose, so that I can power this from the USB connection but not tested them yet, and neither do I have the filter circuits to see if they work with the converted voltage levels.

Without Filters

I've also been debating about not including the filters at all (and therefore also removing the mixer circuit and CV DACs). In this case, we can get all four AY chips and the ESP32-S3 on one board. On this board I have also re-instated the Gate outputs. The Gate outputs are simple signals which are active when an oscillator is playing, else inactive. These could be used in a modular synth setup to trigger and sync other modules in time with the oscillators. I could include the Gate outputs also on the version with filters.

This simpler design would be much more suitable to build as modular synth modules, for example in Eurorack format. This board design would require a module 16HP wide, but is currently significantly too tall, I'd probably have to re-configure this as a pair of boards stacked one behind the other.

But, going down the modular module route presents me with a bit of a conundrum:

Rather than building my own filter and mixer and power supply circuits, should I just make these simple combination boards only, in a Eurorack compatible format? I could then simply just start building a Eurorack synth for myself using the AYs as the core oscillators. That would allow me much more flexibility in how to process the oscillator outputs using any commercially available or DIY euro modules. I could also then just buy a euro case and power supply and the whole thing would be packaged very nicely in a standard format.

But ... that leads to investing in a euro modular system (£££ 🤮), when all I really wanted was some MIDI controlled AYs.

I have in fact already reached the latter goal. I already hand made a combination board for myself on some prototype board. I've repurposed an old enclosure I had laying around which was already fitted with 1/4" jack sockets for the outputs:

I could stop there and do everything else in software even. But it's not particularly attractive. And, I now have a glut of other chips to build into units, and I know I'm going to either sell or give away those units and those units need to be usable by other musicians, who would probably much rather prefer them to be made to some standard form factor. And I still want to impart a little bit of visual design into this synth to reflect the 1980's era vibes of the old computers which contained these AY chips as their sound generators.

Compiling a Bill of Materials

To see if cost comes into play to help guide me on the form factor decision I decided to do some calculations. Based on the with-filters boards, I submitted my boards to JLCPCB to find out how much they'd cost to manufacture. Also, I needed to work out what other components to purchase to complete each synth, made a list and added up the cost of all of those:

To make all 6 Fourays synths with filters and all the panel controls:

ItemCostQtyCost/ItemQty/SynthCost/Synth
AY boards£93.0825£3.724£14.89
Control board£21.8810£2.191£2.19
Non PCBA components£152.966£25.491£25.49
Enclosure (TBC)£5.001£5.001£5.00

TOTAL per synth £47.57

That's not bad actually. But it would be significantly cheaper as a Eurorack module, even if the rest of the modular system is likely to be extremely expensive. If anyone has any tips on how to do euro modular on a strict budget, do let me know 😉