DSPRelated.com
Forums

Keyboard Synthesizer

Started by Cedron 5 months ago7 replieslatest reply 5 months ago203 views
Yep, turn your computer keyboard into a music keyboard.  Almost four full octaves.

https://forum.gambas.one/viewtopic.php?t=1651

This is a project that implements a rudimentary note synthesizer.  Download the version in the third posting, not the first.

The driver wrapper program is written in Gambas, a platform only available on Linux.

ppa:gambas-team/gambas3

The PPA is usually better than a distro depository.

The working code is in a C++ shared library.  Code and a makefile are provided in the downloaded source archive.  The library should be callable from any platform which can provide a polling call 100 times a second.

Many improvements are coming, but this is a good starter point for the basic functionality.

Have fun.
[ - ]
Reply by patdspNovember 26, 2023

Hi,

myself is collecting synths since 1982 and is also interested in soft-synths. 

As I moved to linux (and myself is very happy with it) since some years (although it was due for over twenty years at least) your offering seems to be attractive, thanks therefore !

But, what features currently does your synth has resp. sound like ? Does it generate a sine tone ? This is quite a lot. Several Decades ago, I was using a programming sample providing a simple interrupt service routine adressing the PC-Timer IC, and, it made a square-wave, pulse width modulation appeared possible. :-)

You say in the linked-forum, feature requests be encouraged. Okay, I presume, this would require to start a discussion, how a basic synth should look like, is it eg. 

a) a subtractive Synth (with Oscillators, Filter, enough Modulation LFO's, Amplifiers), 

is it b) an FM-Synth like a DX-7 or a OpSix, ie. both of a) and b), or is it 

c) a different type of Synth-Architecture (Phase Modulation, Additive, Granular, Bit-Slice Synthesis etc.). 

Even FSK, PSK, QAM, MIMO whatsoever could sound nice, if you listened ever to a telefax and said, 'hmm, I'm gonna sample this and use it suitably in a song'.

Anyway, as far as I know, an important issue to any synth is, besides the filter to shape the sound, always the Oscillator bank (it can be an analog Modeling, a digital controlled analog emulation, and various types of digital resp. numerical oscillators, direct digital sampling, sampling type, wavetable, etc.). Nowaday's synths provide several types (see JP-8000 only for example).

Interesting would also be a capability to run VST-Synth plugins and to have a possibility to post-process the sound eg. using output signals as a tone generating source in the frame of or as an Oscillator. 

However, I would avoid to fully re-invent the wheel. Rather I would try to find out, what already has been made and seems convincing to implement, and approach step by step. 

Reliability, stability and low latency of the whole construction is most important. This requires a lot of testing and patience everywhere.

And, I also would always take a look on midi implementation, so as to control just or almost everything making sense with CC's etc.

Besides, I would even try to read already made patch libraries, if possible.

So as to the incentives made, I would try to setup a plan, how to start with what. I wish best success. 

greetings,

patdsp

[ - ]
Reply by CedronNovember 26, 2023
Thanks for the reply.

I recommend you download the code and look at it, and your answers would be obvious.  I am not trying to re-invent the wheel.  I did delve into the VST world a few years back and found it difficult to work with, particularly on Linux.

The purpose of posting this is the same reason I write my blog articles, for education and to provide starter code for people to learn programming with.

I am using the PusleAudio drivers in polling mode.  Currently I'm not even checking status, simply feeding 10 msec chunks at 44100 samples per second, so a 441 sample stereo buffer.

The tone waveforms and envelopes are lookup table driven.  I am currently working on the loading of text file definitions for these.  Ultimately my software will let you create and edit new ones through the interface.

I am using the standard ADSR model, with the attack, decay, and release time settable per note played.  The generic model has linear attack and decay, with an exponential decay release.  I am currently working on 'instrument models' to set these parameters as a function of frequency and 'intrument type'.  Each instrument placement defines a panning volume and appropriate stereo channel delay.

The default, working, tone is a simple pure tone with a hard coded 0.1, 0.1, 0.5 second timing on the notes.

The chunks are built in 32 bit, with an implied 6 fractional bits, then converted to 16 bit with a hyperbolic limiter.  Up to a 1000 notes could be playing at the same time, though I have still been able to produce clipping by a lot less than that.

The offering right now is the fundamental working core.  I am now improving the configuration features to the library, then I will work on the GUI to employ them.

It should be possible to make it call VST modules, I have no plans for that, but anybody is welcome to.  It should just be a few calls from the StreamJockey class.

This is the current class reference hierarchy.

         GUI
          |
          V
       libNotes
        |    |
        |    V
        |  StreamJockey
 +---+  |    |     |
 |   V  V    V     |
 |  PlayingNote    |
 |   |  |    |     |
 +---+  |    V     V
        | InstrumentPlace
        |  |           |
        V  V           V
    PlayCursor    InterpolatedModel
I have this planned out completely, but I'm not set in stone with it.  I do intend to support MIDI file reading and writing, but I don't promise all features in either direction.
[ - ]
Reply by patdspNovember 27, 2023

Thanks a lot for quite comprehensive information provided. 

As far I can understand, a very versatile vehicle working core, or horse :-) is available. The approach looks nice and should definitively make any further development promising and interesting, no matter what finally will be transported so to speak. 

As to VST-Modules, I would be interested to know what that stuff makes problems on linux, as only some software supports it more or less successfully; possibly, it is a too-proprietary I/F or is it again microsoft with half-undocumented interface definitions, etc ... .

As to your engine, up to thousand notes is really impressive. So the code seems already quite optimized. Myself remembers foregoing was a hardly conquerable problem in former days. So either working with a banana slug or having in-depth knowledge of compiler and assembler construction on SDK developer level - fully let alone more than one kind-of processor core.

Practicably for a synth, around 32 Voices might be sufficient (eg. like the Korg OPSix); however, what makes a voice can be several partials (eg. four like in a Roland Synth), so as to make sound fat, allowing for effects based on tuning, quite before any effect section. And, if using hi-res waveforms, computing power may easily set the limits how many voices are maintainable for a certain time to much less.

If and as far the following is compatible with your engine: What would be interesting to know is, as far it can be realized at all, how fine tuning resolution can be achieved (may require a lot of testing and theoretical consideration resp. derivings which easily can turn out everything else than only demanding), and if the sampling buffers can finely adjusted as to sampling frequencies (per voice, so this may be an management issue, including buffer model design parameters, table management), and if said reading-out clocks can be controlled by LFOs within of, say eg., .001 Hz resolution and LFO waveforms (based on the same mechanic - this makes a synth already partly fractal in the end and strongly appears to be one of the secret of magic sounds which touches the hearer). Finally, in case the foregoing is possible such an approach might challenge even the most professional systems on the market - so dramatically as drastically :-) . 

[ - ]
Reply by CedronNovember 27, 2023

I appreciate the encouraging words. This is one of those back burner projects I finally decided to tackle. It is very efficient, and you asked before, the tones sound like angels playing flutes. Perfect tones.

I am making good progress on the configuration code and the ability for custom definitions. More on that as it is released.

I'm running on a Raspberry Pi 4 and Gambas is single threaded. Even with pressing my hand on all the keys on the key board and swishing the mouse over the keyboard, the CPU load stays below like 30%.

When it is idling it is extremely lightweight. The limitation has not been the number of notes, rather the sums getting too large so the limiter still makes clipping tones.

A multitude of tones can be submitted by the client program, as defined by whatever rules that program wants to use. The API is very straightforward. Here is what I have so far in Gambas syntax.

 
 Library "/tmp/R/libNotes"
 
Extern Notes_Start() As Integer
Extern Notes_Finish() As Integer
 
Extern Notes_AddWaveforms(ArgDefinitionsText As String) As Integer
Extern Notes_GetWaveformsList(RetWaveformNames As String, ArgSize As Integer) As Integer
Extern Notes_GetWaveformData(ArgIndex As Integer, RetWaveformData As Integer[], ArgSize As Integer) As Integer
 
Extern Notes_AddProfiles(ArgDefinitionsText As String) As Integer
Extern Notes_GetProfilesList(RetProfileNames As String, ArgSize As Integer) As Integer
Extern Notes_GetProfileData(ArgIndex As Integer, RetProfileData As Integer[], ArgSize As Integer) As Integer
 
Extern Notes_KeyPress(ArgKeyCode As Integer) As Integer
Extern Notes_KeyRelease(ArgKeyCode As Integer) As Integer
Extern Notes_MidiPress(ArgMidiCode As Integer) As Integer
Extern Notes_MidiRelease(ArgMidiCode As Integer) As Integer
Extern Notes_Tick() As Integer
 

This list will grow as I develop the user interface and need functionality.

I also heard your plea about wanting "plug-in capability", so I stubbed in a 'AudioHelper' class as a stand in. This should obviously be a wrapper to an external library where your processing code is located.

//-----------------------------------------------------------------------------
class AudioHelper
{
public:

    int MyState;

    int MySamplesPerSecond;
    int MySamplesPerChunk;
    int MyChannelCount;

//--- Methods

    int Initialize( int ArgSamplesPerSecond,
                    int ArgSamplesPerChunk,
                    int ArgChannelCount );

    int Terminate();

    int Contribute( int* ArgChunkData, int ArgNoNotesFlag );
    int Filter(     int* ArgChunkData );

    int Touchup( short int* ArgChunkStream );

};
//-----------------------------------------------------------------------------

The code below in the StreamJockey Tick() method shows where the last three are called. The first two are obviously in the Start() and End() respectively of the StreamJockey.

To understand why it is so efficient, follow the Tick() code. There are no memory allocations done during normal processing. The 1000 figure is simply the capacity of the number of playing notes. It is implemented as two linked lists. One for playing notes and one for available notes to be played.

I've changed the routine so it doesn't submit idle chunks. Therefore, as soon as a key gets pressed, a note gets queued and in the next tick event it gets started. Within 0.01 seconds.

Starting in the Gambas client.

'=============================================================================
Public Sub ThePollingTimer_Timer()

        Notes_Tick()

End
'=============================================================================

This calls into libNotes:

//=============================================================================
int Notes_Tick()
{
        return MyStreamJockey.Tick();
}
//=============================================================================

So far, all wrappers. Then we get to the real routine in the StreamJockey.

//=============================================================================
int StreamJockey::Tick()
{
        MyTickCounter++;

//--- Bail if not busy

        int theIdleFlag = ( MyFirstBusyNote == NULL );

        if( theIdleFlag && ( MyHelper.MyState == NOTES_HELPER_IDLE ) )
        {
            return 1;
        }
    
//--- Zero the chunk data buffer

        memset( MyChunkData, '\0', 4 * 882 );

//--- Let the helper lay down their contribution

        int theExitFlag = 0;

        if( MyHelper.MyState == NOTES_HELPER_ACTIVE )
        {
            theExitFlag = MyHelper.Contribute( MyChunkData, theIdleFlag );
        }

        if( theExitFlag ) return 2;

//--- Submit current playing notes

        PlayingNote* theChunkNote = MyFirstBusyNote;
        PlayingNote* thePriorNote = NULL;

        while( theChunkNote != NULL )
        {
            theChunkNote->AddToChunk( MyChunkData );

            PlayingNote* theNextNote = theChunkNote->MyNextNote; 

            if( theChunkNote->MyLeftCursor.MyState  == NOTES_STATE_EXPIRED &&
                theChunkNote->MyRightCursor.MyState == NOTES_STATE_EXPIRED )
            {
                theChunkNote->MyLeftCursor.MyState  = NOTES_STATE_IDLE;
                theChunkNote->MyRightCursor.MyState = NOTES_STATE_IDLE;
                
                if( thePriorNote == NULL )
                {
                    MyFirstBusyNote = theNextNote;
                }
                else
                {
                    thePriorNote->MyNextNote = theNextNote;
                }

                theChunkNote->MyNextNote = MyFirstAvailableNote;
                MyFirstAvailableNote     = theChunkNote;
            }
            else
            {
                thePriorNote = theChunkNote;
            }

            theChunkNote = theNextNote;
        }

//--- Let the helper filter the produced data

        if( MyHelper.MyState == NOTES_HELPER_ACTIVE )
        {
            theExitFlag = MyHelper.Filter( MyChunkData );
        }

        if( theExitFlag ) return 3;

//--- Limiter

        int theExcess;

        for( int s = 0; s < 882; s++ )
        {
            int theValue = MyChunkData[s] >> 8;

            if( theValue < -30000 )
            {
                theExcess = -MyChunkStream[s] - 30000;
                theValue  = -32767 + 2767 * 2767 / ( 2767 + theExcess );
            }
            else if( theValue > 30000 )
            {
                theExcess = MyChunkStream[s] - 30000;
                theValue  = 32767 - 2767 * 2767 / ( 2767 + theExcess );
            }

            MyChunkStream[s] = (short int) theValue;
        }

//--- Let the helper maybe touch up the data

        if( MyHelper.MyState == NOTES_HELPER_ACTIVE )
        {
            theExitFlag = MyHelper.Touchup( MyChunkStream );
        }

        if( theExitFlag ) return 4;

//--- Submit to playback

        int theError;

        int theResult = pa_simple_write( MyStream,
                                         MyChunkStream,
                                         2 * 2 * 441,
                                         &theError );

        if( theResult < 0)
        {
            printf( "pa_simple_write() failed: %s\n",
                    pa_strerror( theError ) );
        }

//--- Increment chunk number

        MyChunkCounter++;

        return 0;
}
//=============================================================================

The key statement in there is the "theChunkNote->AddToChunk( MyChunkData );" call. It gets handled like this:

//=============================================================================
int PlayingNote::AddToChunk( int* ArgChunkData )
{
        addToChannel( &ArgChunkData[0], &MyLeftCursor  );
        addToChannel( &ArgChunkData[1], &MyRightCursor );

        return 0;
}
//=============================================================================
int PlayingNote::addToChannel( int*        argChannelData,
                               PlayCursor* argCursor )
{
        int* p = argChannelData;

        for( int s = 0; s < 441; s++ )
        {
            int theValue = MyInstrument->GetSampleValue( argCursor );

            *p += theValue;  p += 2;

            argCursor->Increment( MyDecaySec, MyReleaseSec, MyReleasedFlag );
        }

        return 0;
}
//=============================================================================

You can see that I have two channels hard coded. This could be generalized to multiple tracks, but I'm KISSing it.

Getting the sample value is done through the instrument. Which has two InterpolationModels, one for the wave form, which is stepped through over and over, and one for the volume envelope profile which is stepped through once.

//=============================================================================
int InstrumentModel::GetSampleValue( PlayCursor* ArgCursor )
{                             
        int theWaveformValue = MyWaveform->ValueOf( ArgCursor->MyWaveformSpot );
        int theProfileValue  = MyProfile->ValueOf(  ArgCursor->MyProfileSpot );

        return ( theWaveformValue * theProfileValue ) >> 6;
}
//=============================================================================

Each model currently has 2048 points with linear interpolation. There are several bits of fractional value that will get truncated at the end, so this is way more than precise enough and is as precise as you can be.

//=============================================================================
int InterpolatedModel::ValueOf( double ArgSpot )
{
        int theIndex = (int) floor( ArgSpot );

        if( theIndex >= NOTES_MODEL_SIZE ) return 0;

        int theNotch = (int) ( ( ArgSpot - theIndex ) * 1024.0 );

        int theTweak = ( MyDeltas[theIndex] * theNotch ) >> 10;

        return MySamples[theIndex] + theTweak;
}
//=============================================================================

The fractional portion (between each of the 2048 samples) is accurate to more than 3 significant digits. This is super precise, yet very efficient.

Finally, the cursor needs to be incremented for each sample. There is a cursor for the left side and the right side and they have to be independent statewise because they may be offset a bit in time.

//=============================================================================
int PlayCursor::Increment( double ArgDecaySec,
                           double ArgReleaseSec,
                           int    ArgReleasedFlag )
{
//--- Along the waveform

        MyWaveformSpot += MyWaveformStep;

        if( MyWaveformSpot >= NOTES_MODEL_SIZE )
        {
            MyWaveformSpot -= NOTES_MODEL_SIZE;
        }

//--- Along the Profile

        MyProfileSpot += MyProfileStep;

//--- Look for state change

        int theProfileIndex = (int) floor( MyProfileSpot );

        switch( MyState )
        {
          case NOTES_STATE_ATTACK:
            if( theProfileIndex >= NOTES_MODEL_SIZE/4 )
            {
                MyState       =   NOTES_STATE_DECAY;
                MyProfileStep = ( NOTES_MODEL_SIZE/4 )
                              / ( ArgDecaySec * 44100.0 );
            }
            break;

          case NOTES_STATE_DECAY:
            if( theProfileIndex >= NOTES_MODEL_SIZE/2 )
            {
                if( ArgReleasedFlag )
                {
                    MyState       =   NOTES_STATE_RELEASE;
                    MyProfileStep = ( NOTES_MODEL_SIZE/2 )
                                  / ( ArgReleaseSec * 44100.0 );

                }
                else
                {
                    MyState       = NOTES_STATE_SUSTAIN;
                    MyProfileStep = 0.0;
                }
            }
            break;

          case NOTES_STATE_SUSTAIN:
            if( ArgReleasedFlag )
            {
                MyState        =   NOTES_STATE_RELEASE;
                MyProfileStep  = ( NOTES_MODEL_SIZE/2 )
                               / ( ArgReleaseSec * 44100.0 );
            }
            break;

          case NOTES_STATE_RELEASE:
            if( theProfileIndex >= NOTES_MODEL_SIZE )
            {
                MyState = NOTES_STATE_EXPIRED;
                MyProfileStep = 0.0;
                MyProfileSpot = NOTES_MODEL_SIZE - 1.0;
            }
            break;
        }

        return 0;
    
}
//=============================================================================

Here is the 'state machine' expressed. This will get more sophisticated. I am able to accurately time the interval between keypress and keyrelease to the resolution of the computer, not just at the chunk level. Therefore I will be able to make a formula which adjusts the step sizes so the keys become "touch sensitive".

A lot of work to go. You don't have to install Gambas to download and look at the source code. They are in a standard tar.gz file which you just right click and "uncompress..."

The Gambas directories have leading periods so they are hidden. The Gambas source is in the .src directory.

I have near zero experience with actual synthesizers, or existing software. Once you understand my basic skeleton you'll be able to target your suggestions better. Starting with any other AudioHelper routines you feel may be needed.

Long ago, I did 'In's and 'Out's to ports on an 8088 .......

[ - ]
Reply by patdspNovember 28, 2023

This is unparalleled. Thank you very much for commenting the code.

As far as I can see from the coding technology: This must be really powerful approach right from the start.

However, myself apologizes that it will take some time before getting fully aware of this awesome piece of cake.

Besides, there are some guys around who make so-called waveblaster boards, and I can well imagine that they could be discussion partners, too. 

With the present design it strongly appears that any type of synth architecture can be realized, either by processing look-up tables with some math or wave tables with samples. 

Also, there are many sample libraries and GM sound font sets available. 

A basic option could be - either compute rather the sound (approach of a generic *.*-Synthesis, FM, PD, etc.) or compute rather the management of the sound (approach on a generic Sample Player). 

However, when manipulation is done within look-up tables or within the samples by some magic routines (bit helicopter, etc.), sounds should become really exciting or even surprising ... . :-)

[ - ]
Reply by CedronNovember 29, 2023

Yes, there will be function calls where you can construct customized notes with any pitch, delays, and panning. There will also be embellishments to the note model including profiles for vibrato, 3D trajectory simulation, and time varying waveforms.

In most real instruments the higher overtones die out faster. Constructing a "realistic note" is more complicated. Overtones are not always pure harmonics so they would have to be defined independently and layered.

The Spectral Complexity of a Single Musical Note

Again, it isn't the number of notes playing that is the limitation, it is whether the sum of them all clip too much.

I have also changed the code to separate out the production of the data from the submittal to be played and the data buffers are provided by the application. Now, an application will be able to render a sound file directly, take metrics, and/or make visual displays.

The model can express all the precision needed in the features that you listed. Since a mixer is being implemented, mixing in audio files is no problem at all. You might guess where I am headed with that.

You should be able to programmatically import any waveforms from anywhere by expressing in the definition files of the program.

It's simple, like this:

Name: Bucktooth

               *
                  *
                     *
                        *
                           *
                           *
                           *
                           *
                           *
                        *
                     *
                  *
               *
            *
         *
      *
   *
   *
   *
   *
   *
      *
         *
            *

Use up to 100 lines (current setting) to define a 2048 value profile. The file is read, a DFT taken, Fourier coefficients determined, and the profile is generated. This is standard interpolation for waveforms.

The program will also export waveform definition files in any size specified. Gibbs ringing is a thing, you'll see it.

I'm really disappointed in the number of downloads so far, but people may want to wait till the next version which is a lot more usable.

This is meant to be a toy for users and programmers alike.

[ - ]
Reply by patdspNovember 29, 2023

... awesome ... description sounds just elephan-tastic !

... and, appears (well behind the horizon) that Cedron knows much more about synthesizers than he knows.  :-)


-> The foregoing is pointing to a poem to former US-defence secretary Donald Rumsfeld "the unknown", here in terms of the fourth combination (therein missing) and leading to a final and very basic question after some time: "Do we know what we know? " - here it is bit more than just philosophical, it is in deed quite practical: in that having a full-featured audio processing engine.

Wave-Definition by text file is really fine. 

Possibly quite similar, Sequential Prophet VS synth accepted midi sample dump standard with very small data.

Waldorf microwave I, a wave table synth, provided (in OS ver. 2) kind of an interface, processing small graphics into so-called user up-loadable wave table, upaw. Thus, one can experiment, what does a picture of cloudy sky sound like when used as wave table, swept forth and back under LFO control.

As to clipping in case of too much voices present, myself is contemplating, if a mechanism or smart headroom control makes sense which adaptively adjusting limits eg. either the voices or damps further emerging voices, if overloading is detected (perhaps like working with an oscilloscope: if the wave is chopped, ie. amplitude is to large to be captured, lower sensitivity to get it on the screen).

However, sometimes a clipping, kind of "amplitude modulation", isn't, could be deliberately desired as an "effect" ... typically with a low pass filter set behind ... and may broaden the sound (including generated bit noise), but rather and more reconcilable to the ears, an analogous saturation-type version would be a choice regrading a gradual "analogue" overdrive, instead of a hard sudden "digital" limit-cycle-behavior (saw-down wave) like in case of an overloaded converter. So a problem may be converted in to a nice by-product ,,, ,

By the way, there were realized some very interesting synthesis approaches, eg. EmU-Systems Morpheus, Oberheim/Viscont OB-12, using z-Transform which might go beyond the typical FIR and IIR filters which might by time awake some of the deep professionals here in this community ... :-)

So what about making a "let's dance" with the points on, or in, the unit-circle, by some algorithm. I presume, sounds could be the result.