class SoundEngine: def __init__( self, samples_to_load: Dict[str, Tuple[Union[str, Sample], int]]) -> None: global samples samples.clear() self.output = Output(mixing="mix") if any(isinstance(smp, str) for smp, _ in samples_to_load.values()): print("Loading sound files...") for name, (filename, max_simultaneously) in samples_to_load.items(): if isinstance(filename, Sample): samples[name] = filename else: data = pkgutil.get_data(__name__, "sounds/" + filename) if data: tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".ogg") try: tmp.write(data) tmp.close() samples[name] = Sample( streaming.AudiofileToWavStream(tmp.name), name).stereo() finally: os.remove(tmp.name) else: raise SystemExit("corrupt package; sound data is missing") self.output.set_sample_play_limit(name, max_simultaneously) print("Sound API initialized:", self.output.audio_api) def play_sample(self, samplename, repeat=False, after=0.0): self.output.play_sample(samples[samplename], repeat, after) def silence(self, sid_or_name=None): if sid_or_name: self.output.stop_sample(sid_or_name) else: self.output.silence() def close(self): self.output.close()
class SynthGUI(tk.Frame): def __init__(self, master=None): super().__init__(master) self.master.title("Software FM/PWM Synthesizer | synthplayer lib v" + synthplayer.__version__) self.waveform_area = tk.Frame(self) self.osc_frame = tk.Frame(self) self.oscillators = [] self.piano_frame = tk.Frame(self) self.pianokeys_gui = PianoKeyboardGUI(self.piano_frame, self) self.pianokeys_gui.pack(side=tk.BOTTOM) filter_frame = tk.LabelFrame(self, text="Filters etc.", padx=10, pady=10) self.envelope_filter_guis = [ EnvelopeFilterGUI(filter_frame, "1", self), EnvelopeFilterGUI(filter_frame, "2", self), EnvelopeFilterGUI(filter_frame, "3", self)] self.echo_filter_gui = EchoFilterGUI(filter_frame, self) for ev in self.envelope_filter_guis: ev.pack(side=tk.LEFT, anchor=tk.N) self.arp_filter_gui = ArpeggioFilterGUI(filter_frame, self) self.arp_filter_gui.pack(side=tk.LEFT, anchor=tk.N) f = tk.Frame(filter_frame) self.tremolo_filter_gui = TremoloFilterGUI(f, self) self.tremolo_filter_gui.pack(side=tk.TOP) lf = tk.LabelFrame(f, text="A4 tuning") lf.pack(pady=(4, 0)) lf = tk.LabelFrame(f, text="Performance") self.samplerate_choice = tk.IntVar() self.samplerate_choice.set(22050) tk.Label(lf, text="Samplerate:").pack(anchor=tk.W) subf = tk.Frame(lf) tk.Radiobutton(subf, variable=self.samplerate_choice, value=44100, text="44.1 kHz", fg=lf.cget('fg'), selectcolor=lf.cget('bg'), pady=0, command=self.create_synth).pack(side=tk.LEFT) tk.Radiobutton(subf, variable=self.samplerate_choice, value=22050, text="22 kHz", fg=lf.cget('fg'), selectcolor=lf.cget('bg'), pady=0, command=self.create_synth).pack(side=tk.LEFT) subf.pack() tk.Label(lf, text="Piano key response:").pack(anchor=tk.W) subf = tk.Frame(lf) self.rendering_choice = tk.StringVar() self.rendering_choice.set("realtime") tk.Radiobutton(subf, variable=self.rendering_choice, value="realtime", text="realtime", pady=0, fg=lf.cget('fg'), selectcolor=lf.cget('bg'),).pack(side=tk.LEFT) tk.Radiobutton(subf, variable=self.rendering_choice, value="render", text="render", pady=0, fg=lf.cget('fg'), selectcolor=lf.cget('bg'),).pack(side=tk.LEFT) subf.pack() lf.pack(pady=(4, 0)) f.pack(side=tk.LEFT, anchor=tk.N) self.echo_filter_gui.pack(side=tk.LEFT, anchor=tk.N) misc_frame = tk.Frame(filter_frame, padx=10) tk.Label(misc_frame, text="To Speaker:").pack(pady=(5, 0)) self.to_speaker_lb = tk.Listbox(misc_frame, width=8, height=5, selectmode=tk.MULTIPLE, exportselection=0) self.to_speaker_lb.pack() lf = tk.LabelFrame(misc_frame, text="A4 tuning") self.a4_choice = tk.IntVar() self.a4_choice.set(440) tk.Radiobutton(lf, variable=self.a4_choice, value=440, text="440 Hz", pady=0, fg=lf.cget('fg'), selectcolor=lf.cget('bg')).pack() tk.Radiobutton(lf, variable=self.a4_choice, value=432, text="432 Hz", pady=0, fg=lf.cget('fg'), selectcolor=lf.cget('bg')).pack() lf.pack(pady=(4, 0)) tk.Button(misc_frame, text="Load preset", command=self.load_preset).pack() tk.Button(misc_frame, text="Save preset", command=self.save_preset).pack() for _ in range(5): self.add_osc_to_gui() self.to_speaker_lb.select_set(4) self.waveform_area.pack(side=tk.TOP) self.osc_frame.pack(side=tk.TOP, padx=10) filter_frame.pack(side=tk.TOP) misc_frame.pack(side=tk.RIGHT, anchor=tk.N) self.piano_frame.pack(side=tk.TOP, padx=10, pady=10) self.statusbar = tk.Label(self, text="<status>", relief=tk.RIDGE) self.statusbar.pack(side=tk.BOTTOM, fill=tk.X) self.pack() self.synth = self.output = None self.create_synth() self.echos_ending_time = 0 self.currently_playing = {} # (note, octave) -> sid self.arp_after_id = 0 showwarning("garbled sound output", "When using miniaudio 1.20+, the audio could be garbled (not always the case). I haven't had time yet to debug and fix this. Sorry for any inconvenience.") def bind_keypress(self, key, note, octave): def kbpress(event): self.pressed_keyboard(note, octave, False) def kbrelease(event): self.pressed_keyboard(note, octave, True) self.master.bind(key, kbpress) if key == '[': key = "bracketleft" if key == ']': key = "bracketright" self.master.bind("<KeyRelease-%s>" % key, kbrelease) def create_synth(self): samplerate = self.samplerate_choice.get() self.synth = WaveSynth(samplewidth=2, samplerate=samplerate) if self.output is not None: self.output.close() self.output = Output(self.synth.samplerate, self.synth.samplewidth, 1, mixing="mix") def add_osc_to_gui(self): osc_nr = len(self.oscillators) fm_sources = ["osc "+str(n+1) for n in range(osc_nr)] osc_pane = OscillatorGUI(self.osc_frame, self, "Oscillator "+str(osc_nr+1), fm_sources=fm_sources, pwm_sources=fm_sources) osc_pane.pack(side=tk.LEFT, anchor=tk.N, padx=10, pady=10) self.oscillators.append(osc_pane) self.to_speaker_lb.insert(tk.END, "osc "+str(osc_nr+1)) def create_osc(self, note, octave, freq, from_gui, all_oscillators, is_audio=False): def create_unfiltered_osc(): def create_chord_osc(clazz, **arguments): if is_audio and self.arp_filter_gui.input_mode.get().startswith("chords"): chord_keys = major_chord_keys(note, octave) if self.arp_filter_gui.input_mode.get() == "chords3": chord_keys = list(chord_keys)[:-1] a4freq = self.a4_choice.get() chord_freqs = [note_freq(n, o, a4freq) for n, o in chord_keys] self.statusbar["text"] = "major chord: "+" ".join(n for n, o in chord_keys) oscillators = [] arguments["amplitude"] /= len(chord_freqs) for f in chord_freqs: arguments["frequency"] = f oscillators.append(clazz(**arguments)) return MixingFilter(*oscillators) else: # no chord (or an LFO instead of audio output oscillator), return one osc for only the given frequency return clazz(**arguments) waveform = from_gui.input_waveformtype.get() amp = from_gui.input_amp.get() bias = from_gui.input_bias.get() if waveform == "noise": return WhiteNoise(freq, amplitude=amp, bias=bias, samplerate=self.synth.samplerate) elif waveform == "linear": startlevel = from_gui.input_lin_start.get() increment = from_gui.input_lin_increment.get() minvalue = from_gui.input_lin_min.get() maxvalue = from_gui.input_lin_max.get() return Linear(startlevel, increment, minvalue, maxvalue) else: phase = from_gui.input_phase.get() pw = from_gui.input_pw.get() fm_choice = from_gui.input_fm.get() pwm_choice = from_gui.input_pwm.get() if fm_choice in (None, "", "<none>"): fm = None elif fm_choice.startswith("osc"): osc_num = int(fm_choice.split()[1]) osc = all_oscillators[osc_num - 1] fm = self.create_osc(note, octave, osc.input_freq.get(), all_oscillators[osc_num-1], all_oscillators) else: raise ValueError("invalid fm choice") if pwm_choice in (None, "", "<none>"): pwm = None elif pwm_choice.startswith("osc"): osc_num = int(pwm_choice.split()[1]) osc = all_oscillators[osc_num-1] pwm = self.create_osc(note, octave, osc.input_freq.get(), osc, all_oscillators) else: raise ValueError("invalid fm choice") if waveform == "pulse": return create_chord_osc(Pulse, frequency=freq, amplitude=amp, phase=phase, bias=bias, pulsewidth=pw, fm_lfo=fm, pwm_lfo=pwm, samplerate=self.synth.samplerate) elif waveform == "harmonics": harmonics = self.parse_harmonics(from_gui.harmonics_text.get(1.0, tk.END)) return create_chord_osc(Harmonics, frequency=freq, harmonics=harmonics, amplitude=amp, phase=phase, bias=bias, fm_lfo=fm, samplerate=self.synth.samplerate) else: o = { "sine": Sine, "triangle": Triangle, "sawtooth": Sawtooth, "sawtooth_h": SawtoothH, "square": Square, "square_h": SquareH, "semicircle": Semicircle, "pointy": Pointy, }[waveform] return create_chord_osc(o, frequency=freq, amplitude=amp, phase=phase, bias=bias, fm_lfo=fm, samplerate=self.synth.samplerate) def envelope(osc, envelope_gui): adsr_src = envelope_gui.input_source.get() if adsr_src not in (None, "", "<none>"): osc_num = int(adsr_src.split()[1]) if from_gui is self.oscillators[osc_num-1]: return envelope_gui.filter(osc) return osc osc = create_unfiltered_osc() for ev in self.envelope_filter_guis: osc = envelope(osc, ev) return osc def parse_harmonics(self, harmonics): parsed = [] for harmonic in harmonics.split(): num, frac = harmonic.split(",") num = int(num) if '/' in frac: numerator, denominator = frac.split("/") else: numerator, denominator = frac, 1 frac = float(numerator)/float(denominator) parsed.append((num, frac)) return parsed def do_play(self, osc): if osc.input_waveformtype.get() == "linear": self.statusbar["text"] = "cannot output linear osc to speakers" return duration = 1.0 osc.set_title_status("TO SPEAKER") self.update() osc.after(int(duration*1000), lambda: osc.set_title_status(None)) o = self.create_osc(None, None, osc.input_freq.get(), osc, all_oscillators=self.oscillators, is_audio=True) o = self.apply_filters(o) sample = self.generate_sample(o, duration) if sample.samplewidth != self.synth.samplewidth: print("16 bit overflow!") # XXX sample = sample.make_16bit() self.output.play_sample(sample) self.after(1000, lambda: osc.set_title_status("")) def do_close_waveform(self): for child in self.waveform_area.winfo_children(): child.destroy() def do_plot(self, osc): if not matplotlib: self.statusbar["text"] = "Cannot plot! To plot things, you need to have matplotlib installed!" return o = self.create_osc(None, None, osc.input_freq.get(), osc, all_oscillators=self.oscillators).blocks() blocks = list(itertools.islice(o, self.synth.samplerate//params.norm_osc_blocksize)) # integrating matplotlib in tikinter, see http://matplotlib.org/examples/user_interfaces/embedding_in_tk2.html fig = Figure(figsize=(8, 2), dpi=100) axis = fig.add_subplot(111) axis.plot(sum(blocks, [])) axis.set_title("Waveform") self.do_close_waveform() canvas = FigureCanvasTkAgg(fig, master=self.waveform_area) canvas.get_tk_widget().pack(side=tk.LEFT, fill=tk.BOTH, expand=1) canvas.draw() close_waveform = tk.Button(self.waveform_area, text="Close waveform", command=self.do_close_waveform) close_waveform.pack(side=tk.RIGHT) def generate_sample(self, oscillator: Oscillator, duration: float, use_fade: bool = False) -> Optional[Sample]: scale = 2**(8*self.synth.samplewidth-1) blocks = oscillator.blocks() try: sample_blocks = list(next(blocks) for _ in range(int(self.synth.samplerate*duration/params.norm_osc_blocksize))) float_frames = sum(sample_blocks, []) frames = [int(v*scale) for v in float_frames] except StopIteration: return None else: sample = Sample.from_array(frames, self.synth.samplerate, 1) if use_fade: sample.fadein(0.05).fadeout(0.1) return sample def render_and_play_note(self, oscillator: Oscillator, max_duration: float = 4) -> None: duration = 0 for ev in self.envelope_filter_guis: duration = max(duration, ev.duration) if duration == 0: duration = 1 duration = min(duration, max_duration) sample = self.generate_sample(oscillator, duration) if sample: sample.fadein(0.05).fadeout(0.05) if sample.samplewidth != self.synth.samplewidth: print("16 bit overflow!") # XXX sample.make_16bit() self.output.play_sample(sample) keypresses = collections.defaultdict(float) # (note, octave) -> timestamp keyrelease_counts = collections.defaultdict(int) # (note, octave) -> int def _key_release(self, note, octave): # mechanism to filter out key repeats self.keyrelease_counts[(note, octave)] -= 1 if self.keyrelease_counts[(note, octave)] <= 0: self.pressed(note, octave, True) def pressed_keyboard(self, note, octave, released=False): if released: self.keyrelease_counts[(note, octave)] += 1 self.after(400, lambda n=note, o=octave: self._key_release(n, o)) else: time_since_previous = time.time() - self.keypresses[(note, octave)] self.keypresses[(note, octave)] = time.time() if time_since_previous < 0.8: # assume auto-repeat, and do nothing return self.pressed(note, octave) def pressed(self, note, octave, released=False): if self.arp_filter_gui.input_mode.get().startswith("arp"): if released: if self.arp_after_id: self.after_cancel(self.arp_after_id) # stop the arp cycle self.statusbar["text"] = "ok" self.arp_after_id = 0 return chord_keys = major_chord_keys(note, octave) if self.arp_filter_gui.input_mode.get() == "arpeggio3": chord_keys = list(chord_keys)[:-1] self.statusbar["text"] = "arpeggio: "+" ".join(note for note, octave in chord_keys) self.play_note(chord_keys) else: self.statusbar["text"] = "ok" self.play_note([(note, octave)], released) def play_note(self, list_of_notes, released=False): # list of notes to play (length 1 = just one note, more elements = arpeggiator list) to_speaker = [self.oscillators[i] for i in self.to_speaker_lb.curselection()] if not to_speaker: self.statusbar["text"] = "No oscillators connected to speaker output!" return if released: for note, octave in list_of_notes: if (note, octave) in self.currently_playing: # stop the note sid = self.currently_playing[(note, octave)] self.output.stop_sample(sid) return first_note, first_octave = list_of_notes[0] first_freq = note_freq(first_note, first_octave, self.a4_choice.get()) for osc in self.oscillators: if osc.input_freq_keys.get(): osc.input_freq.set(first_freq*osc.input_freq_keys_ratio.get()) for osc in to_speaker: if osc.input_waveformtype.get() == "linear": self.statusbar["text"] = "cannot output linear osc to speakers" return else: osc.set_title_status("TO SPEAKER") oscs_to_play = [] for note, octave in list_of_notes: freq = note_freq(note, octave, self.a4_choice.get()) oscs = [self.create_osc(note, octave, freq * osc.input_freq_keys_ratio.get(), osc, self.oscillators, is_audio=True) for osc in to_speaker] mixed_osc = MixingFilter(*oscs) if len(oscs) > 1 else oscs[0] self.echos_ending_time = 0 if len(list_of_notes) <= 1: # you can't use filters and echo when using arpeggio for now mixed_osc = self.apply_filters(mixed_osc) current_echos_duration = getattr(mixed_osc, "echo_duration", 0) if current_echos_duration > 0: self.echos_ending_time = time.time() + current_echos_duration oscs_to_play.append(mixed_osc) if len(list_of_notes) > 1: rate = self.arp_filter_gui.input_rate.get() duration = rate * self.arp_filter_gui.input_ratio.get() / 100.0 self.statusbar["text"] = "playing ARP ({0}) from note {1} {2}".format(len(oscs_to_play), first_note, first_octave) for index, (note, octave) in enumerate(list_of_notes): sample = StreamingOscSample(oscs_to_play[index], self.synth.samplerate, duration) sid = self.output.play_sample(sample, delay=rate*index) self.currently_playing[(note, octave)] = sid self.arp_after_id = self.after(int(rate * len(list_of_notes) * 1000), lambda: self.play_note(list_of_notes)) # repeat arp! else: # normal, single note if self.rendering_choice.get() == "render": self.statusbar["text"] = "rendering note sample..." self.after_idle(lambda: self.render_and_play_note(mixed_osc)) else: self.statusbar["text"] = "playing note {0} {1}".format(first_note, first_octave) sample = StreamingOscSample(oscs_to_play[0], self.synth.samplerate) sid = self.output.play_sample(sample) self.currently_playing[(first_note, first_octave)] = sid def reset_osc_title_status(): for osc in to_speaker: osc.set_title_status("") self.after(1000, reset_osc_title_status) def apply_filters(self, output_oscillator): output_oscillator = self.tremolo_filter_gui.filter(output_oscillator) output_oscillator = self.echo_filter_gui.filter(output_oscillator) return output_oscillator def load_preset(self): file = askopenfile(filetypes=[("Synth presets", "*.ini")]) cf = ConfigParser() cf.read_file(file) file.close() # general settings self.samplerate_choice.set(cf["settings"]["samplerate"]) self.rendering_choice.set(cf["settings"]["rendering"]) self.a4_choice.set(cf["settings"]["a4tuning"]) self.to_speaker_lb.selection_clear(0, tk.END) to_speaker = cf["settings"]["to_speaker"] to_speaker = tuple(to_speaker.split(',')) for o in to_speaker: self.to_speaker_lb.selection_set(int(o)-1) for section in cf.sections(): if section.startswith("oscillator"): num = int(section.split('_')[1])-1 osc = self.oscillators[num] for name, value in cf[section].items(): getattr(osc, name).set(value) osc.waveform_selected() elif section.startswith("envelope"): num = int(section.split('_')[1])-1 env = self.envelope_filter_guis[num] for name, value in cf[section].items(): getattr(env, name).set(value) elif section == "arpeggio": for name, value in cf[section].items(): getattr(self.arp_filter_gui, name).set(value) elif section == "tremolo": for name, value in cf[section].items(): getattr(self.tremolo_filter_gui, name).set(value) elif section == "echo": for name, value in cf[section].items(): getattr(self.echo_filter_gui, name).set(value) self.statusbar["text"] = "preset loaded." def save_preset(self): file = asksaveasfile(filetypes=[("Synth presets", "*.ini")]) cf = ConfigParser(dict_type=collections.OrderedDict) # general settings cf.add_section("settings") cf["settings"]["samplerate"] = str(self.samplerate_choice.get()) cf["settings"]["rendering"] = self.rendering_choice.get() cf["settings"]["to_speaker"] = ",".join(str(v+1) for v in self.to_speaker_lb.curselection()) cf["settings"]["a4tuning"] = str(self.a4_choice.get()) # oscillators for num, osc in enumerate(self.oscillators, 1): section = "oscillator_"+str(num) cf.add_section(section) for name, var in vars(osc).items(): if name.startswith("input_"): cf[section][name] = str(var.get()) # adsr envelopes for num, flter in enumerate(self.envelope_filter_guis, 1): section = "envelope_"+str(num) cf.add_section(section) for name, var in vars(flter).items(): if name.startswith("input_"): cf[section][name] = str(var.get()) # echo cf.add_section("echo") for name, var in vars(self.echo_filter_gui).items(): if name.startswith("input_"): cf["echo"][name] = str(var.get()) # tremolo cf.add_section("tremolo") for name, var in vars(self.tremolo_filter_gui).items(): if name.startswith("input_"): cf["tremolo"][name] = str(var.get()) # arpeggio cf.add_section("arpeggio") for name, var in vars(self.arp_filter_gui).items(): if name.startswith("input_"): cf["arpeggio"][name] = str(var.get()) cf.write(file) file.close()