====== 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.