Die Benutzeroberfläche und ihre Grenzen
Tongenerierung in WPF-Anwendungen
Charles Petzold
Vor Kurzem saß ich in einem neuen Toyota Prius, während ein Mitarbeiter der Autovermietung die fremdartigen Bedienelemente und Anzeigen auf dem Armaturenbrett erklärte. "Wahnsinn", dachte ich mir. "Sogar bei einer Technologie, die so alt wie das Automobil ist, verbessern die Hersteller fortwährend die Benutzeroberfläche."
Im weitesten Sinn ist die Benutzeroberfläche der Ort, an dem Mensch und Maschine interagieren. Das Konzept ist zwar genauso alt wie die Technologie, als Kunstform kam die Benutzeroberfläche jedoch erst mit der PC-Revolution zur Blüte.
Nur ein winziger Teil der heuten PC-Benutzer kann sich an die Zeit vor dem Aufkommen der grafischen Benutzeroberflächen von Apple Macintosh und Microsoft Windows erinnern. Damals (Mitte der 1980er Jahre) befürchteten einige Kritiker, dass die Standardisierung der Benutzeroberfläche Anwendungen eine bedrückende Uniformität aufzwingen würde. Dies war nicht der Fall. Stattdessen mussten die Designer und Programmierer wegen der Verfügbarkeit von Standardsteuerelementen die Bildlaufleiste nicht immer wieder neu erfinden, und Benutzeroberflächen wurden tatsächlich weiterentwickelt und viel interessanter.
In dieser Hinsicht erlaubten die neuen Paradigmen, die mit WPF (Windows Presentation Foundation) eingeführt wurden, sogar eine aufwändigere Ausgestaltung der Benutzeroberflächen. WPF bildet durch Retained Mode-Grafiksystem, Animation und 3-D eine solides Fundament. Hinzu kommt eine baumförmige hierarchische Struktur aus übergeordneten und untergeordneten Elementen und eine mächtige Markupsprache namens XAML. Daraus ergibt sich eine unvergleichliche Flexibilität bei der Anpassung vorhandener Steuerelemente durch die Verwendung von Vorlagen und bei der Erstellung neuer Steuerelemente durch die Zusammenstellung vorhandener Komponenten.
Aber diese neuen Konzepte sind nicht nur auf die Clientprogrammierung anwendbar. Eine gesunde Teilmenge von Microsoft .NET Framework, XAML- und WPF-Klassen wurde durch Silverlight in der Webprogrammierung verfügbar. Der Tag ist gekommen, an dem benutzerdefinierte Steuerelemente in Clientanwendungen und in Webanwendungen gemeinsam verwendet werden können. Ich bin sicher, dieser Trend wird sich auf mobile Anwendungen ausdehnen und schließlich viele verschiedene Informations- und Unterhaltungssysteme umfassen, wobei gleichzeitig die Vorteile neuer Technologien wie die Mehrfingereingabe genutzt werden.
Aus diesen Gründen bin ich davon überzeugt, dass die Benutzeroberfläche zu einem noch wichtigeren Teil der Anwendungsprogrammierung geworden ist. In diesem Artikel wird das Potenzial des Benutzeroberflächenentwurfs in WPF und Silverlight sowie die Verwendung plattformübergreifenden Codes, soweit möglich, untersucht.
Tongenerierung
Es ist nicht immer möglich, auf Anhieb zwischen guten und schlechten Benutzeroberflächenoptionen zu unterscheiden. Clippy, die vermenschlichte Büroklammer, die in Microsoft Office 97 ihren ersten Auftritt hatte, wurde damals wahrscheinlich ein toller Einfall betrachtet. Aus diesem Grund konzentriere ich mich auf das technische Potenzial statt auf das Design. Ich versuche, den Begriff "Best Practices" zu vermeiden. Diese Bewertung sei der Geschichte und dem Markt überlassen.
Beispielsweise könnte man sagen, dass Computer nur während der Wiedergabe einer Video- oder Klangdatei oder in Reaktion auf einen spezifischen Benutzerbefehl Geräusche von sich geben sollten. Ich ignorieren diese Kritik und zeige, wie benutzerdefinierte Töne in einer WPF-Anwendung wiedergegeben werden können, indem zur Laufzeit Waveformdaten generiert werden.
Diese Fähigkeit zur Tonerzeugung ist noch nicht offizieller Bestandteil von .NET Framework, wird jedoch durch die NAudio-Bibliothek ermöglicht, die auf Codeplex (naudio.codeplex.com) verfügbar ist. Diese Website enthält Links zum Blog von Mark Heath, wo Sie Beispielcode finden, und zur Website mit Lernprogrammen von Sebastian Gray.
Sie können die NAudio-Bibliothek in Windows Forms oder WPF-Anwendung verwenden. Weil sie über PInvoke auf Win32-API-Funktionen zugreift, kann sie nicht mit Silverlight verwendet werden.
Für diesen Artikel verwendete ich NAudio Version 1.3.8. Wenn Sie ein Projekt erstellen, in dem NAudio verwendet wird, sollten Sie es für die 32-Bit-Verarbeitung kompilieren. Rufen Sie die Registerkarte "Erstellen" der Eigenschaftenseite auf, und wählen Sie im Dropdownmenü "Zielplattform" die Option "x86" aus.
Obwohl die Bibliothek viele Funktionen für spezielle Anwendungen bietet, in denen die Wiedergabe von Klängen notwendig ist, stelle ich eine Technik vor, die in allgemeineren Anwendungen zum Einsatz kommen kann.
Angenommen, eine Anwendung lässt zu, dass die Benutzer Objekte im Fenster ziehen können, und dieses Ziehen soll durch einen einfachen Ton (z. B. eine Sinuswelle) untermalt werden, dessen Frequenz zunimmt, je weiter sich das Objekt vom Mittelpunkt des Fensters entfernt.
Das ist eine Aufgabe für Waveformaudio.
Fast alle PCs von heute sind mit Tongenerierungshardware ausgestattet, die oft durch einen Chip oder zwei direkt auf der Hauptplatine implementiert ist. Bei dieser Hardware handelt es sich in Regel einfach um ein Paar DAC-Chips (Digital-to-Analog Converter, Digital-Analog-Umsetzer). Wenn die beiden DAC-Chips mit einem konstanten Datenstrom von Ganzzahlen, die ein Wellensignal beschreiben, gefüttert werden, kommt Stereoton heraus.
Wie viele Daten sind dazu erforderlich? Heutige Anwendungen erzeugen üblicherweise Ton in "CD-Qualität". Die Samplingrate beträgt konstant 44.100 Samples pro Sekunde. (Das Nyquist-Theorem besagt, dass die Samplingrate mindestens doppelt so groß wie die zu reproduzierende Frequenz sein muss. Menschen sollen gemeinhin Töne im Frequenzbereich zwischen 20 Hz und 20.000 Hz hören können, daher ist 44.100 ein angemessener Wert.) Jedes Sample ist eine 16-Bit-Ganzzahl mit Vorzeichen, eine Größe, die ein Signal-Rausch-Verhältinis von 96 dB impliziert.
Wellenschlagen
Die Win32 -API bietet über eine Sammlung von Funktionen, die mit "waveOut" beginnen, Zugriff auf die Tongenerieruungshardware. Die NAudioBibliothek kapselt diese Funktionen in der WaveOut-Klasse, die sich um die Win32-Interoperabilität kümmert und außerdem eine Menge hässlicher Details verbirgt.
WaveOut benötigt eine Klasse, die Sie bereitstellen müssen und die die IWaveProvider-Schnittstelle implementiert. Das bedeutet, dass die Klasse eine abrufbare Eigenschaft vom Typ WaveFormat definiert, die (mindestens) die Samplerate und die Anzahl von Kanälen angibt. Die Klasse definiert außerdem eine Methode namens Read. Zu den Argumenten der Read-Methode gehört ein Bytearraypuffer, den die Klasse mit Waveform-Daten füllen muss. In der Standardeinstellung wird diese Read-Methode 10 Mall pro Sekunde aufgerufen. Wenn dieser Puffer nicht schnell genug gefüllt wird, hören Sie unästhetische Lücken in der Tonwiedergabe und unschöne Störgeräusche.
NAudio stellt einige abstrakte Klassen bereit, die IWaveProvider implementieren und die Programmierung gängiger Audiofunktionen erleichtern. Die WaveProvider16-Klasse implementiert eine abstrakte Read-Methode, mit der der Puffer mit short-Werten statt mit Bytewerten gefüllt werden kann, sodass die Samples nicht halbiert werden müssen.
In Abbildung 1 ist eine einfache SineWaveOscillator-Klasse dargestellt, die von WaveProvider16 abgeleitet wurde. Der Konstruktor ermöglicht die Angabe einer Samplingrate, ruft jedoch den Basisklassenkonstruktor mit einem zweiten Argument auf, das einen Kanal für monophonen Ton angibt.
Abbildung 1 Eine Klasse zum Generieren von Sinuswellensamples für NAudio
class SineWaveOscillator : WaveProvider16 {
double phaseAngle;
public SineWaveOscillator(int sampleRate):
base(sampleRate, 1) {
}
public double Frequency { set; get; }
public short Amplitude { set; get; }
public override int Read(short[] buffer, int offset,
int sampleCount) {
for (int index = 0; index < sampleCount; index++) {
buffer[offset + index] =
(short)(Amplitude * Math.Sin(phaseAngle));
phaseAngle +=
2 * Math.PI * Frequency / WaveFormat.SampleRate;
if (phaseAngle > 2 * Math.PI)
phaseAngle -= 2 * Math.PI;
}
return sampleCount;
}
}
SineWaveOscillator definiert zwei Eigenschaften namens Frequency (vom Typ double) und Amplitude (vom Typ short). Das Programm verwaltet ein Feld namens phaseAngle mit Werten im Bereich zwischen 0 und 2π. Für jedes Sample wird das Feld phaseAngle der Math.Sin-Funktion übergeben und dann um einen Wert erhöht, der als Phasenwinkelinkrement bezeichnet wird und aus einer einfachen Berechnung hervorgeht, in die die Frequenz und Samplingrate einfließen.
(Wenn gleichzeitig viele Waveforms generiert werden sollen, sollten Sie die Verarbeitungsgeschwindigkeit erhöhen, indem Sie, soweit wie möglich, Ganzzahlarithmetik verwenden und sogar Sinuswellentabellen als Array von short-Werten implementieren. Für die einfachen Zwecke von Waveform-Audio sind Gleitkommaberechnungen jedoch ausreichend.)
Um SineWaveOscillator in einem Programm zu verwenden, muss ein Verweis auf die Bibliothek NAudio.dll und eine using-Direktive eingefügt werden:
using NAudio.Wave;
Es folgt etwas Code, mit dem Töne wiedergegeben werden.
WaveOut waveOut = new WaveOut();
SineWaveOscillator osc = new SineWaveOscillator(44100);
osc.Frequency = 440;
osc.Amplitude = 8192;
waveOut.Init(osc);
waveOut.Play();
Hier wird die Frequency–Eigenschaft mit 440 Hz initialisiert. In der Musik ist dies das A über dem eingestrichenen C und wird häufig als Kammerton und zum Stimmen verwendet. Natürlich kann die Frequency-Eigenschaft während der Tonwiedergabe geändert werden. Um die Tonwiedergabe zu deaktivieren, kann die Amplitude-Eigenschaft auf 0 festgelegt werden, die SineWaveOscillator-Instanz empfängt dann jedoch weiterhin Aufrufe der Play-Methode. Um diese Aufrufe zu stoppen, wird die Stop-Methode des WaveOut-Objekts aufgerufen. Wenn das WaveOut-Objekt nicht mehr benötigt wird, sollte die Dispose-Methode für das Objekt aufgerufen werden, damit die Ressourcen ordnungsgemäß freigegeben werden.
Misstöne
Als ich SineWaveOscillator in meinem Beispielprogramm verwendete, verhielt sich die Klasse nicht wie gewünscht. Ich wollte einen Ton, der die Objekte begleiten sollte, die im Fenster gezogen werden, und die Frequenz dieses Tons sollte vom Abstand des Objekts vom Fenstermittelpunkt abhängig sein. Wenn ich meine Objekte bewegte, waren die Frequenzübergänge holprig. Ich erhielt ein unruhiges Glissando (wie von Fingern, die über eine Klaviertastatur oder die Seiten einer Harfe gleiten), wollte jedoch ein melodisches Portamento (wie eine Posaune oder die Klarinette am Anfang von Gershwins "Rhapsody in Blue").
Das Problem besteht darin, dass bei jedem Aufruf der Play-Methode von WaveOut der gesamte Puffer gefüllt und hierbei derselbe Frequenzwert zugrunde gelegt wird. Während die Play-Methode den Puffer füllt, kann die Frequenz nicht in Reaktion auf das Ziehen der Maus verändert werden, weil die Play-Methode im Benutzeroberflächenthread ausgeführt wird.
Wie störend ist das Problem, und wie groß sind diese Puffer?
Die WaveOut-Klasse von NAudio enthält die DesiredLatency-Eigenschaft, die standardmäßig auf 300 Millisekunden festgelegt. Die Klasse enthält auch die NumberOfBuffers-Eigenschaft, die auf 3 festgelegt ist. (Mehrere Puffer erhöhen den Durchsatz, weil die API einen Puffer lesen kann, während eine Anwendung einen anderen Puffer füllt.) Folglich entspricht jeder Puffer einer Zehntelsekunde von Samples. Durch Experimentieren entdeckte ich, dass es nicht möglich ist, den Wert der DesiredLatency-Eigenschaft deutlich zu senken, ohne hörbare Lücken zu verursachen. Es ist möglich, die Anzahl von Puffer zu erhöhen (wählen Sie unbedingt einen Wert, sodass die Puffergröße in Bytes ein Vielfaches von 4 ist), aber das schien kaum zur Lösung des Problems beizutragen. Zudem ist es möglich, die Play-Methode in einem sekundären Thread auszuführen, indem der Aufruf der statischen Methode WaveCallbackInfo.FunctionCallback dem WaveOut-Konstruktor übergeben wird, auch das hat nicht viel geholfen.
Es wurde bald klar, dass ich einen Oszillator brauchte, der das Portamento erzeugte und gleichzeitig den Puffer füllte. Statt SineWaveOscillator brauchte ich eine PortamentoSineWaveOscillator-Klasse.
PortamentoSineWaveOscillator
Ich wollte auch andere Änderungen vornehmen. Die menschliche Wahrnehmung von Frequenz ist logarithmisch. Die Oktave ist als Verdoppelung der Frequenz definiert, und Oktaven hören sich über das gesamte Spektrum hinweg ähnlich an. Für das Nervensystem des Menschen ist der Unterschied zwischen 100 Hz und 200 Hz identisch mit dem Unterschied zwischen 1000 Hz und 2000 Hz. In der Musik umfasst jede Oktave 12 für das Ohr gleich große Schritte, die Halbtöne genannt werden. Folglich erhöht sich die Frequenz dieser Halbtöne sequenziell um einen Multiplikationsfaktor, der einem Zwölftel der Wurzel aus Zwei entspricht.
Da mein Portamento auch logarithmisch sein sollte, definierte ich in der PortamentoSineWaveOscillator-Klasse eine neue Eigenschaft namens Pitch, die die Frequenz folgendermaßen berechnet:
Frequency = 440 * Math.Pow(2, (Pitch - 69) / 12)
Das ist eine ziemlich übliche Formel, die aus MIDI (Musical Instrument Digital Interface)-Konventionen abgeleitet ist. Auf MIDI werde ich in einem künftigen Artikel eingehen. Wenn alle Töne eines Klaviers von unten nach oben nummeriert werden und dem gestrichenen C ein Pitch-Wert von 60 zugewiesen wird, dann hat das A über dem gestrichenen C den Pitch-Wert 69, und die Formel berechnet daraus eine Frequenz von 440 Hz. In MIDI sind diese Pitch-Werte Ganzzahlen, in der PortamentoSineWaveOscillator-Klasse ist Pitch jedoch vom Typ double, und daher sind Abstufungen zwischen Tönen möglich.
In der PortamentoSineWaveOscillator-Klasse erkennt die Play-Methode, wenn der Pitch-Wert geändert wurde und ändert dann graduell den Wert, der zur Berechnung der Frequenz verwendet wird (und folglich das Phasenwinkelinnkrement) auf der Grundlage der Restgröße des Puffers. Dies Logik lässt zu, dass sich der Pitch-Wert ändert, während die Methode ausgeführt wird, aber dies geschieht nur, wenn Play in einem sekundären Thread ausgeführt wird.
Wie das AudibleDragging-Programm im Codedownload zeigt, funktioniert das! Das Programm erstellt sieben kleine, verschiedenfarbige Blöcke nahe dem Fenstermittelpunkt. Wenn sie mit der Maus eingefangen werden, erstellt das Programm mithilfe von PortamentoSineWaveOscillator ein WaveOut-Objekt. Während das Objekt gezogen wird, ermittelt das Programm einfach den Abstand vom Fenstermittelpunkt und legt den Pitch-Wert des Oszillators unter Verwendung der folgenden Formel fest.
60 + 12 * distance / 200;
Mit anderen Worten, das gestrichene C plus eine Oktave pro 200 Entfernungseinheiten. AudibleDragging ist natürlich ein albernes kleines Programm und trägt vielleicht nur dazu bei, dass Sie mehr denn je davon überzeugt sind, dass Anwendungen für immer schweigsam sein sollten. Das Potenzial der Generierung benutzerdefinierter Töne zur Laufzeit ist aber einfach zu mächtig, um kategorisch abgelehnt zu werden.
Weiter spielen
Natürlich sind Sie nicht auf Oszillatoren für eine Sinuswelle beschränkt. Sie können von der WaveProvider16-Klasse auch ein Mischpult ableiten und damit mehrere Oszillatoren kombinieren. Sie können einfache Waveforms zu komplexeren Waveforms kombinieren. Die Verwendung der Pitch-Eigenschaft legt eine einfache Möglichkeit zur Angabe musikalischer Töne nahe.
Wenn Ihre Anwendung jedoch Musik und Musikinstrumente aus den Lautsprechern ertönen lassen soll, wird es Sie freuen, dass NAudio auch Klassen enthält, mit denen Sie in Windows Forms oder WPF-Anwendungen MIDI-Nachrichten erzeugen können. Ich werde bald zeigen, wie das geht.
Charles Petzold schreibt seit langem redaktionelle Beiträge für das MSDN Magazin*. Sein neuestes Buch heißt "The Annotated Turing: A Guided Tour through Alan Turing’s Historic Paper on Computability and the Turing Machine" (Wiley, 2008). Petzold schreibt einen Blog auf seiner Website charlespetzold.com.*
Unser Dank gilt dem folgenden technischen Experten für die Durchsicht dieses Artikels: Mark Heath