ブラウザのテキスト読み上げに合わせてバーが伸び縮みするアニメーション

# ブラウザのテキスト読み上げに合わせてバーが伸び縮みするアニメーション

TEXTAREA に入力したテキストを読み上げます。
ページ下部のソースをローカルに保存してご利用ください。

Windows 10 上の Google Chrome、Microsoft Edge、Firefox で動作を確認しています。
Microsoft Edge は、音声で日本語を選択しないと読み上げてくれませんでした。

パソコンのスピーカーから出た音声をマイクで拾って可視化しているので、ヘッドホンなどで音声出力がふさがっていると、バーの表示は何も起きません。
ブラウザが読み上げた台詞だけでなく、ユーザが読み上げた台詞にも反応しますが、気にしてはいけません。

「それは失敗じゃない。”大失敗”だ。」

[Canvasで赤いLEDが残光を曳くアニメーション](https://qiita.com/stonee/items/dd4ea4ca2cafe57037a7) が必要な方は過去の投稿をどうぞ。

# 使い方

TEXTAREA にお好みの名台詞／迷台詞を入力して「話す」ボタンを押すと、マイクの使用許可を求めるダイアログが出ます。
マイクの使用を許可すると、数秒後に読み上げが始まります。

実行するとこんな感じです。

![red_bar.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/513949/db41c9e6-42ef-6664-3041-5ae841a962c2.png)
![yellow_bar.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/513949/1af6f098-d3f8-19d2-b2e9-0347c4e8ee20.png)


# ソース

```html:speak.html
<!DOCTYPE html>
<html lang='ja'>
<head>
  <meta http-equiv='Content-Type' content='text/html; charset=utf-8' />
  <meta http-equiv="Pragma" content="no-cache">
  <meta http-equiv="Cache-Control" content="no-cache">
  <title>What would you like to hear?</title>
<script>
let $ = e => document.getElementById(e);

const LEDColor = {
	red: 0,
	yellow: 1
};
Object.freeze(LEDColor);

class Lamp {
	constructor() {
		this.width = 20;
		this.height = 11;
		this.x = 0;
		this.y = 0;
		this.brightness = 100;		//0 <= brightness <= 100
		this.colortype = LEDColor.red;
	}
	color() {
		const alpha = ((this.brightness == 0) ? 0 : 1);
		return (this.colortype == LEDColor.red) ?
			{r:Math.round(255 * this.brightness / 100), g:0, b:0, a:alpha} : 
			{r:Math.round(255 * this.brightness / 95), g:Math.round(255 * this.brightness / 90), b:0, a:alpha};
	}
	drawon(context) {
		const mycolor = this.color();
		context.fillStyle = "rgba(" + mycolor.r + "," + mycolor.g + "," + mycolor.b + "," + mycolor.a + ")";
		context.fillRect(this.x, this.y, this.width, this.height);
	}
}

const LEDArray = {
	vLamp: [[], [], []],
	col: 3,
	row: 16,
	brightness_min: 10,
	colortype: LEDColor.red,

	init() {
		let r = this.row;
		this.vLamp = new Array(3);
		this.vLamp[0] = new Array(r).fill(0);
		this.vLamp[1] = new Array(r).fill(0);
		this.vLamp[2] = new Array(r).fill(0);

		const gap_x = 3;
		const gap_y = 1;
		let x0 = 40, y0 = 28, x = x0, y = y0;
		for (let i = 0; i < this.col; i++) {
			y = y0;
			for (let j = 0; j < r; j++) {
				this.vLamp[i][j] = new Lamp();
				Object.assign(this.vLamp[i][j], {x:x, y:y, brightness:10});
				y += (this.vLamp[i][j].height + gap_y);
			}
			x += (this.vLamp[i][0].width + gap_x);
		}
	},
	setBrightness_off() {
		for (let i = 0; i < this.col; i++) {
			for (let j = 0; j < this.row; j++) {
				Object.assign(this.vLamp[i][j], 
					{brightness:this.brightness_min, colortype:this.colortype});
			}
		}
	},
	setBrightness(brightness) {
		//center
		for (let j = 0; j < this.row; j++) {
			Object.assign(this.vLamp[1][j], 
				{brightness:brightness * Math.cos((j - 7.5) / 23.5 * Math.PI), colortype:this.colortype});
		}

		//side
		if (this.colortype == LEDColor.red) {
			for (let i = 0; i < this.col; i += 2) {
				for (let j = 0; j < this.row; j++) {
					Object.assign(this.vLamp[i][j], 
						{brightness:brightness * Math.cos((j - 7.5) / 13.5 * Math.PI), colortype:this.colortype});
				}
			}
		} else {
			for (let i = 0; i < this.col; i += 2) {
				for (let j = 0; j < this.row; j++) {
					Object.assign(this.vLamp[i][j], 
						{brightness:brightness * (1 - Math.sin((j + 0.5) / 15.5 * Math.PI)), colortype:this.colortype});
				}
			}
		}
	}
}

const Animation = {
	context: 0,
	init() {
		LEDArray.init();

		const canvas = $("canvas");
		if (!canvas.getContext) { return; }
		this.context = canvas.getContext("2d");
		this.redraw_off();
	},
	redraw_off() {
		LEDArray.setBrightness_off();
		for (let i = 0; i < LEDArray.col; i++) {
			for (let j = 0; j < LEDArray.row; j++) {
				LEDArray.vLamp[i][j].drawon(this.context);
			}
		}
	},
	redraw(brightness) {
		LEDArray.setBrightness(brightness);
		for (let i = 0; i < LEDArray.col; i++) {
			for (let j = 0; j < LEDArray.row; j++) {
				LEDArray.vLamp[i][j].drawon(this.context);
			}
		}
	}
}

const allVoicesObtained = new Promise(function(resolve, reject) {
	let voices = window.speechSynthesis.getVoices();
	if (voices.length !== 0) {
		resolve(voices);
	} else {
		window.speechSynthesis.addEventListener("voiceschanged", function() {
			voices = window.speechSynthesis.getVoices();
			voices.sort(function(a, b) {
				return (a.lang > b.lang) ? 1 : -1;
			});
			resolve(voices);
		});
	}
});

function InitVoice() {
	let ssu = new SpeechSynthesisUtterance();
	ssu.text = " ";
	window.speechSynthesis.speak(ssu);

	allVoicesObtained.then(voices => {
		const select = $("selVoice");

		let nSelected = -1;
		for (let i = 0; i < voices.length; i++) {
			const option = document.createElement("option");
			option.text = voices[i].lang + " " + voices[i].name;
			option.value = i;
			select.appendChild(option);

			if ((nSelected == -1) && (voices[i].lang.substr(0, 2) == "en")) {
				nSelected = i;
			}
		}

		if (nSelected == -1) { nSelected = 0; }
		select.options[nSelected].selected = true;
	});
}

function doSpeak() {
	const select = $("selVoice");
	const option = select.options[select.selectedIndex];

	let ssu = new SpeechSynthesisUtterance();
	ssu.lang = option.text.substr(0, 5);
	ssu.volume = 1.0;
	ssu.rate = 1.0;
	ssu.pitch = 1.0;

	ssu.text = " ";
	window.speechSynthesis.speak(ssu);

	allVoicesObtained.then(voices => {
		for (let i = 0; i < voices.length; i++) {
			if (voices[i].name == option.text.substring(6)) {
				ssu.voice = voices[i];
				break;
			}
		}
	});

	ssu.text = $("script").value;
	ssu.onend = function(ev) {
		//$("btnStop").onclick();
	}
	window.speechSynthesis.speak(ssu);
}

function doShutUp() {
	window.speechSynthesis.cancel();
}

function split_text(text) {
	const eol = text.match(/\r\n|\n|\r/);
	let vLines = text.split(eol).map(s => s.trim());
	return vLines.filter(s => s != "");
}

const MediaStream = {
	stream: {},

	start() {
		//window.speechSynthesis.onvoiceschanged = function() {
		//	window.speechSynthesis.getVoices();
		//};

		let objAudioContext = new (window.AudioContext || window.webkitAudioContext)();

		navigator.mediaDevices.getUserMedia({ audio: true, video: false }).then((stream) => {
			doSpeak();

			this.stream = stream;

			let src = objAudioContext.createMediaStreamSource(this.stream);

			let analyser = objAudioContext.createAnalyser();
			analyser.fftSize = 2048;

			let processor = objAudioContext.createScriptProcessor(0, 1, 1);
			processor.onaudioprocess = (ev) => {
				let dataArray = new Uint8Array(analyser.fftSize);
				analyser.getByteTimeDomainData(dataArray);

				let ymax = 0;
				for (let i = 0; i < dataArray.length; i++) {
					let y = dataArray[i] / 128.0 * 100 - 100;
					ymax = Math.max(ymax, y);
				}
				Animation.redraw(ymax);
			}
			src.connect(processor);
			processor.connect(objAudioContext.destination);

			src.connect(analyser);
		}).catch((err) => {
			console.log(err);
		})
	},

	stop() {
		doShutUp();

		this.stream.getTracks().forEach((track) => {
			track.stop();
		})
	}
}

window.onload = function() {
	if (!navigator.getUserMedia) {
		navigator.getUserMedia = navigator.webkitGetUserMedia || navigator.mozGetUserMedia;
	}

	$("btnStart").onclick = () => {
		$("btnStart").disabled = true;
		$("btnStop").disabled = false;
		MediaStream.start();
	}
	$("btnStop").onclick = () => {
		if (!$("btnStart").disabled) { return; }
		$("btnStart").disabled = false;
		$("btnStop").disabled = true;
		MediaStream.stop();
	}

	document.getElementsByName("rdoColor").forEach(
		e => e.addEventListener("change",
			ev => {
				LEDArray.colortype = (ev.target.value == 1) ? LEDColor.yellow : LEDColor.red;
				Animation.redraw_off();
			}
		)
	);

	InitVoice();

	Animation.init();
}
</script>
</head>
<body>

<h2>テキスト読み上げに合わせてバーが伸び縮み</h2>

<canvas id="canvas" width="150" height="250" style="background-color:#1e1e1e;"></canvas>
<br />

<div>
色：
<input type="radio" name="rdoColor" value="0" checked />赤
<input type="radio" name="rdoColor" value="1" />黄
<br />
音声：
<select id="selVoice" style="width:350;">
</select>
<button id="btnStart">話す</button>
<button id="btnStop" disabled>中止</button>
</div>

<textarea id="script" rows="20" col="100" style="width:700px;height:150px;">
The quick brown fox jumps over the lazy dog.
</textarea>

</body>
</html>
```