Melosynth – ein ziemlich einfacher Synthesizer (116 Codezeilen)

Stellt hier eure Projekte vor.
Internetseiten, Skripte, und alles andere bzgl. Python.
Antworten
gotridofmyphone
User
Beiträge: 33
Registriert: Mittwoch 15. März 2017, 08:54

Hallo,

eigentlich komme ich aus der Perl-Welt. Es kann nicht schaden, dachte ich mir, auch mal über den Tellerrand zu schauen und etwas in Python zu programmieren. Zufällig zur gleichen Zeit interessiere ich mich auch für elektronische Klangerzeugung. Ich denke nicht, dass mein Interesse soweit reicht, dass ich mir perspektivisch eine zehntausende Euro teure Tonstudio-anlage in mein wohnzimmer stellen werde. Aber es reicht gerade eben aus, um mir – nach einem Eintagsgrundlagenstudium der Klangtheorie, Harmonik etc. – einen ganz einfachen Synthesizer zusammen basteln. Unwahrscheinlich ist, dass ich damit irgendeinen professionellen Toningenieur beeindrucken kann, aber ich bekomme wahrscheinlich ein gutes Gefühl dafür, warum entsprechende Gerätschaften so schweineteuer sind.

Natürlich weiß ich, dass es gute Open-Source-Synthesizer gibt. Sowas versuchen selbst zu programmieren ist spannender, als mit fertigen Programmen rumzuspielen.

Ich bin für ehrliche Kritik hinsichtlich Implementierung und Zweckeignung dieses Moduls immer zu haben.

Here we go:

Code: Alles auswählen

# -*- coding: utf-8 -*-
import pyaudio
import numpy as np
import time

""" This is a very simple synthesizer for single sound design

It supports tones composed of fundamental and overtones. Amplitude and or frequency
of each partial can be sine-modulated independently.

Nobody expects that little exercise in python programming to be suitable for
serious sound design, do they? Real instruments produce a wide spectrum of
overtones. I am not sure python can handle these numbers with that approach,
because I do not take care of any real-time provisions.

Based on http://stackoverflow.com/questions/8299 ... -in-python

"""

fs = 44100       # sampling rate, Hz, must be integer

def main():
    
    play_melody(1, ["simple", 440, 3])
    time.sleep(1)
    play_melody(1, ['vibr',220.0, 60]) # a minute of sound with vibrato
    
    stream.stop_stream()

# generate samples, note conversion to float32 array
def get_samples (timbre, freq, duration):
     partial_samples = []
     divisor = 0
     iseq = np.arange(fs*duration) # integer sequence 1 .. fs*duration
     for partial_tone in timbre_inventory[timbre]:
         partial_samples.append(partial_tone.render_samples(freq, iseq))
         divisor += partial_tone.share

     # mix partials by their weights and return an numpy array ready to
     # play
     return (sum(partial_samples)/divisor).astype(np.float32)

class Modulation:

    def __init__(self, frequency, base_share, mod_share):
        self.base_share = base_share 
        self.mod_share  = mod_share
        self.frequency = frequency

    def modulate(self, iseq):
        """ Caution: Not quite sure if that really does what it is supposed to.
              This was programmed by a python learner not fond of trying to
              tell professional sound architects about some new kid in town.
              It is just a very naive way to make a tone more dynamic, spawned
              from my intuition.
               
        [-------|-------|-------|-------] Frequency in intervals per second
           *     *     *     *     *    T
          ***   ***   ***   ***   ***   | ^ mod_share (3)
         ***** ***** ***** ***** *****  | = Modulation intensity in relation
        ******************************* –   to ...
        ******************************* |
        ******************************* |
        ******************************* |
        ******************************* | ^ base_share (6)
        ******************************* _ = Minimum amplitude or frequency
        """
        b = self.base_share
        m = self.mod_share
        f = self.frequency
        return ( m * (np.sin(2*np.pi * iseq * f/fs) + 1) / 2 + b) / (m + b)

class Partial:
    def __init__(self, nfactor=1, share=1, deviation=0, am=None, fm=None):
        self.nfactor = nfactor
        self.deviation = deviation
        self.share = share
        self.amplitude_modulation = am
        self.frequency_modulation = fm

    def render_samples(self, freq, iseq):
        n = self.nfactor
        d = self.deviation
        s = self.share

        if self.amplitude_modulation is not None:
            s *= self.amplitude_modulation.modulate(iseq)
        if self.frequency_modulation is not None:
            freq *= self.frequency_modulation.modulate(iseq)
 
        return np.sin( 2*np.pi * iseq * (n*freq+d) / fs ) * s

P = Partial
M = Modulation

timbre_inventory = {
    'simple': [P(1)],
    'test': [P(1), P(2,0.13), P(3,0.05)],
    'vibr': [P(1), P(2,0.03,fm=M(30,50,1)), P(3,0.15,am=M(5,350,75))],
}

p = pyaudio.PyAudio()
stream = p.open(format=pyaudio.paFloat32,
                channels=1,
                rate=fs,
                output=True)

def play_melody(volume, *sounds):
    for sound in sounds:
        stream.write(volume*get_samples(*sound))
    
if __name__ == "__main__":
   main()

stream.close()
p.terminate()
Wenn ich dieses Programm laufen lasse, kommen mir vier Fragen in den Sinn. Kann sie mir jemand vielleicht beantworten?:
  1. Warum ist eine Sekunde gefühlt drei mal so kurz wie üblich?
  2. Warum ändert sich der zweite Ton mit der Zeit, tritt das Vibrato zunehmend in den Vordergrund und wird immer höher?
  3. Warum gibt es vor dem Abspielen des zweiten Tons einen buffer underrun? Also teilweise kann ich mir das schon erklären, das ist hier ja keine Echtzeitgeschichte. Aber warum nicht bereits vor dem ersten?
  4. Wie kann ich meine genialen Ergüsse der Klangerzeugung in ein WAV gießen, statt sie mir gleich über Lautsprecher auszugeben? Ah, mit dem Modul wave sollte das gehen, haben erste Recherchen ergeben. Was ich mich allerdings frage ist, wie ich den Framebuffer konfigurieren muss. Manche nehmen 1024 oder so. Ist der Wert beliebig?

Danke,
-- gotridofmyphone
Zuletzt geändert von Anonymous am Samstag 18. März 2017, 15:46, insgesamt 1-mal geändert.
Grund: Quelltext in Python-Codebox-Tags gesetzt.
gotridofmyphone
User
Beiträge: 33
Registriert: Mittwoch 15. März 2017, 08:54

Hier noch ein Test des Synthesizers mit einem Geigenton:

Code: Alles auswählen

timbre_inventory = {
    ...
    'string': [
           P(1), P(2,0.5), P(3,0.15), P(4,0.03), P(5,0.03), P(6,0.1),
           P(7,0.15), P(8,0.2), P(9,0.01), P(10,0.03), P(11,0.01), P(12,0.02),
           P(13,0.08), P(14,0.04), P(15,0.02), P(16,0.01)
    ],
     ...
}
...
play_melody(1, ['string', 294.0, 60])
Würde man noch geringste Modulationen applizieren, käme das bestimmt noch näher dran, aber ich habe gerade keine Lust weiter zu tüfteln. Schon jetzt klingt es sehr ähnlich wie eine Geige, daher nenne ich den Test gelungen.

Die obigen Werte habe ich abgelesen aus Abb. links unten im Wikipedia-Artikel zu "Frequenzspektrum". Abschnitt "Amplitudenspektrum eines Audiosignals"(23. September 2016)
gotridofmyphone
User
Beiträge: 33
Registriert: Mittwoch 15. März 2017, 08:54

Ich bin es noch mal,

man kann hier offenbar nur in der ersten Zeit ein Posting bearbeiten. So denn, gibt es halt ein drittes in Folge bei mir, und zwar das letzte vor etwaigen Beiträgen anderer ;) bzw. bis ich mir obige Fragen selbst erklären kann:

Melosynth auf GitHub
jerch
User
Beiträge: 1669
Registriert: Mittwoch 4. März 2009, 14:19

@gotridofmyphone:
Das kann Python auch ohne dieses neumodische numpy-Zeugs: viewtopic.php?f=11&t=34028 :D
gotridofmyphone
User
Beiträge: 33
Registriert: Mittwoch 15. März 2017, 08:54

Hallo jerch,

Numpy ist schneller weil kompiliert, heißt es. Jedenfalls danke für den Link, ich schau mir dein Projekt auch mal zur Inspiration an.

In Vordergrund steht für mich nicht die Melodie, sondern das Klangdesign (auch wenn der Name "melosynth" auf etwas hindeutet, insofern unglücklich gewählt). Es geht also um häufiges additives Überlagern, also Produkte von großen Arrays. Wenn Numpy hält, was es verspricht, sollte es dabei schneller sein als Python-Arrays. Zwar gehört Echtzeit nicht zu meinen Anforderungen, doch lange möchte ich auch nicht warten, bis das Programm den Sound ausspuckt. Allerdings werden meine Audiosamples am Anfang auch so kurz sein, dass das keine Rolle spielen dürfte.

Wobei ich gerade nicht ganz sicher bin, ob Numpy in meinem Fall richtig rechnet. Klar, da klemmt wohl ne riesige Testsuite dahinter, die erfolgreich durchläuft. Daher wird eine mathematische Ursache haben, dass meine Frequenzmodulation exponentiell Geschwindigkeit aufnimmt, statt dafür zu sorgen, dass die Frequenz über die ganze Tonlänge gleichmäßig in einem spezifizierten Korridor oszilliert. Das ist mir zwar nicht so wichtig (zunächst nicht so wichtig wie die Amplitudenmodulation, die ordnungsgemäß funktioniert), aber wär schön, wenn das auch ginge. Hilft wohl nur, mir da noch tüchtig Ahnung anzulesen.

Noch wichtiger ist mir aber das Sekundenproblem, das mir nach wie vor ein Rätsel ist: 44100 Hz ist die Samplingrate, aber ein Ton mit einer spezifizierten Sekunde Länge ist bei mir effektiv nur maximal ne halbe Sekunde lang. Dazu mache ich aber demnächst ein eigenes Thema auf.
gotridofmyphone
User
Beiträge: 33
Registriert: Mittwoch 15. März 2017, 08:54

Hallo,

das wird jetzt hier langsam so eine Art Blogdings, aber wollte nur bescheid sagen, dass sich meine Frage
2. Warum ändert sich der zweite Ton mit der Zeit, tritt das Vibrato zunehmend in den Vordergrund und wird immer höher?
... zum Glück erledigt hat. Der Fehler war rein mathematisch: Gewissermaßen darf ich nicht die Frequenzangabe modulieren, sondern den Frequenzumsatz über der Zeitlinie. Diese Zeitlinie ist eigentlich eine Art Treppe, bei der die Höhe der Stufen oszilliert. Ein Hoch auf np.cumsum, eine Funktion, um die Elemente im Numpy-Array aufzusummieren. Patch auf GitHub

Außerdem: Da ich jüngst gesehen habe, dass der Name "MeloSynth" bereits vergeben war, habe ich das Projekt umbenannt in sompyler. Es ist ein Kunstwort, in dem von mehreren Wörtern ein bisschen drin ist: Compiler, Python, Song, symphony und sound. Diese fünf Wörter umreißen ganz grob den Zweck des Programms, wenn es denn irgendwann mal fertig wird. Ich sehe viel Potenzial.
Benutzeravatar
BigZ
User
Beiträge: 30
Registriert: Mittwoch 1. Juli 2015, 21:18
Wohnort: Hamburg
Kontaktdaten:

Moin,
Ich dachte ich probiere den Code von Github mal aus, aber ich werde gerade von einem Haufen Errors begrüßt :D
Normalerweise nichts worüber ich mich lange ärgere, aber mir sprang gerade ein Error ins Auge den ich doch mal fix beanstanden muss.
Beim ausführen von sompyler.py:

Code: Alles auswählen

Traceback (most recent call last):
  File "sompyler.py", line 9, in <module>
    from instrument import Instrument
  File "/home/jan/gitclones/sompyler/instrument.py", line 25
    def from(file):
           ^
SyntaxError: invalid syntax
from ist ein ganz, ganz schlechter Name für eine Funktion, weil es zu den reservierten Schlagwörtern von Python gehört.
from module import usw
Man sollte meinen das Python den unterschiedlichen Kontext zwischen import und Funktionsaufruf erkennen sollte, aber wie es scheint
mag mein Pythoninterpreter das gerade überhaupt nicht. :D

Desweiteren ist mir noch aufgefallen das die test.py nicht mehr funktioniert. :/
In einen deiner letzten commits hast du die

Code: Alles auswählen

weighted_avg_with (self, dist, other):
in

Code: Alles auswählen

weighted_average (cls, left, dist, right)
umgeändert.
No problemo dachte ich mir und benannte die Funktion in test.py um und übergab ihr noch ein float.
Da wurde ich dann von einem assertion error verprügelt, den ich kurzerhand auskommentierte, worauf mir nochmal nahe gelegt wurde das float kein Attribut .length haben.
In Python gibt es die Funktion len(), aber selbst dann hat ein float keine Länge. :wink:

Ich wurschtel mich gerade mal son bisschen durch und pfusch da ganz fürchterlich dran herum um es zum laufen zu kriegen bei mir.
Deswegen ein paar Fragen.
Lief das bis vor kurzem mal bei dir? oO
Soll ich meine Änderungen commiten, falls ich es zum laufen kriege?
Und was und woher zum Henker kommt sine2wav in sompyler.py?

Code: Alles auswählen

from sine2wav import main as write_wavefile
Das Modul kann ich nirgendwo ums verrecken finden.
Weder im code, noch bei Google :D
Oder hab ich irgendwas anderes gravierendes verpasst? Oo
Greetz,
BigZ
"Ist noch λ?"
"Ja, aber das ϕ ist noch ϱ"
Benutzeravatar
snafu
User
Beiträge: 6731
Registriert: Donnerstag 21. Februar 2008, 17:31
Wohnort: Gelsenkirchen

BigZ hat geschrieben:from ist ein ganz, ganz schlechter Name für eine Funktion, weil es zu den reservierten Schlagwörtern von Python gehört.
from module import usw
Man sollte meinen das Python den unterschiedlichen Kontext zwischen import und Funktionsaufruf erkennen sollte, aber wie es scheint
mag mein Pythoninterpreter das gerade überhaupt nicht. :D
Wäre auf dem ersten Blick ein ganz nettes Feature, wenn der Interpreter das erlauben würde. Vermutlich führt es aber in der Praxis zu Problemen. Zumal das bei anderen Sprachen genau so ist, dass bestimmte Schlüsselwörter nicht neu definiert werden dürfen. Ich kann mir kaum vorstellen, dass die jeweiligen Programmierer zu doof waren, diese Möglichkeit einzubauen oder niemand von denen auf eine ähnliche Idee gekommen ist. Das wird also schon seine Gründe haben...
gotridofmyphone
User
Beiträge: 33
Registriert: Mittwoch 15. März 2017, 08:54

Ihr habt recht, der Code wie er bis jetzt auf Github war, hat hinten und vorne nicht funktioniert (zumindest in HEAD). Hat auch mehr als Gedankenablage fungiert. Solange das Programm pre-alpha ist, mache ich mir auch wenig den Kopf drum, ob das Teil gerade läuft oder nicht, Hauptsache mein Werk lagert nicht nur auf meinem PC, sondern auch extern.

Nach einigem Debuggen und Testen (aktuelle Version auf GitHub) habe ich jetzt ein orgelähnliches Instrument – von einem Klavier bin ich indes noch weit entfernt – auf dem man einfache Melodien spielen kann.

sompyler$ python -m tests.alle-meine-entchen && aplay /tmp/test.wav

Achtung, Dateiname ist noch statisch im Code hinterlegt. Muss ggf. für Windows angepasst werden.

Dieser Post bezieht sich auf Commit 48cdf94d.
Antworten