Created
July 3, 2026 10:07
-
-
Save twobob/ca41bfefa77a496a9879e8a06f1cc491 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import numpy as np | |
| import mido | |
| from scipy.io import wavfile | |
| import os | |
| class RissetExpertSynth: | |
| def __init__(self, sample_rate=44100): | |
| self.fs = sample_rate | |
| # Risset's preferred partials (from Csound GEN10 demo) | |
| # Omit 2, 3, 4 to get that 'hollow/metallic' shimmer | |
| self.partial_weights = {1: 1.0, 5: 0.7, 6: 0.7, 7: 0.7, 8: 0.7, 9: 0.7, 10: 0.7} | |
| self.detune_hz = 0.05 # The 'magic' beating frequency (Delta f) | |
| def generate_risset_expert(self, freq, duration, scan_speed=-1.0): | |
| t = np.linspace(0, duration, int(self.fs * duration), endpoint=False) | |
| output = np.zeros_like(t) | |
| # Logarithmic/Exponential scan trajectory | |
| max_p = max(self.partial_weights.keys()) | |
| start_sigma = np.log(max_p) if scan_speed < 0 else 0 | |
| end_sigma = 0 if scan_speed < 0 else np.log(max_p) | |
| sigmas = np.linspace(start_sigma, end_sigma, len(t)) | |
| width = 0.6 # Narrower width for more distinct 'arpeggio' feel | |
| for n, weight in self.partial_weights.items(): | |
| # 1. Calculate the 'Spectral Envelope' for this partial | |
| # Using a tighter Gaussian to make the 'cascade' pop | |
| amp = weight * np.exp(-((np.log(n) - sigmas)**2) / (2 * width**2)) | |
| # 2. The Shimmer: Three oscillators per partial (Center, +Delta, -Delta) | |
| # This creates the 'phasing' effect heard in the CSound demo | |
| f_n = freq * n | |
| # Fundamental + two beating sidebands | |
| osc_bank = ( | |
| np.sin(2 * np.pi * f_n * t) + | |
| 0.5 * np.sin(2 * np.pi * (f_n + self.detune_hz) * t) + | |
| 0.5 * np.sin(2 * np.pi * (f_n - self.detune_hz) * t) | |
| ) | |
| # 3. Apply ADSR-style smoothing | |
| fade = np.clip(t / 0.02, 0, 1) * np.clip((duration - t) / 0.1, 0, 1) | |
| output += (amp * fade) * osc_bank | |
| return output | |
| def process_midi_expert(midi_path, output_path): | |
| if not os.path.exists(midi_path): | |
| print(f"MIDI file {midi_path} not found.") | |
| return | |
| synth = RissetExpertSynth() | |
| mid = mido.MidiFile(midi_path) | |
| full_buffer = np.zeros(int(44100 * (mid.length + 2.0))) | |
| active_notes = {} | |
| current_time = 0 | |
| print(f"Synthesizing '{midi_path}' with Risset Detuned Banks...") | |
| for msg in mid: | |
| current_time += msg.time | |
| if msg.type == 'note_on' and msg.velocity > 0: | |
| active_notes[msg.note] = (current_time, msg.velocity) | |
| elif (msg.type == 'note_off') or (msg.type == 'note_on' and msg.velocity == 0): | |
| if msg.note in active_notes: | |
| start_t, vel = active_notes.pop(msg.note) | |
| dur = current_time - start_t | |
| if dur > 0.01: | |
| f = 440.0 * (2.0**((msg.note - 69) / 12.0)) | |
| # Pass velocity to amp scaling | |
| wave = synth.generate_risset_expert(f, dur) * (vel / 127.0) | |
| start_idx = int(start_t * 44100) | |
| end_idx = start_idx + len(wave) | |
| if end_idx < len(full_buffer): | |
| full_buffer[start_idx:end_idx] += wave | |
| # Final Soft-Clipping Normalisation | |
| if np.max(np.abs(full_buffer)) > 0: | |
| full_buffer = np.tanh(full_buffer / np.max(np.abs(full_buffer))) | |
| wavfile.write(output_path, 44100, (full_buffer * 32767).astype(np.int16)) | |
| print(f"Complete. Expert Cascade saved to {output_path}") | |
| if __name__ == "__main__": | |
| process_midi_expert('input.mid', 'risset_expert.wav') |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment