This is an old revision of the document!
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
<code python>
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.
