Mein erstes mittelhochdeutsches Hörbuch – Teil 7

Dies ist der siebte Teil des Tutorials. Der Beginn findet sich hier.

Am Ende – Beinahe!

Ein einfacher Befehl genügt, um Hedas Sprachausgabe in eine Audiodatei umzuleiten. Dazu müssen wir nur irgendwo vor dem Speak-Befehl eine einzige Zeile in Main in Program.cs einfügen:

synth.SetOutputToWaveFile("D:/Lanzelet 1 - 49.wav");

Das war es auch schon!  Wir können natürlich auch ein anderes Verzeichnis unserer Wahl bestimmen oder der Datei einen anderen Namen geben.

Auf jeden Fall haben wir gerade erreicht, was wir uns zu Beginn unseres Projektes vorgenommen haben: Sobald wir die Taste F5 betätigen (und einen Moment warten), liegt im von uns gewählten Verzeichnis, unter dem von uns bestimmten Namen, eine Audiodatei bereit, die einen mittelhochdeutschen Text korrekt, wenn auch etwas synthetisch-blechern vorliest. Das Format der Datei entspricht zwar nicht dem für Hörbücher üblichen, wird aber von nahezu jedem Medienabspielgerät verstanden.

Wer sich damit nicht zufrieden geben will (oder kann): Es gibt jede Menge Möglichkeiten, um eine Wave-Datei komfortabel in eine im MP3-Format zu übertragen. Für die MP3-Dateien dieses Tutorials wurde beispielsweise das handliche kleine Programm fre:ac verwendet. Andere Tools erfüllen, bei abweichendem Funktionsumfang, denselben Zweck genauso gut.

Und ja! Es wäre möglich und sogar relativ einfach, eine Lösung in C# zu programmieren, die es uns erlaubt, die MP3-Datei direkt aus unserem Programm zu erzeugen. Nicht zuletzt aufgrund der leichten Verfügbarkeit der oben erwähnten Tools erscheint es jedoch überflüssig, eine entsprechende Lösung in diesem Tutorial zu behandeln, das sein selbstgesetztes Ziel jetzt erreicht hat.

Wie angekündigt, wollen wir jetzt aber noch ein wenig „aufräumen“. Wir sind nicht immer auf direktem Weg zum Ziel gekommen und können, mit mehr Überblick, vielleicht manche Kurve begradigen. Um die Metaphern wahllos zu mischen: Vielleicht gibt es noch das eine oder andere zurückgebliebene Gerüst zu beseitigen, das wir zwischendurch brauchten, um unsere Gewölbe zu errichten, das nun fest und sicher steht.  Wir kümmern uns um solche Aufräumarbeiten auch mit dem Hintergedanken, damit unser Programm für zukünftige Einsätze vorzubereiten. Denn wollen wir uns nach all der Arbeit wirklich damit zufrieden geben, dass wir nur diesen einen kleinen Textausschnitt in eine Lesung von anderthalb Minuten übertragen haben? Warum sollten wir Hedda nicht irgendwelche weiteren Aufgaben geben … den ganzen Text des Lanzelet zu lesen, vielleicht?

Wir wollen uns gar nicht darauf festlegen, wie es weitergehen soll. Im Gegenteil: Wir wollen uns Optionen schaffen und unser Programm für die Wiederverwendbarkeit öffnen. Bisher haben wir beispielsweise mit einer einzigen Textdatei gearbeitet. Es war die naheliegende und einfachste Lösung,  direkt in unserem Programmcode den Ort anzugeben, an dem diese Datei auf unserer Festplatte zu finden ist. Der hat sich ja während unserer Arbeit nicht geändert. Mehr Flexibilität geben wir dem Benutzer, wenn er zukünftig (das können auch wir sein!) die Möglichkeit bekommt, einen beliebigen Dateinamen für eine Textdatei anzugeben, die Hedda ihm vorlesen soll. Das wird er auf zwei Weisen tun können:

Einmal direkt beim Aufruf: Wenn unser Programm von der Konsole aus, zum Beispiel in der Powershell, geöffnet wird, dann kann der Benutzer durch den ersten Parameter, den er dem Programm übergibt, bestimmen, welche Textdatei gelesen werden soll. Anwender von Windows sind mit dieser Art des Dateiaufrufs meist nicht sehr vertraut. Wer an dieser Stelle über das Wort Parameter stolpert, wird vermutlich sowieso die zweite Variante vorziehen.

Wenn das Programm (Windows-typisch) ohne Angabe eines Parameters aufgerufen wird, fragt es in einer penetranten Endlosschleife nach dem Namen der Textdatei, die gelesen werden soll. Diese Endlosschleife wird nur beendet, wenn eine existierende Textdatei angegeben wird.

Es sei denn … der Benutzer betätigt die Enter-Taste und sendet eine leere Eingabe. Das ist die Hintertür, die wir uns selber lassen, um weiterhin ohne lästigen Aufwand mit „unserer“ Textdatei herumexperimentieren zu können. Die wird nämlich in diesem Fall ganz wie gewohnt geöffnet.

Ähnlich einfach versuchen wir die Steuerung des Ausgabe in eine Wave-Datei zu gestalten. Auch hier gibt es wieder zwei, mit Hintertür: zweieinhalb, Optionen:

  1. Die Übergabe des Dateinamens als zweiten Parameter.
  2. Das Betätigen der Taste j, wenn das Programm danach fragt, ob eine Dateiausgabe erwünscht wird. In diesem Fall wird der Name der Datei von dem der Textdatei abgeleitet: Aus Lanzelet.txt wird Lanzelet.wav im selben Verzeichnis. Und schließlich:
  3. Die Hintertür. Wird an dieser Stelle irgendeine andere Taste als j betätigt, erfolgt die Ausgabe wie bisher über den Lautsprecher des Computers.

Ein Link zur Ansicht d er kompletten Datei Program.cs, an der wir in diesem Tutorial kaum noch Änderungen vornehmen werden, findet sich, wie auch einer zu Hedda.cs im Anschluss an dieses Tutorial. Wer Program.cs jetzt bereits aufmerksam liest, wird allerdings bemerken, dass Main noch ein paar weitere neue Zeilen mit Befehlen an Hedda enthält, auf die bisher nicht eingegangen wurde. Leicht geändert hat sich auch die Art und Weise, wie wir mit unerkannten Zeichen umgehen.

In unserem Textausschnitt gibt es keine blinden Stellen mehr, aber wenn wir weitere Texte lesen wollen, kann uns diese Funktionalität weiter nützlich sein. Hedda kann es noch nicht wissen, aber wir werden sie gleich mit einer weiteren neuen und für zukünftige Vorhaben nützlichen Fähigkeit versehen, die wir jetzt bereits abrufen: Sie kann uns auf das Kommando SchreibeLexion eine Liste ausgeben, die die mittelhochdeutschen Wörter und die von uns zugewiesenen UPS-Kodierungen enthält, so wie sie sich in Heddas Lexikon befinden. Wir müssen SchreibeLexikon nur noch implementieren. Als Neuheit enthält Main inzwischen auch die beiden korrespondieren Befehle einen Satz zu starten und zu beenden, die wir an den PromptBuilder senden:

pb.StartSentence();
pb.StartSentence();

Auch diese beziehen sich auf eine Funktionalität von Hedda, die sie erst noch erlernen muss.

Doch beginnen wir mit dem Lexikon, das sie uns anzeigen soll. Solange sie diese Fähigkeit nicht beherrscht (die Methode nicht in der Datei Hedda.cs zu finden ist), lässt sich unser Programm nämlich nicht kompilieren und starten.

Es ist eine ganz simple Methode, die uns die Einträge des Lexikons alphabetisch im Konsolenfenster ausgibt:

        public void SchreibeLexikon()
        {
            Console.WriteLine("*** Lexikon ***");
            foreach (KeyValuePair<string, string> paar in Lexikon.OrderBy(paar => paar.Key)) Console.WriteLine(paar.Key + "=> [" + paar.Value + "]");
        }

Brauchen wir diese Methode denn? Bisher verstauben in unserem Lexikon ein paar Einträge, die wir vor langer Zeit (Teil 3 des Tutorials) dort abgelegt haben. Damals haben wir das Lexikon genutzt, um die Lautschrift eines Wortes festzulegen, das sich nicht auf neuhochdeutsche Art aussprechen ließ. Doch inzwischen erzeugen wir diese Lautschrift automatisiert für fast alle Wörter im Text – ausgenommen die verstaubten Relikte unserer frühen Experimente. Wozu also weiterhin das Lexikon? Ist das nicht gerade eines dieser verwaisten und überflüssig gewordenen Gerüste früherer Bauarbeiten?

Die entscheidende Antwort ist, dass es immer schneller ist, einen Eintrag nachzuschlagen, als die Lautschrift, inklusive der Betonung der Nebensilben etc. programmgesteuert zu ermitteln. Bei 50 Verszeilen fällt es vielleicht nicht so sehr ins Gewicht, wie viel Zeit Hedda für jedes Wort aufwendet, das sie liest, aber wir wollen uns ja viieele Möglichkeiten offen lassen …

Es gibt sie aber auch durchaus, jene Wörter, deren Lautung sich nicht mit einer Programmroutine ermitteln lässt. Genewîs in unserem Textausschnitt gehört dazu. Dieses Ortsname gehört einer fremden Sprache an, deren Lautmuster wenig mit dem Mittelhochdeutschen zu tun hat. Ehrlich gesagt: Für das konkrete Beispiel Genewîs könnten wir das Betonungsmuster sehr zutreffend aus dem Versmaß erschließen. Doch ist der sonst so gleichmäßige Wechsel der Silbenbetonung ein ausgesprochen unzuverlässiger Indikator, sobald es um die  Bestimmung von Fremdsprachenmaterial geht, das der Dichter auf Biegen und Brechen in seinem Text unterbringen muss. Auf die zu einer Erzählung gehörigen Personen- und Ortsnamen lässt sich nun mal schwer verzichten!

Wir wollen das Argument des reduzierten Arbeitsaufwands besonders ernst nehmen. Wörter, deren Lautung Hedda bereits einmal ermittelt hat, braucht sie nicht bei einem zweiten Auftreten im Text noch einmal bearbeiten, wenn sie sie stets brav im Lexikon ablegt. Wir fügen deshalb unmittelbar nach der Ermittlung einer Lautung einen entsprechenden Auftrag an Hedda in LeseZeile ein:

 string Lautung = FindeAussprache(Wort);
                    if (Lautung != "")
                    {
                        Lexikon[Wort] = Lautung;

SchreibeLexikon listet uns nun alle Wörter des Textes auf. So können wir unsere Höreindrücke beziehungsweise Heddas Lesekünste überprüfen: Hat sie wirklich gelesen, was wir zu hören geglaubt haben? Welche Lautzeichen hat sie den Buchstaben zugewiesen?

Wir berauben uns durch diese Maßnahme jetzt allerdings einer Fähigkeit, die wir Hedda gerade erst beigebracht haben: Beim zweiten Lesen eines Kurzwortes findet sie dieses im Lexikon und übernimmt die dort angesetzte Lautung. Das Wort wird also durchgehend ohne Akzentuierung vorgelesen werden, denn die Aufnahme ins Lexikon findet statt, bevor überprüft wird ob es im Kontext des Verse vielleicht mit einer stärkeren Betonung zu lesen ist. Wollen wir, dass Hedda die Position im alternierenden Vers auch dann berücksichtigt, wenn sie ins Lexikon blickt, müssen wir diese Fähigkeit auch beim Nachschlagen im Lexikon zur Verfügung stellen:So

if (Lexikon.Keys.Contains(Wort))
                {
                    string Sonderbetonung = "";
                    if (Kurzwörter.Contains(Wort))
                   { 
                        if ((SilbenImVers-Silbe) % 2 == 1 && Klingend(Zeile)) Sonderbetonung = "S1 " ;
                        if ((SilbenImVers-Silbe) % 2 == 0 && !Klingend(Zeile)) Sonderbetonung = "S1 "; 
                       
                    }
                    //Sonderfall, der im Lexikon gefunden wurde:
                    pb.AppendSsmlMarkup(@"<phoneme alphabet=""ups"" ph=""" +Sonderbetonung + Lexikon[Wort] + @""">"// Die Lautschriftvariante
                                        + Wort + "</Phoneme>");//  gefolgt von der Graphie  
                }

Starten wir einen Testlauf und sehen uns das von ihr ausgegebene Lexikon an,  werden wir unter Umständen bemerken, dass unsere Nebensilbenerkennung in einem bestimmten Fall nicht funktioniert: Sofern wir die Option umgesetzt haben, die Vorkommen eines r nach einem Vokal auf typisch-hochdeutsche Art auszugeben, werden die Nachsilben -er,-ern usw. nicht erkannt da sie ja das UPS-Zeichen EH an der erwarteten Stelle nicht enthalten. Diesen kleinen Fehler zu hören, ist gar nicht so einfach, allenfalls die fehlende Silbentrennung ist bei sehr aufmerksamen Hinhören zu bemerken. Wir erinnern uns noch mit Schrecken an den Tatzelwurm und werden nicht versuchen, auch diese Lautungsvariante in seinen verknoteten Leib einzuarbeiten. Doch so sehr lassen wir von uns dann auch wieder nicht einschüchtern, dass wir kampflos die Waffen streichen! Jetzt, da wir von dem kleinen Fehler wissen, werden wir in für-der nicht mehr ü-berhören können.

Vom bösen Tatzelwurm erstellen wir eine Kopie, die wir etwas umarbeiten (das ist nicht ganz so kompliziert) und lassen dann Hedda in zwei Anläufen nach einem potentiellen Suffix suchen. Unser Lexikon sorgt dafür, dass dieser zweifache Aufwand nur einmal für jedes im Text erscheinende und sich möglicherweise wiederholende Wort notwendig sein wird. Den Klon fügen wir unmittelbar nach dem Einsatz des ersten Tatzelwurms in die FindeAussprache ein:

 // Wir kümmern uns jetzt auch  um Wörter auf -er, bei denen das Ende EHX ist:
               
                reEnde = new Regex(@"(?<Anfang>.+[^\.] )(?<Ei>E lng \+ IH )?(?<Links>(?(Ei)|(P \+ F |T \+ S |SH |[BCDFGHKLMNRSTVZ] )))EHX (?<Rechts>[NTS] )?$");
                Suffix = reEnde.Match(Aussprache);
                if (Suffix.Success)
                {
 
                    // Wenn unsere Suche Erfolg hatte, setzen wir das Ergebnis zusammen:

                      Aussprache = Suffix.Groups["Anfang"] +""+ Suffix.Groups["Ei"]+ ". " + Suffix.Groups["Links"] + "EHX " + Suffix.Groups["Rechts"] + Suffix.Groups["Satzzeichen"];
             
                }

Unsere jetzt sehr viel intensivere Nutzung des Lexikons stößt bisher an eine wahrnehmbare Grenze: Folgt auf ein Wort ein Satzzeichen, wird das Wort nicht im Lexikon gefunden. Das liegt an unserer Methode die Wortgrenzen zu ermitteln: Ein Wort ist in unserer Definition weiterhin der Text zwischen zwei Leerzeichen. Vollkommen logisch, wenn auch sprachlich nicht korrekt, ist es damit, dass Satzzeichen zum Wort gehört, dem sie folgen. In unserem Textausschnitt ist von dieser Regelung beinahe jedes fünfte Wort betroffen. Denn jeder Vers umfasst ungefähr fünf Worte umfasst und beinahe jede Verszeile endet mit einem Satzzeichen (Wer es genau wissen will: Pro Vers sind es durchschnittlich 5,35, pro Satz 7,08 Wörter). Das sind zu viele Wörter, deren Lautung wir wieder und wieder neu ermitteln werden und zu allem Überfluss auch jedes Mal neu im Lexikon speichern!

Die Satzzeichen wollen wir selbstverständlich behalten, die Heddas Lesung abwechslungsreicher machen, aber eben trotzdem das Lexikon für jedes Wort des Verses nutzen können. Und wir möchten unseren Programmcode nicht zu sehr abändern. Deshalb werden wir in zwei Schritten vorgehen: Vor dem Blick ins Lexikon, der uns darüber belehrt, ob wir das Wort darin finden, oder es neu interpretieren müssen, entfernen wir die Satzzeichen. Nachdem wir auf eine der beiden möglichen Arten die Lautung des Wortes ermittelt und gegebenenfalls einen neuen Lexikoneintrag erzeugt haben, setzen wir die Satzzeichen hinter dem Wort ein. Den Programmcode zwischen diesen Schritten werden wir wir nicht ändern. Mit Wort bezeichnen wir jetzt jedoch nur noch den Teil der Zeichenketten zwischen zwei Leerzeichen, der einem Satzzeichen vorausgeht. Für Zeichenketten, die wir durch das Aufspalten der Zeile bei jedem Leerzeichen erzeugen, brauchen wir dann eine neue Bezeichnung. Deshalb ändern wir den Beginn von FindeZeile folgendermaßen ab:


            foreach (string w in Zeile.ToLower().Split(' '))
            {
                
                string Satzzeichen = ".,?!;:".Contains(w.Last()) ? w.Last().ToString():null;
                string Wort = Satzzeichen==null ? w:w.Substring(0, w.Length - 1);

Der Text zwischen zwei Leerzeichen (inklusive eines Satzzeichens) wird von nun an mit w bezeichnet. Diese Bezeichnung ist für genau 2 Zeilen Programmcode von Bedeutung und muss deshalb weder lang noch einprägsam sein. Wort bezeichnet die Buchstaben in w bis zu einem Satzzeichen. Wenn gar kein Satzzeichen am Ende von w steht, übernimmt Wort den gesamten Inhalt von w. In beiden Fällen können wir von nun an Wort genau wie bisher gebrauchen. In Satzzeichen speichern wir das Satzzeichen für den Augenblick, an dem es wieder „gebraucht“ wird (in die Lesung eingeht):

if (Satzzeichen != null)
                {
                    pb.AppendSsmlMarkup(@"<phoneme alphabet=""ups"" ph=""" + LexionDerBuchstabenErsetzung[Satzzeichen] + @""">"// Die Lautschriftvariante
                                             + Satzzeichen + "</phoneme>");//  gefolgt von der Graphie
                    if (".?!".Contains(Satzzeichen))
                    { 
                       /* pb.EndSentence();
                        pb.AppendBreak(PromptBreak.ExtraSmall);
                        pb.StartSentence();*/
                    }
                   
                }

Wir setzen nach der Ermittlung der Lautung für das Wort, wie bisher. ein Satzzeichen ein, das Einfluss auf die Tonhöhe des Wortes hat, das Hedda spricht. Wir geben zudem aber dem PromptBuilder wenn es sich um ein Satzendezeichen handelt, jetzt auch zu verstehen, dass damit der begonnene Satz endet und zugleich ein neuer beginnt. Dazwischen fügen wir, eine weitere, sehr kleine, Pause ein. Diese wird häufig, aber nicht immer auf eine bereits durch das Versende gegebene Pause folgen und diese geringfügig länger erscheinen lassen.

Ein Vers, der mit einem Komma oder gar keinem Satzzeichen abschließt, geht von nun an, relativ gesehen, etwas übergangsloser in den nächsten Vers über. Anders formuliert: Nach Satzenden und vor einem Neuansatz gibt es eine etwas deutlichere Pause.

Zudem können wir Heddas Voreinstellungen für das Konzept Satz nutzen. Sie weiß jetzt immer, wo die Grenzen eines Satzes anzusetzen sind, welche Elemente zu ihm gehören, mit welchem Wort er terminiert und mit welchem er beginnt. Damit kann sie die für das Neuhochdeutsche vorgegeben Satzkurven nutzen und ihre Lesung noch ein wenig ausdrucksvoller gestalten. Jetzt erklärt sich hoffentlich auch die Verwendung von StartSentence/EndSentence in der Methode Main von Program.cs: So lösen wird das Ei/Henne-Problem, das wir gerade geschaffen haben.: Heda beginnt nach jedem Satzende einen Satz, der irgendwann geschlossen werden muss, sie schließt zuvor einen anderen Satz, der irgendwann begonnenen wurde. Es muss einen ersten Satz und einen letzten geben! (Keine gewollte philosophischen Implikationen!)

Auch für uns gibt es einen letzten Satz. Aber bevor wir zu dem kommen, wollen wir uns eine weitere Aufräumarbeit ansehen, die wir unternehmen können:

Wir haben an einigen Stellen reguläre Ausdrücke eingesetzt. An den Tatzelwurm zur Ermittlung der Suffixe erinnern wir uns noch. Viel leichter war es, den entsprechenden regulären Ausdruck zur Ermittlung der Präfixe zu erstellen. Das haben wir (größtenteils) einer Programmroutine überlassen. Die wird dann allerdings jedes mal zum Einsatz kommen, wenn wir ein Wort auf ein potentielles Präfix prüfen, also bei jedem mehrsilbigen Wort. Wir könnten sie jedoch nur einmal ausführen, wenn wir den regulären Ausdruck dabei einmal erzeugen und dann global in der Klasse Hedda.cs speichern. Bei weiteren Verwendungen steht er uns dann zur Verfügung. Der geeignete Ort, an dem wir das Suchmuster aufbauen, ist natürlich der Konstruktor, die Methode Hedda.Hedda.

Wenn wir schon dabei sind, können wir auch alle anderen von uns benutzten regulären Ausdrücke global für die beliebig häufige Wiederverwendung abspeichern. Keiner dieses Ausdrücke wird sich während eines Programmaufrufes ändern, kein einziges Mal, wenn wir ihn benutzen, müssen wir aktuelle Änderungen vornehmen. Der Effekt dieser Maßnahme auf die Effizienz unseres Programmes ist allerdings relativ gering. Es bleibt Ansichtssache, ob die Lesbarkeit unseres Programms davon beeinträchtigt wird, oder gar profitiert. Einerseits wird der Text in den Programmroutinen kürzer und es wird so leichter, deren Ablauf nachzuvollziehen. Es fehlt aber die Möglichkeit, dabei den regulären Ausdruck im Auge zu haben, um mental nachzuvollziehen, wie der Tatzelwurm entknotet wird. Auf der anderen Seite ist es nun möglich, alle regulären Ausdrücke an einer Stelle im Programmcode zu sammeln, was es erleichtert, sie gezielt zu bearbeiten.

Kurz: Es ist eine Frage der persönlichen Vorliebe. Aus diesem Grund wird es am Ende dieses Teils des Tutorials zwei Fassungen von Hedda.cs geben, um einen Vergleich zu erlauben und eine Auswahl zu bieten. Am Ende dieses Teils des Tutorials? Das geht kürzer: Wir sind am Ende. Des Tutorials.

Unser erstes mittelhochdeutsche Wörterbuch befindet sich auf unserer Festplatte. Wir haben erreicht, was wir erreichen wollten und jetzt unser kleines Programm auch für zukünftige Aufgaben gerüstet. (Letzter Satz) Dieses Tutorial ist damit beendet.

Wer trotzdem noch etwas weiterlesen will, wird sich vor allem den Programmcode ansehen wollen:

Der komplette Inhalt von Program.cs
Der komplette Inhalt von Hedda.cs

Der komplette Inhalt von Hedda.cs, die regulären Ausdrücke sind an einer Stelle gesammelt.
Außerdem gibt es noch eine Nachbetrachtung.

In dieser wollen wir versuchen, noch einmal die Schritte nachzuvollziehen, die wir gegangen sind, um zu einer Beurteilung über den Sinn und Zweck unserer Unternehmung zu gelangen und in Ansätzen darüber nachzudenken, wie man Lehren aus unserem Experiment ziehen oder es auf die große., komplizierte Wirklichkeit übertragen kann. Für uns hat sich der Weg (hoffentlich!) gelohnt und wir haben Spaß an der Bastelei gehabt. Wer es anders sieht, hat bemerkenswert lange durchgehalten und hat sich damit meine Bewunderung und mein Beileid verdient. Ich entschuldige mich!

Veröffentlicht von

Doktor Tom

Klar, Studium der Computerlinguistik und Germanistik mit Spezialisierung auf das Mittelhochdeutsche. So gesehen Digital Humanist. Vor allem aber Tüftler, der Spaß an ungewöhnlichen Aufgabenstellungen und einfachen, aber effizienten Lösungen hat.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

I accept that my given data and my IP address is sent to a server in the USA only for the purpose of spam prevention through the Akismet program.More information on Akismet and GDPR.