Table of Contents

Creating an FM Synthesizer in Python

Objective:

This project implements an FM (Frequency Modulation) Synthesizer in Python. The synthesizer processes MIDI files and generates audio in real-time, focusing on emulating polyphonic instruments. The project also aims to explore FM synthesis techniques and create custom audio effects for MIDI playback.

App

import mido
import numpy as np
import sounddevice as sd
import time
 
class Note:
    def __init__(self, note_num, velocity, start_time, program_num=None):
        self.note_num = note_num
        self.velocity = velocity
        self.start_time = start_time
        self.end_time = None
        self.samples = None
        self.position = 0
        self.program_num = program_num
 
class FMSynthesizer:
    def __init__(self, sample_rate=44100, block_size=1024, max_polyphony=24):
        self.sample_rate = sample_rate
        self.block_size = block_size
        self.max_polyphony = max_polyphony
        self.active_notes = {}
        self.note_buffer = np.zeros(block_size)
 
    def fm_synth(self, note, velocity, duration):
        carrier_freq = 440 * 2**((note - 69) / 12)
        modulator_freq = carrier_freq * 1/2 + carrier_freq * 2/6 + carrier_freq * 4/10
        modulation_index = 0.5
 
        # Add a tiny bit of padding to avoid edge clicks
        duration += 0.01
 
        t = np.linspace(0, duration, int(self.sample_rate * duration), endpoint=False)
        modulator = np.sin(2 * np.pi * modulator_freq * t) * modulation_index
        carrier = np.sin(2 * np.pi * carrier_freq * t + modulator)
 
        # Simple envelope
        attack_time = 0.05
        release_time = 0.05
        attack_samples = int(self.sample_rate * attack_time)
        release_samples = int(self.sample_rate * release_time)
 
        envelope = np.ones_like(t)
        # Linear attack and release
        if attack_samples > 0:
            envelope[:attack_samples] = np.linspace(0, 1, attack_samples)
        if release_samples > 0:
            envelope[-release_samples:] = np.linspace(1, 0, release_samples)
 
        return carrier * envelope * (velocity / 127)
 
    def note_on(self, note_num, velocity, start_time, program_num):
        if len(self.active_notes) >= self.max_polyphony:
            oldest_time = float('inf')
            oldest_note = None
            for num, note in self.active_notes.items():
                if note.start_time < oldest_time:
                    oldest_time = note.start_time
                    oldest_note = num
            if oldest_note is not None:
                del self.active_notes[oldest_note]
 
        new_note = Note(note_num, velocity, start_time, program_num)
        self.active_notes[note_num] = new_note
 
    def note_off(self, note_num, end_time):
        if note_num in self.active_notes:
            self.active_notes[note_num].end_time = end_time
 
    def get_audio_block(self):
        self.note_buffer.fill(0)
 
        notes_to_process = list(self.active_notes.items())
        finished_notes = []
 
        for note_num, note in notes_to_process:
            if note_num not in self.active_notes:
                continue
 
            if note.samples is None:
                duration = 1
                if note.end_time is not None:
                    duration = max(1, note.end_time - note.start_time)
                try:
                    note.samples = self.fm_synth(note.note_num, note.velocity, duration)
                except Exception as e:
                    print(f"Error generating samples for note {note_num}: {e}")
                    finished_notes.append(note_num)
                    continue
 
            remaining = len(note.samples) - note.position
            if remaining <= 0:
                finished_notes.append(note_num)
                continue
 
            samples_to_add = min(remaining, self.block_size)
            try:
                self.note_buffer[:samples_to_add] += \
                    note.samples[note.position:note.position + samples_to_add]
                note.position += samples_to_add
            except Exception as e:
                print(f"Error mixing samples for note {note_num}: {e}")
                finished_notes.append(note_num)
 
        for note_num in finished_notes:
            self.active_notes.pop(note_num, None)
 
        # Gentle limiting to prevent harsh clipping
        max_val = np.max(np.abs(self.note_buffer))
        if max_val > 1.0:
            self.note_buffer = self.note_buffer / (max_val * 1.2)  # Add a tiny headroom
 
        return self.note_buffer
 
def play_midi_file(midi_path, selected_instruments=None):
    midi = mido.MidiFile(midi_path)
    synth = FMSynthesizer()
    program_changes = {}
 
    def audio_callback(outdata, frames, time, status):
        try:
            block = synth.get_audio_block()
            outdata[:, 0] = block
        except Exception as e:
            print(f"Error in audio callback: {e}")
            outdata.fill(0)
 
    with sd.OutputStream(channels=1, 
                        callback=audio_callback,
                        samplerate=synth.sample_rate,
                        blocksize=synth.block_size):
 
        start_time = time.time()
        current_time = 0
 
        for msg in midi:
            current_time += msg.time
            wait_time = start_time + current_time - time.time()
            if wait_time > 0:
                time.sleep(wait_time)
 
            try:
                if msg.type == 'program_change':
                    program_changes[msg.channel] = msg.program
                    print(f"Program change: Channel {msg.channel} -> Program {msg.program}")
                elif msg.type == 'note_on':
                    if msg.velocity > 0:
                        if selected_instruments is None or program_changes.get(msg.channel) in selected_instruments:
                            synth.note_on(msg.note, msg.velocity, current_time, program_changes.get(msg.channel))
                    else:
                        synth.note_off(msg.note, current_time)
                elif msg.type == 'note_off':
                    synth.note_off(msg.note, current_time)
            except Exception as e:
                print(f"Error processing MIDI message: {e}")
 
        time.sleep(2)
 
if __name__ == "__main__":
    play_midi_file("C:/temp/canyon.mid", selected_instruments=None)
    # Example: Play only piano (Program 1) and electric piano (Program 5), [1, 5]
    #play_midi_file("C:/Users/unapa/Downloads/DXdiag.mid", selected_instruments=[33, 78, 16, 18, 53, 27, 29, 30])

Script Overview

  1. FM Synth Engine: Generates FM-based audio for MIDI notes.
  2. Polyphony: Supports multiple simultaneous notes with a configurable maximum polyphony limit.
  3. MIDI Playback: Reads and processes MIDI files to generate real-time audio.
  4. Instrument Selection: Allows filtering specific instruments for playback.

Script Execution

FM Synthesis Engine:

Polyphonic Note Handling:

Real-Time Audio Playback

MIDI Parsing:

Command-Line Execution:

python fm_synthesizer.py example.mid

Error Handling:

Usage Example

To play a MIDI file (e.g. `canyon.mid`), run the script as shown below. You can optionally specify a list of instruments to filter:

play_midi_file("C:/temp/canyon.mid", selected_instruments=[1, 5])

- Without Filtering: All instruments in the MIDI file will be played.

- Filtered Playback: Example: Play only pianos (Program 1) and electric pianos (Program 5).

Limitations

1. Simplistic Envelope: The attack-release envelope could be extended with sustain and decay stages for greater realism.

2. Basic Modulation: The FM synthesis implementation uses a fixed modulation scheme. Extending it with dynamic modulation indexes or additional oscillators could improve the tonal range.

3. No Visualization: Currently, the synthesizer does not include any graphical or waveform visualizations.

4. Input Handling: The script does not yet parse command-line arguments for file paths or instrument filters.

Final Note

This FM synthesizer provides a starting point for experimenting with audio synthesis and real-time MIDI playback in Python. Its modular design makes it easy to extend with additional features, such as better envelopes, more advanced synthesis techniques, or visualization tools.

Appendix: Monophonic Synthesizer

This section covers a simple mono synthesizer that processes a single piano track from a MIDI file, synthesizing FM-based audio for each note and playing it in real time.

import mido  # For MIDI parsing
import numpy as np  # For waveform synthesis
import sounddevice as sd  # For real-time audio playback
 
def read_midi(file_path):
    midi = mido.MidiFile(file_path)
    return midi
 
def fm_synth(note, velocity, duration, sample_rate=44100):
    # Example parameters for FM synthesis
    carrier_freq = 440 * 2**((note - 69) / 12)  # MIDI note to frequency
    modulator_freq = carrier_freq * 2 + carrier_freq * 4 + carrier_freq * 6 + carrier_freq * 8
    modulation_index = 0.33
 
    t = np.linspace(0, duration, int(sample_rate * duration), endpoint=False)
    modulator = np.sin(2 * np.pi * modulator_freq * t) * modulation_index
    carrier = np.sin(2 * np.pi * carrier_freq * t + modulator)
 
    return carrier * velocity / 127
 
def process_piano_track(midi):
    sample_rate = 44100
    for track in midi.tracks:
        for msg in track:
            if msg.type == 'note_on' and msg.velocity > 0:  # Note on
                duration = msg.time / midi.ticks_per_beat  # Estimate duration from MIDI timing
                note_audio = fm_synth(msg.note, msg.velocity, duration, sample_rate)
 
                # Play the synthesized audio for the note
                sd.play(note_audio, samplerate=sample_rate)
                sd.wait()  # Wait until the audio is finished playing
 
midi = read_midi("C:/temp/canyon.mid")
process_piano_track(midi)

Overview

Customizing the Modulation Signal

To achieve different sound characteristics, you can modify the `fm_synth` function to generate various modulation waveforms. Here are examples:

1. Triangle Wave Modulation:

modulator = 2 * np.arcsin(np.sin(2 * np.pi * modulator_freq * t)) / np.pi * modulation_index  

2. Sawtooth Wave Modulation:

modulator = 2 * (t * modulator_freq - np.floor(t * modulator_freq + 0.5)) * modulation_index  

3. Square Wave Modulation:

modulator = np.sign(np.sin(2 * np.pi * modulator_freq * t)) * modulation_index  

Each of these alternatives changes the timbre of the output, providing greater flexibility for sound design.

Final Notes

By customizing the modulation signal and adjusting parameters like `modulation_index`, `carrier_freq`, and `modulator_freq`, you can experiment with creating unique sound textures and effects.