Bereits seit fünf Jahren ist in JavaScript die WebSpeech API integriert, aber erst seit noch nicht allzu langer Zeit können Browser flächendeckend Text in computergenerierte Sprache umsetzen und sogar gesprochenes Wort erkennen und in Text umwandeln. Gleichfalls können Browser mittlerweile live Sounds generieren und regelrechte Web-Synthesizer programmiert werden. Derzeit nutzen drei von vier Besuchern einen geeigneten Browser, der den Standard vollumfänglich integriert hat. Damit steht der routinemäßigen Nutzung etwa für Chatbots, webbasierten Screenreadern und etlichen kreativen Anwendungen nichts mehr im Wege.

Für eine Kundin entwickelte ich kürzlich eine Portfolioseite, die einige außergewöhnliche Gimmicks beinhalten sollte. Kreative Ideen waren gefragt und da sie sich ohnehin eine Uhr wünschte, wurde daraus mithilfe der WebSpeech und WebAudio APIs schnell eine Zeitansage.

Das Grundgerüst in HTML5 und CSS

Als Grundgerüst soll für dieses Tutorial eine simple Seite bestehend aus einem Canvas für eine analoge Uhr und zwei Fehlermeldungen, die bei Bedarf eingeblendet werden, dienen. Ein kleines Script im <head>-Bereich überprüft, ob JavaScript aktiviert ist und ersetzt entsprechend die Klasse no-js im <html>-Element durch die Klasse js, um im CSS entsprechend reagieren zu können.

<!doctype html>
<html lang="de-DE" class="no-js">
<head>
	<meta charset="UTF-8">
	<meta http-equiv="x-ua-compatible" content="ie=edge">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<title>Zeitansage</title>
	<link rel="stylesheet" href="styles.css" />
	<script>
		var html = document.getElementsByClassName('no-js');
		for (var i = 0; i < html.length; i++) { html[i].className = 'js'; }
	</script>
	<script type="text/javascript" src="main.js"></script>
</head>
<body>
<div class="wrapper">
	<canvas class="clock" width="600" height="600"></canvas>
	<div class="jsdisabled">Du hast JavaScript deaktiviert. Schade.</div>
	<div class="speechnotice">Dein Browser unterstützt die Speech API nicht. Daher gibt's hier nur eine langweilige Uhr.</div>
</div>
</body>
</html>

Das überschaubare Styling in styles.css erledigt den Rest:

html, body, div, canvas {
	margin: 0;
	padding: 0;
	border: 0;
	font-size: 100%;
	font: inherit;
	vertical-align: baseline;
}

html, body {
	width: 100vw;
	height: 100vh;
	font-size: 16px;
}

body {
	color: #FFFFFF;
	background: linear-gradient(135deg, #2DB1E5 0%,#6E7FCC 100%);
	font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
	line-height: 1.6;
}

.wrapper {
	width: 100%;
	height: 100%;
	display: flex;
	justify-content: center;
	align-items: center;
	flex-direction: column;
}

.clock {
	width: 30vmax;
	height: 30vmax;
}

.js .jsdisabled,
.speechnotice {
	display: none;
}

.no-js .jsdisabled,
.speechdisabled .speechnotice {
	display: block;
	margin-top: 4vh;
	max-width: 350px;
}

Die Grundiee

Der wesentliche Teil dieser kleinen Anwendung findet ausschließlich in main.js statt. Dazu bauen wir zunächst eine simple Analoguhr, die auf drei globale Variablen hours, minutes und seconds zurückgreift. Die Werte derselben Variablen werden von einer weiteren Funktion ausgelesen, welche zu bestimmten Zeitpunkten die entsprechende Ansage generiert und immer zur vollen Minute einen Signalton ausgibt. In dem Fall, dass der Browser des Users nicht die notwendigen technischen Voraussetzungen erfüllt, wird stattdessen die bereits erstellte Meldung .speechnotice eingeblendet. Für das Timing greifen wir auf die requestAnimationFrame()-API zurück, welche es erlaubt, auch auf sehr langsamen Rechnern verzögerungsfrei zu arbeiten und darüberhinaus von allein aussetzt, wenn das Fenster inaktiv wird.

Der bisherige Stand. Ziemlich langweilig.

Für den JavaScript-Code definieren wir daher zunächst eine sog. IIFE, die automatisch ausgeführt wird, sobald die Seite geladen ist, und alle weiteren Anweisungen enthält. Für das eigentliche Programm benötigen wir eine Handvoll globaler Variablen und Funktionen sowie das -Event des Fensters, um alles zum gegebenen Zeitpunkt in Gang zu setzen.

(function() {
	var canvas, ctx, time, synth, audioCtx, radius, isTalking, textBase,
		hours, minutes, seconds;

	var language = 'de-DE',
		pitch = 1,
		rate = 0.95,
		whenToStart = 10,
		voices = [];

	var beep = 'Beim nächsten Ton ist es ',
		esIst = 'Es ist ',
		uhr = ' Uhr ',
		und = 'und ',
		minuten = ' Minuten.',
		minute = ' Minute.';
				
	var tick = function() {
		radius = canvas.height / 2;

		ctx.translate(radius, radius);
		radius = radius * 0.90;
		ctx.strokeStyle = '#FFFFFF';
		ctx.lineCap = 'round';

		tock();
		tellMeTheTime();
	}

	var tock = function() {
		…
		requestAnimationFrame(tock);
	}

	var getCoord = function(angle, radius) {
	}

	var toRadians = function(angle) {
	}

	var drawHand = function(angle, length, width) {
	}

	var tellMeTheTime = function() {
	}

	var talkToMe = function() {
		…
		requestAnimationFrame(talkToMe);
	}

	var noSpeech = function() {
		document.querySelector('body').classList.add('speechdisabled');
	}

	window.addEventListener('load', function() {
		canvas = document.querySelector('.clock');
		ctx = canvas.getContext("2d");
		audioCtx = new (window.AudioContext || window.webkitAudioContext)();

		tick();
	});
})();

Die globalen Variablen in den ersten beiden Zeilen werden nach und nach mit Werten befüllt, interessant wird es aber in den Zeilen darunter. Im ersten Block werden einige Variablen definiert, die später als Einstellungen für die computergenerierte Stimme dienen und u.U. feinjustiert werden müssen. Dazu zählen die Sprache des zu sprechenden Texts, die Stimmhöhe pitch, die Sprechgeschwindigkeit rate und der Zeitpunkt whenToStart, an dem die Stimme ertönen soll (später interpretiert als Abweichung von jedem Minutendrittel, also in dieser Einstellung immer bei Sekunde 10, Sekunde 30 und Sekunde 50). Im folgenden Block werden die einzelnen Wortschnipsel definiert, aus denen später der vollständige Satz konstruiert wird.

Im Window-Load-Event werden zunächst der Canvas und sein Context definiert, sobald diese verfügbar sind, sowie der AudioContext für die WebAudio API mit Polyfill für ältere Webkit-Versionen bestimmt. Anschließend wird mit der Funktion tick() die Uhr initialisiert. Dazu wird einerseits der Radius der Uhr als eine halbe Canvas-Breite definiert, der Koordinatenursprung des Canvas von der oberen linke Ecke in die Mitte verlegt und anschließend die eigentlichen Funktionen tock() und tellMeTheTime() ausgeführt, welche die Animation und die Zeitansage in Gang setzen.

Die Uhr

Für eine einfache Analoguhr benötigen wir einerseits ein Ziffernblatt, andererseits die drei Zeiger. Alle Elemente müssen für jeden Animationsschritt neu gezeichnet werden, sodass tock() zunächst den Canvas löscht und dann in einer for-Schleife die zwölf Striche für die einzelnen Uhrzeiten einzeichnet. Dazu werden die jeweiligen Winkel in 30°-Schritten erzeugt und in einer Funktion getCoord() (dazu später mehr) die entsprechenden Koordinaten für den Canvas berechnet. Für drei, sechs, neun und zwölf Uhr sollen die Striche etwas länger gezeichnet werden, sodass hier hier der Startpunkt der Linie etwas näher am Mittelpunkt liegt (0.8facher Radius statt 0.85facher Radius).

var tock = function() {
	ctx.clearRect(- radius, - radius, 2 * radius, 2 * radius);

	for (var angle = 0; angle <= 360; angle += 30) {
		var start = getCoord(angle, angle % 90 === 0 ? 0.8 * radius : 0.85 * radius),
			 end   = getCoord(angle, 0.9 * radius);

		ctx.beginPath();
		ctx.moveTo(start.x, start.y);
		ctx.lineTo(end.x, end.y);
		ctx.stroke();
	}

getCoord() soll die Aufgabe übernehmen, aus einem Winkel und einer Längen (d.h. der Entfernung vom Mittelpunkt) ein Koordinaten-Paar bestehend aus einem x- und einem y-Wert zu berechnen. Das mag auf den ersten Blick etwas umständlich erscheinen, aber da eine Uhr nichts weiter als ein System aus Winkeln und Abständen ist, erlaubt dieser Wechsel von einem Polar- zu einem kartesischen Koordinatensystem es, alle Berechnungen anhand von Winkeln und Längen vorzunehmen und erst im Anschluss alles für den Canvas aufzubereiten. Die benötigten Formeln lassen sich leicht implementieren, wobei beachtet werden muss, dass trigonometrische Funktionen in JavaScript standardmäßig in Radians und nicht in Grad berechnet werden. Daher schreiben wir noch eine kleine Hilfsfunktion, die diese Umrechnung übernimmt.

var getCoord = function(angle, length) {
	angle = toRadians(angle);

	return {
		x: length * Math.cos(angle),
		y: length * Math.sin(angle)
	}
}

var toRadians = function(angle) {
	return angle * Math.PI / 180;
}

Auf dieser Grundlage können wir in tock() noch den Zeitgeber einfügen, der die aktuelle Zeit ausliest, in den globalen Variablen hours, minutes und seconds speichert und daraus die aktuelle Zeigerpositionen bestimmt. Auch diese Teilfunktion können wir als drawHand() bündeln, wobei der Winkel, die Länge und die Breite eines Zeigers übergeben werden sollen. Abschließend wird mit requestAnimationFrame(tock) die Funktion neu gestartet.

var tock = function() {
	…

	time = new Date();
	hours = time.getHours();
	minutes = time.getMinutes();
	seconds = time.getSeconds();

	drawHand((hours % 12) * 30 + minutes / 2, 0.4 * radius, 8);
	drawHand(minutes * 6, 0.6 * radius, 8);
	drawHand(seconds * 6, 0.8 * radius, 3);

	requestAnimationFrame(tock);
}

var drawHand = function(angle, length, width) {
	var end = getCoord(angle - 90, length);
		
	ctx.beginPath();
	ctx.lineWidth = width;
	ctx.moveTo(0, 0);
	ctx.lineTo(end.x, end.y);
	ctx.stroke();
}
Jetzt auch animiert.
Jetzt auch animiert.

Damit ist der visuelle Teil abgeschlossen und eine animierte Uhr wie im nebenstehenden Bild sollte erscheinen.

Die Zeitansage

Die eigentliche Zeitansage liegt noch vor uns. Dafür werden wir die Funktion tellMeTheTime() befüllen. Zunächst müssen wir überprüfen, ob das window-Objekt die speechSynthesis-API unterstützt und im negativen Fall durch Aufruf der Funktion noSpeech() (siehe oben) die entsprechende Nachricht einblenden. Im positiven Falle können wir eine Liste aller verfügbaren Stimmen anfordern. Außerdem definieren wir eine Variable isTalking, die je nach aktuellem Status false oder true ist und nur im ersten Falle die Generierung einer weiteren Tonspur erlaubt. Außerdem wird ausgelesen, ob audioCtx erfolgreich initialisiert werden konnte, um zwischen den Textschnipseln „Beim nächsten Ton ist es“ und „Es ist“ auswählen zu können.

Das Grundgerüst von tellMeTheTime() sieht daher damit zunächst wie folgt aus:

var tellMeTheTime = function() {
	if ('speechSynthesis' in window) {
		isTalking = false,
		textBase = audioCtx ? beep : esIst;

		synth = window.speechSynthesis;
		if (synth.onvoiceschanged !== undefined) {
			synth.onvoiceschanged = function() {
				voices = synth.getVoices().filter(function(item) {
					return item.lang === language;
				});

				if (voices.length > 0)
					talkToMe();
				else
					noSpeech();
			};
		}
	} else {
		noSpeech();
	}
}

Hinter der Kulissen geht bereits einiges vor sich. Mit synth wird die WebSpeech-API geladen. Da die Liste der verfügbaren Stimmen nur asynchron aufgerufen werden, bemühen wir das onvoiceschanged-Event, ehe wir die Liste anfordern. Deren Umfang und Zusammensetzung variiert je nach Betriebssystem und Browser, enthält etwa in Mac OS Sierra und mit Chrome 69 Einträge, sowohl weibliche als auch männliche und einige eher ungewöhnliche Stimmen. In jedem Falle gibt synth.getVoices() ein Array von SpeechSynthesisVoice-Objekten zurück, welchen jeweils u.a. ein Name („Alex“, „Thomas“ usw.) und eine Sprache als locale („de-DE“, „en-EN“ usw.) zugeordnet ist.

Für unsere Zwecke filtern wir die Stimmenliste nach der Sprache, die wir zuvor in language definiert haben, und wählen die erste der übrig bleibenden aus. Falls die Liste leer ist, brechen wir ab und führen auch hier noSpeech() aus. Andernfalls wird die Funktion talkToMe() aufgerufen, welche den eigentlichen Sprechteil übernimmt. Dort regeln zwei if-Abfragen das weitere Vorgehen. In der ersten wird überprüft, ob nicht bereits ein Sprachausgabe läuft und ob der richtige Zeitpunkt für eine weitere Sprachausgabe erreicht ist. Je nachdem, ob die WebAudio-API verfügbar ist, ist dies entweder die Abweichung von jedem Minutendrittel wie in whenToStart definiert (sodass drei Ansagen je Minute abgespielt werden), oder wenn der Sekundenzeiger gerade auf Null steht. Zu jeder vollen Minute soll zudem ein Signalton abgespielt werden, was in der zweiten if-Abfrage erfolgt. Zuletzt wird wie oben über requestAnimationFrame() die Funktion erneut gestartet.

var talkToMe = function() {
	if (!isTalking && (
		( audioCtx && seconds % 20 === whenToStart) ||
		(!audioCtx && seconds === 0))) {

		…
	}

	if (seconds === 0 && !isTalking && audioCtx) {
		…
	}

	requestAnimationFrame(talkToMe);
}

Im ersten Block muss nun einerseits der zu sprechende Text generiert und die Einstellungen bzgl. Stimmhöhe und Sprechgeschwindigkeit angewendet werden. Letztlich stellt die WebSpeech-API ein SpeechSynthesisUtterance-Objekt bereit, das mit dem Text initialisiert, anschließend mit einigen Optionen verändert und zuletzt über die Methode speak() gestartet werden kann. Sobald der Text fertig gesprochen wurde, feuert es ein onend-Event ab, was wir nutzen können, um den Status von isTalking zurückzusetzen und den Weg für eine weitere Sprachausgabe zu öffnen.

if (!isTalking && (
			( audioCtx && seconds % 20 === whenToStart) ||
			(!audioCtx && seconds === 0))) {

	var nextMinute = (minutes < 59) ? minutes + 1 : 0, nextHour = (nextMinute === 0) ? hours + 1 : hours, text = textBase + nextHour + uhr; if (nextMinute > 0) text += und + nextMinute;
	if (nextMinute === 1) text += minute;
	if (nextMinute > 1) text += minuten;

	var talk = new SpeechSynthesisUtterance(text);

	isTalking = true;
	talk.pitch = pitch;
	talk.rate = rate;
	talk.voice = voices[0];

	talk.onend = function() {
		isTalking = false;
	}

	synth.speak(talk);
}

Der Signalton

Damit fehlt nur noch der Signalton, der in der anschließenden if-Schleife zum richtigen Zeitpunkt, nämlich immer zur vollen Minute generiert werden kann. Für dieses Beispiel soll ein einfacher Sinuston mit einer Frequent von 440Hz genügen. Dazu wird mit dem AudioContext-Objekt ein neuer Oscillator erzeugt, der im wesentlichen wie ein einfacher Synthesizer funktioniert und u.a. über die Eigenschaften type und frequency.value diese beiden Werte aufnehmen kann, womit später der gewünschte Ton generiert wird. Anschließend muss das Oscillator-Objekt noch mit connect() registriert werden und kann dann gestartet werden. Daraufhin wird solange der in Oscillator definierte Ton abgespielt, bis die stop()-Methode aufgerufen wird. Mit setTimeout() kann das etwa nach einer einer Sekunde, also 1000 Millisekunden erfolgen. Der letzte Teil des Codes setzt sich daher wie folgt zusammen:

if (seconds === 0 && !isTalking && audioCtx) {
	var oscillator = audioCtx.createOscillator();

	isTalking = true;
	oscillator.type = 'sine';
	oscillator.frequency.value = 440;
	oscillator.connect(audioCtx.destination);
	oscillator.start();
	setTimeout(function() {
		oscillator.stop();
		isTalking = false;
	}, 1000);
}

Zusammenfassung

Nach etwas mehr als 150 Zeilen Code ist die animierte Analog-Uhr mit integrierter Zeitansage fertig. Das Ergebnis kann hier aufgerufen werden und der vollständige Code ist unter dem am Ende dieses Artikels hinterlegten Github-Link einsehbar. Mit nur wenigen Schritten war es möglich, generierten Text vorlesen zu lassen und einen – sehr simplen – Synthesizer zu erstellen.

Die WebSpeech-API ist leicht zu handhaben und kann für einfache Text-zu-Sprache-Anwendungen ohne großen Aufwand genutzt werden. Einzig das Abspielen mehrerer Sprachausgaben zum gleichen Zeitpunkt bleibt ein Problem, was zwar für die meisten Anwendungen ohnehin nicht notwendig sein wird, aber wer einen Kanon singen lassen möchte, muss vorerst auf kommende Erweiterungen warten. Auf der anderen Seite ermöglicht die API bereits jetzt schon einfache Spracherkennung und kann daher etwa für eine sprachgesteuerte Navigation genutzt werden. Das schließt zwar noch nicht die automatische Erkennung des Sinns eines gesprochenen Satzes ein, aber in dieser Hinsicht wurde bereits für Chatbots ähnliche Software entwickelt und damit eine wichtige Grundlage für eine webbasierte Anwendung nach dem Vorbild von Siri geschaffen.

Ähnlich einfach lässt sich die WebAudio-API ansprechen, wenn auch die Handhabung für komplexere Anforderungen, etwa für musikalische ansprechende Tonerzeugung samt Waveform-Modulation, Rhythmus, Dynamik usw. noch eine Herausforderung darstellt. Auf diesem Gebiet wurden jedoch schon einige vielversprechende Open-Source-Libraries veröffentlicht, welche eine einfache Schnittstelle anbieten und somit den Großteil der Arbeit abnehmen.

Der Umgang mit diesen beiden APIs wird wohl auch in Zukunft noch leichter werden. Man darf also gespannt sein, welchen kreativen Anwendungen Webdesigner und -entwickler damit erschaffen werden!