""" Frequency to MIDI Node - Converts a raw frequency signal into quantized MIDI note number and velocity based on the 12-tone equal temperament scale. Place this file in the 'nodes' folder """ import numpy as np from PyQt6 import QtGui import cv2 import math import __main__ BaseNode = __main__.BaseNode PA_INSTANCE = getattr(__main__, "PA_INSTANCE", None) QtGui = __main__.QtGui # Reference Frequency: A4 = 440 Hz (MIDI note 69) A4_FREQ = 440.0 A4_MIDI = 69 # MIDI Note formula: N = 69 + 12 * log2(f / 440) class FreqToMidiNode(BaseNode): NODE_CATEGORY = "Transform" NODE_COLOR = QtGui.QColor(150, 50, 200) # Musical Purple def __init__(self, midi_offset=0): super().__init__() self.node_title = "Freq to MIDI" self.inputs = { 'frequency_in': 'signal', 'amplitude_in': 'signal' } self.outputs = { 'midi_note': 'signal', 'velocity': 'signal' } self.midi_offset = int(midi_offset) # Shifts the output keyboard range self.output_note = 0.0 self.output_velocity = 0.0 def _freq_to_midi(self, frequency): """Converts frequency (Hz) to the nearest integer MIDI note number.""" if frequency <= 0: return 0 # Off note try: # N = 69 + 12 * log2(f / 440) midi_note_float = A4_MIDI + 12 * np.log2(frequency / A4_FREQ) # Round to the nearest integer note midi_note = int(round(midi_note_float)) # Apply offset and clamp to MIDI range [0, 127] return np.clip(midi_note + self.midi_offset, 0, 127) except ValueError: return 0 def step(self): # 1. Get raw inputs freq_in = self.get_blended_input('frequency_in', 'sum') amp_in = self.get_blended_input('amplitude_in', 'sum') # 2. Process Frequency # Map input signal [-1, 1] to an audible range (e.g., 50 Hz to 2000 Hz) if freq_in is not None: # We assume the input signal is normalized (e.g., from SpectrumAnalyzer) # Map [-1, 1] to [50, 2000] Hz target_freq = (freq_in + 1.0) / 2.0 * 1950.0 + 50.0 self.output_note = float(self._freq_to_midi(target_freq)) # 3. Process Amplitude if amp_in is not None: # Map signal [0, 1] (or [-1, 1]) to normalized velocity [0.0, 1.0] # Use abs() to treat negative signals as volume velocity_norm = np.clip(np.abs(amp_in), 0.0, 1.0) self.output_velocity = float(velocity_norm) else: self.output_velocity = 0.0 def get_output(self, port_name): if port_name == 'midi_note': # Only output the note if the velocity is above a threshold return self.output_note if self.output_velocity > 0.05 else 0.0 elif port_name == 'velocity': return self.output_velocity return None def get_display_image(self): w, h = 96, 48 img = np.zeros((h, w, 3), dtype=np.uint8) # Draw piano key visualization note = int(self.output_note) # Calculate Octave and Note Name note_name_map = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] note_name = note_name_map[note % 12] octave = note // 12 - 1 # Color based on velocity vel_norm = self.output_velocity color_val = int(vel_norm * 255) if vel_norm > 0.05: # Draw an active key (white or black key color based on sharp/flat) is_sharp = ('#' in note_name) fill_color = (255, 0, color_val) if is_sharp else (color_val, color_val, color_val) # Red/Magenta for sharps text_color = (0, 0, 0) if not is_sharp else (255, 255, 255) cv2.rectangle(img, (0, 0), (w, h), fill_color, -1) else: text_color = (100, 100, 100) # Draw Note Label label = f"{note_name}{octave}" cv2.putText(img, label, (w//4, h//2), cv2.FONT_HERSHEY_SIMPLEX, 0.6, text_color, 2, cv2.LINE_AA) # Draw MIDI number cv2.putText(img, f"MIDI: {note}", (w//4, h//2 + 18), cv2.FONT_HERSHEY_SIMPLEX, 0.4, text_color, 1, cv2.LINE_AA) img = np.ascontiguousarray(img) return QtGui.QImage(img.data, w, h, 3*w, QtGui.QImage.Format.Format_BGR888) def get_config_options(self): return [ ("Keyboard Offset (semitones)", "midi_offset", self.midi_offset, None), ]