User Tools

Site Tools


sintetizador_fm_para_archivos_midi_en_python

This is an old revision of the document!


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:
  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:

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

def read_midi(file_path):  
    midi = mido.MidiFile(file_path)  
    return midi  
 
def fm_synth(note, velocity, duration, sample_rate=44100):  
    carrier_freq = 440 * 2**((note - 69) / 12)  
    modulator_freq = carrier_freq * 2  
    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:  
                duration = msg.time / midi.ticks_per_beat  
                note_audio = fm_synth(msg.note, msg.velocity, duration, sample_rate)  
                sd.play(note_audio, samplerate=sample_rate)  
                sd.wait()  

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:

  1. Mono Only: The synthesizer processes and plays a single note at a time. Overlapping notes or chords are not supported.
  2. No Envelope Control: The FM synthesis does not include dynamic ADSR envelopes, which might result in abrupt starts/stops.
  3. 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:
    1. Use this script for experimentation or as a starting point for more advanced synthesizer projects.
    2. 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.

sintetizador_fm_para_archivos_midi_en_python.1736784972.txt.gz · Last modified: 2025/01/13 16:16 by oso