MIDI
Programmierung
Author D.Selzer-McKenzie
Video: https://youtu.be/nrS2Ajmbt4U
1 MIDI-Grundlagen
1.1 Was ist MIDI?
MIDI („Musical
Instruments Digital Interface“) wurde in den 1980ern von verschiedenen
amerikanischen und japanischen Herstellern als standardisierte Schnittstelle zur
Steuerung und Kopplung von Synthesizern und Klangmodulen entwickelt. Der
MIDI-Standard [http://www.midi.org] spezifiziert eigentlich drei verschiedene
Aspekte der Schnittstelle:
Eine
Hardware-Schnittstelle zur Kommunikation zwischen verschiedenen MIDI-Geräten.
Dabei handelt es sich um eine Art serielle Schnittstelle mit einer
Datenübertragungsrate von 31250 bps (Bits pro Sekunde). Synthesizer und andere
MIDI-Geräte werden üblicherweise mittels 5-poliger DIN-Buchsen verbunden. Die
meisten MIDI-Geräte verfügen über drei solche Buchsen für MIDI Input, MIDI
Output und (optional) MIDI „Thru“. Letztere ermöglicht die Weitergabe
empfangener MIDI-Daten. Damit können Ketten von MIDI-Geräten gebildet werden,
die alle mit dem gleichen MIDI-Datenstrom arbeiten, wie z.B. verschiedene
Synthesizer, Drum-Boxes etc., die von einem MIDI-Keyboard aus gesteuert werden.
Ein
Kommunikationsprotokoll, das die Syntax und Semantik der verschiedenen Typen
von MIDI-Nachrichten spezifiziert, mit denen MIDI-Geräte gesteuert werden können.
Die wichtigsten Nachrichten sind jene, mit denen Noten begonnen und beendet
werden, so genannte „Note On“- und „Note Off“-Nachrichten. Diese Nachrichten
werden z.B. von MIDI-Keyboards gesendet, wenn eine Taste angeschlagen bzw.
losgelassen wird. Mit anderen Typen von Nachrichten kann der gewählte
Instrumenten-Klang und der Status verschiedener Kontrollelemente verändert
werden. Diese Nachrichten korrespondieren typischerweise mit Knöpfen,
Schiebereglern u.ä. auf einem MIDI-Instrument.
Ein (binäres) Dateiformat
zur permanten Speicherung von Folgen („Sequenzen“) von MIDI-Ereignissen, d.h.
mit einem Zeitstempel versehenen MIDI-Nachrichten. Die Zeitstempel, die in
einer MIDI-Datei als Zeitdifferenzen (so genannte „Delta“-Werte) gespeichert
werden, geben an, zu welchem Zeitpunkt die Wiedergabe eines Ereignisses beim
Abspielen einer MIDI-Datei erfolgen soll. Eine MIDI-Datei kann mehrere
Sequenzen (so genannte „Spuren“) enthalten. Tatsächlich gibt drei verschiedene
Typen von MIDI-Dateien: „Typ 0“, mit dem eine einzelne Sequenz gespeichert
wird; „Typ 1“ zur Speicherung eines einzelnen Stückes, das aus mehreren Spuren
besteht; und „Typ 2“ zur Speicherung mehrerer Stücke (jeweils eines in jeder
Spur). Neben den üblichen Typen von MIDI-Nachrichten enthalten MIDI-Dateien
auch so genannte „Meta“-Ereignisse, z.B. Tonart-, Tempo- und Metrum-Angaben,
Spur-Beschriftungen u.ä.
WICHTIG: Im
Unterschied zu digitalem Audio (z.B. WAV oder MP3) enthalten MIDI-Datenströ-me
keine tatsächlichen Klänge – nur die zur Steuerung eines Klangmoduls
notwendigen Kontrollinformationen werden übertragen. Daher benötigt MIDI
erheblich weniger Bandbreite als die Klänge, die daraus in einem
angeschlossenen Synthesizer erzeugt werden. Dadurch wird es möglich,
MIDI-Ströme programmgesteuert zu analysieren, zu modifizieren und zu
synthetisieren, sogar in „Echtzeit“. Mögliche Anwendungen werden in Kapitel 2
skizziert.
- 1 -
1.2 Kurze
MIDI-Geschichte
Vor MIDI
1965: Erster
kommerziell verfügbarer Moog-Synthesizer
1960er, 70er:
CV-basierte Analog-Synthesizer
Ende 1970er:
erste digitale Synthesizer
Wichtige
MIDI-Meilensteine
Juni 1981:
Treffen von I. Kakehashi (Roland Corporation), Tom Oberheim (Oberheim
Electronics) und Dave Smith (Sequential Circuits) bei der Trade Show der
National Association of Music Merchants (NAMM)
November 1981:
Dave Smith stellt ersten Entwurf des so genannten „Universal Synthesizer
Interface“ (USI) bei der Audio Engineers Society vor
Januar 1982:
Japanische Hersteller (Korg, Kawai, Yamaha) schließen sich der Kooperation an
Juni 1982: Grundlegende Elemente der MIDI-Spezifikation werden bei der NAMM
vorgestellt 1982-83: erste Implementierungen
August 1983:
Veröffentlichung des MIDI 1.0 Standards 1985: MIDI ist de facto
Industriestandard
1991: General
MIDI (GM) Standard, MIDI Tuning Standard
Vor MIDI konnten
Synthesizer verschiedener Hersteller nur unter großem technischen Aufwand
miteinander verbunden werden. Mit MIDI ist dies nun auf einfache Weise möglich.
Darüber hinaus verfügen MIDI-Geräte auch über Möglichkeiten zur digitalen
Speicherung von Instrumentenklängen und Sequenzen; die aufwendige
„Programmierung“ der analogen Synthesizer mittels „Patches“ entfällt.
Tatsächlich können analoge Synthesizer (deren Klänge sich heute wieder
zunehmender Beliebtheit erfreuen) mittlerweile vollständig in Software durch
MIDI-Synthesizer und PC-Programme simuliert werden. MIDI hat sich wegen seiner
vielen Vorteile als sehr erfolgreich erwiesen und bereits in den 80er Jahren
große Verbreitung gefunden. Heute kommt praktisch kein neuer Synthesizer mehr
auf den Markt, der nicht MIDI-kompatibel ist.
MIDI ist ein
kommerzieller Standard, der von zwei Organisationen, der „MIDI Manufacturer's
Association“ (MMA) [http://www.midi.org] und ihrem japanischen Gegenstück, dem
„Japan MIDI Standards Committee“, gehütet wird. Alle Änderungen des Standards
benötigen die Zustimmung beider Organisationen.
Quelle:
Kristopher D. Giesing: A Brief History of MIDI.
http://ccrma-www.stanford.edu/~kgiesing/Midi/
(letzter Zugriff: 11.9.2002).
- 2 -
1.3 Das
MIDI-Format
MIDI-Nachrichten
setzen sich aus einem oder mehreren Bytes zusammen: ein so genanntes
Status-Byte (d.h. Befehls-Byte) gefolgt von einer Folge von Daten-Bytes. Bis
auf die so genannten Sysex-Nachrichten, die eine variable Anzahl von Daten-Bytes
haben können, haben alle Typen von MIDI-Nachrichten stets 0, 1, 2 oder 3
Daten-Bytes, je nach Befehlstyp. Status- und Daten-Bytes werden danach
unterschieden, ob das höchste Bit gesetzt ist. Status-Bytes haben stets Werte
zwischen (hexadezimal) 0x80 und 0xFF (dezimal 128 bis 255), Daten-Bytes Werte
zwischen 0x00 und 0x7F (dezimal 0 bis 127).
Exkurs: Bits und
Bytes
Zur Darstellung
von MIDI-Befehlen verwendet man meist Hexadezimalzahlen, bei der die Byte-Werte
in Basis 16 mit den Ziffern 0 bis 9 und A bis F ausgedrückt werden. Zur
besonderen Kennzeichnung schreiben wir Hexadezimalzahlen mit der Präfix 0x. In
der Hexadezimal-Schreibweise lassen sich leicht die Bestandteile eines
MIDI-Befehls, insbesondere des Status-Bytes erkennen:
8 Bit = 1 Byte =
2 Hexadezimalziffern. Das Byte ist die kleinste adressierbare Speichereinheit
im Computer, sie umfasst 256 Werte von 0x00 bis 0xFF (dezimal: 0 bis 255). Ein
Bit ist die kleinste Informationseinheit im Computer, entsprechend einer
einzigen Binärziffer (0 oder 1 = „aus“ oder „an“).
4 Bit = 1 Nibble
= 1 Hexadezimalziffer. Das Nibble ist ein „halbes Byte“ mit Werten im Bereich
0x0 bis 0xF (0 bis 15). Innerhalb eines Bytes unterscheidet man das Lo-Nibble
(Bits 0 bis 3) und das Hi-Nibble (Bits 4 bis 7). Z.B.: Byte-Wert = 0xA5 ?
Hi-Nibble = 0xA, Lo-Nibble = 0x5.
Man unterscheidet
so genannte „Voice“- und „System“-Nachrichten. Erstere sind für einen
bestimmten MIDI-Kanal bestimmt, während letztere für alle angeschlossenen
MIDI-Module gelten. MIDI unterstützt 16 Kanäle (0 bis 15). Angeschlossene
Geräte können je nach Bedarf auf bestimmte Kanäle eingestellt werden, so dass
sie nur Voice-Nachrichten des jeweiligen Kanals bearbeiten bzw. erzeugen.
Außerdem verfügt auf so genannten multitimbralen Synthesizern jeder MIDI-Kanal
über seine eigenen Instrumenten- und Controller-Einstellungen.
Das Status-Byte
von Voice-Nachrichten setzt sich zusammen aus einem Befehlscode im Hi-Nibble
und der Kanalnummer im Lo-Nibble. Folgende Typen von Voice-Nachrichten sind
vorhanden:
Status-Byte1
Beispiel
Bedeutung
0x8n
0x80 0x3C 0x40
„Note Off“ auf
Kanal 0, Note #60 (Mittel-C), Stärke 64
0x9n
0x92 0x3C 0x40
„Note On“ auf
Kanal 2, Note #60, Stärke 64
0xAn
0xA5 0x3C 0x7F
„Key Pressure“
auf Kanal 5, Note #60, Wert 127
0xBn
0xB0 0x07 0x7F
„Control Change“
auf Kanal 0, Controller #7
(Volume/Coarse),
Wert 127
0xCn
0xC0 0x05
„Program Change“
auf Kanal 0, Instrument #5 (GM-Standard: Electric Piano 1)
0xDn
0xD0 0x7F
„Channel
Pressure“ auf Kanal 0, Wert 127
0xEn
0xE0 0x00 0x40
„Pitch Wheel“ auf
Kanal 0, Wert 0x2000 = 8192 (Mittelstellung)
1 n = Kanalnummer
- 3 -
Status-Bytes im
Bereich zwischen 0xF0 und 0xFF kennzeichnen System-Befehle, die weiter in die
so genannten „System Common“- und „System Realtime“-Befehle unterteilt werden.
Auf letztere Befehle sollen MIDI-Geräte sofort, in Echtzeit, reagieren. Zwei
wichtige System-Befehle sind:
Sysex („System
Exclusive“, 0xF0): ein System Common-Befehl, beginnt mit einem 0xF0-Status,
endet mit 0xF7, dazwischen eine Folge variabler Länge von Daten-Bytes. Mit
diesem Befehl können eine Vielzahl von geräteabhängigen Synthesizer-Parametern
und -Funktionen gesteuert werden.
Reset (0xFF): ein
System Realtime-Befehl, der angeschlossene MIDI-Geräte in den Ausgangszustand
zurückversetzt.
Daneben gibt es
noch eine ganze Reihe weiterer, so genannter „Meta“-Nachrichten, die man
ausschließlich in MIDI-Dateien findet, wo sie dazu dienen, bestimmte
zusätzliche Informationen wie z.B. Tonart, Taktart und Tempo eines Stückes zu
speichern. Meta-Nachrichten beginnen stets mit einem Status-Byte von 0xFF (das
eigentlich vom MIDI-Standard für „Reset“ reserviert ist; es ist also nicht
möglich, in MIDI-Dateien einen Reset-Befehl zu speichern). Eine typische
Meta-Nach-richt ist z.B. die „Tempo“-Nachricht, die mit 0xFF 0x51 0x03 beginnt,
gefolgt von drei Daten-Bytes, die zusammen das Tempo des Stückes in der Einheit
„Mikrosekunden je Viertel-Note“ bezeichnen. Z.B. beschreibt die Folge 0xFF 0x51
0x03 0x07 0xA1 0x20 ein Tempo von 500.000 µs/Viertel, also 120 BPM.
Weitere Informationen
und eine genaue Beschreibung aller MIDI-Befehle finden sich auf Jeff Glatts
Website „MIDI Technical Docs and Programming“ [ http://www.borg.com/~jglatt/].
Hinweis: Die
technischen Details des MIDI-Nachrichten-Formats haben wir nur angeführt, um
Ihnen einen Blick „hinter die Kulissen“ zu ermöglichen. Sie werden für die
Benutzung der in Kapitel 2 eingeführten Q-Midi-Schnittstelle nicht benötigt, da
Q-Midi die MIDI-Nachrichten mehr oder weniger „im Klartext“ dargestellt, z.B.:
note_on 0 60 64, siehe Kapitel 5.
- 4 -
2 Grundlagen
Programmierung
2.1 Warum
MIDI-Programmierung?
Heutige PCs sind
standardmäßig mit Sound-Karten ausgestattet, die über eine MIDI-Schnittstelle
und größtenteils auch einen eingebauten MIDI-Synthesizer verfügen. Dies eröffnet
die Möglichkeit, mit eigenen Programmen auf dem PC MIDI-Daten zu bearbeiten,
auch in Echtzeit. Mittels Programmierung können z.B. komplexe
Bearbeitungsfunktionen automatisiert oder Stücke direkt in MIDI „komponiert“
werden.
Exkurs: Was ist
ein Programm?
Programme (auch
„Software“ genannt) sind die Folgen von Instruktionen, die ein Computer
ausführt, um ein bestimmtes Ergebnis zu erreichen, beispielsweise das
Ausdrucken eines Dokuments oder die Aufzeichnung einer MIDI-Sequenz. Programme
werden in speziellen Sprachen formuliert, die der Computer „versteht“, so
genannten Programmiersprachen.
Solange ein
Computer läuft, führt er stets irgendwelche Programme aus. Wenn ein Computer
gestartet wird, übernimmt ein System-Programm die Kontrolle, dass die System-Ressourcen
(z.B. Speicher und externe Geräte) verwaltet und es dem Benutzer erlaubt,
weitere so genannte Anwendungs-Programme zu starten.
Durch Erstellung
eigener Programme lassen sich MIDI-Anwendungen realisieren, die über den
Funktionsumfang gängiger Sequenzer-Programme hinausgehen, z.B.:
Algorithmische
Komposition: Musik lässt sich beschreiben als ein Prozess, der sich in der Zeit
vollzieht, und hat in diesem Sinn Ähnlichkeiten mit einem Computerprogramm. Es
liegt daher nahe, Programme zu verwenden, um Musik zu erzeugen. Dies kann nach
einem vorgegeben Schema, entweder deterministisch oder zufallsgesteuert,
geschehen. Durch Echtzeit-Ver-arbeitung eingehender MIDI-Signale kann der
Kompositionsprozess auch dynamisch, während der Ausführung eines Stückes, gesteuert
werden. Diese technischen Möglichkeiten finden in der zeitgenössischen Musik
häufig Verwendung.
Musikalische
Analyse: MIDI-Dateien oder in Echtzeit eintreffende MIDI-Daten können
programmgesteuert analysiert werden. Ein bekanntes Beispiel hierfür ist die
Markov-Analyse, mit der z.B. die Häufigkeit bestimmter Notenfolgen untersucht
werden kann. Die Analyseergebnisse können dann verwendet werden, um neue Stücke
zu synthetisieren, was eine Verbindung mit al-gorithmischen
Kompositionstechniken ermöglicht.
Bearbeitung von
MIDI-Dateien: Mit Programmen können beliebig komplexe und umfangreiche
Bearbeitungsschritte automatisiert werden, z.B. das Herausfiltern und
Modifizieren einzelner Typen von MIDI-Nachrichten, Quantisierung von
Noten-Werten, Änderung der Anschlagsdyna-mik, Variation von Kontrollparametern,
Verwendung von reinen oder mikrotonalen Stimmungen, etc.
Programmierung
ist ein kreativer Prozess, der viele Aspekte umfasst. Wir können hier nicht auf
alle Aspekte eingehen, wollen aber an Hand einfacher Beispiele die wesentlichen
Grundlagen der MIDI-Programmierung kennen lernen, die zur Erstellung eigener
MIDI-Applikationen notwendig sind. Dabei wird insbesondere auf die Bearbeitung
vorhandener MIDI-Sequenzen und die Verarbeitung von MIDI-Ereignissen in
Echtzeit eingegangen.
- 5 -
2.2
MIDI-Programmierung mit Q
Zur
Programmierung von MIDI-Anwendungen benötigen wir eine Programmiersprache, in
der die auszuführenden Verarbeitungsfunktionen formuliert werden können, und
ferner eine Programmierschnittstelle (API = „Application Programmer
Interface“), mit der wir auf die MIDI-Schnitt-stelle des Computers zugreifen
können. Wir verwenden hier die Programmiersprache Q (eine interpretierte,
funktionale Programmiersprache), die die Programmierung der MIDI-Schnittstelle
ermöglicht, ohne dass man sich mit den vielen kleinen technischen Details
auseinandersetzen muss, die die MIDI-Programmierung z.B. in der
Programmiersprache C recht mühselig machen. Q verfügt über eine
MIDI-Schnittstelle, Q-Midi genannt, mit der auf einfache Weise auf die
wesentlichen MIDI-Funktionen der zugrundeliegenden APIs der Betriebssysteme
Linux und Windows zugegriffen werden kann.
Exkurs:
Programmiersprachen
In der
Computer-Technik unterscheidet man zwischen imperativen, funktionalen und
logischen Programmiersprachen. Programme imperativer Programmiersprachen wie C
und Pascal sind Befehlsfolgen, die genau festlegen, welche Instruktionen in
welcher Reihenfolge ausgeführt werden müssen. Demgegenüber erlauben funktionale
und logische Programmiersprachen wie z.B. Lisp und Prolog, die gewünschte
Lösung mehr in abstrakter Form, entweder als mathematische Funktion oder als
logischen Ausdruck, anzugeben, was die Programmierung oft erheblich
vereinfacht.
Ferner
unterscheidet man zwischen interpretierten und compilierten Sprachen. Programme
einer interpretierten Sprache wie Lisp oder Basic werden von einem speziellen
Programm, dem Interpreter, ausgeführt, während Programme einer compilierten
Sprache wie C oder Pascal zunächst von einem so genannten Compiler in
Maschinensprache übersetzt werden müssen. Programme interpretierter Sprachen
lassen sich komfortabel im Dialog mit dem Interpreter ausführen, dafür bieten
compilierte Sprachen normalerweise eine höhere Ausführungsgeschwindigkeit der
Programme.
Um die Portierung
auf verschiedene Betriebssysteme zu erleichtern, verwendet Q-Midi nicht direkt
die MIDI-API des Betriebssystems, sondern die von Grame in Lyon entwickelte
MidiShare-Biblio-thek, die für eine ganze Reihe unterschiedlicher Betriebssysteme
verfügbar ist [http://www.grame.fr/MidiShare/]. Eine Q-Midi-Anwendung ist ein
Q-Programm, das durch den Q-Interpreter ausgeführt wird, und über Q-Midi und
MidiShare auf die MIDI-Schnittstelle zugreift. Den Aufbau dieses Systems zeigt
folgende Abbildung:
Q-Midi-Anwendung
Hardware
- 6 -
2.3 Ein einfaches
Q-Midi-Beispiel
Um einen ersten
Eindruck vom Aufbau und der Benutzung eines Q-Midi-Programmes zu vermitteln,
zeigen wir ein einfaches Programm, das eingehende MIDI-Ereignisse mit einer gewissen
Verzögerung wieder ausgibt, also eine Art „MIDI-Echo“ realisiert.
/* bsp01.q:
einfaches Q-Midi-Beispielprogramm */
import midi, mididev;
def (_,IN,_) = MIDIDEV!0, (_,OUT,PORT) = MIDIDEV!0,
APP = midi_open "bsp01", _ = midi_connect IN APP || midi_connect APP
OUT;
delay DT =
midi_flush APP || loop DT (midi_get APP);
loop _ (_,_,_,stop) = ();
loop DT (_,_,T,MSG) = midi_send APP PORT (T+DT,MSG) ||
loop DT (midi_get APP);
Um das Programm
auszuprobieren, öffnen Sie die Datei bsp01.q mit xemacs und starten Sie den
Q-Interpreter mit der Tastenfolge [Strg][C] [Strg][C]. (Unter Windows können
Sie das Programm auch mit der Qpad-Anwendung öffnen und dann mit [F9] starten.)
Der Eingabeprompt des Interpreters erscheint:
==>
Geben Sie nun den
Funktionsnamen delay ein, gefolgt von der gewünschten Verzögerungsrate in
Millisekunden. Schließen Sie Ihre Eingabe mit der [Return]-Taste ab. Z.B.
(Eingaben sind kursiv gekennzeichnet):
==> delay 1000
Auf dem
angeschlossenen MIDI-Keyboard gespielte Noten sollten nun mit einer Verzögerung
von einer Sekunde erneut wiedergegeben werden. (Damit dies auch bei kürzeren
Verzögerungszeiten funktioniert, muss Ihr Synthesizer die selbe Note mehrfach
auf dem gleichen MIDI-Kanal wiedergeben können.) Zum Beenden des Programms betätigen
Sie auf dem MIDI-Keyboard die Stop-Taste (soweit vorhanden) oder beenden Sie
den Interpreter mit der Tastenfolge [Strg][\] (einzugeben im Q-Eval-Puffer von
XEmacs).
2.4 Q-Midi Player
Die
Q-Midi-Installation umfasst auch ein mit Q-Midi realisiertes graphisches
Programm zur Wiedergabe und Aufnahme von MIDI-Sequenzen, den Q-Midi Player. Sie
können dieses Programm entweder in Ihre eigenen Programme einbinden order auch
direkt von der Kommandozeile aus starten, z.B.:
$ player
prelude3.mid
- 7 -
Die folgende
Abbildung zeigt den Q-Midi Player in Aktion:
Weitere
Informationen zu diesem Programm finden Sie in der Datei etc/README-Player im
Q-Verzeichnis (/usr/share/q für Linux bzw. /Programme/Qpad für Windows).
- 8 -
3 Einführung in Q
Vor den Erfolg
haben die Götter bekanntlich den Schweiß gesetzt, und so müssen wir nun
zunächst die Programmiersprache Q kennen lernen, bevor wir mit der eigentlichen
MIDI-Programmierung beginnen können. Glücklicherweise ist Q eine
verhältnismäßig einfache Sprache, mit der man sich auch ohne tiefere
Vorkenntnisse durchaus in einigen Tagen vertraut machen kann.
Hinweis: Wir
können im folgenden nur auf die wichtigsten Elemente der Programmiersprache Q
eingehen. Weitere Details entnehmen Sie bitte bei Bedarf dem „Q-Handbuch“ The Q
Programming Language, das auch „online“ verfügbar ist. Das Handbuch, den
Interpreter und das Q-Midi-Modul finden Sie zum Download unter der URL
http://www.musikwissenschaft.uni-mainz.de/~ag/q.
3.1 Erstellung
eines Skripts
Q-Programme werden
auch kurz „Skripts“ genannt. Um mit einem Skript zu arbeiten, müssen Sie es in
einer Datei (normalerweise mit der Endung .q) speichern, und dann den
Q-Interpreter aufrufen. Dies lässt sich am bequemsten erledigen, indem Sie das
Skript mit dem XEmacs-Editor erstellen; sie können dann den Interpreter einfach
mit der Tasten-Kombination [Strg][C] [Strg][C] starten.
Zur Übung
erstellen wir ein kleines Skript, mit dem wir die Zeichenfolge „Hello, world!“
ausgeben können. (Dies ist traditionell das erste Programm, dass man in jeder
Programmiersprache kennenlernt.) Starten Sie XEmacs wie folgt:
$ xemacs hello.q
Geben Sie nun die
folgenden Zeilen ein:
/* hello.q: Mein
erstes Q-Skript. */
hello = writes
"Hello, world!\n";
Speichern Sie das
Skript mit [Strg][X] [Strg][S] und starten Sie den Q-Interpreter mit [Strg][C]
[Strg][C]. Das Editor-Fenster wird geteilt und in der unteren Hälfte läuft nun
der Interpreter. Am Eingabeprompt ==> des Interpreters geben Sie hello ein,
gefolgt von einem Zeilenvorschub:
==> hello
Hello, world!
()
==>
Die Zeichenkette
„Hello, world!“ wird wie verlangt mit einem Zeilenvorschub am Ende ausgegeben.
(Der Wert () in der darauffolgenden Zeile stellt das „Ergebnis“ der
aufgerufenen writes-Funktion dar. Dazu später mehr.) Anschließend steht der
Kursor wieder hinter dem Eingabeprompt des Interpreters, der nun Ihre nächste
Eingabe erwartet. Um den Interpreter zu beenden, geben Sie nun entweder quit
ein oder die Tastenkombination [Strg][D]. Sie können XEmacs auch ganz beenden
durch Eingabe von [Strg][X] [Strg][C].
- 9 -
Um das Skript
nach Beendigung von XEmacs erneut aufzurufen, starten Sie XEmacs mit dem Namen
des Skripts wie oben, oder öffnen Sie innerhalb von XEmacs eine Skript-Datei
mit [Strg][X] [Strg][F]. Sie können auch den Interpreter ohne ein Skript
starten mit der Tastenkombination [Strg][C] [Strg][Q]. Die Online-Version des
Q-Handbuchs erhalten Sie in XEmacs mit der Tastenkombination [Strg][H]
[Strg][Q].
Hinweis: Unter
Windows können Sie auch die „Qpad“-Anwendung verwenden, um Skripts zu erstellen
und auszuführen. Starten Sie dazu das Qpad-Programm mit dem entsprechenden
Symbol auf der Arbeitsfläche, geben Sie das Skript in das obere Editor-Fenster
ein und speichern Sie es mit [Strg][S]. Mit der Taste [F9] können Sie das
Skript nun starten. Für eine genauere Beschreibung von Qpad siehe die
Hilfefunktion des Programms. In der Qpad-Hilfe finden Sie auch das komplette
Q-Handbuch.
Exkurs: Arbeiten
mit dem Q-Interpreter
Statt wie oben
gezeigt mit XEmacs oder Qpad können Sie den Interpreter auch direkt von der
Kommandozeile aus aufrufen, durch Eingabe des Kommandos q gefolgt von dem
Dateinamen des Skripts, z.B.:
$ q hello.q
Dazu müssen Sie
das Skript zunächst mit einem Editor erstellen. Sie können den Interpreter auch
ohne den Namen eines Skripts aufrufen, in diesem Fall stehen nur die
vordefinierten Funktionen zur Verfügung.
Wenn der
Eingabe-Prompt des Interpreters erscheint, können Sie einen beliebigen Ausdruck
eingeben. Der Interpreter wertet den eingegebenen Ausdruck aus und zeigt den
errechneten Wert an, z.B:
==> sqrt
(16.3805*5)/.05
181.0
Sie können die so
genannte „anonyme Variable“ _ verwenden, um auf das Ergebnis der letzten
Berechnung zuzugreifen:
==> 16.3805*5
81.9025
==> sqrt _/.05
181.0
Zwischenergebnisse
können auch explizit in Variablen gespeichert werden:
==> def X =
16.3805*5
==> sqrt X/.05
181.0
Die Definition
einer Variablen können Sie mit undef wieder löschen:
==> undef X
==> X
X
Mehrere Kommandos
und auszuwertende Ausdrücke können auf der selben Zeile eingegeben werden. Dazu
trennen Sie die verschiedenen Kommandos mit einem Semikolon voneinander ab:
==> def X =
16.3805*5; sqrt X/.05; undef X
181.0
Der Interpreter
verfügt über eine so genannte „Kommando-Geschichte“, in der eingegebene
Kommandos gespeichert werden. Sie können vorangegangene Kommandos mit den
vertikalen Pfeiltasten [ ] und [ ] wieder aufrufen. (Dies funktioniert auch in
XEmacs und Qpad, wenn Sie die Pfeiltasten mit der [Strg]-
- 10 -
Taste
kombinieren.) Innerhalb der Kommandozeile kann der Kursor mit den horizontalen
Pfeiltasten [ ] und [ ] bewegt werden, und Sie können die üblichen
Editier-Funktionen (z.B. den „Backspace“ oder die [Entf]-Taste) verwenden, um
Ihre Eingabe zu bearbeiten, bevor Sie sie mit der Return-Taste „abschicken“.
Nützlich ist auch die „Komplettierungs“-Funktion, mit der Sie einen Funktions-
oder Variablen-Namen vervollständigen können; geben Sie dazu den Beginn des
Namens ein und betätigen Sie die Tabulator-Taste.
Der Interpreter
kennt eine ganze Reihe weitere spezielle Kommandos, auf die wir hier nicht alle
eingehen können. Z.B. können Sie alle definierten Variablen auf einen Schlag
mit dem Kommando clear löschen. Sie können auch ein Skript innerhalb des
Interpreters mit dem Kommando edit bearbeiten, oder ein neues Skript mit dem
run-Kommando aufrufen. Mit den Kommandos cd und pwd können Sie das aktuelle
Verzeichnis wechseln und sich das eingestellte Verzeichnis anzeigen lassen.
Eine vollständige Übersicht aller Interpreter-Kommandos finden Sie im Abschnitt
„Using Q“ des Q-Handbuchs. Wenn Sie das GNU Info-Programm installiert haben,
können Sie die Online-Version des Handbuchs auch direkt im Interpreter mit dem
Kommando help aufrufen. Um z.B. eine Übersicht der Interpreter-Kommandos zu erhalten,
geben Sie das folgende Kommando ein:
==> help
commands
Verwenden Sie die
[Bild ]- und [Bild ]-Tasten, um in der Anzeige zu blättern. Wenn
Sie mit Lesen fertig sind, geben Sie q ein, um das Info-Programm zu beenden.
Um den
Interpreter zu beenden, verwenden Sie die Funktion quit oder geben Sie am
Beginn der Kommandozeile die Tastenkombination [Strg][D] ein. Wenn Sie den
Interpreter von XEmacs oder Qpad aus gestartet haben, wird bei Beenden von
XEmacs bzw. Qpad natürlich auch automatisch der Q-Interpreter beendet.
3.2 Anatomie
eines Skripts
Wir wollen uns
nun etwas genauer mit dem Aufbau von Q-Skripts befassen. Ein Q-Skript besteht
im wesentlichen aus den folgenden Elementen, die in beliebiger Reihenfolge
aufgeführt werden können:
Kommentare: Kommentare
beginnen mit /* und enden mit */. Der dazwischenliegende Text darf beliebig
lang sein und mehrere Zeilen umfassen. Außerdem können zeilenorientierte
Kommentare wie in C++ oder in Prolog angegeben werden, d.h. durch // bzw. %
gefolgt von beliebigem Text bis zum Zeilenende.
Deklarationen:
Deklarationen dienen dazu, neue Funktions-, Variablen- und Typ-Symbole zu
vereinbaren, oder auf die Symbole und Definitionen anderer Skripts zuzugreifen.
Siehe Abschnitt 3.3.
Gleichungen:
Diese bilden den Kern eines Q-Skripts. Mit ihnen werden die Funktionen eines
Skripts definiert, wie z.B. die Funktion hello im vorangegangenen Abschnitt.
Eine Gleichung besteht immer aus einer linken und einer rechten Seite, die
voneinander durch das Symbol = getrennt sind. Beide Seiten einer Gleichung
können im Prinzip beliebige „Ausdrücke“ (s. Abschnitt 3.4) sein. Der
Interpreter wendet Gleichungen immer von links nach rechts an, indem er
innerhalb eines auszuwertenden Ausdrucks eine passende linke Seite durch die
entsprechende rechte Seite ersetzt. Man nennt dies auch „Termersetzung“. Mehr
dazu in Abschnitt 3.6 und 3.7.
Variablen-Definitionen:
Man kann den Wert eines Ausdrucks auch in einer Variablen speichern. Der
entsprechende Wert kann dann auf der rechten Seite einer Gleichung oder auf der
Eingabezeile des Interpreters durch Angabe des Variablen-Namens verwendet
werden. Siehe Abschnitt 3.8.
- 11 -
3.3 Deklarationen
In Q müssen
Variablen- und Funktions-Symbole nicht deklariert werden; der Interpreter nimmt
beim Fehlen einer Deklaration für ein neues Symbol automatisch an, dass es sich
um ein „privates“ Symbol handelt, das nur innerhalb des aktuellen Skripts
verwendet wird. Soll ein Symbol zur Verwendung in anderen Skripts
bereitgestellt („exportiert“) werden, so muss es explizit als „public“
vereinbart werden, z.B.:
public hello, foo X Y Z;
public var BAR;
Die erste
Deklaration führt zwei Funktionssymbole ein, hello und foo. Dabei wird foo als
ein Funktionssymbol mit drei Parametern vereinbart, hier mit X, Y und Z bezeichnet.
Die Kennzeichnung der Parameter ist optional, und ist als eine Zusicherung zu
verstehen, dass die entsprechende Funktion drei Argumente erwartet – wird ein
Symbol mehrfach deklariert, so müssen die Anzahl der Parameter (nicht jedoch
deren Namen) übereinstimmen.
Die zweite
Deklaration führt ein Variablen-Symbol BAR ein. Diesem kann später mittels def
ein Wert zugewiesen werden, vgl. Abschnitt 3.8.
Generell muss die
erste Deklaration eines Symbols jeweils vor seiner ersten Verwendung stehen.
Mehrere Deklarationen des gleichen Symbols sind zulässig, die Deklarationen
müssen aber miteinander konsistent sein.
Man kann ein
Symbol auch explizit als „private“ vereinbaren:
private hallo, bar X Y;
private var
PRIVATE;
Dies ist dann
notwendig, wenn ein privates Symbol eingeführt werden soll, das anderswo (d.h.
in einem importierten Skript, s.u.) bereits als „public“ vereinbart wurde.
Um auf die
„public“-Symbole und Definitionen eines anderen Skripts zuzugreifen, benötigt
man eine „import“-Deklaration. Solche Deklarationen stehen üblicherweise am
Beginn eines Skripts:
import midi,
mididev;
Hier werden zwei
Skripts midi.q und mididev.q importiert (der Interpreter ergänzt die Endung .q
des Dateinamens automatisch).
Statt des
Schlüsselwortes import kann man auch include verwenden. Der Unterschied zur
„import“-Deklaration besteht darin, dass die „public“-Symbole eines mit include
importierten Skripts „reexportiert“, also zusammen mit den anderen
„public“-Symbolen des Skripts exportiert werden, als ob das importierte Skript
Bestandteil des importierenden Skripts wäre. Dies ist insbesondere dann
nützlich, wenn verschiedene bereits vorhandene Skripts zu einem neuen Skript
zusammengefasst werden sollen.
Ein weiterer
Deklarations-Typ ermöglicht die Vereinbarung von Datentypen. Z.B. lässt sich
ein „Aufzählungstyp“ mit den Tagen der Woche wie folgt vereinbaren:
- 12 -
public type Day = const sun, mon, tue, wed, thu, fri,
sat;
Auch
Datentyp-Deklarationen können als „public“ oder „private“ gekennzeichnet
werden. Das Schlüsselwort const in obiger Deklaration legt fest, dass es sich
bei den Funktions-Symbolen sun, mon, etc. um „Konstanten“ handelt, die nicht
als linke Seite einer Gleichung auftreten, also nicht neu definiert werden
dürfen. Der MidiMsg-Datentyp der Q-Midi-Schnittstelle ist auf ähnliche Weise
vereinbart. Weitere Informationen zur Deklaration und Verwendung von Datentypen
finden sich in Abschnitt 3.9.
3.4 Ausdrücke
Wie in den
meisten funktionalen Programmiersprachen dreht sich in Q alles um die Auswertung
von Ausdrücken. Wir unterscheiden genau wie in anderen Programmiersprachen
einfache (oder elementare) und zusammengesetzte Ausdrücke. Ein Unterschied zu
den meisten anderen Programmiersprachen besteht allerdings darin, dass der Wert
eines Ausdrucks selbst wieder ein zusammengesetzter Ausdruck sein kann; darauf
werden wir in Abschnitt 3.7 näher eingehen.
Einfache
Ausdrücke
Ganze Zahlen
werden als eine dezimale Ziffernfolge ohne Dezimalpunkt notiert. Negative
Zahlen werden durch ein vorangestelltes Minuszeichen gekennzeichnet. Im
Unterschied zu vielen anderen Programmiersprachen können ganze Zahlen in Q
beliebig große und kleine Werte annehmen. Beispiele: 0, -5, 17, -99,
192837190379190237. Ganze Zahlen können auch in Hexadezimal-Notation angegeben
werden, indem man ihnen das Präfix 0x voranstellt; z.B. entspricht 0x7F15B der
Dezimalzahl 520539. Die Hexadezimalziffern A bis F dürfen auch in Kleinschrift
angegeben werden, z.B. 0x7f15b. Auch eine Angabe als Oktalzahl (d.h. zur Basis
8, mit Ziffern 0-7) ist möglich; dazu wird der Zahl die Ziffer 0 vorangestellt,
z.B. 033 = 27.
Fließkommazahlen
sind Ziffernfolgen, die einen Dezimalpunkt und/oder einen Exponenten (zur Basis
10) enthalten, z.B.: 0., .1, -0.7, 1.23456E78. Letztere stellt die Zahl 1.23456
? 1078 dar. Fließkommazahlen werden in Q stets mit 64 Bit dargestellt
(entsprechend einem absoluten Darstellungsbereich von etwa 1.7E-308 bis 1.7E308
mit ca. 15 Dezimalstellen Genauigkeit).
Zeichenketten
bestehen aus beliebigen druckbaren ASCII-Zeichen (außer " und \), die in
doppelte Anführungszeichen eingefasst werden, z.B.: "" (leere
Zeichenkette), "a" (einzelnes Zeichen), "abc",
"!$%&" oder "Hello, world!". Um die Zeichen ", \
und spezielle nicht druckbare Zeichen wie z.B. den Zeilenvorschub anzugeben,
wird das \-Zeichen („Backslash“) in Kombination mit anderen Zeichen verwendet
(so genannte „Escape-Sequenzen“). Die gebräuchlichsten Escape-Sequenzen sind in
folgender Tabelle zusammengefasst:
\n
Zeilenvorschub
(Newline)
\r
Wagenrücklauf
(Return)
\t
Tabulator-Zeichen
\\
Backslash
\"
doppelte
Anführungszeichen
\n
ASCII-Zeichen Nr.
n (dezimal/hexadezimal/oktal)
- 13 -
Z.B. hat das so
genannte „Escape“-Zeichen den ASCII-Code 27, lässt sich also als
"\27", "\033" oder "\0x1b" darstellen, je
nachdem, ob man die dezimale, oktale oder hexadezimale Schreibweise vorzieht.
Exkurs: Der
ASCII-Code
Bei dem so
genannten ASCII-Code („American Standard Code for Information Interchange“)
handelt es sich um einen weit verbreiteten Zeichen-Code, in dem jedes Zeichen
durch eine 7-Bit-Zahl dargestellt wird. Heutige Computer verwenden meist einen
erweiterten ASCII-Code, in dem jedes Zeichen 8 Bit (also ein Byte) umfasst. In
solchen Codes lassen sich 256 verschiedene Zeichen, mit den Codes 0 bis 255,
darstellen. Der ASCII-Code ist so aufgebaut, dass die druckbaren Zeichen bei
Code 32 (dem Leerzeichen) beginnen, und die Ziffern in numerischer, die Groß-
und Kleinbuchstaben jeweils in alphabetischer Reihenfolge angeordnet sind. Die
Ziffern beginnen bei ASCII-Code 48, die Großbuchstaben bei Code 65 und die
Kleinbuchstaben bei Code 97. Die Buchstaben und Ziffern werden auch zusammen
alphanumerische Zeichen genannt. Der ASCII-Code 0 wird in vielen
Programmiersprachen (auch in Q) benutzt, um das Ende einer Zeichenkette
anzuzeigen. Die Codes 1 bis 31 (die so genannten Kontrollzeichen) sind allesamt
nicht druckbar, sondern dienen zur Steuerung eines Ausgabegeräts. Die
wichtigsten Kontrollzeichen sind ASCII-Code 10 (Zeilenvorschub), der allgemein
verwendet wird, um das Zeilenende anzuzeigen, und Code 7, das Tabulatorzeichen,
das einen Sprung an die nächste Tabula-torposition darstellt.
Funktions- und
Variablen-Symbole werden durch Bezeichner angegeben, Folgen von
alphanumerischen Zeichen, die mit einem Buchstaben beginnen (der Unterstrich _
zählt dabei als Buchstabe). Wie in Prolog werden Symbole, die mit einem
Großbuchstaben beginnen, als Variablen-Symbole aufgefasst. Bezeichner, die mit
einem Kleinbuchstaben (inklusive _) anfangen, sind Funktions-Symbole. (Eine
Sonderrolle spielt das Symbol _, die so genannte „anonyme Variable“, siehe
Abschnitt 3.6.)
Auf Symbole in
anderen Skripts kann auch mit so genannten qualifzierten Bezeichnern der Form
Skriptname::Bezeichner zugegriffen werden. Dies ist manchmal notwendig, um
Mehrdeutigkei-ten aufzulösen. Exportieren z.B. zwei Skripts foo1.q und foo2.q
jeweils ein Funktions-Sym-bol mit Namen foo, so wird ersteres mit foo1::foo und
letzteres mit foo2::foo bezeichnet.
Wichtig:
Bestimmte alphanumerische Zeichenfolgen sind in Q als Schlüsselworte reserviert
und können nicht als Bezeichner verwendet werden. Es sind dies die
folgenden:
as
and
const
def
div
else
extern
if
import
in
include
mod
not
or
otherwise var
private where
public
special
then
type
undef
Zusammengesetzte
Ausdrücke
Beginnend mit den
elementaren Ausdrücken können wir nun schrittweise immer kompliziertere
Ausdrücke zusammensetzen. Dazu gibt es in Q die folgenden Konstruktionen:
Listen werden wie
in Prolog durch eine in eckigen Klammern eingeschlossene Aufzählung der
Listen-Elemente dargestellt, z.B. ist [1,2,3] die Liste der drei Zahlen 1, 2
und 3. Listen dürfen verschiedenartige Elemente enthalten und geschachtelt
werden, wie z.B. in [[1,2,3], ["world",3.14]]. Listen werden wie in
Prolog als mit dem „Listen-Operator“ [|] gebildete rechts-rekursive Strukturen
repräsentiert und können auch so notiert werden; z.B. ist [1,2]
- 14 -
identisch mit
[1|[2|[]]]. Generell stellt [] die leere Liste, und [X|Xs] eine Liste mit
erstem Element X und Rest-Liste Xs dar. Diese Schreibweise ist nützlich, wenn
rekursive Listen-Operationen definiert werden sollen, vgl. Abschnitt 3.6.
Tupel sind analog
zu Listen aufgebaut, werden aber intern als so genannte „Vektoren“
repräsentiert, was eine platzsparende Speicherung und schnellen Zugriff auf
einzelne Elemente ermöglicht. Im Unterschied zu Listen werden Tupel, wie in der
Mathematik üblich, in runde Klammern eingefasst, z.B. () (leeres Tupel), (99)
(1-Tupel mit 99 als einzigem Element), (1,2,(a,b)) (Tripel, bestehend aus den
Zahlen 1, 2 und dem Paar (a,b)). Wie bei Listen kann die Schreibweise (X|Xs)
verwendet werden, um ein Tupel mit Anfangselement X und Rest-Tupel Xs
darzustellen.
Funktions-Anwendungen
werden in Q durch einfaches Nebeneinander-Schreiben notiert, wie z.B. in sin
0.5. Hier wird ein Funktions-Symbol sin (die „eingebaute“ Sinus-Funktion, vgl.
Abschnitt 3.5) auf die Fließkomma-Zahl 0.5 angewendet, was bei der Auswertung
im Interpreter den Wert des Sinus an der Stelle 0.5 ergibt. Im allgemeinen Fall
können sowohl die angewendete Funktion als auch das Funktions-Argument selbst
wieder beliebig komplizierte Ausdrücke sein, wobei geschachtelte Funktions- und
Operator-Anwendungen im Argument ge-klammert werden müssen, wie z.B. in sin
(3.1415/2).
Funktions-Anwendungen
mit mehreren Argumenten werden ebenfalls durch Nebeneinander-Schreiben notiert,
z.B. max 7 12. Dabei wird implizit Links-Klammerung angenommen, d.h., max X Y
ist dasselbe wie (max X) Y. Der Gedanke dabei ist, dass max X selbst wieder
eine Funktion darstellt, die bei Anwendung auf das Argument Y den Wert (max X)
Y = max X Y liefert; diese Art, Funktionen mehrerer Veränderlicher zu notieren,
wird nach dem amerikanischen Logiker Haskell B. Curry als Currying bezeichnet
und ist in modernen funktionalen Programmiersprachen sehr verbreitet.
Operator-Anwendungen
sind spezielle Funktions-Anwendungen, bei denen ein vordefinierter Operator wie
z.B. + die Rolle der Funktion übernimmt. Der Unterschied besteht darin, dass
zur Vereinfachung der Notation bei Operatoren die gebräuchliche Infix-Schreibweise
verwendet wird. Der Interpreter kennt die üblichen Präzedenz-Regeln, bei davon
abweichender Aus-wertungs-Reihenfolge müssen Klammern gesetzt werden.
Beispiele: (X+1)*(Y-1), X+3*(Y-1), X+1/sin(Y-1). Man beachte, dass
Funktions-Anwendungen stets Vorrang vor Operatoren haben (vgl. letztes
Beispiel!).
Wie auch in
anderen funktionalen Programmiersprachen (z.B. Haskell) sind
Operator-Anwendungen nur eine bequeme Kurzform für entsprechende
Funktions-Anwendungen. Man kann jeden Operator in eine gewöhnliche Präfix-Funktion
verwandeln, indem man ihn einklammert. Z.B. ist X+Y genau dasselbe wie (+) X Y.
Außerdem kann man bei Infix-Operatoren so genannte Operator-Sektionen bilden,
bei denen entweder das linke oder rechte Argument fehlt. So ist z.B. (1/) die
reziproke Funktion: (1/) X = 1/X, (*2) die Verdopplungs-Funktion: (*2) X = X*2.
Exkurs:
Operatoren
Operatoren werden
verwendet, um gängige arithmetische und logische Operationen wie Addition,
Multiplikation, Division und Vergleiche sowie logische Verknüpfungen („und“,
„oder“, „nicht“) auszudrücken. Man unterscheidet unäre (einstellige) und binäre
(zweistellige) Operatoren, je nach Anzahl der Operator-Argumente (die man auch
Operanden nennt). Erstere werden meist als Präfix notiert (z.B. -X für unäres
Minus, not X für logische Negation), letztere in Infix-Schreibweise, d.h.
zwischen den Operanden (z.B. X*Y für Multiplikation, X<=Y für „kleiner oder
gleich“, X and Y für logisches „und“).
- 15 -
Wie in den
meisten Programmiersprachen werden auch in Q Operatoren nicht einfach von links
nach rechts ausgewertet, sondern es werden die üblichen Präzedenzregeln
beachtet (z.B. „Punkt vor Strich“). Bei Operatoren gleicher Präzedenz wird
normalerweise von links nach rechts ausgewertet, z.B. X-Y-Z = (X-Y)-Z. Man
nennt solche Operatoren auch links-assoziativ. Eine Ausnahme ist der
Exponentia-tions-Operator " („X hoch Y“), der entsprechend üblichem
mathematischen Gebrauch rechts-assoziativ ist, d.h. X"Y"Z =
X"(Y"Z). Gleiches gilt für den Index-Operator: X!I!J = X!(I!J); Mehrfach-Indizes
müssen also geklammert werden: (X!I)!J. Ein anderer Sonderfall sind die
Vergleichs-Operatoren (<, >, <=, >=, etc.), die nicht-assoziativ
sind, d.h. Kombinationen wie X
Die folgende
Tabelle listet alle für uns wichtigen Operatoren in absteigender
Präzedenz-Reihenfolge auf. (Eine vollständige Auflistung findet man im
Q-Handbuch. Für eine Beschreibung der Funktionsweise der wichtigsten Operatoren
siehe Abschnitt 3.5.)
Gruppe
Operatoren
Bedeutung
Beispiel
Exponentiation/
Subskript
"
!
Exponentiation
Index
X"Y
X!I
unäre Präfix- Operatoren
-
#
not
unäres Minus
Anzahl
logisches „nicht“
-X
#X
not X
Multiplikations- Operatoren
*
/ div mod and and
then
Multiplikation
Division
ganzzahlige
Division
Rest der
ganzzahligen Division
logisches „und“
logisches „und
dann“
X*Y
X/Y
X div Y
X mod Y
X and Y
X and then
Y
Additions- Operatoren
+
- ++ or or
else
Addition
Subtraktion
Konkatenation
logisches „oder“
logisches „oder sonst“
X+Y
X-Y
X++Y
X or Y
X or else
Y
Relationale Operatoren
<
> <= >=
= <> in
„kleiner als“
„größer als“
„kleiner oder
gleich“
„größer oder
gleich“
„gleich“
„ungleich“
„in“
X
X>Y
X<=Y
X>=Y
X=Y
X<>Y
X in Y
Sequenz-Operator
||
Hintereinander-Ausführung
X||Y
Zum Abschluss
fassen wir die verschiedenen Typen von Ausdrücken in einer kleinen
Übersichtstabelle zusammen:
- 16 -
Einfache
Ausdrücke
Zusammengesetzte
Ausdrücke
Ausdrucks-Typ
Beispiele
Ausdrucks-Typ
Beispiele
Ganze Zahl
12345678
0xa0
-033
Liste
[]
[a]
[a,b,c]
Fließkommazahl
0.
-1.0
1.2345E-78
Tupel
()
(a)
(a,b,c)
Zeichenkette
""
"abc"
"Hello, world!\n"
Funktions-Anwendung
sin 0.5
max X (sin Y)
(*2) (sin X)
Symbol
foo
BAR
foo1::foo
Operator-Anwendung
-X
X+Y
X or Y
3.5 Vordefinierte
Operatoren und Funktionen
In der
Programmiersprache Q sind eine große Anzahl von Operatoren und Funktionen
bereits zur sofortigen Verwendung vordefiniert. Wir können hier nicht auf alle
diese Operationen eingehen, wollen aber die wichtigsten Funktionsgruppen kurz
vorstellen.
Arithmetische
Operationen: Die gebräuchlichsten arithmetischen Operatoren (Addition,
Subtraktion, Multiplikation, Division, Exponentiation) können sowohl auf ganze
als auch auf Fließkomma-Zahlen angewendet werden. Bei Addition, Subtraktion und
Multiplikation entspricht der Typ des Ergebnisses stets den Operanden, z.B.
ergibt 123*456 die ganze Zahl 56088, während 123.0*456.0 die Fließkommazahl
56088.0 liefert. Bei gemischten Operanden wird eine Fließkommazahl
zurückgeliefert: 123*456.0 = 56088.0. Der Divisions-Operator liefert stets eine
Fließkommazahl, z.B. 12/3 = 4.0, genau wie der Exponentiations-Operator: 5^3 =
125.0. Außerdem gibt es die Operatoren div und mod, die den Wert und den Rest
der ganzzahligen Division liefern: 17 div 3 = 5 und 17 mod 3 = 2.
Numerische
Operationen: Die üblichen trigonometrischen Funktionen wie sin und cos,
Quadratwurzeln (sqrt), Logarithmen (ln, log) und die Exponentialfunktion (exp)
sind alle vordefiniert. Außerdem gibt es die random-Funktion zur Erzeugung von
gleichverteilten Pseu-do-Zufallszahlen.
Zeichenketten-Operationen:
Zeichenketten können mit dem ++-Operator ‚konkateniert“, d.h.
an-einandergehängt werden: "abc"++"def" =
"abcdef". Der Anzahl-Operator # liefert die Länge einer Zeichenkette:
#"abc" = 3. Mit dem Index-Operator kann man auf die einzelnen Zeichen
einer Zeichenkette zugreifen: "abcde"!2 = "c". (Man
beachte, dass Indizes wie in der Programmiersprache C stets bei 0 beginnen, S!0
ist also das erste, S!(#S-1) das letzte Zeichen einer Zeichenkette S.) Daneben
gibt es noch die Funktion sub, mit der man einen bestimmten Abschnitt einer
Zeichenkette extrahieren kann (z.B. sub "abcde" 1 3 =
"bcd"), und die Funktion pos, die das erste Vorkommen einer
Zeichenkette in einer anderen Zeichenkette liefert (z.B. pos "cd"
"abcde" = 2).
Listen- und
Tupel-Operationen: Konkatenation, Längen-Bestimmung, Element-Indizierung und
die Extraktion von Teil-Sequenzen funktionieren auch bei Listen und Tupeln,
z.B.: [a,b,c]++[d,e,f] = [a,b,c,d,e,f], #[a,b,c] = 3, (a,b,c,d,e)!2 = c, sub
(a,b,c,d,e) 1 3 = (b,c,d). Daneben definiert die so genannte ‚Standard-Biblio-
- 17 -
thek“ eine große
Zahl weiterer nützlicher Listen-Funktionen; auf diese kommen wir später zurück.
Vergleichs-Operationen:
Die Operatoren <, >, <=, >=, = und <> können verwendet
werden, um zwei Zahlen, Zeichenketten, Listen oder Tupel miteinander zu
vergleichen. Der Vergleich zweier Zeichenketten oder Listen erfolgt dabei
„lexikographisch“ (d.h. elementweise von links nach rechts); Tupel können nur
auf Gleichheit/Ungleichheit miteinander verglichen werden. Das Ergebnis des
Vergleichs ist ein so genannter Wahrheitswert: entweder true (wahr) oder false
(falsch). Die Werte true und false sind vordefinierte Konstantensymbole. Auch
die Wahrheitswerte selbst können verglichen werden, wobei false < true gilt;
dies ist nützlich, um z.B. logische Implikationen zu testen.
Logische
Operationen: Wahrheitswerte können mit den logischen Operatoren not, and und or
in der üblichen Weise verknüpft werden. Das Ergebnis ist jeweils wieder ein
Wahrheitswert, z.B. true and true = true, false or false = false, not true =
false. (Außerdem können diese Operatoren auch als „bitweise“ Operationen auf
ganzzahlige Werte angewendet werden; siehe das Q-Handbuch für Details.) Daneben
gibt es noch die so genannten „Kurzschluss“-Operatoren and then und or else.
Diese funktionieren im wesentlichen wie die oben genannten Verknüpfungen and
und or, werten den zweiten Operanden aber nur aus, wenn der erste Operand noch
keine Entscheidung über das Ergebnis erlaubt. Z.B. ist false and then X =
false, gleichgültig was der Wert von X ist; true and then X ergibt dagegen den
Wert von X. Ein mit diesen Operatoren gebildeter logischer Ausdruck wird also
nur so weit ausgewertet, wie es zur Bestimmung des Ergebnisses notwendig ist.
Er ist darum für komplexe Bedingungen, deren einzelne Teilbedingungen viel
Rechenzeit erfordern können, vorzuziehen.
Umrechnungs-Operationen:
Eine Fließkommazahl kann mit round auf eine ganze Zahl gerundet werden. Z.B.:
round 1.5 = 2. Mittels trunc wird dagegen der Teil hinter dem Dezimalpunkt
einfach abgeschnitten: trunc 1.5 = 1. Umgekehrt wandelt die float-Funktion eine
ganze Zahl in die entsprechende Fließkomma-Zahl um: float 0 = 0.0. Auch zur
Umrechnung zwischen Zahlen und Zeichenketten gibt es einige nützliche
Funktionen. So liefert ord den zu einem Zeichen gehörigen ASCII-Code: ord
"A" = 65; umgekehrt wandelt die chr-Funktion eine Zahl zwischen 0 und
255 in das entsprechende ASCII-Zeichen um: chr 65 = "A". Wichtig sind
außerdem die Funktionen str und val, mit denen ein beliebiger Q-Ausdruck in
eine Zeichenkette, und umgekehrt eine Zeichenkette wieder in den entsprechenden
Ausdruck umgewandelt werden kann: str (2*(X+1)) = "2*(X+1)", val
"2*(X+1)" = 2*(X+1). Schließlich gibt es noch die Funktionen list und
tuple, mit denen man Tupel in Listen und umgekehrt konvertieren kann.
Ein-/Ausgabe-Operationen:
Zur Ein- und Ausgabe verfügt Q über eine ganze Reihe verschiedener Operationen.
Für den Anfang sind vor allem zwei Funktionen wichtig: writes, mit der eine
Zeichenkette auf dem Terminal angezeigt wird, und reads, mit der man eine Zeile
vom Terminal einlesen kann. Diese beiden Operationen werden oft mit dem
Sequenz-Operator || verknüpft, um einen Dialog mit dem Benutzer zu realisieren.
Z.B. gibt der folgende Ausdruck,
writes
"Bitte Dateinamen eingeben: " || reads
eine Meldung auf
dem Terminal aus, wonach eine Eingabe des Benutzers eingelesen wird. Die
writes-Funktion liefert als Wert stets die Konstante (), während reads die
gelesene Zeichenkette zurückgibt. Mit dem ||-Operator werden die beiden
Operationen hintereinanderge-schaltet; das Ergebnis ist der Rückgabewert des
letzten Teilausdrucks, im Beispiel ist dies das Ergebnis der reads-Funktion.
- 18 -
Es ist auch
möglich, beliebige Ausdrücke vom Terminal einzulesen und auszugeben; dazu
werden die Funktionen read und write verwendet:
def X = writes
"Bitte X eingeben: " || read
writes "Das
Ergebnis ist: " || write (X/sin X) || writes "!\n"
Eine andere
nützliche Funktion ist printf, die der C-printf-Routine nachempfunden ist und
eine formatierte Ausgabe ermöglicht, z.B:
printf "Das
Ergebnis ist: %g!\n" (X/sin X)
Der „Platzhalter“
%g zeigt hier an, wo eine Fließkommazahl in den ausgegebenen Text eingebettet
werden soll. Ähnliche Symbole gibt es auch zur Einfügung von ganzen Zahlen und
Zeichenketten.
Außerdem gibt es
auch Operationen, die die Eingabe von und die Ausgabe in Dateien ermöglichen,
die auf der Festplatte gespeichert sind, wie z.B. die fwrites- und
freads-Operationen; da wir diese im folgenden nicht benötigen, verweisen wir
für eine Beschreibung dieser Funktionen auf das Q-Handbuch.
Spezielle
Operationen: Hier ist insbesondere die halt-Funktion zu nennen, mit der die
Auswertung eines Ausdrucks abgebrochen werden kann, und die quit-Funktion, mit
der der Interpreter verlassen wird. Die time-Funktion liefert die Systemzeit in
Sekunden seit 00:00:00 UTC (Coordinated Universal Time), 1. Januar 1970, was
zum Beispiel zur Zeitmessung nützlich ist. Mit der
Standard-Bibliotheks-Funktion ctime kann dieser Wert in aktuelles Datum und
Uhrzeit der eingestellten Zeitzone umgerechnet werden, z.B.: ctime time =
"Tue Sep 17 17:17:54 2002". Mit der sleep-Funktion schließlich kann
die aktuelle Berechnung für eine gegebene Zeitspanne unterbrochen werden.
Gönnen wir dem Interpreter (und uns!) doch einmal eine Pause von zwei Minuten:
sleep 120.
Standard-Bibliotheks-Funktionen
Die meisten der
oben genannten Operationen sind „eingebaut“, also im Interpreter fest
verdrahtet. Daneben gibt es aber noch eine große Zahl von Funktionen, die in
der so genannten Standard-Bibliothek definiert sind. Dabei handelt es sich um
eine Sammlung von Q-Skripts mit allgemein nützlichen Funktionen, die in jeder
Q-Installation enthalten sind und vom Interpreter immer automatisch geladen
werden. Auch auf diese Funktionen kann also stets zugegriffen werden. Die
Standard-Bibliothek enhält u.a. eine Sammlung zusätzlicher Zeichenketten- und
Listen-Funktionen, weitere numerische Funktionen, Operationen mit komplexen
Zahlen, wichtige „Container“-Daten - typen (das sind indizierte Datentypen wie
„Arrays“ oder „Dictionaries“, in denen beliebige Informationen abgelegt werden
können), und zusätzliche System-Funktionen. Für unsere Zwecke sind insbesondere
die zusätzlichen Listen-Funktionen wichtig, da wir mit ihrer Hilfe
MIDI-Sequen-zen erzeugen und manipulieren werden. Die folgenden Funktionen sind
alle im Standard-Biblio-theks-Skript stdlib.q definiert:
map F Xs: map
wendet die Funktion F auf jedes Element der Liste Xs an.
Beispiel: Addiere
1 zu jedem Element der Liste [1,2,3]:
map (+1) [1,2,3]
= [2,3,4]
do F Xs: do
wendet genau wie map die Funktion F auf jedes Element der Liste Xs an, gibt
aber statt der Liste aller Ergebnisse der Funktionsanwendung einfach () zurück.
Dies ist dann
- 19 -
nützlich, wenn
eine Operation nur wegen ihrer Nebeneffekte auf die Listenelemente angewendet
werden soll.
Beispiel: Gib
eine Liste von ganzen Zahlen Zeile für Zeile auf dem Terminal aus: do (printf
"%g\n") [1,2,3] = ()
filter P Xs:
filter liefert die Liste aller Elemente der Liste Xs, für die das Prädikat P
erfüllt ist, d.h. den logischen Wert true liefert. (Ein Prädikat ist eine
Funktion, die stets einen Wahrheitswert liefert.)
Beispiel: Alle
positiven Elemente einer Liste:
filter (>0)
[1,-1,2,0,3] = [1,2,3]
hd Xs: hd
(‚Head') liefert das erste Element einer Liste.
Beispiel: hd
[1,2,3] = 1
tl Xs: tl
(‚Tail') entfernt das erste Element aus einer Liste.
Beispiel: tl
[1,2,3] = [2,3]
cons Xs: cons
fügt ein Element am Beginn einer Liste oder eines Tupels ein. Beispiel: cons 1
[2,3] = [1,2,3]
push Xs: push
fügt genau wie cons ein Element am Beginn einer Liste oder eines Tupels ein;
allerdings ist die Reihenfolge der Argumente hier umgekehrt.
Beispiel: push
[2,3] 1 = [1,2,3]
pop Xs: pop
entfernt das erste Element aus einer Liste oder einem Tupel. Die pop-Funktion
arbeitet genau wie tl, kann aber auch auf Tupel angewendet werden. Zusammen
werden push und pop zur Realisierung so genannter ‚Stacks' verwendet.
Beispiel: pop
[1,2,3] = [2,3]
take N Xs,
takewhile P Xs: take liefert die Liste der ersten N Elemente der Liste Xs,
während takewhile die Liste der Anfangs-Elemente von Xs bestimmt, die das
Prädikat P erfüllen.
Beispiel: Die
ersten drei Elemente einer Liste:
take 3
[-3,-2,-1,0,1,2,3] = [-3,-2,-1]
Die negativen
Elemente am Beginn einer Liste:
takewhile (<0 b="">0>
drop N Xs,
dropwhile P Xs: drop und dropwhile sind die Gegenstücke von take und takewhile,
die Elemente vom Beginn einer Liste entfernen.
Beispiel: Eine
Liste ohne die ersten drei Elemente:
drop 3
[-3,-2,-1,0,1,2,3] = [0,1,2,3]
Entferne die
negativen Elemente am Beginn einer Liste:
dropwhile (<0 b="">0>
- 20 -
all P Xs, any P
Xs: all liefert true genau dann wenn alle Elemente von Xs das Prädikat P
erfüllen, any liefert true genau dann wenn mindestens ein Element P erfüllt.
Beispiel:
Bestimme, ob alle Elemente einer Liste positiv sind:
all (>0)
[-1,0,1] = false
Bestimme, ob
mindestens ein Element positiv ist: any (>0) [-1,0,1] = true
foldl F A Xs,
foldr F A Xs: foldl und foldr wenden eine binäre Operation F beginnend mit
einem Startwert A auf alle Elemente einer Liste Xs an; der Rückgabewert ist das
Ergebnis der letzten Anwendung von F, oder der Startwert, falls foldl/foldr auf
die leere Liste angewendet wird. Die beiden Funktionen unterscheiden sich
darin, wie die rekursiven Anwendungen von F geklammert werden: bei foldl wird
„nach links“ geklammert (also z.B. foldl F 0 [1,2] = F (F 0 1) 2), bei foldr
„nach rechts“ (foldr F 0 [1,2] = F 1 (F 2 0)).
Beispiel:
Berechnung der Summe aller Listen-Elemente:
foldl (+) 0
[1,2,3] = 6
Die
Summen-Funktion ist übrigens auch direkt unter dem Namen sum verfügbar: sum
[1,2,3] = 6
Wir finden in der
Standard-Bibliothek auch einige nützliche Funktionen zur Konstruktion von
Listen:
mklist X N:
mklist konstruiert eine Liste von N X's.
Beispiel: Liste
mit drei Nullen:
mklist 0 3 =
[0,0,0]
nums N M: nums
liefert die Liste aller Zahlen zwischen N und M, in Einser-Schritten.
Beispiel: Liste
aller ganzen Zahlen von 0 bis 10: nums 0 10 = [0,1,2,3,4,5,6,7,8,9,10]
numsby K N M:
numsby funktioniert wie nums, erlaubt aber die Angabe einer Schrittweite K.
Beispiel: Liste
aller geraden Zahlen von 0 bis 10:
numsby 2 0 10 =
[0,2,4,6,8,10]
iter N F A: iter
liefert ausgehend von einem Startwert A die Liste der ersten N wiederholten
Anwendungen von F (also A, F A, F (F A), ???).
Beispiel: Die
ersten sieben Zweierpotenzen:
iter 7 (2*) 1 =
[1,2,4,8,16,32,64]
while P F A:
while liefert ausgehend von einem Startwert A die Liste aller wiederholten
Anwendungen von F, die das Prädikat P erfüllen.
Beispiel: Liste
aller Zweierpotenzen ? 1000:
while (<=1000)
(2*) 1 = [1,2,4,8,16,32,64,128,256,512]
- 21 -
zip Xs Ys: zip
bildet die Liste der Paare entsprechender Elemente der Listen Xs und Ys.
Beispiel: Tabelle
der ersten sieben Zweierpotenzen:
zip (nums 0 6)
(iter 7 (2*) 1) =
[(0,1),(1,2),(2,4),(3,8),(4,16),(5,32),(6,64)]
unzip XYs: unzip
ist das Gegenstück von zip, das eine Liste von Paaren in ein Paar von Listen
zerlegt.
Beispiel: Zerlege
die oben berechnete Tabelle der Zweierpotenzen:
unzip
[(0,1),(1,2),(2,4),(3,8),(4,16),(5,32),(6,64)] =
([0,1,2,3,4,5,6],[1,2,4,8,16,32,64])
listof X C:
listof ist eine allgemeine Listen-Konstruktions-Funktion, mit der Listen auf
eine Weise spezifiziert werden können, die der mathematischen Beschreibung
einer Menge entspricht (so genannte „list comprehensions“). Dabei kann eine
„Lauf-Variable“ nacheinander mit den Werten einer Liste belegt werden, und es
werden alle Elemente herausgefiltert, die die angegebenen Bedingungen nicht
erfüllen.
Beispiel: Liste
aller Primzahl-Paare zwischen 1 und 100:
listof (I,I+2) (I in nums 1 100, isprime I and isprime
(I+2)) =
[(3,5),(5,7),(11,13),(17,19),(29,31),(41,43),(59,61),(71,73)]
(Hier wird die
Standardbibliotheks-Funktion isprime verwendet, um festzustellen, bei welchen
Elementen es sich tatsächlich um Primzahl-Paare handelt.)
3.6 Gleichungen
Mittels der eingebauten
Funktionen von Q lassen sich bereits viele nützliche Berechnungen vornehmen.
Wir benutzen dabei den Interpreter wie eine Art Taschenrechner. Um aber neue
Funktionen zu definieren, müssen wir ein Skript, d.h. ein Programm, schreiben.
In Q ist das Programmieren neuer Funktionen verhältnismäßig einfach. Alle
Definitionen haben die Form von Gleichungen. Trotz dieser einfachen Form ist
die Programmiersprache Q im technischen Sinne „universell“, d.h., es lassen
sich alle Funktionen realisieren, die man überhaupt in irgendeiner
Programmiersprache programmieren kann.
Jede Gleichung
besteht aus zwei Ausdrücken, der linken und rechten Seite, die durch das Symbol
= voneinander getrennt sind. Am Ende jeder Gleichung steht ein Semikolon. Eine
Gleichung kann auch eine Bedingung der Form if Ausdruck oder den Zusatz
otherwise zur Kennzeichnung eines Standard-Falls enthalten; außerdem können
mehrere aufeinanderfolgende Gleichungen mit derselben linken Seite
zusammengefasst werden. (Das Schlüsselwort otherwise wird vom Interpreter wie
ein Kommentar behandelt, es dient nur dazu, bestimmte Definitionen lesbarer zu
machen.)
Gleichungen sind
als Ersetzungsregeln zu lesen, die stets von links nach rechts angewendet
werden. Immer dann, wenn ein Ausdruck mit der Form der linken Seite auftritt,
kann er durch die entsprechende rechte Seite ersetzt werden. Betrachten wir
dazu zunächst ein einfaches Beispiel: die Funktion sqr (für „square“) soll es
ermöglichen, eine (ganze oder Fließkomma-) Zahl zu quadrieren. Dazu soll das Argument
der sqr-Funktion mit sich selbst multipliziert werden. Die entsprechende
Gleichung lautet:
sqr X = X*X;
- 22 -
Einfach genug,
oder nicht? Tatsächlich sieht unser „Programm“ eher wie eine mathematische
Definition aus, und tatsächlich ist es das auch. Die linke Seite der Gleichung
steht für einen beliebigen Ausdruck der Form sqr X, wobei die Variable X ein
Platzhalter für das tatsächliche Argument der sqr-Funktion ist. (Zur
Erinnerung: X wird vom Interpreter automatisch als eine Variable erkannt, da es
sich um ein Symbol handelt, das mit einem Großbuchstaben anfängt.) Um die
Definition zu testen, erstellen Sie ein Skript mit obiger Zeile und führen Sie
es aus:
==> sqr 4
16
==> sqr 3.5
12.25
Auf einen
wichtigen Unterschied zwischen Q und praktisch allen anderen
Programmiersprachen wollen wir gleich an dieser Stelle hinweisen.
Q-Definitionen sind symbolische Ersetzungsregeln, die auf beliebige Ausdrücke
(auch solche mit Variablen) angewendet werden können. Z.B.:
==> sqr (Y+2)
(Y+2)*(Y+2)
Tatsächlich kann
auf der linken Seite einer Gleichung im Prinzip ein beliebiger Ausdruck stehen,
auch verschachtelte Ausdrücke und Ausdrücke, die Operatoren enthalten.
Kombinieren wir spaßeshalber einmal unsere Definition der sqr-Funktion mit
einigen bekannten Rechenregeln für arithmetische Ausdrücke:
/* bsp02.q:
symbolische Definitionen */
sqr X = X*X;
// Distributiv-Gesetz
(A+B)*C =
A*C+B*C;
A*(B+C) =
A*B+A*C;
// Assoziativ-Gesetz
A+(B+C) =
(A+B)+C;
A*(B*C) =
(A*B)*C;
Wir erhalten nun:
==> sqr (Y+2)
Y*Y+Y*2+2*Y+4
Man erkennt, dass
die zusätzlichen Gleichungen verwendet wurden, um den Ausdruck (Y+2)*(Y+2)
weiter zu „vereinfachen“. (Wir könnten an dieser Stelle weitere symbolische
Regeln hinzufügen, um den Ausdruck in eine noch einfachere Form zu bringen,
wollen es aber dabei bewenden lassen.)
- 23 -
Betrachten wir
als nächstes ein etwas schwierigeres Beispiel, die so genannte
Fibonacci-Funktion, die im Zusammenhang mit natürlichen Wachstumsvorgängen und
dem „goldenen Schnitt“ eine Rolle spielt. Die Fibonacci-Funktion wird wie folgt
definiert:
/* bsp03.q:
Fibonacci-Funktion */
fib 0 = 0;
fib 1 = 1;
fib N = fib (N-2)
+ fib (N-1) if N>1;
Zu dieser
Definition sind zwei Dinge anzumerken:
1. Es handelt
sich um eine rekursive Definition, d.h. die Funktion fib wird durch sich selbst
definiert. Damit dies funktioniert, muss die Definition „induktiv“ sein, d.h.
die rekursiven Anwendungen von fib müssen „einfacher“ zu berechnen sein als die
linke Seite – es macht z.B. wenig Sinn, fib N durch fib (N+1) definieren zu
wollen. Unsere Definition erfüllt diese Bedingung, da nur auf kleinere Werte
von fib zurückgegriffen wird, und die ersten beiden Werte durch die ersten
beiden Gleichungen festgelegt sind.
2. Die letzte
Gleichung in der Definition ist eine bedingte Gleichung, deren Anwendbarkeit
durch eine logische Bedingung beschränkt wird. Die Bedingung ist hier, dass der
Parameter N größer als 1 ist, was durch den Zusatz if N>1 ausgedrückt wird.
Um die ersten
paar Werte der Fibonacci-Funktion zu berechnen, können wir eine vordefinierte
Listenfunktion, die map-Funktion verwenden (vgl. Abschnitt 3.5). Mit dieser
Funktion lässt sich eine beliebige Funktion auf alle Elemente einer Liste
anwenden:
==> map fib
[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]
[0,1,1,2,3,5,8,13,21,34,55,89,144,233,377,610]
Man erkennt hier
leicht das Bildungsgesetz der Fibonacci-Folge: Jedes Glied der Folge ist die
Summe der beiden vorherigen Glieder. (So ist die Funktion ja auch definiert.)
Rekursion und
Iteration
Unsere Definition
der Fibonacci-Funktion ist zwar korrekt, hat aber einen wesentlichen Mangel,
der schnell deutlich wird, wenn Sie versuchen, fib N für größere Werte von N zu
berechnen: die Rechenzeit steigt sehr schnell an. Dies liegt daran, dass die
rekursive Gleichung Nummer 3 der Definition zwei rekursive Anwendungen von fib
enthält. Tatsächlich ist die Anzahl der Rechenschritte zur Bestimmung von fib N
proportional zu fib N selbst, und wie man an der Tabelle der ersten paar
Folgenglieder schon erkennen kann, wächst diese Funktion recht schnell. Mit
etwas Mathematik zeigt man leicht, dass das Wachstum sogar exponentiell ist,
d.h. proportional zu 2N.
Nun gibt es
Funktionen, deren Berechnung notwendigerweise exponentielle Rechenzeit
benötigt. Die Fibonacci-Funktion lässt sich aber glücklicherweise auch so
definieren, dass man nur eine zu N proportionale Anzahl von Rechenschritten
braucht. Wie man an der rekursiven Definition unmittelbar sieht, brauchen wir
nämlich zur Berechnung jedes Wertes der Funktion jeweils nur die zwei
vorhergehenden Werte. Wenn wir diese Werte irgendwie „zwischenspeichern“
können, so brauchen wir in jedem Berechnungsschritt nur eine zusätzliche
Operation (eine Addition) auszuführen.
- 24 -
Die Lösung
besteht darin, eine zusätzliche Funktion fib2 einzuführen, die die zu
speichernden Werte als Argumente mitführt:
/* bsp04.q:
Fibonacci-Funktion, iterative Version */
fib N = fib2 0 1
N;
fib2 A B N =
fib2 B (A+B) (N-1) if N>0;
= A otherwise;
In der ersten
Gleichung rufen wir fib2 mit den ersten beiden Funktionswerten (A=0, B=1) und
dem gewünschten Parameter N auf. Die zweite Gleichung ist eine Art „Schleife',
in der die Funktion fib2 sich immer wieder selbst „aufruft', bis die notwendige
Anzahl von Additionen durchgeführt worden ist. Sobald dies der Fall ist,
liefert die dritte Gleichung das Endergebnis. Solch einen Rechenprozess
bezeichnet man auch als Iteration oder End-Rekursion.
Um die beiden
Definitionen der Fibonacci-Funktion zu vergleichen, laden Sie zunächst das
Skript bsp03.q, dann bsp04.q, und berechnen Sie jeweils z.B. fib 30. Der
Unterschied in den Rechenzeiten sollte offensichtlich sein.
Ein weiterer
wünschenswerter Effekt unserer iterativen fib-Version besteht übrigens darin,
dass sie bei der Auswertung im Interpreter weniger Speicherplatz benötigt. Für
die erste fib-Version muss der Interpreter nämlich Zwischenergebnisse
speichern, so lange er die rekursiven fib-Anwendungen bearbeitet. Der benötigte
Speicherplatz wächst hier mit der Größe von N. Dies wird in der zweiten Version
vermieden, da sich die Funktion fib2 nach Berechnung der neuen Parameter-Werte
direkt selbst aufruft; der zusätzliche Speicherbedarf ist hier eine konstante
Größe.
Definition von
Listen-Funktionen
Um zu zeigen,
dass Gleichungs-Definitionen nicht nur zur Berechnung von numerischen
Funktionen geeignet sind, betrachten wir abschließend einige einfache
Listen-Funktionen. Wie wir in Abschnitt 3.4 gesehen haben, haben Listen
entweder die Form [] (leere Liste) oder die Form [X|Xs], wobei X für das erste
Element der Liste und Xs für die Liste der restlichen Elemente steht. Zwei
nützliche Listen-Funktionen sind hd („head') und tl („tail'), mit denen man das
Anfangs-Element X und die Rest-Liste Xs einer nichtleeren Liste bestimmen kann.
Wir können diese Funktionen wie folgt definieren. (Da auch die
Standard-Bibliothek Definitionen für hd und tl enthält, verwenden wir hier eine
explizite private-Deklaration, um sicherzustellen, dass der Interpreter für
unser Skript neue private Funktionssymbole hd und tl verwendet.)
/* bsp05.q: hd-
und tl-Funktionen */
private hd Xs, tl
Xs;
hd [X|_] = X;
tl [_|Xs] = Xs;
In diesen
Definitionen haben wir die so genannte anonyme Variable _ verwendet. Die
anonyme Variable steht für einen beliebigen Wert, den wir für die weitere Berechnung
nicht benötigen. So ist es zum Beispiel für die Definition von hd egal, welchen
Wert die Rest-Liste hat; wir benötigen ja nur den Wert des Anfangs-Elements.
Man beachte, dass die anonyme Variable nur auf der linken Seite verwendet
werden kann, niemals auf der rechten Seite oder im Bedingungs-Teil einer
Gleichung.
- 25 -
(Tatsächlich kann
die anonyme Variable auf der linken Seite mehrfach verwendet werden, und steht
dann u.U. jedesmal für einen anderen Wert.)
Das folgende
Beispiel zeigt die hd- und tl-Funktionen in Aktion:
==> hd [1,2,3]
1
==> tl [1,2,3]
[2,3]
Ein etwas
interessanteres Beispiel einer rekursiven Listen-Funktion ist die Funktion map,
die weiter oben schon verwendet wurde, um eine Funktion auf jedes Element einer
Liste anzuwenden. Wir können map wie folgt definieren (auch hier setzen wir
wieder eine private-Deklaration ein, da die Standard-Bibliothek bereits eine
Funktion namens map bereitstellt):
/* bsp06.q:
map-Funktion */
private map F Xs;
map F [] = [];
map F [X|Xs] = [F X|map F Xs];
Bei map müssen
wir den Fall der leeren Liste [] und den der zusammengesetzten Liste [X|Xs]
unterscheiden. Im ersten Fall ist das Ergebnis einfach wieder die leere Liste.
Im zweiten Fall wird die als Parameter F übergebene Funktion auf das erste
Element angewendet und die Funktion map dann rekursiv für die Rest-Liste
aufgerufen.
Um die
Korrektheit der Definition zu testen, wenden wir map zunächst auf ein
symbolisches Funktions-Argument an, z.B.:
==> map F
[1,2,3]
[F 1,F 2,F 3]
Versuchen wir es
nun mit einer konkreten Funktion, z.B. der „Verdopplungs“-Funktion (*2):
==> map (*2)
[1,2,3]
[2,4,6]
Um die
Arbeitsweise von map genauer zu verstehen, können wir auch den „Debugger“
verwenden, der die einzelnen Berechnungsschritte während der Auswertung eines
Ausdrucks anzeigt. Der Debugger wird später auch nützlich sein, um „Bugs“ in
fehlerhaften Skripts zu entdecken. (Daher der Name.) Um den Debugger zu
aktivieren, geben Sie im Interpreter das Kommando debug on ein; mit debug off
schalten Sie den Debugger wieder aus. Wenn der Debugger aktiv ist, zeigt er
seinen Prompt, einen Doppelpunkt am Beginn der Zeile. Hier können Sie einige
einfache Kommandos eingeben; versuchen Sie z.B. einmal das Kommando ? für
„Hilfe“. Um die laufende Auswertung abzubrechen, geben Sie das Kommando h für
„Halt“ ein. Zur Fortsetzung der Auswertung betätigen Sie jeweils einfach die
Return-Taste. Sobald der Interpreter mit der Anwendung einer bestimmten
- 26 -
Gleichung beginnt
oder zu ihr zurückkehrt, wird die Gleichung im Debugger angezeigt, unter Angabe
des Skript-Namens und der Zeile, in der sich die Gleichung befindet. Wenn die
Auswertung der rechten Seite beendet ist, wird das jeweilige Zwischenergebnis
in einer Meldung der Form „ ** linke Seite ==> rechte Seite“ vermerkt.
Beispiel:
3.7 Auswertung
von Ausdrücken
In diesem
Abschnitt befassen wir uns etwas genauer mit der Frage, wie der Q-Interpreter
Ausdrücke auswertet. Wie wir bereits gesehen haben, wendet der Interpreter dazu
Gleichungen an, wobei stets die linke Seite einer Gleichung durch die
entsprechende rechte Seite ersetzt wird. Dieser Ersetzungsprozess wird
fortgesetzt, bis keine Gleichungen mehr anwendbar sind. Dies entspricht
ziehmlich genau der algebraischen Manipulation von Formeln, so wie man dies in
der Schule lernt. Man bezeichnet diesen Prozess auch als Termersetzung, und
betrachtet die Gleichungen in diesem Zusammenhang als Termersetzungs-Regeln.
Betrachten wir
zunächst den Ersetzungs-Schritt selbst. Als erstes muss der Interpreter
feststellen, welche linke Seite einer Gleichung auf einen Teil-Ausdruck des
auszuwertenden Ausdrucks „passt“, und welche Werte die Variablen der linken
Gleichungs-Seite dafür annehmen müssen. Ein passender Teilausdruck des
auszuwertenden Ausdrucks wird auch als Redex bezeichnet. Um einen
Ersetzungs-Schritt durchzuführen, eine so genannte Reduktion, wird der Redex
durch das so genannte Redukt ersetzt, die entsprechende rechte Seite der
Gleichung, wobei für die Variablen der rechten Seite die entsprechenden Werte
eingesetzt werden.
Wenn wir z.B. die
Gleichung sqr X = X*X aus dem vorangegangenen Abschnitt auf den Ausdruck sqr 3
anwenden, so ist der gesamte Ausdruck ein Redex, wobei für die Variable X der
Wert 3 einzusetzen ist. Die Anwendung der Gleichung liefert das Redukt X*X =
3*3. Wir haben hier
also die
Reduktion sqr 3 ? 3*3 durchgeführt. Dies funktioniert genauso, wenn der Redex
ein Teilausdruck des auszuwertenden Ausdrucks ist, und die einzusetzenden
Variablen-Werte selbst
wieder
zusammengesetzte Ausdrücke sind, z.B.: sqr (Y+2)*2?? (Y+2)*(Y+2)*2, wobei Redex
und Redukt jeweils durch Unterstreichen hervorgehoben wurden.
Dieser Prozess
wird in entsprechender Weise auch auf die „eingebauten“ Operationen angewendet,
wobei diese behandelt werden, als ob sie durch eine große Zahl „eingebauter
Gleichungen“ definiert
- 27 -
wären. Z.B.
liefert die eingebaute Definition des Multiplikations-Operators für den
Ausdruck 3*3 die Zahl 9. Dies ist eine Konstante, auf die keine weiteren
Gleichungen mehr angewendet werden können. Man bezeichnet solche nicht mehr
weiter reduzierbaren Ausdrücke auch als Normalformen, und betrachtet sie als
den Wert eines Ausdrucks. Man beachte, dass Normalformen nicht unbedingt
elementare Ausdrücke sein müssen; z.B. ist die Normalform des Ausdrucks sqr
(sin Y) der zusammengesetzte Ausdruck sin Y*sin Y. Auch gibt es Ausdrücke, die
überhaupt keine Normalform haben; wie auch bei konventionellen
Programmiersprachen kann die Auswertung eines Ausdrucks in eine endlose
Rekursion führen, die nie eine Antwort liefert. (Man versuche dies z.B. mit dem
Ausdruck loop 0 und der Gleichung loop N = loop (N+1).)
Richtig schwierig
wird die Sache allerdings erst, wenn es im auszuwertenden Ausdruck mehrere
Redizes gibt, oder mehrere anwendbare Gleichungen. Betrachten wir z.B. den
Ausdruck sqr (3+3). Sollen wir zunächst 3+3 reduzieren oder den gesamten
Ausdruck? Es gibt hier drei mögliche Rechenwege:
In diesem Fall
ist der schließlich berechnete Wert, die Normalform, dieselbe, nicht aber die
Anzahl der notwendigen Berechnungsschritte. Im allgemeinen Fall kann durchaus
auch das Endergebnis und sogar die Existenz einer Normalform vom gewählten
Rechenweg abhängen. Die leidvollen Erfahrungen vieler Gymnasiasten, die sich in
Mathe-Klausuren mit komplexen algebraischen Umformungen herumquälen müssen,
bestätigen dies.
Um solche
Mehrdeutigkeiten aufzulösen, verwendet der Q-Interpreter eine so genannte
Aus-wertungs-Strategie. Diese bestimmt genau, welcher Redex in jedem Schritt
reduziert werden soll, und welche Gleichung dafür anzuwenden ist. Die
Standard-Auswertungs-Strategie des Q-Interpreters lässt sich kurz mit „von
links nach rechts und innen nach außen“ und „die erste anwendbare Gleichung“
zusammenfassen. Genauer:
Ausdrücke werden
stets von links nach rechts ausgewertet, und von innen nach außen. Man nennt
diese Berechnungs-Reihenfolge auch „leftmost-innermost“, da immer derjenige
Redex gewählt wird, der am weitesten links liegt und der keinen weiteren Redex
mehr enthält. Eine andere Bezeichnung dafür lautet „call by value“, da die
Argumente einer Funktion vor der Funktionsanwendung ausgewertet werden, eine
Funktion also stets mit den Werten ihrer Argumente „gefüttert“ wird. Dies
entspricht der in vielen Programmiersprachen gebräuchlichen
Aus-wertungs-Strategie, und ist auch häufig die Art und Weise, in der Menschen
Berechnungen manuell ausführen. Der erste der oben beschriebenen Rechenwege ist
also derjenige, den der Q-Interpreter bei der Auswertung des Ausdrucks sqr
(3+3) tatsächlich verwendet.
Sind mehrere
Gleichungen anwendbar, so wird immer die erste anwendbare Gleichung verwendet,
gemäß der Reihenfolge der Gleichungen im Skript. Dabei haben „eingebaute“
Definitionen stets Vorrang vor den Gleichungen eines Skripts. Eine wichtige
Konsequenz dieser Regel ist, dass „speziellere“ Gleichungen vor „allgemeineren“
aufgelistet werden müssen. Als Beispiel betrachte man die Definition der
fib2-Funktion im vorangegangenen Abschnitt. Hier wird die speziellere Gleichung
für den Fall N>0 vor dem „Standard-Fall“ aufgeführt. (Würde man die
Reihenfolge der beiden Gleichungen umkehren, so wäre der Wert der fib2-Funktion
stets 0.)
Es bleibt
anzumerken, dass die oben skizzierte „call by value“-Strategie nicht die
einzige Aus-wertungs-Strategie ist, die der Q-Interpreter kennt; man kann auch
mittels so genannter „Spezialformen“ die Reihenfolge der Auswertung von
Argument-Ausdrücken selber kontrollieren,
- 28 -
was für spezielle
Anwendungen nützlich ist. Wir werden dies aber im folgenden nicht verwenden.
Für weitere technische Details der Ausdrucks-Auswertung, die den Rahmen dieser
Einführung sprengen würden, verweisen wir den Leser wieder auf das Q-Handbuch.
An dieser Stelle
müssen wir auch auf einen weiteren wesentlichen Unterschied zwischen Q und
anderen funktionalen Programmiersprachen hinweisen. In Sprachen wie Haskell und
ML gibt es eine grundlegende Dichotomie zwischen Funktions- und so genannten
„Konstruktor“-Symbolen. Letztere dienen ausschließlich dazu, Werte zu
repräsentieren, während Funktions-Symbole immer vollständig definierte
Funktionen darstellen, die letztlich irgendeinen Wert liefern müssen. Eine
Funktions-Anwendung stellt also immer eine Anweisung zur Berechnung eines
Wertes dar, und steht nie für sich selbst. Wenn eine Funktion bei der
Berechnung auf irgendeine „Ausnahme-Situation“ stößt, die es unmöglich macht,
die Berechnung fortzusetzen (z.B. Division durch Null), so wird ein
entsprechender „Laufzeit-Fehler“ ausgegeben.
Als reine
Termersetzungs-Sprache unterscheidet Q dagegen nicht zwischen Konstruktoren und
„definierten“ Funktionen. Tatsächlich kennt der Q-Interpreter das Konzept einer
Funktions-Defini-tion überhaupt nicht; er wendet nur „blind“ die Gleichungen
eines Skripts auf den auszuwertenden Ausdruck an. Ist keine Gleichung mehr
anwendbar, so ist der resultierende Ausdruck in Normalform. Normalformen stehen
immer für sich selbst, und stellen „Werte“ in der Programmiersprache Q dar. Die
eingebauten „konstanten“ Objekte (Zahlen, Zeichenketten, sowie aus Konstanten
zusammengesetzte Listen und Tupel) sind stets Normalformen, dies gilt aber auch
für alle Symbole und Funktions-Anwendungen, die nicht durch eine Gleichung
„definiert“ sind. Insbesondere führen Fehlerbedingungen wie Division durch Null
nicht standardmäßig zur Generierung eines Laufzeit-Fehlers, sondern der
entsprechende Ausdruck stellt dann eine Normalform dar. Z.B.:
==> 23/0
23/0
Es mag auf den
ersten Blick etwas befremdlich erscheinen, dass ein Ausdruck wie 23/0 in Q
tatsächlich ein zulässiger Wert ist, dies stellt aber eine der wichtigsten
Eigenschaften einer auf Termersetzung beruhenden Programmiersprache dar, und
erhöht die Flexibilität der Programmiersprache erheblich. In Q ist es jederzeit
möglich, die Definition einer eingebauten oder vom Benutzer definierten
Operation durch weitere Gleichungen zu „verfeinern“. Wenn gewünscht, kann man
also ohne weiteres eine „Fehler-Regel“ zur Behandlung bestimmter Ausnahmefälle
hinzufügen, z.B.:
X/0 = throw "Division durch Null!";
Tatsächlich gibt
es bestimmte kritische Situationen, in denen auch der Q-Interpreter von selbst
einen Laufzeit-Fehler generiert, z.B. dann, wenn der verfügbare Hauptspeicher
zur Neige geht, wenn der Bedingungs-Teil einer Gleichung keinen Wahrheits-Wert
als Ergebnis liefert, oder wenn der Benutzer mittels [Strg][C] die Auswertung
abbricht. Ein Q-Skript kann auch selbst Ausnahme-Fehler wie oben gezeigt mit
der eingebauten Funktion throw generieren. Tritt ein Ausnahme-Fehler auf, so
kann die Auswertung des aktuellen Ausdrucks nicht fortgesetzt werden; es ist
aber möglich, solche Fehlerbedingungen mit der eingebauten Funktion catch
abzufangen. Zur genaueren Beschreibung dieser Funktionen wird auf das
Q-Handbuch verwiesen.
- 29 -
3.8
Variablen-Definitionen
Wie wir in den
beiden vorangegangenen Abschnitten gesehen haben, finden Variablen auf der
linken Seite einer Gleichung Verwendung als „Platzhalter“ für die tatsächlichen
Werte in einem auszuwertenden Ausdruck. Man bezeichnet diese Variablen auch als
gebunden. Variablen können aber auch frei auftreten, nämlich als Variablen
innerhalb eines auszuwertenden Ausdrucks, oder als Variablen auf der rechten
Seite (oder im Bedingungs-Teil) einer Gleichung, die nicht auf der linken Seite
auftreten. Als Beispiel einer Gleichung, die auf der rechten Seite eine freie
Variable enthält, betrachte man die folgende Definition:
foo X = C*X;
Die Variable C
tritt hier frei auf der rechten Seite auf. Solange wir dieser Variablen keinen
Wert zuordnen, steht sie einfach für sich selbst. Z.B:
==> foo 99
C*99
Um einer freien
Variablen einen Wert zuzuordnen, können wir eine Variablen-Definition
verwenden. Diese sieht syntaktisch wie eine Gleichung aus, wird aber mit dem
Schlüsselwort def eingeleitet. Man kann eine Variable direkt im Interpreter
definieren:
==> def C = 2;
foo 99
198
Wie man sieht,
wurde nun der Wert von C verwendet, um das Endergebnis C*99 = 2*99 = 198 zu
berechnen. Wir können die Definition von C auch wieder rückgängig machen; dafür
gibt es den Befehl undef:
==> undef C;
foo 99
C*99
Im Interpreter
werden Variablen häufig verwendet, um Zwischenergebnisse zu speichern, z.B.:
==> def X =
16.3805*5; sqrt X/.05
181.0
Nach dem
Schlüsselwort def können mehrere Variablen-Definitionen stehen, die mit einem
Komma voneinander abgetrennt werden. Auch undef erlaubt die Angabe mehrerer
Variablen-Symbole.
==> def X =
16.3805*5, Y = .05; sqrt X/Y
181.0
==> undef X, Y
- 30 -
Wie bei
Gleichungen kann die linke Seite einer Variablen-Definition auch ein
zusammengesetzter Ausdruck sein, wobei die Variablen in der linken Seite mit
den entsprechenden Werten der rechten Seite belegt werden:
==> def (X,Y) = (16.3805*5, .05); sqrt X/Y
181.0
Dabei muss der
Ausdruck auf der rechten Seite natürlich „passen“, sonst zeigt der Interpreter
einen Fehler an:
==> def (X,Y) = [16.3805*5, .05]
! Value mismatch in definition
>>> def
(X,Y) = [16.3805*5, .05]
^
Variablen-Definitionen
können auch in einem Skript stehen; hier müssen sie wie eine Gleichung mit
einem Semikolon abgeschlossen werden:
def C = 2;
foo X = C*X;
Variablen-Definitionen
in einem Skript werden nur einmal ausgewertet (nämlich zu der Zeit, wenn das
Skript vom Interpreter geladen wird) und werden in der Reihenfolge ihres
Auftretens im Skript abgearbeitet, so dass jede Definition auf bereits vorher
definierte Variablen zurückgreifen kann. Man verwendet solche Definitionen
häufig dazu, um irgendwelche Tabellen oder spezielle Datenobjekte zu speichern,
die innerhalb des Skripts verwendet werden, und deren Berechnung einen gewissen
Rechenaufwand erfordert oder Operationen mit „Nebeneffekten“ involviert.
Entsprechende Beispiele werden wir später kennenlernen.
Man beachte auch,
dass Variablen zwar mit def und undef innerhalb des Interpreters verändert
werden können, niemals aber innerhalb einer Gleichung. (Es ist allerdings
möglich, eine Variable „lokal“, d.h. innerhalb einer Regel neu zu definieren,
s.u.) Daher hat eine Variable während der Auswertung eines Ausdrucks stets den
selben Wert (obwohl sie bei zwei Auswertungen des selben Ausdrucks verschiedene
Werte annehmen kann). Man nennt dies auch „referentielle Transparenz“.
Referentielle Transparenz stellt eine wesentliche Eigenschaft moderner
funktionaler Programmiersprachen dar. Der Ausdruck bedeutet, dass man stets
„Gleiches mit Gleichem“ ersetzen kann, um einen Ausdruck auszuwerten. (Q ist
allerdings nur in bedingtem Maße referentiell transparent, da einige
Operationen so genannte „Nebeneffekte“ haben. Dies gilt insbesondere für die
Ein-/Ausgabe-Operationen.)
Lokale Variablen
Die mittels def
definierten Variablen eines Skripts werden auch globale Variablen genannt, da
ihre Gültigkeit sich auf alle Gleichungen des Skripts erstreckt. Daneben kennt
Q auch lokale Variablen, deren Gültigkeit jeweils auf die rechte Seite (und den
Bedingungs-Teil) einer Gleichung beschränkt ist. Diese sind nützlich, wenn der
gleiche Wert auf der rechten Seite mehrfach verwendet werden soll. Mittels
einer lokalen Definition, auch „where-Klausel“ genannt, kann man einen solchen
Wert einer Variablen zuweisen, so dass der Wert nur einmal berechnet werden
muss. Die Form
- 31 -
einer lokalen
Definition entspricht der einer def-Anweisung, außer dass die Definition mit
dem Schlüsselwort where beginnt und am Ende einer Gleichung steht.
Betrachten wir
als Beispiel die Auflösung einer quadratischen Gleichung der Form
Die Lösungen
einer solchen Gleichung sind bekanntlich gegeben durch
p
x1,2 2
p2
4 q .
Damit überhaupt
eine (reelle) Lösung existiert, muss der Ausdruck unter der Wurzel, die so
genannte Diskriminante
2
D p4 q
einen Wert 0 haben. Wir können also die Funktion solve,
die als Argumente die Parameter p und q erhält und als Ergebnis ein Tupel mit
den beiden Lösungen liefert (sofern diese existieren), wie folgt definieren:
/* bsp07.q:
quadratische Gleichungen */
solve P Q = (-P/2 + sqrt D, -P/2 - sqrt D) if D >= 0
where D = P^2/4-Q;
Einige Beispiele
für die Anwendung von solve:
==> solve
solve 0 4
0
4
//
X^2+4 =
0 (keine reelle Lösung)
==> solve
(0.0,0.0)
0
0
//
X^2 = 0
(eine Lösung)
==> solve
(2.0,-2.0)
0
(-4)
//
X^2-4 =
0 (zwei Lösungen)
==> solve
1
(-4)
//
X^2+X-4
= 0 (zwei Lösungen)
(1.56155281280883,-2.56155281280883)
Genau wie
def-Anweisungen können auch where-Klauseln mehrere Definitionen umfassen, die
mittels Kommas voneinander abgetrennt werden. Die einzelnen Definitionen werden
in der Reihenfolge ihres Auftretens ausgewertet, und jede Definition kann auf
alle bereits definierten Werte und die Variablen der linken Seite zugreifen.
Wir können z.B. die mehrfache Berechnung des Werts von P/2 vermeiden, indem wir
dafür eine weitere Variable P_2 einführen:
solve P Q =
(-P_2 + sqrt D, -P_2 - sqrt D) if D >= 0
where P_2 = P/2, D = P_2^2-Q;
Eine Gleichung
kann allgemein auch mehrere Bedingungen und where-Klauseln in beliebiger
Reihenfolge umfassen, die in der umgekehrten Reihenfolge ihres Auftretens
ausgewertet werden. Dies
- 32 -
ist z.B. dann
nützlich, wenn für den Bedingungs-Teil einer Gleichung einige Variablen
definiert werden sollen, andere Variablen aber nur innerhalb der rechten Seite
verwendet werden. Letztere kann man oberhalb der Bedingung definieren, sie
werden dann nur berechnet, wenn die Bedingung gültig ist. Um z.B. eine weitere
Variable E für die in der rechten Seite der Definition von solve zweimal
auftretende Quadratwurzel der Diskriminante zu definieren, gehen wir wie folgt
vor:
solve P Q =
(-P_2 + E, -P_2 - E) where E = sqrt D if D >= 0
where P_2 = P/2, D = P_2^2-Q;
Schließlich kann
wie bei def auch die linke Seite einer lokalen Definition ein beliebiger
zusammengesetzter Ausdruck sein, der mit dem Wert auf der entsprechenden
rechten Seite verglichen wird. Solche Definitionen bilden gleichzeitig auch
zusätzliche Bedingungen; die Gleichung kann nur dann angewendet werden, wenn
der Ausdruck auf der linken Seite jeder Definition mit dem Wert der
entsprechenden rechten Seite zusammenpasst (dies ist natürlich gewährleistet,
wenn die linke Seite wie oben immer eine Variable ist).
Definitionen mit
zusammengesetzter linker Seite sind insbesondere dann nützlich, wenn eine
Funktion wie solve ein zusammengesetztes Ergebnis liefert, dessen Bestandteile
für weitere Berechnungen auf der rechten Seite einer Gleichung benötigt werden.
Beispiel:
test P Q =
(X1^2+P*X1+Q, X2^2+P*X2+Q)
where (X1,X2) = solve P Q;
Hier werden die
Lösungen der solve-Funktion in die entsprechende quadratische Gleichung
eingesetzt, um das Ergebnis zu kontrollieren. Das Ergebnis der test-Funktion
ist (0.0,0.0), wenn solve die Gleichung im Rahmen der Rechengenauigkeit exakt
gelöst hat.
==> test 0 4 // X^2+4 = 0, keine Lösung test 0 4
==> test 1
(-4) // X^2+X-4 = 0, korrekt gelöst
(0.0,0.0)
Man beachte, dass
die erste test-Rechnung einfach den eingegebenen Ausdruck zurückliefert, da für
die gegebenen Parameter keine Lösung existiert und solve daher auch kein Tupel
liefert, die where-Klausel in der Definition von test also „scheitert“.
3.9 Datentypen
Unter einem
Datentyp versteht man allgemein eine Klasse gleichartiger Werte, auf denen
bestimmte Operationen in gleicher Weise arbeiten. Wie wir gesehen haben, kennt
Q eine gewisse Anzahl eingebauter Datentypen, nämlich ganze Zahlen,
Fließkommazahlen, Zeichenketten, Listen und Tupel. Außerdem kann man in Q auch
eine Menge von Funktions-Symbolen und -Anwendungen als Datentyp vereinbaren.
Wir wollen hier nur die wichtigsten Grundkonzepte der Q-Datentypen kurz vorstellen,
weitere Details und ausführliche Beispiele findet man im Q-Handbuch.
- 33 -
Exkurs:
Datentypen
So gut wie alle
Programmiersprachen unterscheiden zwischen verschiedenen eingebauten
Datentypen, und die meisten heutigen Programmiersprachen erlauben auch die
Definition neuer Anwendungs-spezi-fischer Datentypen. Es gibt jedoch zwischen
den Programmiersprachen wesentliche Unterschiede in der Art und Weise, wie
Datentypen erkannt und angewendet werden.
Der wichtigste
Unterschied ist der zwischen statischer und dynamischer Typisierung. In
Programmiersprachen mit statischer Typisierung, wie z.B. C, Fortran und Pascal,
ist der Typ eines Ausdrucks bereits zur Compilier-Zeit bekannt, und eine
Variable kann stets nur den Wert eines vorgegebenen Datentyps annehmen. Dagegen
liegt bei Programmiersprachen mit dynamischer Typisierung, wie z.B. Lisp,
Prolog und Smalltalk, der Typ eines Ausdrucks erst zur Laufzeit, nach der
Auswertung des Ausdrucks, fest. Hier kann eine Variable normalerweise Werte
eines beliebigen Datentyps repräsentieren. Auch Q ist eine Programmiersprache
mit dynamischer Typisierung.
Der Hauptvorteil
der statischen Typisierung besteht darin, dass bereits der Compiler bestimmte
Unstimmigkeiten in einem Programm, so genannte Typ-Fehler, ausfindig machen
kann, was die Fehlersuche vereinfacht. Demgegenüber erlaubt die dynamische
Typisierung eine größere Flexibilität bei der Definition von polymorphen
Operationen, die auf Werte verschiedener Datentypen angewendet werden können.
Compilierte Sprachen verwenden hauptsächlich statische Typisierung, während
interpretierte Sprachen häufig mit dynamischer Typisierung arbeiten. Einige
neuere compilierte Programmiersprachen wie Ada und C++ verwenden statische
Typisierung zusammen mit zusätzlichen Elementen zur Konstruktionen von
„generischen“ Datentypen, um damit einen Teil der Flexibilität dynamischer
Typisierung zu gewinnen. Darüberhinaus werden Datentypen in modernen statisch
typisierenden funktionalen Sprachen wie Haskell und ML automatisch erkannt, so dass
Datentypen nicht unbedingt deklariert werden müssen.
Eingebaute
Datentypen
Wenden wir uns
zunächst den eingebauten Datentypen von Q zu. Wie bereits gesagt, handelt es
sich dabei um die Typen der ganzen Zahlen, Fließkommazahlen, Zeichenketten,
Listen und Tupel, die mit den Symbolen Int, Float, String, List und Tuple
bezeichnet werden. (Außerdem gibt es auch noch den Type File zur Darstellung
von Datei-Objekten, auf diesen werden wir aber in dieser Einführung nicht näher
eingehen.) Daneben gibt es noch den Typ Num, der sowohl Werte vom Typ Int als
auch Float umfasst, und den Type Char, der die Teilmenge der aus exakt einem
Zeichen bestehenden Zeichenketten bezeichnet. (Technisch gesehen ist Num der
„Supertyp“ von Int und Float, während Char ein „Subtyp“ von String ist. Es ist
in Q möglich, Datentypen aus anderen Datentypen „abzuleiten“, was dem
Klassen-System mit „einfacher Vererbung“ in objektorientierten
Programmiersprachen wie Smalltalk entspricht. Weitere Hinweise dazu findet man
im Q-Handbuch.) Schließlich gibt es auch noch den Typ Bool, der die
Wahrheitswerte true und false umfasst.
Typ-Symbole
können auf der linken Seite einer Gleichung oder einer Variablen-Definition in
einem Skript als „Typ-Wächter“ verwendet werden, um den Typ einer Variablen einzuschränken.
Die Definition ist dann nur anwendbar, wenn der tatsächliche Wert der Variable
dem angegebenen Typ entspricht. Um z.B. sicherzustellen, dass die sqr-Funktion
aus Abschnitt 3.6 stets nur auf Fließ-kommazahlen angewendet wird, können wir
die linke Seite mit einem Typ-Wächter ausstatten:
sqr X:Float = X*X;
Wollen wir die
Funktion auch auf ganze Zahlen anwenden, so können wir stattdessen den
übergeordneten Datentyp Num verwenden:
- 34 -
sqr X:Num = X*X;
Benutzer-definierte
Datentypen
Q gestattet auch
die Definition neuer Datentypen, wozu Typ-Deklarationen verwendet werden (vgl.
Abschnitt 3.3). Die Werte solcher Datentypen können entweder Funktions-Symbole
sein oder die Anwendung eines Funktions-Symbols auf ein oder oder mehrere
Argumente. Man spricht daher auch von „algebraischen“ Datentypen. Die
Funktions-Symbole werden normalerweise als const vereinbart, da es sich bei
ihnen um „Konstruktoren“ handelt, mit denen konstante Werte konstruiert werden
sollen.
Um z.B. einen
Datentyp zu vereinbaren, dessen Elemente dazu geeignet sind, eine binäre
Baum-Datenstruktur zu repräsentieren (solche Datenobjekte spielen z.B. beim
Suchen und Sortieren eine Rolle), verwendet man eine Deklaration wie die
folgende:
type BinTree =
const nil, bin X T1 T2;
Hier werden mit
dem Datentyp BinTree zwei entsprechende Konstruktor-Symbole vereinbart: das
Symbol nil zur Darstellung eines „leeren“ Baums (dieses Symbol erwartet keine
Argumente, und wird daher auch als Konstanten-Symbol bezeichnet) und der
Konstruktor bin, mit dem ein Baum mit der Information X und den beiden
„Teilbäumen“ T1 und T2 repräsentiert wird. Ein Datenobjekt des Typs BinTree hat
z.B. folgende Gestalt:
bin 5 (bin 3 nil
nil) (bin 7 nil (bin 9 nil nil))
Der neue Datentyp
kann dann wie eines der vordefinierten Typ-Symbole als Wächter auf der linken
Seite einer Gleichung eingesetzt werden.
Ein Hinweis zu
den Typ-Symbolen: Typ-Bezeichner bilden eine spezielle Symbol-Klasse, können
also nicht mit Funktions- und Variablen-Symbolen „kollidieren“. Üblicherweise
beginnen Typ-Symbole mit einem Großbuchstaben, dies ist aber nicht zwingend
vorgeschrieben. Die Verwendung „qualifizierter“ Bezeichner zur Auflösung von
Mehrdeutigkeiten bei gleichnamigen Typen in verschiedenen Skripts wird wie bei
Funktions- und Variablen-Symbolen gehandhabt (vgl. Abschnitt 3.4).
Eine spezielle
Form der Typ-Deklaration dient dazu, einen so genannten „Aufzählungs-Typ“ zu
definieren, dessen Konstruktoren alle Konstanten-Symbole sind. Z.B.:
type Day = const sun, mon, tue, wed, thu, fri, sat;
Aufzählungs-Typen
werden in Q speziell unterstützt. Die Werte eines Aufzählungs-Typs können
miteinander verglichen werden, wobei die Elemente in der Reihenfolge angeordnet
werden, in der sie in der Typ-Deklaration aufgeführt sind, also im vorstehenden
Beispiel sun < mon, mon < tue, tue < wed, etc. Außerdem liefert die
eingebaute ord-Funktion angewendet auf ein Element des Aufzählungs-Typs die
Ordnungszahl der Konstante (ord sun = 0, ord mon =
- 35 -
1, usw.) und mit
den Funktionen succ und pred kann der Nachfolger und der Vorgänger eines
Elements bestimmt werden: succ sun = mon, pred mon = sun.
Ein Beispiel
eines eingebauten Aufzählungs-Typs ist der Typ Bool, den man sich wie folgt
deklariert denken kann:
public type Bool = const false, true;
Auch der
eingebaute Char-Datentyp wird als Aufzählungstyp behandelt; dabei sind die
einzelnen Zeichen entsprechend dem ASCII-Code angeordnet.
Die
Q-Standard-Bibliothek enthält übrigens ein Sortiment verschiedener nützlicher
Datentypen zusammen mit den entsprechenden Operationen; siehe den Abschnitt
„Standard Types“ in Kapitel 11 des Q-Handbuchs.
3.10
Parallel-Verarbeitung
Verschiedene
Typen von MIDI-Programmen, so z.B. die gleichzeitige Wiedergabe und
Aufzeichnung von MIDI-Sequenzen, lassen sich am einfachsten unter Einsatz der
Parallel-Verarbeitung (so genanntes „Multithreading“) realisieren. Dabei wertet
der Q-Interpreter gleichzeitig mehrere Ausdrücke aus. Dies wird ermöglicht
durch die Standardbibliotheks-Funktion thread, die als Argument einen auszuwertenden
Ausdruck erwartet. Dieser wird dann sozusagen „im Hintergrund“ verarbeitet,
während die thread-Funktion unmittelbar ein „Thread“-Objekt zurückliefert, über
das zu gegebener Zeit das Ergebnis der Auswertung abgefragt werden kann.
Exkurs: Prozesse
und Threads
Frühe
Computer-Systeme konnten jeweils nur ein Programm ausführen. Mit der Einführung
der sogenannten Timesharing-Systeme wurde es aber notwendig, die Eingaben einer
großen Zahl verschiedener Benutzer gleichzeitig zu verarbeiten. Dazu wurden so
genannte Mehr-Benutzer und Mehr-Prozess-Betriebssysteme entwickelt. Auch die
heutigen PC-Betriebssysteme sind in der Lage, eine Vielzahl von Programmen
gleichzeitig auszuführen.
Ein im Computer
ablaufendes Programm wird auch als Prozess bezeichnet. Ein
Mehr-Prozess-Betriebssystem verteilt die auszuführenden Befehle verschiedener
Prozesse auf die zur Verfügung stehenden Prozessoren (CPUs = Central Processing
Units). Ist, wie in den meisten heutigen PCs, nur eine CPU vorhanden, so werden
jeweils immer nur ein paar Befehle eines Prozesses auf der CPU ausgeführt,
danach kommt der nächste Prozess an die Reihe, usw. Auf diese Weise können auch
auf einer einzigen CPU mehrere Prozesse gleichzeitig ausgeführt werden. (In
Wirklichkeit wird natürlich zu jedem Zeitpunkt immer nur ein Befehl eines
Prozesses von der CPU bearbeitet. Da die verschiedenen Prozesse sich aber in
sehr schneller Folge abwechseln, entsteht dabei die Illusion einer
gleichzeitigen Ausführung.)
Heutige
Betriebssysteme gestatten aber nicht nur die gleichzeitige Ausführung
verschiedener Prozesse, sondern auch die weitere Aufteilung von Prozessen in
parallele Ausführungs-„Fäden“, die Threads genannt werden. Bei einem Thread
handelt es sich also sozusagen um einen „Prozess innerhalb eines Prozesses“. Im
Unterschied zu verschiedenen Prozessen, deren Daten fein säuberlich voneinander
getrennt sind, können verschiedene Threads innerhalb eines Prozesses gemeinsam
auf die Daten des Programms zugreifen. Jeder Prozess verfügt stets über
mindestens einen Thread, den so genannten „Main Thread“, kann aber daneben auch
weitere Threads erzeugen, in denen verschiedene Programmteile gleichzeitig
abgearbeitet werden. Dann spricht man von Multithreading.
Damit man später
auf das Ergebnis eines Threads zugreifen kann, muss das von thread gelieferte
Thread-Objekt z.B. in einer Variablen zwischengespeichert werden. Beispiel:
- 36 -
==> def TH =
thread (sum (nums 1 1000000))
Die Berechnung
der Summe findet nun im Hintergrund statt, man kann währenddessen also weitere
Ausdrücke berechnen:
==> 2*17/9
3.77777777777778
==> writes
"Hello!\n"
Hello!
()
Will man
schließlich das Resultat des Threads abfragen, so wendet man dazu die
Standardbiblio-theks-Funktion result auf das Thread-Objekt an. Die
result-Funktion wartet gegebenenfalls, bis die Berechnung abgeschlossen ist und
liefert dann das Ergebnis:
==> result TH
500000500000
Man kann die
thread-Funktion aber auch verwenden, um mehrere Berechnungen simultan
auszuführen. Zum Beispiel startet die folgende main-Funktion zwei Threads, die
gleichzeitig in zufälligen Zeitintervallen mit der printf-Funktion Ausgaben auf
dem Terminal erzeugen:
task N =
sleep_some || printf "task #%d\n" N || task N;
sleep_some =
sleep (random/0x100000000);
main = (thread
(task 1), thread (task 2));
Beispiel:
==> def (TH1,TH2) = main
==> task #1
task #2
task #1
task #1
task #2
...
- 37 -
Teil II:
MIDI-Programmierung mit Q
4 Grundlegendes
Nachdem wir uns
nun mit den Grundlagen der Programmierprache Q vertraut gemacht haben, zeigen
wir im folgenden, wie die Q-Midi-Schnittstelle zur Programmierung einfacher
MIDI-Anwendungen in Q eingesetzt wird. Auch hier können wir nur auf die
wichtigsten Funktionen eingehen. Weiterführende Informationen findet man in der
etc/README-Midi-Datei im Q-Verzeichnis (/usr/share/q für Linux bzw.
/Programme/Qpad für Windows), und im Q-Midi-Skript lib/midi.q.
Zunächst müssen
wir uns damit vertraut machen, wie man in einem Q-Skript auf die
Q-Midi-Funktionen zugreift. Da diese Funktionen nicht zur Standard-Bibliothek
gehören, müssen sie explizit importiert werden. Dies geschieht durch eine
import-Deklaration am Beginn des Q-Skripts:
import midi,
mididev;
Das Skript midi.q
enthält die eigentlichen Q-Midi-Funktionen. Wir importieren außerdem das Skript
mididev.q, das einige weitere Variablen und Funktionen zum portablen Zugriff
auf die MIDI-Ein-/Ausgabegeräte bereitstellt. Diese werden im folgenden noch
näher erläutert.
4.1 Registrieren
eines MidiShare-Clients
Bevor man mit den
Q-Midi-Funktionen MIDI-Ereignisse einlesen oder ausgeben kann, muss zunächst
ein ‚Client“ bei MidiShare registriert werden. Innerhalb eines Skripts können
auch mehrere Clients angemeldet und verwendet werden. Beipielsweise könnte ein
Sequencer-Programm mit ‚Record“-Funktion so aufgebaut sein, dass es zwei
separate Clients, einen für die Wiedergabe und einen für die Aufzeichnung,
umfasst. Zur Registrierung eines Clients wird die Funktion midi_open verwendet,
wobei der gewünschte Name des Clients zu übergeben ist. Bei dem Namen kann es
sich um eine beliebige Zeichenkette handeln, man sollte allerdings vermeiden,
dass der gleiche Name mehrfach verwendet wird. Die Funktion liefert die
‚Referenznummer“ des Clients zurück, auf die dann in allen den Client
betreffenden MIDI-Ein- und Ausgabe-Operationen Bezug genommen wird. Die
Referenznummer muss also für die spätere Verwendung gespeichert werden. Am
einfachsten erreicht man dies, indem man das Ergebnis der midi_open-Funktion
einer globalen Variablen zuweist, z.B.:
==> def REF =
midi_open "Beispiel"
Sobald ein Client
registriert wurde, erscheint seine Referenznummer in der Liste aller
registrierten MidiShare-Clients, die man mit der Funktion midi_clients abfragen
kann:
==>
midi_clients
[0,1]
- 39 -
Den Namen eines
durch seine Referenznummer gegebenen MidiShare-Clients erhält man mit der
Funktion midi_client_name. Z.B. kann man die Liste der Namen aller
MidiShare-Clients durch Anwendung der midi_client_name-Funktion auf die
Elemente der midi_clients-Liste abfragen:
==> map midi_client_name midi_clients
["MidiShare","Beispiel"]
Mit der
midi_client_set_name-Funktion kann man den Namen eines registrierten Clients
ändern:
==> midi_client_set_name 1 "Bsp"
()
==> map midi_client_name midi_clients
["MidiShare","Bsp"]
Die
midi_client_ref-Funktion liefert die Referenznummer für einen durch seinen
Namen gegebenen Client:
==>
midi_client_ref "Bsp"
1
Wird ein Client
nicht weiter benötigt, so kann er bei MidiShare mit der Funktion midi_close
abgemeldet werden:
==> midi_close
1
()
==>
midi_clients
[]
Man beachte, dass
der Client mit der Referenznummer 0 und dem Namen "MidiShare" stets
durch die MidiShare-Bibliothek vordefiniert wird, sobald MidiShare aktiv ist,
d.h. wenn mindestens ein Anwendungs-Client definiert wurde. Dieser Client dient
unter Windows zur Adressierung der physikalischen MIDI-Ein-/Ausgabegeräte; in
der Linux-Version von MidiShare hat er zur Zeit keine besondere Funktion, da
die MIDI-Ein-/Ausgabe über andere, spezielle „Treiber“-Clients erfolgt (s.u.).
Ein weiterer
Unterschied zwischen der Windows- und der Linux-Version von MidiShare besteht
darin, welche Clients jeweils in einer Q-Midi-Anwendung sichtbar sind. Unter
Linux enthält die midi_clients-Liste alle registrierten MidiShare-Clients, auch
die Clients anderer Programme. Dies ist praktisch, wenn man z.B. die Ausgabe
eines Programms direkt mit der Eingabe eines anderen Programms verknüpfen
möchte. Unter Windows ist dies derzeit nicht möglich, da stets nur die
innerhalb der jeweiligen Anwendung registrierten MidiShare-Clients sichtbar
sind.
- 40 -
4.2 Clients für
die MIDI-Ein- und Ausgabe
Auch zum Zugriff
auf die physikalischen MIDI-Ein- und Ausgabegeräte werden entsprechende
MidiShare-Clients verwendet. Diese werden normalerweise vor der Ausführung des
Skripts automatisch geladen, müssen also nicht in der oben beschriebenen Weise
explizit registriert werden. Um die Referenznummer eines solchen Clients zu
erhalten, kann die oben bereits erwähnte Funktion midi_client_ref benutzt
werden. Allerdings bestehen bei dem Zugriff auf MIDI-Ein-/Ausgabegeräte mittels
MidiShare große Unterschiede zwischen Linux und Windows. Während nämlich unter
Windows die MIDI-Ein- und Ausgabe stets über den MidiShare-Client mit der
Referenznummer 0 erfolgt, sind dafür in der Linux-Version (zur Zeit, d.h. für
MidiShare 1.86) verschiedene andere, so genannte ‚Treiber“-Clients zuständig,
die separat gestartet werden müssen. Wir verwenden daher im folgenden
stattdessen die portablen MIDI-Geräte-Definitionen in mididev.q. Diese erlauben
einen vom jeweiligen Betriebssystem unabhängigen Zugriff auf die vorhandenen
MIDI-Geräte.
Zu diesem Zweck
definiert das mididev-Skript eine Gerätetabelle in Form einer Liste, auf die
über die globale Variable MIDIDEV zugegriffen werden kann. Diese Tabelle muss
ggf. an die vorhandene Systemkonfiguration angepasst werden. Im weiteren gehen
wir von der folgenden Standard-Belegung der Einträge der MIDIDEV-Tabelle aus:
MIDIDEV!0
repräsentiert die externe MIDI-Schnittstelle (z.B. ein angeschlossener
MIDI-Syn-thesizer), die sowohl für die Eingabe als auch für die Ausgabe
verwendet werden kann.
MIDIDEV!1
repräsentiert den internen Synthesizer, der eine direkte MIDI-Ausgabe über die
Soundkarte ermöglicht. Dieses Gerät kann normalerweise nur für die Ausgabe
verwendet werden.
MIDIDEV!2
repräsentiert das Netzwerk (z.B. ein lokales Ethernet, über das verschiedene
Rechner miteinander vernetzt sind). Auf diesem Gerät ist sowohl Ein- als auch
Ausgabe möglich.
Jeder Eintrag der
MIDIDEV-Tabelle ist ein Tripel (NAME,REF,PORT) mit den folgenden Informationen:
NAME: Der Name
des Geräts, eine im Prinzip frei wählbare Zeichenkette. Diese dient nur zu
Informationszwecken, z.B. wenn man eine Liste der zur Verfügung stehenden
Geräte anzeigen möchte.
REF: Die Referenznummer
des Clients, über den das Gerät angesprochen wird. Unter Windows ist dies stets
der Wert 0 (der Standard-MidiShare-Client), während unter Linux hier die
aktuellen Referenznummern der geladenen Treiber-Clients erscheinen. Das
mididev-Skript sorgt auch dafür, dass die Treiber-Clients bei Bedarf
automatisch gestartet werden.
PORT: Die
‚Portnummer“ des Geräts. Unter Linux ist diese bedeutungslos, da die
Adressierung von MIDI-Ereignissen allein über die Referenznummern der
Treiber-Clients vorgenommen wird. Unter Windows findet man hier die logische
MidiShare-Portnummer, die mit dem msDrivers-Programm eingestellt wurde. Die
Portnummern werden unter Windows verwendet, um ein auszugebendes MIDI-Ereignis
an ein bestimmtes MIDI-Gerät zu adressieren.
Um ein
Eingabegerät in einer Q-Midi-Anwendung verwenden zu können, benötigt man
normalerweise nur die Client-Nummer. Für die portable Adressierung eines
Ausgabegeräts wird dagegen sowohl die Client- als auch die Portnummer
verwendet. Diese Werte kann man aus der MIDIDEV-Tabelle abrufen und
entsprechenden globalen Variablen zuweisen. Ein Q-Midi-Skript beginnt daher oft
mit einer Zeile wie der folgenden, in der neben der Abfrage der Gerätenummern
auch die bereits oben diskutierte Registrierung eines eigenen Clients erfolgt.
(Man vergleiche auch unser erstes Q-Midi-Beispiel, bsp01.q, in Abschnitt 2.3.)
- 41 -
def (_,IN,_) = MIDIDEV!0, (_,OUT,PORT) = MIDIDEV!0,
REF = midi_open "Bsp";
4.3 Herstellen
von Verbindungen zwischen Clients
Die zum Zugriff
auf die MIDI-Ein-/Ausgabe notwendigen Vorbereitungen sind nun fast
abgeschlossen. Zum Schluss müssen nur noch Verbindungen zwischen dem
registrierten MidiShare-Client und den Clients für die Ein- und Ausgabe
geschaltet werden. Dies erreicht man mit der Funktion midi_connect, die als
Argumente die Referenznummern eines Ursprungs- und eines Ziel-Clients erhält.
Im folgenden setzen wir Definitionen der Variablen IN, OUT und REF wie im
vorangegangenen Abschnitt voraus (die PORT-Variable wird hier noch nicht
benötigt, sondern erst bei der MIDI-Ausgabe; s. folgenden Abschnitt). Die
Verbindungen zwischen unserem Client REF und der MIDI-Ein- und Ausgabe stellen
wir dann wie folgt her:
==> midi_connect IN REF || midi_connect REF OUT
Natürlich können
wir die Verbindungen auch innerhalb des Skripts einrichten. Wir verwenden hier
eine Variablendefinition, um die notwendigen Initialisierungen beim Starten des
Skripts durchzuführen, z.B.:
def _ = midi_connect IN REF || midi_connect REF OUT;
Man beachte, dass
diese Zeile nach der Definition der Variablen IN, OUT und REF stehen muss. Da
wir das Ergebnis der midi_connect-Aufrufe nicht benötigen, haben wir auf der
linken Seite der Definition die anonyme Variable verwendet.
Jedes
Q-Midi-Skript, mit dem von der MIDI-Eingabe gelesen und auf die MIDI-Ausgabe
geschrieben werden soll, benötigt mindestens die oben skizzierten Verbindungen.
Verbindungen können allgemein aber auch zwischen beliebigen Clients hergestellt
werden. Enthält ein Skript mehrere Clients, so kann man diese also auf beliebige
Weise untereinander verknüpfen. Unter Linux können auch Verbindungen zu Clients
in anderen Q-Midi-Anwendungen hergestellt werden. Dies kann entweder innerhalb
des Q-Midi-Programms mittels midi_connect geschehen, oder auch mit dem externen
msconnect-Programm, das zusammen mit der MidiShare-Bibliothek installiert wird.
Das msconnect-Programm ist auch nützlich, um die aktuell registrierten
MidiShare-Clients und deren Verbindungen zu überprüfen, wenn eine
Q-Midi-Anwendung gestestet werden soll.
Innerhalb eines
Q-Midi-Skripts kann mittels midi_disconnect eine bestehende Verbindung auch
wieder gelöst werden. Darüberhinaus lässt sich der Status einer Verbindung mit
dem Prädikat midi_connected überprüfen, und man erhält die Liste aller
Client-Nummern der eingehenden und ausgehenden Verbindungen eines Clients mit
midi_in_connections und midi_out_connections:
==> midi_clients
[0,1,2,3,4,5]
==> map midi_client_name _
["MidiShare","/dev/midi","iiwusynth","msWANDriver","localhost","Bsp"]
- 42 -
==> midi_connected REF OUT
true
==> midi_in_connections REF; midi_out_connections
REF
[1]
[1]
==> midi_disconnect REF OUT
()
==> midi_connected REF OUT
false
4.4 MIDI-Ein- und
Ausgabe
Sobald ein
MidiShare-Client registriert und die Verbindungen zu den MIDI-Ein- und
Ausgabegeräten geschaltet wurden, können MIDI-Nachrichten mit der Funktion
midi_get von der MIDI-Ein-gabe gelesen und mit der midi_send-Funktion an die
MIDI-Ausgabe gesendet werden. Um dies zu testen, erstellen wir zunächst ein
einfaches Skript, das die in den vorangegangenen Abschnitten besprochenen
Initialisierungen vornimmt:
/* bsp08.q:
Demonstration MIDI-Ein- und Ausgabe */ import midi, mididev;
def (_,IN,_) = MIDIDEV!0, (_,OUT,PORT) = MIDIDEV!0,
REF = midi_open "bsp08", _ = midi_connect IN REF || midi_connect REF
OUT;
Nachdem das
Skript gestartet wurde, ist also ein MidiShare-Client namens "bsp08"
registriert und mit der MIDI-Ein- und Ausgabe verknüpft. Über diesen Client
laufen nun die weiteren Operationen.
MIDI-Eingabe
Betrachten wir
zunächst die MIDI-Eingabe. Die midi_get-Funktion wird mit einem einzigen
Argument aufgerufen, der Referenznummer des Clients. Wir können also einzelne
MIDI-Ereignisse vom angeschlossenen Keyboard wie folgt einlesen:
==> midi_get REF
(1,0,3068410,note_on ==> midi_get REF
0
60
117)
(1,0,3068670,note_on
0
60
0)
Die
midi_get-Funktion liefert für jedes MIDI-Ereignis ein Tupel (REF,PORT,TIME,MSG)
mit den folgenden Angaben:
REF: Die
Referenznummer des Clients, von dem das Ereignis empfangen wurde. Im vorliegenden
Beispiel ist dies die Nummer des Treiber-Clients, also identisch mit dem Wert
der IN-Variable.
- 43 -
PORT: Die
Portnummer des Ereignisses. Unter Linux ist diese stets 0. Unter Windows wird
hier die Nummer des MidiShare-Ports geliefert, so dass man unterscheiden kann,
von welchem Gerät das Ereignis erzeugt wurde.
TIME: Die
„MidiShare-Zeit“ des Ereignisses in Millisekunden. Wie wir später noch genauer
sehen werden, hat MidiShare einen internen Zähler, in dem die aktuelle Zeit
seit der letzten Aktivierung von MidiShare gespeichert wird. Damit kann
MidiShare auf Millisekunden genau die Zeit bestimmen, zu der eine Nachricht von
der MIDI-Schnittstelle empfangen wurde. Der von midi_get gelieferte
„Zeitstempel“ sagt uns also, wann genau das empfangene MIDI-Ereignis stattfand.
MSG: Die
empfangene MIDI-Nachricht. Diese wird, wie wir in Kapitel 5 noch genauer
diskutieren, als ein Element des Q-Datentyps MidiMsg dargestellt. Z.B. wird
eine Note-On-Nach-richt als ein Ausdruck der Form note_on C P V kodiert, wobei
C der MIDI-Kanal („Channel“), P die Tonhöhe („Pitch“) und V die Anschlagstärke
(„Velocity“) ist.
Im vorliegenden
Beispiel wurde also auf MIDI-Kanal 0 das Mittel-C (MIDI-Note Nummer 60) mit der
Dynamik 117 angeschlagen und nach 3068670-3068410=260 Millisekunden wieder
losgelassen.
Die
midi_get-Funktion liefert eingehende MIDI-Ereignisse in dem Moment, in dem sie
empfangen werden, so dass die Verarbeitung der Ereignisse in „Echtzeit“ möglich
ist.2 Damit Nachrichten nicht verlorengehen, während das Programm mit anderen
Dingen beschäftigt ist, werden eingehende Nachrichten, die nicht sofort
abgerufen werden, in einem Eingabepuffer abgelegt, und zwar in der Reihenfolge,
in der sie eingehen. Die Nachrichten können dann zu einem späteren Zeitpunkt in
der Reihenfolge ihres Eintreffens abgerufen werden. Der Eingabepuffer arbeitet
also nach dem Prinzip einer „Warteschlange“, weswegen man ihn auch als
„Eingabe-Schlange“ („input queue“) bezeichnet. Jeder registrierte Client
verfügt über seinen eigenen Eingabepuffer, in dem alle Nachrichten abgelegt
werden, die ihn über die mit midi_connect geschalteten eingehenden Verbindungen
erreichen. Manchmal ist es notwendig, diesen Eingabepuffer zu löschen. Dazu
verwendet man die Funktion midi_flush:
==> midi_flush
REF
()
Nach Anwendung
dieser Operation ist der Eingabepuffer leer; ein darauf folgendes midi_get wird
also darauf warten, dass neue Ereignisse in den Puffer eingespeist werden.
MIDI-Ausgabe
Kommen wir nun
zur MIDI-Ausgabe. Diese erfolgt über die Funktion midi_send, die mit den
folgenden Parametern aufgerufen wird:
REF: Die
Referenznummer des Clients, über den die Nachricht gesendet wird.
PORT: Der
Ziel-Port der Nachricht. Unter Linux kann dieser stets auf 0 gesetzt werden;
unter Windows wird mit dieser Nummer das Ausgabegerät adressiert. In einem
portablen Programm,
2 Der Terminus
„Echtzeit“ ist hier „cum grano salis“ zu verstehen. Weder Windows noch Linux
sind wirkliche Echtzeit-Systeme, die immer innerhalb vorgegebener Zeitgrenzen
arbeiten. Tatsächlich erfolgt also die Verarbeitung nicht wirklich „sofort“,
sondern „sobald wie möglich“ innerhalb der Grenzen der Hardware und des
Betriebssystems unter Berücksichtigung z.B. der momentanen Prozessorlast. Bei
heutigen PC-Systemen, deren Prozessortakt mindestens einige hundert MHz
beträgt, ist dies aber für den Zweck der MIDI-Programmierung normalerweise
völlig ausreichend.
- 44 -
das sowohl unter
Linux als auch Windows laufen soll, verwendet man hier den aus der
MIDIDEV-Tabelle ermittelte Wert für die Portnummer (vgl. bsp08.q).
MSG: Die zu
übertragende Nachricht, als Element des MidiMsg-Datentyps kodiert.
Will man also
z.B. ein Mittel-C (Note Nummer 60 auf Kanal 0 mit maximaler Lautstärke) auf dem
angeschlossenen Synthesizer ausgeben und danach wieder abschalten, so wird
midi_send wie folgt aufgerufen:
==>
()
midi_send REF PORT
(note_on
0
60
127)
==>
midi_send REF PORT
(note_on
0
60
0)
()
Man beachte, dass
in diesem Fall die Ausgabe der MIDI-Nachrichten sofort, also in „Echtzeit“
erfolgt. Man kann stattdessen auch einen Zeitpunkt angeben, zu dem die Ausgabe
erfolgen soll. Dazu wird als drittes Argument von midi_send ein Paar (T,MSG)
verwendet, wobei T der gewünschte Zeitpunkt und MSG wieder die auszugebende
MIDI-Nachricht ist. Zur Errechnung des Zeitpunkts ist die Funktion midi_time
nützlich, die den momentanen Zeitwert liefert. Um z.B. das Mittel-C sofort
anzuschlagen und nach einer halben Sekunde (= 500 Millisekunden) wieder
loszulassen, verwendet man midi_send wie folgt:
Tip: Zur
Kodierung von Noten-Ereignissen gibt es auch eine besondere
MidiShare-spezifische Erweiterung, die note-Nachricht, bei der man die Dauer
der Note direkt angeben kann. Hierbei handelt es sich nicht um eine „echte“
MIDI-Nachricht; note-Nachrichten werden von MidiShare bei der Ausgabe
automatisch in zwei separate MIDI-Ereignisse (ein „Note-On“ gefolgt von einem
„Note-Off“ nach der angegebenen Dauer) umgesetzt. Man kann also das Ergebnis
des letzten Beispiels (Mittel-C für eine halbe Sekunde) auch einfacher wie
folgt erreichen:
==> midi_send REF PORT (note 0 60 127 500)
()
Die
note-Nachrichten erleichtern die direkte Transkription einer Partitur nach
MIDI. Beim Einlesen von MIDI-Nachrichten von einem MIDI-Gerät werden aber stets
„echte“ note_on- und note_off-Nachrichten geliefert, hier findet also keine
automatische Umsetzung statt.
4.5
Filterfunktionen
Einige
MIDI-Keyboards erzeugen von sich aus MIDI-Ereignisse in regelmäßigen Abständen,
z.B. active_sense- oder clock-Nachrichten, die für spezielle Anwendungen,
insbesondere für die Synchronisierung mehrerer MIDI-Geräte, nützlich sind. Oft
werden diese Nachrichten aber gar nicht benötigt, oder sind sogar lästig, etwa
wenn man interaktiv MIDI-Ereignisse mit der midi_get-Funktion abfragen möchte.
Leider erlauben viele Keyboards nicht, die automatische Erzeugung dieser
Nachrichten abzuschalten. Die Q-Midi-Schnittstelle stellt daher die Funktion
- 45 -
midi_accept_type
bereit, mit der man bestimmte Typen von Nachrichten ganz aus der MIDI-Eingabe
herausfiltern kann. Diese Funktion wird mit drei Argumenten aufgerufen, der
Nummer des Client, dem Konstruktor-Symbol der Nachricht und einem Wahrheitswert
(true: Nachricht wird nicht gefiltert; false: Nachricht wird gefiltert). Um
z.B. alle active_sense-Nachrichten herauszufiltern, geht man wie folgt vor:
==> midi_get REF //
Gerät liefert active_sense-Nachrichten (1,0,754870,active_sense)
==> midi_get REF
(1,0,755110,active_sense)
==> midi_accept_type REF active_sense false //
Aktivieren des Filters ()
==> midi_flush
REF // Leeren des Eingabepuffers ()
==> midi_get REF //
active_sense wird nun gefiltert
(1,0,780740,note_on ==> midi_get REF
0
53
115)
(1,0,780870,note_on
0
53
0)
Neben
midi_accept_type gibt es auch noch weitere Filterfunktionen, mit denen
MIDI-Ereig-nisse eines bestimmten MidiShare-Ports oder eines vorgegebenen
MIDI-Kanals gefiltert werden können. Man kann auch sämtliche Filterfunktionen
auf einen Schlag mit der Funktion midi_accept_all ausschalten. Weitere
Informationen dazu finden sich in der Q-Midi-Doku-mentation.
- 46 -
5
MIDI-Nachrichten und -Ereignisse
Wie bereits
erwähnt wurde, werden MIDI-Nachrichten von Q-Midi nicht direkt als
Byte-Sequenzen kodiert, sondern als Elemente eines speziellen Datentyps
MidiMsg. Dies erleichtert die Interpretation und Bearbeitung von
MIDI-Nachrichten in einem Programm. Z.B. wird eine „Note On“-Nach - richt durch
einen konstanten Ausdruck der Form note_on CHAN PITCH VEL dargestellt. Die
Deklaration des MidiMsg-Datentyps findet sich ziehmlich am Beginn des
midi.q-Skripts. Im folgenden gehen wir kurz auf die wichtigsten Typen von
MIDI-Nachrichten und deren Darstellung als MidiMsg-Datenelemente ein.
5.1
Nachrichten-Kategorien
Wie wir bereits
in Abschnitt 1.3 gesehen hatten, unterscheidet man bei MIDI verschiedene
Kategorien von Nachrichten. Diese Unterteilung der Nachrichten wird auch in der
Q-Midi-Schnittstelle beachtet. Q-Midi unterstützt sämtliche gängigen, vom
MIDI-Standard vorgesehenen Nachrichten-Kategorien:
Voice-Nachrichten:
Hierbei handelt es sich um die Nachrichten, die für einen bestimmten MIDI-Kanal
bestimmt sind. Dazu zählen z.B. note_on und note_off, Controller-(ctrl_change)
und Instrument-Nachrichten (prog_change).
System-Common-Nachrichten:
Hierzu zählen alle System-Nachrichten, die nicht in Echtzeit verarbeitet
werden, d.h., deren Verarbeitung eine gewisse Zeit in Anspruch nehmen kann. Das
gebräuchlichste Beispiel sind die sysex-Nachrichten, mit denen
Hardware-spezifische Steuerfunktionen aufgerufen werden. Außerdem findet man in
dieser Kategorie Nachrichten wie quarter_frame und song_pos, die zur
Synchronisierung der MIDI-Wiedergabe mit einer externen Sound-Quelle dienen,
und andere Nachrichten wie tune zur Steuerung des angeschlossenen Synthesizers.
System-Realtime-Nachrichten:
Im Unterschied zur System-Common-Kategorie werden diese Nachrichten in
Echtzeit, also sofort, von MIDI-Geräten verarbeitet. Realtime-Nachrichten sind
ausnahmslos sehr kurz; sie dienen zur Synchronisierung der laufenden
MIDI-Wiedergabe (active_sense und clock) und zur Kontrolle der MIDI-Wiedergabe
(start, stop, continue,reset).
Meta-Nachrichten:
Diese Nachrichten findet man nur in MIDI-Dateien. Sie dienen dort zur
Speicherung bestimmter Meta-Informationen wie z.B. Tonart- und Metrum-Angaben,
Spur-Bezeichnungen, Liedtexte, Marker usw. Mit diesen Nachrichten werden wir
uns erst in Kapitel 8 beschäftigen.
Neben den oben
angeführten Nachrichten-Kategorien unterstützt Q-Midi auch noch weitere,
MidiShare-spezifische Nachrichten-Typen. Dazu gehören die bereits erwähnte
note-Nachricht, verschiedene Nachrichten zur Steuerung spezieller
Controller-Funktionen, und die midi_stream-Nachricht, mit der man eine
beliebige „rohe“ Byte-Folge an ein MIDI-Gerät senden kann. Alle diese
Nachrichten werden bei der Ausgabe von MidiShare automatisch in Stan-dard-MIDI-Nachrichten
umgesetzt. Im folgenden behandeln wir (mit Ausnahme der note-Nach-richt) nur
die Standard-MIDI-Nachrichten.
5.2 Noten
Die wichtigsten
MIDI-Nachrichten sind naturgemäß jene, mit denen Noten kodiert werden, also die
Standard-MIDI-Nachrichten note_on und note_off. Daneben gibt es noch die
MidiShare-spe-zifische note-Nachricht, mit der eine Note mit einer bestimmten
Dauer ausgegeben werden kann.
- 47 -
note_on CHAN
PITCH VEL
„Note-On“ auf
Kanal CHAN (0-15) mit Tonhöhe PITCH (0-127) und Dynamik VEL (0-127)
note_off CHAN
PITCH VEL
„Note-Off“
(Parameter wie bei note_on)
note CHAN PITCH
VEL DUR
Note mit CHAN,
PITCH, VEL wie bei note_on, DUR = Dauer der Note (in Millisekunden)
5.3
Instrumentierung und Controller-Nachrichten
Zur Festlegung des
Instrumenten-Klangs und zur Steuerung verschiedener Controller-Funktionen
werden die Voice-Nachrichten prog_change und ctrl_change verwendet.
prog_change
CHAN PROG
„Program Change“
auf Kanal CHAN (0-15) für Instrument Nummer PROG (0-127)
ctrl_change
CHAN CTRL VAL
„Control Change“
auf Kanal CHAN (0-15) für Controller Nummer CTRL (0-127), Wert VAL (0-127)
Eine Aufstellung
aller Standard-Controller-Nummern findet man auf Jeff Glatts Website
[http://www.borg.com/~jglatt/]. Einige Controller haben zwei verschiedene
Controller-Nummern für die Grob- und Feineinstellung, so z.B. 0 und 32 („Bank
Select Coarse“ und „Fine“, mit denen man auf vielen neueren Synthesizern
verschiedene Sätze mit jeweils 128 Instumenten-Klängen ansprechen kann), 1 und
33 („Modulation Wheel Coarse/Fine“, mit denen normalerweise die Stärke eines
Vibrato-Effekts gesteuert wird), und 7 und 39 („Volume Coarse/Fine“). Weitere
wichtige Controller sind 91 und 93, mit denen man auf vielen Geräten die Stärke
des Reverb- und Chorus-Effekts einstellen kann. Darüberhinaus gibt es noch
einige spezielle Controller-Nummern (98-101 und 38), mit denen man bis zu 32768
weitere Parameter, die so genannten „RPNs“ („Registered Parameter Numbers“) und
„NRPNs“ („Non-Registered Parameter Numbers“) setzen kann. Die Bedeutung der
NRPNs ist Geräte-abhängig, während die Funktion der RPNs von der MMA
einheitlich festgelegt sind. Zu den wichtigsten RPNs zählen z.B. die Parameter
„Pitch Bend Range“ (Einstellung der Sensitivität des Tonhöhenrades) sowie
„Master Coarse“ und „Master Fine Tuning“, mit denen die Grundstimmung des
Synthesizers geändert werden kann.
5.4 Weitere
Voice-Nachrichten
Mit den folgenden
Nachrichten lassen sich weitere Kanal-spezifische Parameter einstellen.
key_press CHAN PITCH VAL
„Key Pressure“ auf Kanal CHAN (0-15) für Tonhöhe PITCH
(0-127), Wert VAL (0-127)
chan_press
CHAN VAL
„Channel Pressure“ auf Kanal CHAN (0-15), Wert VAL
(0127)
pitch_wheel
CHAN LSB MSB
„Pitch Wheel“ auf Kanal CHAN (0-15), Wert LSB und MSB
(jeweils 0-127)
Mit der pitch_wheel-Nachricht
wird die Tonhöhe aller Noten des angegebenen Kanals um den gleichen
(Cent-)Betrag geändert. Die Einstellung wird mit zwei 7-Bit-Werten vorgenommen,
MSB für die Grob- und LSB für die Feineinstellung. Der Gesamtwert ergibt sich
aus der Kombination
- 48 -
beider Werte,
also als LSB+128*LSB. Die Mittelstellung ist stets LSB=0x0, MSB=0x40,
entsprechend einem Gesamtwert von 0x2000; kleinere Werte verändern die Tonhöhe
nach unten, größere nach oben. Die maximale Tonhöhenänderung sollte gemäß der
General-MIDI-Spezifikation standardmäßig einen Ganzton nach unten und oben
umfassen; bei älteren Synthesizern findet man aber unterschiedliche
Default-Einstellungen. Außerdem lässt sich die Sensitivität des Tonhöhen-Rades
auch durch Änderung des entsprechenden „RPN'-Parameters einstellen (s.o.).
5.5
System-Common-Nachrichten
Mit den
System-Common-Nachrichten werden verschiedene, meist Geräte-abhängige
Funktionen gesteuert. Wir behandeln hier nur die wichtigste
System-Common-Nachricht, sysex.
sysex BYTES
„System
Exclusive'-Nachricht, BYTES = Liste der zu sendenden Byte-Werte (jeder Wert im
Bereich 0-127)
Eine
sysex-Nachricht setzt sich aus einer (7-Bit-)Byte-Folge beliebiger Länge
zusammen. Der Inhalt der Byte-Folge hängt vom jeweiligen Geräte-Hersteller und
der aufzurufenden Funktion ab, und beginnt stets mit einer
Hersteller-spezifischen Kennung (z.B. 0x43 für Yamaha XG-Synthesi-zer). Auf
diese Weise kann ein Synthesizer die für Geräte eines anderen Herstellers
bestimmten Nachrichten erkennen und einfach ignorieren. Eine Aufstellung der
zulässigen sysex-Nachrichten findet man normalerweise im Handbuch des
Synthesizers. Bei Yamaha-Synthesizern kann man z.B. mit Nachrichten der Form
sysex [0x43,0x10,0x4c,AH,AM,AL,D+0x40] die Stimmung des Synthesizers ändern.
Dabei adressiert AH, AH, AL einen Eintrag der Stimm-Tabelle (entsprechend einer
einzelnen Note innerhalb der MIDI-Oktave, für einen gegebenen MIDI-Kanal), und
D bezeichnet die gewünschte Abweichung von der gleichtemperierten Stimmung in
Cents.
5.6 System-Realtime-Nachrichten
Die
System-Realtime-Nachrichten dienen hauptsächlich zur Synchronisierung
verschiedener MIDI-Geräte und -Applikationen. Nützlich sind insbesondere die
„Sequencer'-Nachrichten start, stop und continue, mit denen auch die Funktion eines
Sequencer-Programms von außen (z.B. von einem MIDI-Keyboard aus) gesteuert
werden kann.
active_sense
Einige
Synthesizer senden kontinuierlich diese Nachricht, um damit kundzutun, dass sie
noch „da' sind.
clock
Die „Clock'-Nachricht wird verwendet, um das
Wiedergabe-Tempo
verschiedener MIDI-Geräte zu synchronisieren.
start
Starten der
MIDI-Wiedergabe
stop
Stoppen der
MIDI-Wiedergabe
continue
Fortsetzen der
MIDI-Wiedergabe
reset
Zurücksetzen des
Synthesizers
Die genaue
Wirkung einer reset-Nachricht hängt vom jeweiligen Gerät ab; normalerweise
sollte sich ein Synthesizer beim Empfangen einer solchen Nachricht sofort in
seine
- 49 -
„Ausgangskonfiguration“
zurückversetzen, was immer vom jeweiligen Gerätehersteller darunter verstanden
wird.
5.7 Kodierung von
MIDI-Ereignissen
Wie wir in
Kapitel 4 gesehen haben, liefert die midi_get-Funktion nicht nur eine
MIDI-Nach-richt, sondern auch einige zusätzliche Informationen, nämlich eine
Client-Referenz- und eine Port-Nummer sowie einen „Zeitstempel“, der angibt,
wann die Nachricht empfangen wurde. Wenn wir MIDI-Nachrichten aufzeichnen
wollen, so ist der Zeitstempel natürlich sehr wichtig, da er angibt, in welcher
zeitlichen Abfolge die Nachrichten eingegangen sind. Man speichert daher in
einer MIDI-Sequenz normalerweise die MIDI-Nachrichten stets zusammen mit den
Zeitwerten. Zu diesem Zweck wird der Zeitstempel üblicherweise mit der
entsprechenden MIDI-Nachricht zu einem Paar der Form (TIME,MSG)
zusammengefasst. Solche Zeit/Nachrichten-Paare werden auch als MIDI-Ereignisse
bezeichnet. Man beachte, dass also die letzten beiden Komponenten eines von
midi_get zurückgegebenen Tupels ein MIDI-Ereignis bilden.
- 50 -
6
Echtzeit-Verarbeitung
Unter
Echtzeit-Verarbeitung versteht man den Empfang, die Analyse und ggf. die
Tranformation und Ausgabe von MIDI-Ereignissen in Echtzeit, d.h., das Programm
wartet auf Eingaben vom MIDI-Eingabegerät und verarbeitet diese bei Empfang
sofort. Echtzeit-Verarbeitung kommt also bei allen Anwendungen zum Einsatz, mit
denen MIDI-Ereignisse unmittelbar z.B. während des Spiels eines Keyboards oder
beim Abspielen einer MIDI-Datei weiterverarbeitet werden. Typische Beispiele
für solche Anwendungen sind Begleitautomatiken und Akkord-Analyse-Programme.
Ein
MIDI-Echtzeit-Programm besteht üblicherweise aus einer Schleife, in der immer
wieder die folgenden Schritte abgearbeitet werden:
1. Eingabe: Lies
ein Ereignis von der MIDI-Eingabe.
2. Analyse:
Stelle fest, um welches Ereignis es sich handelt und analysiere ggf. die
Parameter des Ereignisses.
3. Ausgabe: Gib
die Ergebnisse der Analyse z.B. auf dem Terminal aus, und/oder erstelle aus der
Eingabe ein oder mehrere neue MIDI-Ereignisse und sende diese an die
MIDI-Ausgabe.
Schritt 1 ist
normalerweise immer gleich, während die weiteren Schritte von der jeweiligen
Verarbeitungsfunktion abhängen. Wichtig: Damit ein solches Programm tatsächlich
in Echtzeit funktionieren kann, dürfen die Verarbeitungsschritte nicht zu
aufwendig sein. Die Verarbeitung jedes eingehenden Ereignisses sollte also in
einer sehr kurzen Zeit (typischerweise weniger als 1 Millisekunde)
abgeschlossen sein, damit keine wahrnehmbaren Latenzen entstehen. Dies ist für
Q-Skripts normalerweise gewährleistet, wenn die Berechnung der
Verarbeitungsfunktion nicht mehr als einige hundert Reduktionen erfordert. (Der
genaue Wert hängt natürlich von der Leistung des Prozessors ab. Im Zweifelsfall
sollte man die Funktion midi_time verwenden, um die tatsächlichen Latenzzeiten
abzuschätzen.)
6.1 Grundform
eines Echtzeit-Programms
Ein simples
Beispiel eines Echtzeit-Programms hatten wir bereits in bsp01.q kennengelernt.
Im folgenden behandeln wir nun, wie ein solches Programm im Detail realisiert
wird. Zunächst benötigen wir natürlich wie üblich den Import der Q-Midi-Skripts
und die Infrastruktur der MIDI-Ein-und Ausgabe. Diese übernehmen wir aus
Kapitel 4 (vgl. bsp08.q):
import midi,
mididev;
def (_,IN,_) = MIDIDEV!0, (_,OUT,PORT) = MIDIDEV!0,
REF = midi_open "bsp09", _ = midi_connect IN REF || midi_connect REF
OUT;
Als nächstes müssen
wir uns über die Organisation des Programms Gedanken machen. Unser Programm hat
allgemein die Form einer Schleife, in der eine Verarbeitungsfunktion auf jede
eingelesene MIDI-Nachricht angewendet wird. Diese kann als eine endrekursive
Funktion realisiert werden (vgl. Abschnitt 3.6), der die anzuwendende
Verarbeitungsfunktion als Parameter übergeben wird. Außerdem möchten wir das
Programm auch beenden können. Wir müssen also eine Eingabemöglichkeit vorsehen,
über die dem Programm mitgeteilt wird, dass die Schleife verlassen werden soll.
Viele heutige Synthesizer verfügen zu diesem Zweck über eine „Stop“-Taste, mit
der eine MIDI-„Stop“-Nachricht an den PC gesendet wird. Wir brauchen also nur
innerhalb der Schleife zu überprüfen, ob eine „Stop“-Nachricht empfangen wurde.
Dies ist am einfachsten möglich, wenn wir die erste MIDI-Nachricht bereits
vorher abrufen und als Argument an die Schleife übergeben. Die
Schleifen-Funktion führt dann die folgenden beiden Schritte aus:
- 51 -
1. Überprüfen auf
„Stop“-Nachricht. Falls eine „Stop“-Nachricht empfangen wurde, Ende.
2. Ansonsten
Verarbeitung der Nachricht mit der übergebenen Funktion. Einlesen des nächsten
Ereignisses und zurück zum Beginn der Schleife (Schritt 1).
Die beiden
Schritte lassen sich auf einfache Weise in Form von zwei Gleichungen
realisieren:
loop F (_,_,_,stop) = ();
loop F (_,_,_,MSG) = F MSG || loop F (midi_get REF)
otherwise;
Diese generische
Fassung der Eingabeschleife kann auf beliebige Verarbeitungsfunktionen
angewendet werden, die ausschließlich auf dem eingehenden MIDI-Ereignis
operieren. (Komplexere Verarbeitungsfunktionen „mit Gedächtnis“ werden im
folgenden Abschnitt behandelt.) Betrachten wir als einfaches Beispiel einer
Verarbeitungsfunktion die Transposition um eine gegebene Zahl von
Halbtonschritten. Hier müssen wir also in allen Nachrichten, die Notennummern
enthalten, die Notennummer um den angegebenen Betrag verschieben, was mit einer
einfachen Addition zu bewerkstelligen ist. Andere Nachrichten werden
unverändert wieder ausgegeben. Zur Unterscheidung der relevanten
Nachrichten-Typen benötigen wir vier Gleichungen, wie folgt:
transp N (note_on C P V) = midi_send REF PORT (note_on C (P+N) V);
transp N (note_off C P V) = midi_send REF PORT (note_off C (P+N) V);
transp N (key_press C P V) = midi_send REF PORT (key_press C (P+N) V);
transp N MSG = midi_send REF PORT MSG otherwise;
Zum Schluss fügen
wir noch eine Hauptfunktion transpose hinzu, mit der die Eingabegeschleife
gestartet wird. Voilà! Fertig ist unser Transpositions-Programm:
/* bsp09.q:
Einfache MIDI-Echtzeit-Anwendung (Transposition) */ import midi, mididev;
def (_,IN,_) = MIDIDEV!0, (_,OUT,PORT) = MIDIDEV!0,
REF = midi_open "bsp09", _ = midi_connect IN REF || midi_connect REF
OUT;
/* generische
Eingabeschleife */
loop F
(_,_,_,stop) = ();
loop F (_,_,_,MSG) =
F MSG || loop F (midi_get REF) otherwise;
/* Transpositions-Verarbeitungsfunktion */
transp N (note_on C P V) = midi_send REF PORT (note_on C (P+N) V);
transp N (note_off C P V) = midi_send REF PORT (note_off C (P+N) V);
transp N (key_press C P V) = midi_send REF PORT (key_press C (P+N) V);
transp N MSG = midi_send REF PORT MSG otherwise;
/* Hauptfunktion
*/
private transpose N;
transpose N =
midi_flush REF ||
loop (transp N) (midi_get REF);
- 52 -
Hinweise:
1. Die
Hauptfunktion wird hier explizit als „private“ deklariert, da auch die
Standard-Bibliothek eine transpose-Funktion enthält und wir eine
Namenskollision vermeiden wollen.
2. Vor Aufruf der
Schleife wird in transpose zunächst der Eingabepuffer mit midi_flush geleert,
um evtl. vor Aufruf der Funktion eingegebene Noten zu löschen.
3. Wenn Sie das
Programm mit einem externen Synthesizer testen, erklingen normalerweise sowohl
die eingegebenen als auch die transponierten Noten. Um dies zu vermeiden,
können Sie die lokale Wiedergabe des Synthesizers abschalten, wodurch nur über
die MIDI-Schnittstelle empfangene MIDI-Nachrichten klingen. Falls dies nicht
möglich ist, verwenden Sie statt MIDIDEV!0 für die MIDI-Ausgabe den internen
Synthesizer MIDIDEV!1 und drehen Sie die Lautstärke des externen Synthesizers
herunter.
4. Wenn Ihr
Synthesizer nicht über eine „Stop“-Taste verfügt, so kann die Schleife nur
durch Abbruch der Berechnung unterbrochen werden. Eine sauberere Methode ist in
diesem Fall die Ausführung der Transpositions-Funktion „im Hintergrund“ (so
genannter „Thread“). Dazu wird der Aufruf der transpose-Funktion als Argument
der Standardbibliotheks-Funktion thread übergeben und das Ergebnis einer
Variablen zugewiesen. Die transpose-Funktion läuft nun im Hintergrund und die
Kommandozeile bleibt weiter verfügbar. Wir können daher über einen weiteren
„Kontroll“-Client eine stop-Nachricht an die Schleife senden, um diese zu
beenden:
==> def TASK = thread (transpose 5)
==> def CTRL = midi_open "ctrl";
midi_connect CTRL REF
()
==> midi_send CTRL PORT stop
()
6.2
Echtzeit-Programme mit Gedächtnis
Die im vorigen
Abschnitt besprochene Eingabeschleife ist nur für Verarbeitungsfunktionen
geeignet, die ausschließlich auf der eingegebenen Nachricht operieren. Damit
lassen sich bereits viele einfache Anwendungen realisieren. Für kompliziertere
Verarbeitungsfunktionen muss die Eingabeschleife aber so abgewandelt werden,
dass sie die Mitführung eines aktuellen Zustands (eine Art „Gedächtnis“)
ermöglicht. Ein typisches Beispiel sind Harmonie-Analysatoren, die zur
Bestimmung des aktuellen Akkords die Information benötigen, welche Noten
momentan klingen, d.h. bereits an- aber noch nicht abgeschaltet wurden. Der
Einfachheit halber klammern wir hier das Problem der Akkord-Erkennung aus und
betrachten eine einfachere Aufgabenstellung, nämlich die Protokollierung der
momentan klingenden Noten auf dem Terminal. Diese soll jedesmal dann erfolgen,
wenn sich der aktuelle Akkord ändert, d.h., bei jedem Empfang einer „Note-On“-
oder „Off“-Nachricht.
Unsere
Verarbeitungsfunktion benötigt hier zusätzlich zum jeweils empfangenen
MIDI-Ereignis auch die Menge der zuletzt klingenden Noten. Die neue Noten-Menge
ergibt sich dann aus der vorherigen, indem die Note eines „Note-On“ der Menge
hinzugefügt, und die Note eines „Note-Off“ aus ihr entfernt wird. Schließlich
muss die Verarbeitungsfunktion auch noch die aktuelle Menge auf dem Terminal
ausgeben. Zur Realisierung der Notenmenge kann man den Datentyp Set aus der Standard-Bibliothek
verwenden. Dieser bietet zwei Funktionen insert und delete, mit denen Elemente
hinzugefügt und entfernt werden können. Unsere Verarbeitungsfunktion lässt sich
mit diesen Hilfsmitteln wie folgt implementieren:
- 53 -
c NOTES (note_on C P V) = print NOTES || NOTES
where NOTES = insert NOTES P if V>0;
= print NOTES || NOTES
where NOTES = delete NOTES P otherwise;
c NOTES (note_off C P V) = print NOTES || NOTES
where NOTES = delete NOTES P;
c NOTES MSG =
NOTES otherwise;
Man beachte, dass
die Funktion c ein zusätzliches erstes Argument hat, die aktuelle Noten-Menge
NOTES, und als Ergebnis die neue Noten-Menge zurückliefert. Die Ausgabefunktion
print kann für den Anfang so realisiert werden, dass wir einfach die momentante
Noten-Menge als Liste ausgeben (die Standardbibliotheks-Funktion list
konvertiert dabei einen Set-Ausdruck in eine Liste):
print NOTES =
printf "%s\n" (str (list NOTES));
Es fehlt noch
eine modifizierte Version der Eingabeschleife. Diese hat die zusätzliche Aufgabe,
die aktuelle Noten-Menge mitzuführen und als Argument der Verarbeitungsfunktion
zuzuführen, was sich mit einem zusätzlichen Parameter STATE wie folgt erreichen
lässt:
loop F STATE (_,_,_,stop) = ();
loop F STATE (_,_,_,MSG) = loop F (F STATE MSG) (midi_get REF)
otherwise;
Wir fügen nun
noch die Hauptfunktion chord hinzu, die die Eingabeschleife mit der
Verarbeitungsfunktion c und dem Initialwert emptyset für die leere Noten-Menge
aufruft:
chord =
midi_flush REF ||
loop c emptyset (midi_get REF);
Ein Beispiel für
die Ausgabe des Programms, wenn ein C-Dur Akkord und dann eine „Stop“-Nach -
richt eingegeben wird:
==> chord
[60]
[60,64]
[60,64,67]
[60,64]
[60]
[]
()
Das Programm
lässt sich noch verbessern, indem wir statt Notennummern symbolische Notennamen
ausgeben. Wir können dazu die folgende kleine note_name-Funktion verwenden, die
zu einer Notennummer den entsprechenden MIDI-Notennamen zurückgibt. Die Namen
der zwölf Noten einer Oktave (inklusive Vorzeichen) werden aus einer Tabelle
ermittelt und um die MIDI-Oktav-Nummer (0-10) ergänzt:
- 54 -
def NAMES =
("C","C#","D","Eb","E","F","F#","G","G#","A","Bb","B");
note_name P =
sprintf "%s%d"
(NAMES!(P mod 12), P div 12);
Wir gestalten die
print-Funktion unter Zuhilfenahme der Standardbibliotheks-Funktion join so um,
dass nun eine von mit Bindestrichen unterteilte Folge von Notennamen ausgegeben
wird:
print NOTES =
printf "%s\n"
(join " - " (map note_name (list NOTES)));
Das fertige
Programm:
/* bsp10.q:
MIDI-Echtzeit-Anwendung mit Gedächtnis (Akkord-Protokoll) */ import midi,
mididev;
def (_,IN,_) = MIDIDEV!0, (_,OUT,PORT) = MIDIDEV!0,
REF = midi_open "bsp10", _ = midi_connect IN REF || midi_connect REF
OUT;
/* generische
Eingabeschleife mit Zustandsparameter */
loop F STATE
(_,_,_,stop) = ();
loop F STATE (_,_,_,MSG) = loop F (F STATE MSG) (midi_get REF)
otherwise;
/*
Verarbeitungsfunktion: Protokollieren des momentan klingenden Akkords */
c NOTES (note_on C P V) = print NOTES || NOTES
where NOTES = insert NOTES P if V>0;
= print NOTES || NOTES
where NOTES = delete NOTES P otherwise;
c NOTES (note_off C P V) = print NOTES || NOTES
where NOTES = delete NOTES P;
c NOTES MSG =
NOTES otherwise;
/* Ausgabe des
momentanen Akkords */
print NOTES = printf "%s\n"
(join " - " (map note_name (list NOTES)));
def NAMES =
("C","C#","D","Eb","E","F","F#","G","G#","A","Bb","B");
note_name P =
sprintf "%-2s%d"
(NAMES!(P mod 12), P div 12);
/* Hauptfunktion
*/
chord = midi_flush REF ||
loop c emptyset (midi_get REF);
Ein Beispiel für
die Ausgabe des fertigen Programms:
- 55 -
==> chord
C5
C5 - E5
C5 - E5 - G5
C5 - E5
C5
()
- 56 -
7 Sequencing
Eine der
wichtigsten Grundfunktionen vieler MIDI-Programme ist das so genannte
Sequencing, d.h. die Aufzeichnung und Wiedergabe von MIDI-Ereignissen, die z.B.
über das angeschlossene Keyboard eingegeben werden. Zur Aufzeichnung speichern
wir eingehende MIDI-Ereignisse in einer Liste, die auch MIDI-Sequenz genannt
wird. Eine MIDI-Sequenz hat also die folgende Gestalt:
[(T1,MSG1),(T2,MSG2),(T3,MSG3),...]
Dabei sind T1,
T2, ... die Zeitwerte und MSG1, MSG2, ... die zugehörigen MIDI-Nachrichten. Der
Einfachheit halber setzen wir im folgenden stets voraus, dass es sich bei den
Zeitstempeln immer um absolute Werte handelt, die aufsteigend angeordnet sind.
Dies erleichtert die Wiedergabe einer Sequenz, da die Ereignisse in genau der
Reihenfolge ausgegeben werden können, in der sie in der Liste vorliegen. Die
Zeit zwischen zwei benachbarten Ereignissen einer Sequenz ist dann durch die
Differenz der entsprechenden Zeitstempel gegeben. Dem Anfangszeitpunkt der
Sequenz messen wir hier keine Bedeutung bei; bei der Wiedergabe wird das erste
Ereignis also stets sofort ausgegeben.
Im folgenden
behandeln wir zunächst die Aufzeichnung einer Sequenz und dann deren
Wiedergabe. Schließlich beschäftigen wir uns auch noch damit, wie man beide
Funktionen miteinander kombiniert, um während der Wiedergabe einer Sequenz
gleichzeitig eine neue Sequenz aufnehmen zu können, und gehen kurz auf die
notwendigen Erweiterungen der Algorithmen ein, um mit mehrspurigen Sequenzen
arbeiten zu können.
7.1 Aufnahme
Das Aufzeichnen
einer MIDI-Sequenz lässt sich recht einfach mit einer MIDI-Eingabe-Schleife mit
Gedächtnis realisieren, die wir bereits im vorangegangenen Kapitel
kennengelernt hatten. Der Zustands-Parameter der Schleife speichert hier die
gesamte Folge der MIDI-Ereignisse, die seit Aufruf der Funktion eingelesen
wurden. Wir verwenden dazu eine Liste, in die wir die MIDI-Ereig-nisse einfügen,
sobald diese eintreffen.
/* bsp11.q:
Aufzeichnung einer MIDI-Sequenz */
import midi, mididev;
def (_,IN,_) = MIDIDEV!0, (_,OUT,PORT) = MIDIDEV!0,
REF = midi_open "bsp11", _ = midi_connect IN REF || midi_connect REF
OUT;
/* Herausfiltern
unerwünschter Ereignisse */
def _ =
midi_accept_type REF active_sense false || midi_accept_type REF clock false;
/*
Eingabeschleife */
recloop L (_,_,_,stop) =
reverse L;
recloop L (_,_,T,MSG) =
midi_send REF PORT MSG ||
recloop [(T,MSG)|L] (midi_get REF)
otherwise;
/* Hauptfunktion */
public record;
record =
midi_flush REF || recloop [] (midi_get REF);
- 57 -
Man beachte, dass
die Hauptfunktion record hier als „public“ vereinbart wurde. Damit können wir
durch Importieren von bsp11.q auf diese Funktion auch in anderen Skripts
zurückgreifen. Zur Eingabeschleife selbst (Funktion recloop) sind drei Dinge
anzumerken:
1. Im Unterschied
zu den Echtzeitfunktionen in Kapitel 6 werden hier die kompletten Ereignisse,
d.h. Nachrichten und Zeitstempel verarbeitet. Wir benötigen die Zeitstempel ja,
um die Folge nachher korrekt wiedergeben zu können.
2. Die Sequenz
wird hier zunächst in umgekehrter Reihenfolge aufgebaut, da die Ereignisse
stets am Beginn der Liste eingefügt werden. Am Ende der Schleife wird die
Ergebnisliste dann mit der Standardbibliotheks-Funktion reverse umgekehrt. Dies
ist effizienter als das Anfügen der Ereignisse jeweils am Ende der Liste. Das
Anfügen eines Elements benötigt nämlich eine zur Länge der Liste proportionale
Rechenzeit, während das Einfügen am Beginn in konstanter Rechenzeit erledigt
wird.
3. Wir gehen hier
davon aus, dass die lokale Wiedergabe des MIDI-Eingabegeräts ausgeschaltet ist.
Daher wird eine empfangene MIDI-Nachricht sogleich wieder ausgegeben. (Dies
funktioniert auch, wenn das Eingabegerät ein Keyboard ohne eigene Wiedergabe
ist; Sie müssen in diesem Fall nur den internen Synthesizer MIDIDEV!1 statt
MIDIDEV!0 als Ausgabegerät verwenden.) Falls bei Ihrem MIDI-Synthesizer die
lokale Wiedergabe eingeschaltet ist, können Sie den Aufruf von midi_send
auskommentieren. Achtung: Einige Synthesizer ohne separate „MIDI
Thru“-Schnittstelle verfügen über eine so genannte „Merge“-Funktion o.ä., die
dafür sorgt, dass vom Gerät empfangene MIDI-Nachrichten automatisch wieder über
„MIDI Out“ an den PC zurückgesendet werden. Diese Funktion sollte unbedingt
ausgeschaltet sein, um Rückkopplungseffekte zu vermeiden!
Als Beispiel für
die Verwendung der record-Funktion haben wir hier ein C-Dur-Arpeggio
aufgezeichnet:
7.2 Wiedergabe
Eine simple Methode
zur Wiedergabe einer MIDI-Sequenz hatten wir bereits kennengelernt. Wir können
nämlich bei der Ausgabe einer MIDI-Nachricht mit midi_send auch die Zeit
angeben, zu der die Ausgabe erfolgen soll. Zum Beispiel kann die oben
aufgezeichnete Sequenz durch Verschieben des Anfangspunkts auf die momentane
MidiShare-Zeit wie folgt wiedergegeben werden:
Zur Wiedergabe
einer umfangreichen MIDI-Sequenz ist diese Methode aber wenig geeignet. Zum
einen kann der interne MidiShare-Speicher überlaufen, da die Ereignisse bis zu
ihrer Ausgabe zwischengespeichert werden müssen. Zum anderen erlaubt die
Methode es nicht, eine laufende Ausgabe zu unterbrechen. Das richtige Verfahren
zur Wiedergabe einer MIDI-Sequenz besteht daher darin, die Ereignisse einzeln
auszugeben, und zwar erst dann, wenn sie „fällig“ sind. Auf diese
- 58 -
Weise wird der
interne MidiShare-Speicher entlastet und die Wiedergabe kann jederzeit beendet
werden (z.B. beim Empfang einer stop-Nachricht).
Um die Zeit bis
zur Fälligkeit eines Ereignisses abzuwarten, verwendet man die Funktion
midi_wait. Die Funktion wird mit zwei Argumenten, der Referenznummer eines
MidiShare-Clients und einem MidiShare-Zeitwert aufgerufen, wartet die
Zeitspanne bis zum Eintreffen der gegebenen Zeit ab, und kehrt dann sofort zur
aufrufenden Funktion zurück. Der Rückgabewert ist die aktuelle Zeit bei
Beendigung von midi_wait, die normalerweise mit dem übergebenen Zeit-Parameter
identisch ist. Man beachte, dass midi_wait im Unterschied zur Systemfunktion
sleep die Angabe eines absoluten Zeitwerts erwartet. Soll also für eine
bestimmte Zeitspanne gewartet werden, so muss zu diesem Wert (in Millisekunden)
die aktuelle MidiShare-Zeit, midi_time, addiert werden. Zum Beispiel erhält man
wie folgt eine Pause von einer halben Sekunde:
==> midi_wait REF (midi_time+500)
3492140
Bei der
Wiedergabe einer Sequenz unterscheiden wir zwischen der momentanen Sequenzzeit
S und der tatsächlichen Echtzeit T. Während der Wiedergabe müssen wir laufend
aus den Zeitdifferenzen innerhalb der Sequenz die Echtzeit des jeweils nächsten
Ereignisses berechnen. Das erste Ereignis der Sequenz liefert den Startwert der
Sequenzzeit S, die momentane MidiShare-Zeit den Startwert von T. Die Ereignisse
werden dann wie folgt in einer Schleife abgearbeitet:
1. Falls das
erste Ereignis der Sequenz den Zeitstempel S hat, gib das Ereignis aus.
(Gegebenfalls kann das Ereignis hier auch protokolliert werden.) Weiter mit der
Restliste und Schritt 1.
2. Berechne die
Zeitdifferenz D zwischen S und dem Zeitstempel S1 des ersten Ereignisses der
Liste. Warte bis T1=T+D. Dann weiter mit S=S1, T=T1 und Schritt 1.
Die obige
Beschreibung lässt sich in folgende endrekursive Funktionsdefinition umsetzen:
playloop S T [] =
[];
playloop S T [(S,MSG)|SEQ]
= midi_send REF
PORT MSG || print (T,MSG) ||
playloop S T SEQ;
playloop S T SEQ =
playloop S1 T1 SEQ
where [(S1,_)|_] = SEQ, D = time_diff S S1,
T1 = midi_wait
REF (T+D);
Die
playloop-Funktion kann nun von der Hauptfunktion play wie folgt aufgerufen
werden, wobei wir nur noch die Startwerte für die Sequenz- und Echtzeit
festlegen müssen:
play [] = [];
play SEQ =
playloop S T SEQ
where [(S,_)|_] = SEQ, T = midi_time;
Man beachte, dass
wir in beiden Definitionen auch den Sonderfall einer leeren Sequenz behandeln
müssen. Eine weitere geringfügige Komplikation entsteht bei der Berechnung der
Zeitdifferenzen innerhalb von playloop: Die interne MidiShare-Uhr ist nämlich
ein 32-Bit-Zähler, der nach dem Erreichen des Maximums 0xffffffff wieder auf 0
zurückspringt. Daher müssen wir alle Zeit-
- 59 -
werte modulo
0x100000000 nehmen und negative Differenzen in positive umrechnen. Dies
erledigt die Funktion time_diff:
time_diff T1 T2 = ifelse (D>=0) D (D+0x100000000)
where D = T2 mod
0x100000000 - T1 mod 0x100000000;
Zum Beispiel:
==> time_diff
0xfffffff0 0
16
Das
Protokollieren der Wiedergabe besorgt eine Funktion print, die man z.B. wie
folgt realisieren kann:
print (T,MSG) = printf "%10d: %s\n" (T,str
MSG);
Unser Programm
ist nun fast fertig. Wir müssen nur noch eine Abfrage einfügen, um die
Wiedergabeschleife durch Eingabe einer stop-Nachricht unterbrechen zu können.
Hierbei ist zu beachten, dass wir dazu nicht einfach midi_get aufrufen können,
sonst würde die Schleife bis zum Eintreffen eines MIDI-Ereignisses „hängen“.
Wir können aber mit der Q-Midi-Funktion midi_avail vorher überprüfen, ob ein
Ereignis vorhanden ist, dieses im positiven Falle einlesen und überprüfen, ob
es sich um ein stop-Ereignis handelt. Dazu ist die Definition von playloop wie
folgt zu ergänzen:
playloop _ _ SEQ =
SEQ if midi_avail REF and then (midi_get REF!3=stop);
Diese Zeile ist
vor den anderen Gleichungen für playloop einzufügen, damit die Überprüfung zu
Beginn jedes Schleifendurchlaufs stattfindet. Man beachte die Verwendung des
logischen Kurzschluss-Operators and then, die hier essentiell ist. Die Abfrage
der MIDI-Nachricht mit midi_get darf ja nur dann stattfinden, wenn tatsächlich
schon eine Nachricht im Eingabepuffer ist. Außerdem beachte man, dass bei
Empfang einer stop-Nachricht der Rest der Sequenz zurückgegeben wird. Auf diese
Weise kann die Wiedergabe fortgesetzt werden, indem man den Rückgabewert von
play einfach wieder als Argument eines nachfolgenden play-Aufrufs verwendet.
Schließlich fügen wir nun noch wie bei der record-Funktion im vorangegangenen
Abschnitt einen Aufruf von midi_flush am Beginn der Hauptfunktion ein, damit
der Eingabepuffer bei Beginn der Wiedergabe geleert wird. Das fertige Programm
ist nun wie folgt:
/* bsp12.q:
Wiedergabe einer MIDI-Sequenz */
import midi, mididev;
def (_,IN,_) = MIDIDEV!0, (_,OUT,PORT) = MIDIDEV!0,
REF = midi_open "bsp12", _ = midi_connect IN REF || midi_connect REF
OUT;
def _ = midi_accept_type REF active_sense false ||
- 60 -
midi_accept_type REF clock false;
/* Wiedergabeschleife */
playloop _ _ SEQ =
SEQ if midi_avail REF and then (midi_get REF!3=stop);
playloop S T [] =
[];
playloop S T [(S,MSG)|SEQ]
= midi_send REF
PORT MSG || print (T,MSG) ||
playloop S T SEQ;
playloop S T SEQ =
playloop S1 T1 SEQ
where [(S1,_)|_] = SEQ, D = time_diff S S1,
T1 = midi_wait
REF (T+D);
/* Berechnung der
Zeitdifferenzen */
time_diff T1 T2 = ifelse (D>=0) D (D+0x100000000)
where D = T2 mod
0x100000000 - T1 mod 0x100000000;
/* Protokollieren
der Ereignisse */
print (T,MSG) = printf "%10d: %s\n" (T,str
MSG);
/* Hauptfunktion */
public play SEQ;
play [] = [];
play SEQ =
midi_flush REF || playloop S T SEQ
where [(S,_)|_] = SEQ, T = midi_time;
Zum Testen des
Programms können wir nach Starten von bsp12.q mit dem import-Kommando des
Interpreters das Skript bsp11.q hinzuladen. Wir zeichnen zunächst eine Folge
auf und geben diese dann wieder:
Es sei an dieser
Stelle angemerkt, dass wir aus Gründen der Einfachheit auf die Behandlung
einiger Detail-Probleme verzichtet haben und das obige Programm daher noch
nicht perfekt ist. Zum einen findet bei jedem Durchlauf der Wiedergabeschleife
nur ein Test auf stop statt; es wird also jeweils nur ein Ereignis von der
MIDI-Eingabe verarbeitet. Damit der Abbruch der Schleife bei dieser Realisierung
des Tests ohne große Zeitverzögerung funktioniert, sollten nur stop-Ereignisse
ein-
- 61 -
gegeben werden.
Man kann dies auch dadurch sicherstellen, dass man mittels midi_accept_type
alle Ereignisse außer stop aus der Eingabe herausfiltert.
Ein weiterer
Mangel des Programms ist, dass bei Unterbrechen der Wiedergabe eventuell noch
klingende Noten nicht mehr abgeschaltet werden. Um dies zu vermeiden, kann man
ähnlich wie bei bsp10.q in Kapitel 6 in der Eingabeschleife einen zusätzlichen
NOTES-Parameter mitführen, in dem die Menge der momentan klingenden Noten
gespeichert wird. Bei Abbruch der Wiedergabe müssen dann für alle Elemente von
NOTES entsprechende „Note-Off“-Nachrichten gesendet werden. Die dazu
notwendigen Erweiterungen des Programms überlassen wir dem Leser zur Übung.
7.3 Gleichzeitige
Aufnahme und Wiedergabe
Für die Aufnahme
komplizierterer MIDI-Sequenzen ist es nützlich, wenn man die Aufzeichnung in
mehreren Durchgängen vornehmen kann. Dazu ist es notwendig, Aufnahme und
Wiedergabe miteinander zu kombinieren, wobei die Zeitwerte der neuen Sequenz
mit denen der wiedergegebenen Sequenz synchronisiert werden. Eine einfache
Methode dafür behandeln wir in diesem Abschnitt. Unser Verfahren verwendet zwei
separate MidiShare-Clients, einen für die Wiedergabe und einen für die
Aufnahme. Der Aufnahme-Client wird mit der MIDI-Eingabe, der Wiedergabe-Client
mit der MIDI-Ausgabe gekoppelt; ferner stellen wir eine Verbindung vom
Aufnahme- zum Wieder-gabe-Client her, damit eine von der Aufnahmeschleife
empfangene stop-Nachricht an den Wiedergabe-Client weitergereicht werden kann:
def (_,IN,_) = MIDIDEV!0, (_,OUT,PORT) = MIDIDEV!0,
PLAY = midi_open "bsp13 - play", REC =
midi_open "bsp13 - rec",
_ = midi_connect IN REC || midi_connect REC PLAY || midi_connect
PLAY OUT;
Die
Wiedergabefunktion übernehmen wir im wesentlichen unverändert aus bsp12.q – wir
müssen nur die Variable REF durch PLAY ersetzen. In der Eingabeschleife sind
allerdings umfangreichere Anpassungen notwendig. Erstens führen wir nun genau
wie bei der Wiedergabe auch bei der Aufnahme die momentane Sequenz- und
Echtzeit mit, um die Zeitstempel der empfangenen MIDI-Ereignisse in
Sequenzzeiten umzurechnen. Zweitens muss das „Echo“ der empfangenen
MIDI-Nachricht nun über den Wiedergabe-Client ausgegeben werden. Drittens
schließlich reichen wir eine empfangene stop-Nachricht über den Aufnahme-Client
an den Wiedergabe-Client weiter, so dass daraufhin auch die Wiedergabe-Schleife
beendet werden kann.
recloop S T L (_,_,_,stop)
= midi_send REC PORT stop || reverse L;
recloop S T L (_,_,T1,MSG)
= midi_send PLAY PORT MSG ||
recloop S1 T1 [(S1,MSG)|L] (midi_get REC)
where D = time_diff T T1, S1 = S+D;
time_diff T1 T2 = ifelse (D>=0) D (D+0x100000000)
where D = T2 mod
0x100000000 - T1 mod 0x100000000;
Die Hauptfunktion
der Aufnahme, record, wird nun mit einem Parameter, der wiederzugebenden
Sequenz aufgerufen. Wir unterscheiden zwei Fälle, je nachdem, ob die
Wiedergabe-Sequenz leer ist oder nicht. Im ersten Fall brauchen wir wie in
bsp11.q nur die Eingabeschleife aufzurufen; als Startwert für die Sequenzzeit
können wir hier einen beliebigen Wert annehmen, z.B. S=0:
- 62 -
record [] =
midi_flush REC || recloop S T [] (midi_get REC) where S = 0, T = midi_time;
Im zweiten Fall
wird der Startwert der Sequenzzeit auf den Zeitstempel des ersten Ereignisses
der Wiedergabe-Sequenz gesetzt. Außerdem muss zusätzlich zur Aufnahme auch die
Wiedergabeschleife gestartet werden. Damit Aufnahme und Wiedergabe gleichzeitig
ablaufen können, wird erstere als „Thread“ im Hintergrund ausgeführt. Sobald
die Wiedergabeschleife beendet ist, ermitteln wir schließlich das Ergebnis des
Aufnahme-Threads, d.h. die aufgezeichnete Sequenz; dies erfolgt durch Anwendung
der Standardbibliotheks-Funktion result, die auf die Beendigung des angegebenen
Threads wartet und sein Resultat zurückliefert.
record SEQ =
midi_flush PLAY || playloop S T SEQ || result TH
where [(S,_)|_] = SEQ, T = midi_time,
TH = midi_flush REC || thread (recloop S T []
(midi_get REC));
Damit ist die
Definition der record-Funktion vollständig. Das neue Programm ist wie folgt:
/* bsp13.q:
Aufnahme+Wiedergabe von MIDI-Sequenzen */
import midi, mididev;
def (_,IN,_) = MIDIDEV!0, (_,OUT,PORT) = MIDIDEV!0,
PLAY = midi_open "bsp13 - play", REC = midi_open
"bsp13 - rec",
_ = midi_connect IN REC || midi_connect REC PLAY ||
midi_connect PLAY OUT;
def _ = midi_accept_type REC active_sense false ||
midi_accept_type REC clock false;
/*
Eingabeschleife */
recloop S T L
(_,_,_,stop)
= midi_send REC PORT stop || reverse L;
recloop S T L (_,_,T1,MSG)
= midi_send PLAY PORT MSG ||
recloop S1 T1 [(S1,MSG)|L] (midi_get REC)
where D = time_diff T T1, S1 = S+D;
time_diff T1 T2 = ifelse (D>=0) D (D+0x100000000)
where D = T2 mod
0x100000000 - T1 mod 0x100000000;
/*
Wiedergabeschleife */
playloop _ _ SEQ =
SEQ
if midi_avail PLAY and then (midi_get PLAY!3=stop);
playloop S T [] =
[];
playloop S T [(S,MSG)|SEQ]
= midi_send PLAY PORT MSG || print (T,MSG) ||
playloop S T SEQ;
playloop S T SEQ =
playloop S1 T1 SEQ
where [(S1,_)|_] = SEQ, D = time_diff S S1,
T1 = midi_wait
PLAY (T+D);
print (T,MSG) = printf "%10d: %s\n" (T,str
MSG);
- 63 -
/* Aufnahme */
public record SEQ;
record [] =
midi_flush REC || recloop S T [] (midi_get REC) where S = 0, T = midi_time;
record SEQ =
midi_flush PLAY || playloop S T SEQ || result TH
where [(S,_)|_] = SEQ, T = midi_time,
TH = midi_flush REC || thread (recloop S T []
(midi_get REC));
/* Wiedergabe */
public play SEQ;
play [] = [];
play SEQ =
midi_flush PLAY || playloop S T SEQ
where [(S,_)|_] = SEQ, T = midi_time;
Die Aufnahme
zweier Teil-Sequenzen kann nun erfolgen, indem zunächst die record-Funktion mit
einer leeren Sequenz aufgerufen und das Ergebnis in einer Variablen SEQ1
gespeichert wird. Danach wird record erneut, nun mit SEQ1 als
Wiedergabesequenz, aufgerufen und der zweite Teil der Sequenz in einer neuen
Variablen SEQ2 gespeichert:
==> def SEQ1 = record []
==> def SEQ2 = record SEQ1
Nun ergibt sich
die Frage, wie man beide Sequenzen gleichzeitig abspielt. Dazu müssen wir die
Sequenzen zunächst zu einer neuen Sequenz ‚zusammenmischen“, die wieder nach
Zeitstempeln aufsteigend sortiert sein muss. Da die beiden Teilsequenzen
bereits sortiert sind, kann man dies mit einem einfachen Verfahren bewerkstelligen,
das die Grundlage des so genannten ‚Merge-Sort“-Algorithmus ist. Das Verfahren
funktioniert genauso, wie man z.B. zwei vorsortierte Kartenstapel
zusammensortiert: Man nimmt stets die kleinste Karte unter den obenliegenden
Karten der beiden Stapel und legt diese auf einen dritten Stapel. Nachdem alle
Karten von den beiden ursprünglichen Stapeln auf den dritten Stapel abgelegt
wurden, ist dieser sortiert. So verfahren wir auch mit unseren beiden
Teilsequenzen, nur dass wir nach Zeitstempeln statt nach Kartenwerten
sortieren. Der Algorithmus kann in Q z.B. wie folgt implementiert werden:
/* Mischen zweier
MIDI-Sequenzen */
public mix SEQ1
SEQ2;
mix SEQ1 SEQ2 =
SEQ1 if null SEQ2;
= SEQ2 if null SEQ1;
= [hd SEQ1|mix (tl SEQ1) SEQ2] if T1 <= T2
where (T1,_) = hd
SEQ1, (T2,_) = hd SEQ2;
= [hd SEQ2|mix SEQ1 (tl SEQ2)] otherwise;
Unter
Zuhilfenahme von mix kann man die kombinierte Sequenz nun wie folgt abspielen:
- 64 -
==> play (mix
SEQ1 SEQ2)
7.4 Mehrspurige
Sequenzen
Bislang haben wir
in diesem Kapitel nur mit einfachen Sequenzen gearbeitet, die sozusagen nur aus
einer einzelnen Spur bestehen. In MIDI-Dateien findet man aber normalerweise
Sequenzen, die in mehrere Spuren (MIDI-Tracks) aufgeteilt sind. Eine solche
Organisation ist recht nützlich, um z.B. die verschiedenen Stimmen eines
Musikstücks unterscheiden zu können. Um mehrspurige Sequenzen darzustellen,
gibt es in Q im Prinzip zwei verschiedene Möglichkeiten:
1. Man behandelt
jede Spur als eine eigene Sequenz. Ein mehrspuriges Stück wird dann als Liste
oder Tupel von Sequenzen behandelt.
2. Alle Spuren
eines Stückes bilden eine gemeinsame Sequenz. Die Information darüber, zu
welcher Spur jedes Ereignis gehört, wird als Nummer im Ereignis gespeichert.
Ein Ereignis wird also nun durch ein Tripel (TRACK,TIME,MSG) repräsentiert,
wobei TRACK die Spurnummer des Ereignisses ist.
Die erste Methode
erschwert die effiziente Wiedergabe einer Sequenz nicht unerheblich. Wir werden
daher die zweite Darstellungsweise verwenden. Diese hat allerdings den
Nachteil, dass man nicht mehr einfach durch Indizierung auf eine Spur direkt
zugreifen kann, sondern diese bei Bedarf aus der Gesamtsequenz herausfiltern
muss (am Ende dieses Abschnitts diskutieren wir eine dafür geeignete
Hilfsfunktion).
Betrachten wir
nun die Auswirkungen, die die neue Darstellung auf die Funktionen play und
record hat. Für die Funktion play ändert sich nur, dass die MIDI-Ereignisse in
einer Sequenz nun drei Komponenten haben, von denen die erste (die Spurnummer)
bei der Ausgabe an die MIDI-Schnittstelle einfach ignoriert werden kann.
Außerdem möchten wir bei der Protokollierung der MIDI-Ereignisse auch die
Spurnummer mit ausgeben. Bei der Funktion record sind ebenfalls nur einige
triviale Änderungen erforderlich. Die naheliegendste Weise zur Erweiterung von
record besteht darin, dass die Funktion nun mit der gewünschten Spurnummer als
Argument aufgerufen wird, der die aufgezeichneten Ereignisse zugeordnet werden
sollen.
Auch die
Hilfsfunktion mix kann leicht an die neue Darstellung angepasst werden. Die für
mehrspurige Sequenzen überarbeitete Fassung des Skripts bsp13.q ist wie folgt:
/* bsp14.q:
mehrspurige Aufnahme+Wiedergabe */
import midi, mididev;
def (_,IN,_) = MIDIDEV!0, (_,OUT,PORT) = MIDIDEV!0,
PLAY = midi_open "bsp14 - play", REC =
midi_open "bsp14 - rec",
_ = midi_connect IN REC || midi_connect REC PLAY ||
midi_connect PLAY OUT;
def _ = midi_accept_type REC active_sense false ||
midi_accept_type REC clock false;
/*
Eingabeschleife */
recloop K S T L
(_,_,_,stop)
= midi_send REC PORT stop || reverse L;
recloop K S T L (_,_,T1,MSG)
= midi_send PLAY PORT MSG ||
recloop K S1 T1 [(K,S1,MSG)|L] (midi_get REC)
where D = time_diff T T1, S1 = S+D;
- 65 -
time_diff T1 T2 = ifelse (D>=0) D (D+0x100000000)
where D = T2 mod
0x100000000 - T1 mod 0x100000000;
/*
Wiedergabeschleife */
playloop _ _ SEQ =
SEQ
if midi_avail PLAY and then (midi_get PLAY!3=stop);
playloop S T [] =
[];
playloop S T [(K,S,MSG)|SEQ]
= midi_send PLAY PORT MSG || print (K,T,MSG) ||
playloop S T SEQ;
playloop S T SEQ
= playloop S1 T1 SEQ
where [(_,S1,_)|_] = SEQ, D = time_diff S S1,
T1 = midi_wait
PLAY (T+D);
print (K,T,MSG) = printf "%10d: %3d: %s\n" (T,K,str
MSG);
/* Aufnahme */
public record SEQ;
record K [] =
midi_flush REC || recloop K S T [] (midi_get REC) where S = 0, T = midi_time;
record K SEQ =
midi_flush PLAY || playloop S T SEQ || result TH
where [(_,S,_)|_] = SEQ, T = midi_time,
TH = midi_flush REC || thread (recloop K S T []
(midi_get REC));
/* Wiedergabe */
public play SEQ;
play [] = [];
play SEQ =
midi_flush PLAY || playloop S T SEQ
where [(_,S,_)|_] = SEQ, T = midi_time;
/* Mischen zweier
MIDI-Sequenzen */
public mix SEQ1
SEQ2;
mix SEQ1 SEQ2 =
SEQ1 if null SEQ2;
= SEQ2 if null SEQ1;
= [hd SEQ1|mix (tl SEQ1) SEQ2] if T1 <= T2
where (_,T1,_) =
hd SEQ1, (_,T2,_) = hd SEQ2;
= [hd SEQ2|mix SEQ1 (tl SEQ2)] otherwise;
Eine zweispurige
Aufnahme erfolgt nun genau wie im vorangegangenen Abschnitt, nur dass neben der
Wiedergabe-Sequenz auch noch jeweils die gewünschte Spurnummer angegeben wird,
z.B:
==> def SEQ1 = record 1 []
==> def SEQ2 = record 2 SEQ1
==> def SEQ =
mix SEQ1 SEQ2
- 66 -
Zum Abschluss
betrachten wir noch die Aufgabe, aus einer mehrspurigen Sequenz eine bestimmte
Spur zu extrahieren. Wir definieren dazu die folgende Hilfsfunktion track:
/* Extraktion
einer Spur */
public track K;
track K SEQ =
filter (trackeq K) SEQ;
trackeq K (K,_,_) =
true;
trackeq _ _ =
false otherwise;
Die Funktion
track verwendet also die Standardbibliotheks-Funktion filter mit einem
geeigneten Prädikat trackeq zum Herausfiltern aller Ereignisse für eine
gegebene Spurnummer. Z.B. kann man nun eine bestimmte Spur einer
aufgezeichneten Sequenz SEQ wie folgt abspielen:
==> play (track 1 SEQ)
- 67 -
8 MIDI-Dateien
Wie wir bereits
in Kapitel 1 bemerkt haben, können MIDI-Sequenzen, also Folgen von
MIDI-Ereignissen, auch in Dateien gespeichert werden. Dazu wird das so genannte
MIDI-Dateiformat verwendet. Dieses (binäre) Dateiformat ist maschinenunabhängig;
MIDI-Dateien können daher auf jedem MIDI-fähigen System abgespielt werden,
gleichgültig, auf welchem anderen System sie erstellt wurden. Im Internet
findet man umfangreiche Sammlungen von MIDI-Dateien. Für viele Anwendungen kann
man daher auf bereits vorhandenes Material zurückgreifen, und muss die
MIDI-Sequenzen nicht selbst aufzeichnen. Unsere eigenen Sequenzen können wir in
MIDI-Dateien speichern, um sie später wieder einlesen und verwenden zu können.
Vorhandene MIDI-Dateien können mit Q-Midi-Anwendungen auch bearbeitet und
danach wieder gespeichert werden.
MIDI-Dateien
können aus mehreren Teil-Sequenzen bestehen, die man als Spuren (Tracks)
bezeichnet. Man unterscheidet drei verschiedene Typen von MIDI-Dateien: Typ 0
(enthält immer nur eine Spur), Typ 1 (enthält eine oder mehrere Spuren, die
zusammen ein Musikstück bilden) und Typ 2 (enthält eine oder mehrere Spuren,
jede Spur stellt ein eigenes Musikstück dar). Der am häufigsten verwendete Typ
ist Typ 1.
Im Unterschied zu
„gewöhnlichen“ MIDI-Sequenzen können MIDI-Dateien auch spezielle
„Meta“-Ereignisse enthalten, mit denen zusätzliche Informationen zu einem Stück
angegeben werden. Dazu zählen z.B. Tempo, Metrum und Tonart eines Stückes,
zusätzliche Instrument- und Spur-Bezeichnungen u.ä. Außerdem werden die
Zeitwerte in MIDI-Dateien statt in Millisekunden häufig in musikalischer Zeit,
d.h. in Unterteilungen von Viertelnoten, angegeben. Zur Wiedergabe von
MIDI-Dateien müssen diese Angaben also in Abhängigkeit vom Tempo in
Computer-Zeit (Millisekunden) umgerechnet werden. Auf die Umrechnung zwischen
musikalischer und Computer-Zeit gehen wir im nächsten Abschnitt ein. Danach
geben wir einen Überblick über die verschiedenen Typen von Meta-Nachrichten, so
wie diese im MidiMsg-Datentyp kodiert werden. Schließlich beschäftigen wir uns
damit, wie MIDI-Dateien mit Q-Midi eingelesen, gespeichert und bearbeitet
werden können.
8.1 Musikalische
Zeit vs. Computer-Zeit
Wie wir im
letzten Kapitel gesehen haben, werden bei der Aufzeichnung und Wiedergabe einer
MIDI-Sequenz die Einsatzzeitpunkte der verschiedenen Ereignisse in
physikalischer Zeit gemessen. Wir wollen dieses Zeitmaß, das durch einen
internen Zähler wie z.B. midi_time repräsentiert wird, als Computer-Zeit
bezeichnen. Demgegenüber finden wir in den meisten Partituren von Musikstücken
überhaupt keine physikalischen Zeitangaben, sondern nur die symbolischen
Zeitwerte, die sich aus den Notenwerten ergeben. Dieser Zeitbegriff wird als
musikalische Zeit bezeichnet. Im Kontext von MIDI wird musikalische Zeit in der
symbolischen Einheit „Ticks“ (Unterteilungen einer Viertelnote) gemessen. Die
Anzahl der Ticks innerhalb einer Viertelnote ist variabel und wird auch als
„Auflösung“ oder „Pulse je Viertel-Note“ („Pulses per Quarter Note“, abgekürzt
PPQN) bezeichnet. Um Rundungsfehler zu minimieren, verwendet man häufig Werte
für PPQN, die Vielfache von 2, 3 und 5 sind, wie z.B. 96, 120, 192, 384 und
768. Bei 96 PPQN entspricht z.B. eine Achtelnote 48 Ticks.
Zur Umrechnung
zwischen musikalischer und Computer-Zeit benötigen wir neben PPQN natürlich
auch noch das Tempo. Musikalisch wird das Tempo üblicherweise in Viertel je
Minute (a.k.a. „Schläge je Minute“, „Beats per Minute“, abgekürzt BPM)
angegeben. Aus technischen Gründen verwendet MIDI dagegen Tempoangaben in
Mikrosekunden je Viertelnote; dies erlaubt eine bessere Rechengenauigkeiten bei
der Umrechnung der Noten- in Zeitwerte. Z.B. entspricht 120 BPM einem
MIDI-Tempo von 500.000 µsec/Viertel, also hat eine Achtelnote in diesem Tempo
eine Dauer von 250 Millisekunden. Zur Umrechnung zwischen BPM und MIDI-Tempo
sowie zwischen Ticks und Millisekunden gelten die folgenden Formeln:
- 69 -
TEMPO = 60.000.000/BPM
MSEC = TICKS/PPQN?TEMPO/1000
In vielen
Musikstücken finden wir wechselnde Tempi; in MIDI-Dateien wird das jeweilige
Tempo mit der Meta-Nachricht tempo angegeben (siehe nächsten Abschnitt). In
diesem Fall muss man die Millisekunden-Werte stückweise aus den jeweiligen
Tempoabschnitten zusammensetzen. Wird also zunächst eine Viertel-Note in 120
BPM, dann eine Achtel-Note in 100 BPM gespielt, so sind nach den beiden Noten
500+300=800 Millisekunden verstrichen.
8.2
Meta-Nachrichten
Neben
gewöhnlichen MIDI-Ereignissen können MIDI-Dateien verschiedene Typen so
genannter Meta-Ereignisse enthalten, mit denen zusätzliche Informationen über
das enthaltene Musikstück kodiert werden. Genau wie ein gewöhnliches
MIDI-Ereignis besteht ein Meta-Ereignis aus dem Zeitstempel und der
entsprechenden Nachricht. Auch Meta-Ereignisse haben also eine bestimmte
Position innerhalb der Sequenz. Insbesondere gelten Meta-Nachrichten zur
Kennzeichnung von Tonart (key_sign), Metrum (time_sign) und Tempo (tempo)
jeweils ab der Position, an der sie stehen. Diese Typen von Nachrichten findet
man in einer MIDI-Datei vom Typ 1 üblicherweise in einem separaten
„Tempo-Map“-Track am Beginn der Datei (also in der ersten Spur). Der
MidiMsg-Datentyp umfasst eine Reihe von Konstruktoren, mit denen alle vom
Standard vorgesehenen Meta-Nachrichten dargestellt werden können. Die
wichtigsten Typen von Meta-Nachrichten sind in der folgenden Tabelle kurz
beschrieben. Eine vollständige Übersicht findet man im midi.q-Skript.
key_sign SIGN KEY
Bezeichnet die
Tonart des Stückes. SIGN ist ein Wert zwischen -7 und 7 und gibt die Anzahl der
Vorzeichen (7 B's bis 7 Kreuze) an. KEY ist 0 für Dur und 1 für Moll. Z.B:
key_sign (-2) 0
= B-Dur.
time_sign NUM
QUARTER_DEF
DENOM
CLICK
Bezeichnet das
Metrum eines Stückes. NUM ist der Zähler und DENOM der Zweierlogarithmus des
Nenners der Taktart, CLICK die Anzahl von MIDI-Clocks (24 Clocks =
1 Viertel) per
Metronom-Klick, QUARTER_DEF die
Anzahl von 32teln
in einer Viertelnote. Z.B.: time_sign
3 2 24 8 = 3/4 mit 1 Metronom-Klick per Viertel und
Standard-Viertel-Definition
(1 Viertel = 8 32tel).
Alle Parameter sind
8 Bit-Werte. Fehlt diese Nachricht, so wird ein Standard-4/4-Metrum angenommen.
tempo TEMPO
Gibt das Tempo in
Mikrosekunden je Viertel an. Fehlt diese Angabe, so wird ein Default-Wert von
120 BPM (d.h. tempo 500000) angenommen.
- 70 -
text TEXT
copyright TEXT
seq_name TEXT
instr_name TEXT
lyric TEXT
marker TEXT
cue_point TEXT
Mit diesen
Nachrichten können verschiedene textuelle Informationen in einer MIDI-Datei
gespeichert werden. Der TEXT-Parameter ist dabei eine beliebige Zeichenkette.
Z.B. werden text und copyright für allgemeine Beschreibungen und
Copyright-Informationen und lyric
für Liedtexte verwendet. Die seq_name- und
instr_name-Nachrichten
dienen zur Festlegung eines Sequenz- bzw. Instrument-Namens. Mit der
cue_point-Nachricht kann man Einsatz-Punkte für bestimmte „Cues“ (z.B.
Audio-Dateien) angeben. Die marker-Nachrichten werden in interaktiven
Sequencer-Programmen zur Kenn-
zeichnung von Abschnitten innerhalb einer Sequenz
verwendet.
end_track
Zeigt das Ende
einer MIDI-Spur an. In wohlgeformten MIDI-Dateien muss dieses Ereignis am Ende
jeder Spur stehen.
8.3 Einlesen von
MIDI-Dateien
In Q-Midi werden
MIDI-Dateien durch einen speziellen Datentyp MidiFile repräsentiert. Es handelt
sich dabei um einen „externen“ Datentyp, d.h., die Elemente dieses Typs sind
keine „echten“ Q-Objekte, sondern in der System-Programmiersprache C
implementiert. MidiFile-Objekte werden daher im Interpreter durch das spezielle
Symbol <> angezeigt. Soll von einer MIDI-Datei gelesen
werden, so sind dazu die folgenden Schritte notwendig:
1. Öffnen der
MIDI-Datei zum Lesen. Hierzu ruft man die Funktion midi_file_open mit dem Namen
der Datei auf. Die Funktion liefert ein Objekt des MidiFile-Typs zurück, das
als Argument für die weiteren Dateioperationen verwendet wird.
2. Abfrage der
Datei-Attribute. Mit den Funktionen midi_file_format, midi_file_division und
midi_file_num_tracks bestimmt man, um welchen Typ von MIDI-Datei (0, 1 oder 2)
es sich handelt, in welcher Einheit die Zeitstempel angegeben werden und aus
wieviel Spuren die MIDI-Datei besteht.
3. Einlesen der
Spuren. Um die MIDI-Ereignisse einzeln einzulesen, verwendet man zunächst
midi_file_open_track, um die erste Spur zu öffnen. Die MIDI-Ereignisse der Spur
werden dann nacheinander mit midi_file_read eingelesen. Schließlich wir die
Spur mit midi_file_close_track wieder geschlossen (dies erfolgt automatisch,
wenn mit midi_file_read_track über das Ende der Spur hinausgelesen wurde).
Diese Prozedur wiederholt man so lange, bis alle Spuren eingelesen wurden.
Alternativ dazu kann man auch mit midi_file_read_track eine ganze Spur auf
einmal einlesen; das Ergebnis wird dann als Liste zurückgegeben.
4. Schließen der
MIDI-Datei. Mit der Funktion midi_close wird die MIDI-Datei geschlossen. Diese
Operation wird auch automatisch ausgeführt, wenn auf ein MidiFile-Objekt nicht
mehr zugegriffen werden kann.
Wichtig: Bei der
Verwendung der MIDI-Dateioperationen ist zu beachten, dass diese Operationen
aus technischen Gründen nur funktionieren, wenn bereits ein MidiShare-Client
mit midi_open registriert wurde.
- 71 -
Ob man besser
einzelne MIDI-Ereignisse oder komplette Spuren auf einmal einliest, hängt von
der jeweiligen Anwendung ab. Da wir in dieser Einführung immer vollständige
Sequenzen auf einmal verarbeiten, bietet sich die zweite Methode an und diese
werden wir im folgenden auch verwenden. In jedem Fall werden die
MIDI-Ereignisse als (TIME,MSG)-Paare kodiert, wobei die Zeitstempel absolute,
aufsteigend angeordnete Werte sind. (Tatsächlich werden die Zeitwerte in einer
MIDI-Datei als „Deltas“ gespeichert, d.h. als Differenzen zwischen
aufeinanderfolgenden Ereignissen. Die Umrechnung in absolute Zeitstempel wird
automatisch von der MidiShare-Bibliothek vorgenommen.)
Um welche Zeitwerte
es sich im Einzelfall handelt, wird mit der midi_file_division-Funktion
festgestellt. Ist der Rückgabewert eine einzelne Zahl, so gibt diese einen
PPQN-Wert an. In diesem Fall sind die Zeitstempel als musikalische Zeitwerte zu
verstehen, die mittels der Tempoangaben (tempo-Meta-Ereignisse) für die
Wiedergabe in Millisekunden umgerechnet werden müssen. Der Rückgabewert von
midi_file_division kann aber auch ein Paar (FPS,TICKS) sein, das ein so
genanntes „SMPTE“-Zeitmaß bezeichnet. SMPTE (das wie „Simpty“ ausgesprochen
wird) steht für „Society of Motion Picture and Television Engineers“, die 1967
den unter ihrem Namen bekannten Zeit-Code zur Synchronisierung von Film und
Tonspur einführte. In diesem Zeitmaß sind pro Sekunde die durch FPS („frames
per second“) gegebene Anzahl von „Frames“ auszugeben, wobei jeder Frame in die
gegebenene Anzahl von Ticks unterteilt wird. Das bedeutet, dass jeder Tick
1000/(FPS*TICKS) Millisekunden entspricht. Der SMPTE-Standard sieht vier
mögliche Werte für FPS vor, nämlich 24, 25, 29 oder 30. Z.B. bezeichnet eine
SMPTE-Division von (25,40) also Zeitstempel in Millisekunden, da 25*40=1000.
Als Beispiel
betrachten wir einmal die Datei prelude3.mid. Wir starten dazu das
midi.q-Skript und registrieren einen MidiShare-Client:
==> run midi
==> def REF = midi_open "test"
Danach können wir
die MIDI-Datei wie folgt öffnen:
==> def F = midi_file_open "prelude3.mid"
Es handelt sich
hier um eine Typ 1-Datei mit zwei Spuren und 120 Viertel-Ticks (also
musikalische Zeit mit PPQN = 120):
==> (midi_file_format F,midi_file_num_tracks
F,midi_file_division F) (1,2,120)
Die erste Spur
lesen wir wie folgt ein:
==> midi_file_read_track F
[(0,port_prefix 0),(0,sysex
[65,16,66,18,64,0,127,0,65]),(0,port_prefix 0),(0,time_sign 3 3 12 8),(0,key_sign
7 0),(0,tempo 545455),(0,marker "File Copyright © 1994 by James Kometani. All rights reserved. "), (0,end_track)]
- 72 -
Wie man sieht,
handelt es sich hier in Übereinstimmung mit dem Typ 1-Format um eine
„Tempo-Map“-Spur, die außer der einen sysex-Nachricht nur Meta-Ereignisse
enthält. Die Signatur-Nachrichten zeigen uns, dass es sich um ein Stück in
C#-Dur mit Metrum 3/8 handelt. Das Tempo 545455 entspricht in etwa 110 BPM. Die
weiteren MIDI-Ereignisse (1626 an der Zahl) finden sich in der nächsten Spur
(wir lassen uns hier nur die ersten 10 Ereignisse anzeigen):
Die erste Note
des Stücks hat die Nummer 77, in der angegebenen Tonart also ein E#, das für 30
Ticks erklingt. Da PPQN=120, handelt es sich um ein Viertel einer Viertel
(30/120=1/4), also eine Sechzehntel-Note, die im gewählten Tempo eine Dauer von
etwa 136 Millisekunden (545455/4/1000) hat.
Das Einlesen
einer MIDI-Datei mit expliziten Aufrufen der oben beschriebenen Funktionen und
die anschließende Umrechnung der Zeitwerte ist ein recht mühseliges Geschäft.
Wir definieren uns daher im folgenden eine kleine Hilfsfunktion load, mit der
wir diese Aufgabe automatisieren können. Wir werden dieser Funktion den Namen
der einzulesenden Datei als Parameter übergeben, und die Funktion soll uns dann
die komplette, bereits abgemischte Sequenz aller MIDI-Ereignisse der Datei mit
in Millisekunden konvertierten Zeitstempeln als Liste zurückgeben. Damit wir
auch in der abgemischten Sequenz die einzelnen Spuren auseinanderhalten können,
numerieren wir diese durch und verwenden die Mehr-Spur-Darstellung der
MIDI-Ereignisse, die wir bereits im vorigen Kapitel eingeführt haben. Die
MIDI-Ereignisse werden also als Tripel (TRACK,TIME,MSG) kodiert, wobei TRACK
die jeweilige Spur-Nummer ist.
Wir unterteilen
die load-Funktion in folgende Arbeitsschritte:
1. Öffnen der
Datei.
2. Einlesen und
Numerieren der Spuren.
3. Mischen der
Spuren.
4. Konvertieren
der Zeitstempel.
Die Hauptfunktion
sieht dementsprechend wie folgt aus:
load NAME =
convert (midi_file_division F)
(foldl mix [] (load_tracks F))
where F = midi_file_open NAME;
Für das Mischen
der Spuren verwenden wir die bereits aus bsp14.q bekannte Funktion mix. Die
Gesamtsequenz wird mittels foldl mix schrittweise aus den einzelnen Spuren
konstruiert. Beginnend mit der leeren Sequenz mischen wir dabei immer das
momentane Zwischenergebnis mit der jeweils nächsten Spur zusammen, bis alle
Spuren verarbeitet wurden.
- 73 -
Das Einlesen der
Tracks können wir wie folgt erledigen. Dabei fügen wir mit Hilfe der
Standardbi-bliotheks-Funktion cons am Beginn jeder Nachricht die jeweilige
Spur-Nummer (beginnend mit 0) ein.
load_tracks F =
map (load_track F)
(nums 0 (midi_file_num_tracks F-1));
load_track F K =
map (cons K) (midi_file_read_track F);
Die Konvertierung
von SMPTE-Zeitstempeln ist ebenfalls nicht sonderlich schwierig:
convert (FPS,TICKS) SEQ
= map (convert_smpte FPS TICKS) SEQ;
convert_smpte FPS TICKS (K,S,MSG)
= (K,round (S/(FPS*TICKS)*1000),MSG);
Der einzige etwas
trickreiche Teil des Programms ist die Konvertierung musikalischer Zeitstempel
auf der Basis der PPQN- und Tempo-Werte. Da das Tempo sich im Lauf der Sequenz
ändern kann, benötigen wir hier die momentanen (musikalischen)
Sequenz-Zeitwerte S und (physikalischen) Millisekunden-Werte T sowie den
momentanen Tempowert als zusätzliche Zustandsparameter. Zur Umrechnung der
Zeitwerte wenden wir den jeweiligen Tempo-Wert auf die Zeitdifferenz zwischen
aktuellem und vorhergehenden Ereignis an. Sodann wird, falls eine
Tempo-Nachricht verarbeitet wurde, noch der Tempo-Parameter aktualisiert. Man
beachte auch, dass als Default-Wert für das Tempo beim Aufruf der
convert_ppqn-Funktion 500000 (entsprechend 120 BPM) festgelegt wird.
convert PPQN SEQ =
convert_ppqn PPQN (500000,0,0) SEQ;
convert_ppqn _ _ [] = [];
convert_ppqn PPQN (TEMPO,S,T) [(K,S1,tempo
TEMPO1)|SEQ]
= [(K,T1,tempo TEMPO1)|
convert_ppqn PPQN (TEMPO1,S1,T1) SEQ]
where T1 = T+round (TEMPO/PPQN*(S1-S)/1000);
convert_ppqn PPQN (TEMPO,S,T) [(K,S1,MSG)|SEQ]
= [(K,T1,MSG)|convert_ppqn
PPQN (TEMPO,S1,T1) SEQ]
where T1 = T+round (TEMPO/PPQN*(S1-S)/1000);
Fertig! Das
komplette Programm in der Übersicht:
/* bsp15.q:
Einlesen einer MIDI-Datei */
import midi;
def REF = midi_open "bsp15";
public load NAME;
- 74 -
load NAME =
convert (midi_file_division F)
(foldl mix [] (load_tracks F))
where F = midi_file_open NAME;
/* Einlesen der Spuren */
load_tracks F =
map (load_track F)
(nums 0 (midi_file_num_tracks F-1));
load_track F K =
map (cons K) (midi_file_read_track F); /* Mischen der Spuren (vgl. bsp14.q) */
mix SEQ1 SEQ2 =
SEQ1 if null SEQ2;
= SEQ2 if null SEQ1;
= [hd SEQ1|mix (tl SEQ1) SEQ2] if T1 <= T2
where (_,T1,_) =
hd SEQ1, (_,T2,_) = hd SEQ2;
= [hd SEQ2|mix SEQ1 (tl SEQ2)] otherwise;
/* Konvertieren
der Zeitstempel in Millisekunden */
convert (FPS,TICKS) SEQ
= map (convert_smpte FPS TICKS) SEQ;
convert_smpte FPS TICKS (K,S,MSG)
= (K,round (S/(FPS*TICKS)*1000),MSG);
convert PPQN SEQ =
convert_ppqn PPQN (500000,0,0) SEQ;
convert_ppqn _ _ [] = [];
convert_ppqn PPQN (TEMPO,S,T) [(K,S1,tempo
TEMPO1)|SEQ]
= [(K,T1,tempo TEMPO1)|
convert_ppqn PPQN (TEMPO1,S1,T1) SEQ]
where T1 = T+round (TEMPO/PPQN*(S1-S)/1000);
convert_ppqn PPQN (TEMPO,S,T) [(K,S1,MSG)|SEQ]
=
[(K,T1,MSG)|convert_ppqn PPQN (TEMPO,S1,T1) SEQ]
where T1 = T+round (TEMPO/PPQN*(S1-S)/1000);
Wir können nun
eine Sequenz wie folgt mit load einlesen und dann sofort mit der in bsp14.q
definierten play-Funktion wiedergeben:
==> import bsp14
==> def SEQ = load "prelude3.mid"
==> play SEQ
Die load-Funktion
in der oben beschriebenen Form ist allerdings nur für Dateien des Typs 0 oder 1
geeignet. Für Dateien des Typs 2 bildet jede Spur für sich ein Musikstück mit
eigenen Tempo-Angaben. In diesem Fall behandelt man jede einzelne Spur so, wie oben
gezeigt wurde. Als Ergebnis kann man dann ein Tupel der konvertierten Spuren
zurückliefern. Die dazu notwendigen Erweiterungen der load-Funktion überlassen
wir dem Leser zur Übung.
- 75 -
8.4 Speichern von
MIDI-Dateien
Der notwendige
Ablauf zum Speichern einer MIDI-Sequenz in einer Datei ist im Prinzip ähnlich
wie beim Einlesen:
1. Öffnen einer
MIDI-Datei zum Schreiben. Dazu verwenden wir die Funktion midi_file_create oder
midi_file_append, je nachdem, ob eine neue Datei erstellt oder einfach nur
zusätzliche Spuren an eine vorhandene Datei angehängt werden sollen. Bei der
Funktion midi_file_create müssen wir neben dem Dateinamen auch das gewünschte
Dateiformat (0, 1 oder 2) und die Division (PPQN oder (FPS,TICKS)) angeben.
2. Speichern der
Spuren. Mit den Funktionen midi_file_new_track, midi_file_write und
midi_file_close_track kann eine Spur Ereignis für Ereignis ausgegeben werden.
Alternativ dazu wird eine komplette, als Liste von Ereignissen spezifizierte
Spur auf einmal mit midi_file_write_track geschrieben. Dies wird solange
wiederholt, bis alle Spuren gespeichert sind.
3. Schließen der
Datei. Mit midi_file_close wird die Datei wieder geschlossen. Dies erfolgt auch
automatisch, wenn auf das Datei-Objekt nicht mehr zugegriffen werden kann.
Als einfaches
Beispiel speichern wir eine aus einem einzelnen Mittel-C bestehende Sequenz in
einer Typ 1-Datei mit Millisekunden-Zeitstempeln:
==> run midi
==> def REF = midi_open "test"
==> def F = midi_file_create "test.mid" 1
(25,40)
==> midi_file_write_track F [(0,note_on 0 60
127),(500,note_on 0 60 0)] ()
Bevor wir die
Datei zur Kontrolle wieder einlesen, muss die noch zum Schreiben geöffnete
Datei erst geschlossen werden:
==> midi_file_close F
()
==> def F = midi_file_open "test.mid"
==> midi_file_read_track F
[(0,note_on 0 60
127),(500,note_on 0 60 0)]
Im allgemeinen
Fall müssen die Zeitstempel der zu speichernden Sequenz in das Ziel-Format
umgerechnet werden (also Millisekunden in die SMPTE- oder PPQN-Zeit der Datei).
Außerdem müssen die einzelnen Spuren extrahiert und separat abgespeichert
werden. Wir automatisieren diesen Prozess mit der im folgenden definierten
Funktion save. Wir betrachten hier nur den Fall einer Typ 1-Datei; die
notwendigen Anpassungen für Dateien des Typs 0 und 2 überlassen wir wieder dem
Leser zur Übung. Unsere save-Funktion ist im Prinzip die Umkehrung der
load-Funktion aus dem letzten Abschnitt. Wir rufen die Funktion mit drei
Parametern auf, dem Dateinamen, dem gewünschten Zeitmaß ((FPS,TICKS) oder
PPQN), und der zu speichernden Sequenz. Der Ablauf der Funktion ist wie folgt:
- 76 -
1. Erstellen
einer Typ 1-Datei mit dem gewünschten Zeitmaß.
2. Konvertieren
der Sequenz in das gewünschte Zeitmaß.
3. Extrahieren
und Abspeichern der einzelnen Spuren.
Die Funktion zum
Konvertieren der Zeitstempel ist die genaue Umkehrung der
Konvertierungsfunktion aus bsp15.q. Wir wenden diese Funktion wieder auf die
Gesamtsequenz (nicht etwa auf die einzelnen Spuren) an, da die
tempo-Ereignisse, die wir normalerweise in der ersten Spur einer Typ 1-Datei
finden, ja für alle Spuren gelten. Zum Speichern der einzelnen Spuren müssen
wir die Spuren dann extrahieren, wozu wir die track-Funktion aus Kapitel 7
verwenden, und auch die Spurnummern am Beginn der Ereignisse entfernen; letzteres
erledigen wir hier mit der Standardbi-bliotheks-Funktion pop. Die in der
Sequenz vorkommenden Spurnummern berechnen wir dabei mit der Funktion
track_nums, indem wir zunächst die Standardbibliotheks-Funktion fst (die das
erste Element eines Tupels liefert) auf alle Ereignisse der Sequenz anwenden,
die resultierende Liste der Spurnummern mit der Standardbibliotheks-Funktion
set in eine Menge und dann mit list wieder zurück in eine Liste umwandeln. (Die
Funktion list zur Umwandlung einer Menge in eine Liste hatten wir bereits
kennengelernt; die Funktion set wandelt umgekehrt eine Liste in eine Menge um.
Man kann daher eine Kombination aus set und list verwenden, um eine Liste zu
sortieren und dabei gleichzeitig mehrfach vorkommende Elemente zu eliminieren.)
Das resultierende
Programm ist wie folgt:
/* bsp16.q:
Speichern einer MIDI-Datei */
import midi;
def REF = midi_open "bsp16";
public save NAME DIV SEQ;
save NAME DIV SEQ =
do (save_track F SEQ) (track_nums SEQ)
where F:MidiFile = midi_file_create NAME 1 DIV,
SEQ = convert DIV
SEQ;
/* Bestimmung der
Spurnummern */
track_nums SEQ =
list (set (map fst SEQ));
/* Speichern der
Spuren */
save_track F SEQ
K = midi_file_write_track F (map pop (track K SEQ));
/* Extrahieren
der Spuren (vgl. bsp14.q) */
track K SEQ =
filter (trackeq K) SEQ;
trackeq K (K,_,_) =
true;
trackeq _ _ = false otherwise;
/* Konvertieren
der Millisekunden-Zeitstempel */
convert (FPS,TICKS) SEQ
= map (convert_smpte FPS TICKS) SEQ;
convert_smpte FPS TICKS (K,T,MSG)
= (K,round (T*FPS*TICKS/1000),MSG);
- 77 -
convert PPQN SEQ =
convert_ppqn PPQN (500000,0,0) SEQ;
convert_ppqn _ _ [] = [];
convert_ppqn PPQN (TEMPO,T,S) [(K,T1,tempo
TEMPO1)|SEQ]
= [(K,S1,tempo TEMPO1)|
convert_ppqn PPQN (TEMPO1,T1,S1) SEQ]
where S1 = S+round ((T1-T)*1000*PPQN/TEMPO);
convert_ppqn PPQN (TEMPO,T,S) [(K,T1,MSG)|SEQ]
= [(K,S1,MSG)|convert_ppqn PPQN (TEMPO,T1,S1) SEQ]
where S1 = S+round ((T1-T)*1000*PPQN/TEMPO);
Als Beispiel für
die Anwendung der save-Funktion speichern wir nochmals eine einfache
MIDI-Sequenz, diesmal im musikalischen Zeitmaß mit 96 Ticks je Viertel:3
==> def SEQ = [(0,0,note_on 0 60
127),(0,500,note_on 0 60 0)]
==> ? save
"test.mid" 96 SEQ
()
Zur Kontrolle
lesen wir die gerade erstellte MIDI-Datei mit unserer load-Funktion aus dem
letzten Abschnitt wieder ein:
==> import bsp15
==> ? load "test.mid"
[(0,0,note_on 0
60 127),(0,500,note_on 0 60 0)]
Wie die folgenden
Kommandos zeigen, ist die Sequenz innerhalb der Datei tatsächlich im
musikalischen Zeitmaß mit PPQN=96 gespeichert:
==> def F = midi_file_open "test.mid"
==> midi_file_division F; midi_file_read_track F
96
[(0,note_on 0 60
127),(96,note_on 0 60 0)]
Wie man sieht,
hat die C-Note in der Datei eine Dauer von 96 Ticks, die bei 96 PPQN der Länge
einer Viertelnote, also 500 Millisekunden bei 120 BPM entsprechen. Da die obige
Sequenz keine Tempo-Angaben enthält, wird ja das Default-Tempo von 120 BPM
angenommen.
3 Man beachte
hier das ?-Kommando am Beginn der Zeile, das dem Interpreter anzeigt, dass ein
auszuwertender Ausdruck folgt. Dies ist notwendig, damit der Interpreter unsere
save-Funktion nicht mit dem speziellen save-Kommando verwechselt, das zum
Speichern der momentan definierten Variablen-Werte dient. Gleiches gilt auch
für die Verwendung von load weiter unten.
- 78 -
8.5 Bearbeiten
von MIDI-Dateien
Zum Abschluss
unserer kleinen Einführung in die MIDI-Programmierung beschäftigen wir uns in
diesem Abschnitt mit der Bearbeitung von MIDI-Dateien, wozu wir die Funktionen
zum Einlesen und Abspeichern aus den vorangegangenen Abschnitten verwenden. Der
grundlegende Ablauf der Bearbeitung ist immer der gleiche:
1. Einlesen einer
MIDI-Datei.
2. Bearbeiten der
MIDI-Sequenz.
3. Abspeichern
der modifizierten Sequenz.
Zum Bearbeiten
der Sequenz hat man die in Q vordefinierten Listenfunktionen wie z.B. map,
foldl oder filter zur Verfügung. Für die anwendungsspezifischen
Bearbeitungsschritte können wir uns dann unsere eigenen Funktionen definieren.
Auf diese Weise hat man mit Q-Midi ein sehr flexibles Werkzeug zur
automatisierten Bearbeitung von MIDI-Dateien zur Hand, das über die
Möglichkeiten der meisten interaktiven Sequencer-Programme weit hinausgeht.
Wir betrachten im
folgenden allerdings der Einfachheit halber nur ein elementares Beispiel zur
Manipulation einer MIDI-Datei, nämlich die Normalisierung der Dynamik-Parameter
von Noten-Ereignissen. Diese Funktion ist z.B. dann praktisch, wenn man mehrere
MIDI-Sequenzen auf ein einheitliches Lautstärke-Niveau anheben will. Man
erreicht dies, indem man alle Dynamik-Werte mit dem gleichen Faktor
multipliziert. Der Faktor wird so gewählt, dass (nach Rundung der
resultierenden Werte) der höchste Dynamik-Wert in der Sequenz auf den
Maximalwert 127 abgebildet wird. Zur Bestimmung des maximalen Dynamik-Parameters
und zur Modifikation der Dynamik-Werte bietet sich der Einsatz der generischen
Listenfunktionen map und foldl an.
Betrachten wir
zunächst die Bestimmung des Faktors, um den die Dynamikwerte verstärkt werden
müssen. Dieser beträgt 127/MAX, wobei MAX der maximale Dynamik-Wert eines
note_on-Ereignisses ist. Ist SEQ die Eingabe-Sequenz, so können wir MAX
berechnen als foldl max 0 (map vel SEL), wobei die folgende vel-Funktion dazu
dient, die Dynamik-Werte aus den Ereignissen der Sequenz zu extrahieren:
vel (_,_,note_on _ _ V) = V;
vel _ = 0 otherwise;
Ist MAX>0, so
können wir nun folgende Funktion amp mit FACT=127/MAX auf die einzelnen
Ereignisse anwenden, um die Dynamik-Werte auf das gewünschte Niveau anzuheben.
(Falls MAX=0 ist, so enthält die Datei keine echten „Note-On“-Ereignisse und
eine Normalisierung ist daher nicht möglich.)
amp FACT (K,T,note_on C P V) = (K,T,note_on C P (round (FACT*V)));
amp FACT EV = EV
otherwise;
Es fehlt jetzt
nur noch die Hauptfunktion, die die gewünschte MIDI-Datei einliest, den
maximalen vel-Wert berechnet, mit amp die Dynamik-Werte anhebt, und schließlich
das Ergebnis wieder abspeichert. Für die Dateioperationen verwenden wir einfach
die Operationen aus den beiden vorhergehenden Beispielen. Das fertige Programm
ist wie folgt:
- 79 -
/* bsp17.q:
Dynamik-Normalisierung einer MIDI-Datei */
import midi, bsp15, bsp16;
def REF = midi_open "bsp17";
public normalize NAME;
normalize NAME =
save NAME DIV (map (amp (127/MAX)) SEQ)
if MAX>0
where SEQ = load NAME,
MAX = foldl max 0 (map vel SEQ), DIV =
midi_file_division (midi_file_open NAME);
/* Bestimmung der
Dynamikwerte */
vel (_,_,note_on
_ _ V) = V;
vel _ = 0 otherwise;
/* Verstärkung
der Dynamikwerte */
amp FACT (K,T,note_on C P V) = (K,T,note_on C P (round (FACT*V)));
amp FACT EV = EV
otherwise;
Das folgende
Beispiel zeigt die Anwendung von normalize auf unsere Beispiel-Datei
prelude3.mid (da die Datei von normalize überschrieben wird, speichern Sie
bitte eine Kopie der Original-Datei an einem sicheren Platz). Schauen wir uns
zunächst einmal an, welche Dynamik-Werte in der Original-Datei vorkommen. Die
Berechnung der verschiedenen Werte kann man wie im vorangegangenen Abschnitt
mit den Funktionen set und list erledigen, die wir hier auf die vel-Werte der
Ereignisse in der Sequenz anwenden:
==> list (set (map vel (load
"prelude3.mid")))
[0,95,96,97,98,99,100,101,102,103,104,105]
Die Anwendung von
normalize:
==> normalize
"prelude3.mid"
()
Welche
Dynamik-Werte finden wir nun in prelude3.mid?
==> list (set (map vel (load
"prelude3.mid")))
[0,115,116,117,119,120,121,122,123,125,126,127]
Wie man sieht,
wurden die Werte wie gewünscht so angehoben, dass das Maximum nun 127 ist.
Will man übrigens
nicht nur eine, sondern sämtliche MIDI-Dateien in einem Verzeichnis
normalisieren, so kann man dazu die Standardbibliotheks-Funktion glob
verwenden, die aus einem Dateinamen-Muster die Liste der tatsächlichen
Dateinamen berechnet. Z.B.:
- 80 -
==> do
normalize (glob 'v*.mid'v)
Wir haben hier
nur eine recht einfache Anwendung skizziert. Auf ähnliche Weise kann man den
Dynamik-Bereich einer MIDI-Sequenz auch komprimieren oder expandieren, ähnlich
wie man dies mit Audio-Dateien tut. Durch Verwendung entsprechender
Bearbeitungs-Funktionen kann man auch MIDI-Kanalnummern der Voice-Ereignisse
ändern, Controller-Werte transformieren, die Zeitstempel variieren, usw. Da man
mit Q eine universelle Programmiersprache zur Hand hat, lassen sich mit Q-Midi
und den in diesem Kapitel besprochenen Hilfsfunktionen alle Transformationen
von MIDI-Dateien durchführen, die überhaupt „berechenbar“ sind, wobei die
generischen Listenfunktionen von Q ein wesentliches Hilfsmittel darstellen, mit
dem man viele Programme auf einfache Weise realisieren kann.
- 81 -
Keine Kommentare:
Kommentar veröffentlichen
Hinweis: Nur ein Mitglied dieses Blogs kann Kommentare posten.