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
- Filename: `fm_synthesizer.py`
- Dependencies: `mido`, `numpy`, `sounddevice`, `time`
- Features:
- FM Synth Engine: Generates FM-based audio for MIDI notes.
- Polyphony: Supports multiple simultaneous notes with a configurable maximum polyphony limit.
- MIDI Playback: Reads and processes MIDI files to generate real-time audio.
- Instrument Selection: Allows filtering specific instruments for playback.
Script Execution
FM Synthesis Engine:
- Carrier and Modulator Frequencies: Derived from the MIDI note number.
- Modulation Index: Affects the timbre by varying modulation depth.
- Envelope: Simple attack-release envelope applied to smooth transitions.
Polyphonic Note Handling:
- Dynamic Management: Tracks active notes, ensuring the maximum polyphony limit isn’t exceeded.
- Automatic Note Cleanup: Frees resources of finished notes to manage performance.
Real-Time Audio Playback
- Callback Architecture: Processes audio blocks in real-time using `sounddevice`.
- Audio Limiting: Avoids clipping by gently limiting the output amplitude.
MIDI Parsing:
- Processes MIDI messages (e.g., `note_on`, `note_off`, `program_change`).
- Provides the option to select specific instruments via their MIDI program numbers.
Command-Line Execution:
- Modify the script to accept a MIDI file as a parameter for better flexibility.
- Example:
python fm_synthesizer.py example.mid
Error Handling:
- Detects and reports errors during audio generation, MIDI parsing, or playback.
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
- Purpose: To demonstrate a basic FM synthesis-based mono synthesizer that processes MIDI piano tracks. Each note is synthesized and played one at a time.
- Limitations:
- Mono Only: The synthesizer processes and plays a single note at a time. Overlapping notes or chords are not supported.
- No Envelope Control: The FM synthesis does not include dynamic ADSR envelopes, which might result in abrupt starts/stops.
- Simplistic Waveform Modulation: Only sine waves are used as the carrier and modulator.
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
- Expected Behavior: This script processes the MIDI file track by track, playing each note sequentially. It is best suited for simple piano tracks with minimal overlap between notes.
- Recommendations:
- Use this script for experimentation or as a starting point for more advanced synthesizer projects.
- To handle polyphony or add envelopes, consider integrating this code with the main FM synthesizer described earlier.
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.
