この記事はM5Stack Advent Calendar 2021 17日目の記事です。
前日の記事:M5Stack CAT-M UNIT をSORACOMで使う
子供は音が鳴るおもちゃが大好きです。特にボタンを押したり、身振り手振りに合わせて音が変わると楽しくなりますね。 M5Stackにはスピーカーが付いているので、多くのメイカーがM5Stackで楽器作りにチャレンジしています。 私もその一人で、M5Stackを手に入れてすぐに、距離センサーを組み合わせてテルミン風の楽器を作ろうと画策していました。
「M5Stackでテルミン、これはいいアイディアだ!」「子供が寝たあとでこっそり練習して曲を披露しよう!」と、ウキウキで実装を始める私。 ところが、M5Stackでビープ音を再生した途端に鳴り響く爆音…。そう、M5Stackのスピーカーの音質があまり良くないことで有名です(もっとも、今では音質悪化の原因や悪化を防ぐ方法などがシェアされています)。
さて、困りました。これでは「寝た子も起きるM5Stack」状態で、曲の練習どころではありません。M5Stackで静かに音を鳴らすには、どうするか。Webエンジニアの私は「ブラウザで鳴らせばいいんじゃないか?」と思いつきました。
そして作ったのが「M5テルミン」です。
「M5テルミン」はM5StackとPCのブラウザが連携して動作するアプリケーションです。M5Stack背面の距離センサに手をかざすと、手とセンサの距離に応じた音がPCから再生されます。手が近ければ高い音、遠ければ低い音が出ます。音声出力をPCブラウザで行うことで、音量や音質の変更も自在にできるようになります。
構成を次に示します。M5Stackの他に「WebSocketサーバ」と「ブラウザ」を使います。まず距離センサユニットでM5Stackと手の距離を取得して、出したい音の周波数を計算します。そして周波数の情報をサーバを介してブラウザに送信します。最後にブラウザで実際に音を出します。
次のものが必要です。
- M5Stack
- M5Stack FireやM5StickCでも動作可能です。
- M5Stack用ToF測距センサユニット
- サーバ(Node.jsインストール済)
- PCまたはスマートフォン
- PCとM5Stackが両方繋がる無線LANネットワーク
サンプルコードはModdableサンプルアプリケーション集のthereminディレクトリにあります。
https://github.com/meganetaaan/moddable-examples/tree/master/theremin
M5テルミンを動かすために「WebSocketサーバの起動」と「WebSocketクライアント(M5Stack)の書き込み・起動」が必要です。コードはそれぞれtheremin/serverとtheremin/clientに分かれています。
まずWebSocketサーバを起動します。サーバはNode.jsで構築されています。コンソールを開いてディレクトリに移動し、npmを使って依存ライブラリをインストールすれば準備完了です。npm startでサーバを起動します。
1 2 3 4 5 6
$ cd moddable-examples/theremin/server $ npm install $ npm start > server@0.1.0 start /home/sskw/moddable-examples/theremin/server > node index.js listening on port 8080
続いてWebSocketクライアント(M5Stack)の書き込みです。書き込む前に、M5StackのGroveコネクタにToFセンサを接続しておきましょう。 M5StackからWiFiを使ってサーバにアクセスするために、「無線LANのSSIDとパスワード」と「WebSocketサーバのIPアドレス」の情報が必要です。サーバのIPアドレスはマニフェストファイル(theremin/client/manifest.json)のconfigプロパティに記述しておきます。
1 2 3 4
"config": { "host": "接続先PCのIPアドレス(例:192.168.1.100)", "startupSound": false },
無線LANのSSIDとパスワードはビルドコマンドと同時に、次のようにして指定します。
1 2
$ cd moddable-examples/theremin/client $ mcconfig -d -m -p esp32/m5stack ssid={無線LANのSSID} password={無線LANのパスワード}
Xsbugのコンソールに次のようなログが出力されれば接続は成功です。
1 2 3 4 5 6
Wi-Fi connected to "無線LANのSSID" IP address “サーバのIPアドレス” socket connect websocket handshake success websocket message received: 880 …
最後にブラウザからサーバにアクセスします。サーバと同じPCのブラウザを使う場合、アドレス欄にhttp://localhost:8080/を入力してページを開きます。左上の音量ボタンを押すと音声が再生されます。M5Stackのセンサに手をかざして音が変わるか確認してみてください。
ここからはサンプルコードを追いながら、ModdableとWebを連携させたアプリケーションの開発方法を説明します。
まずはM5Stack側のコードです。M5Stackでは次の処理を実行します。
- WebSocketサーバに接続する
- 距離センサから距離を取得する
- 距離を音程に変換する
- 音程の情報をWebSocket経由で送信する
コードとともに順番に説明します。
1つ目はWebSocketサーバへの接続です。これにはModdableのWebSocketモジュールを利用します。モジュールを利用するため、次のように依存関係の定義をあらかじめマニフェストファイルに記述します。
1 2 3 4 5 6 7 8 9 10
"modules": { "*": [ "./main", "$(MODULES)/network/websocket/*", "$(MODULES)/data/base64/*", "$(MODULES)/data/logical/*", "$(MODULES)/crypt/digest/*", "$(MODULES)/crypt/digest/kcl/*" ] },
$(MODULES)という記述で環境変数MODULESを参照していますが、これはModdableの公式モジュールが揃ったディレクトリ(moddable/modules)を指しています。WebSocketに必要なファイル群はModdableのWebSocketクライアントのサンプルコード(moddable/examples/network/websocket/websocketclient/manifest.json)からコピーしました。Moddableでは各機能ごとのサンプルコードが同梱されています。自分のやりたいことと近いサンプルコードを探し、それを出発点にして実装を始めると効率がよいです。
設定できたら、スクリプトでWebSocketクライアント(Client)のクラスをインポートします。
1
import { Client } from 'websocket'
続いてインポートしたClientクラスのインスタンスを作ります。コンストラクタ引数として接続先サーバのホスト(host), ポート(port)をそれぞれ指定します。先程のマニフェストファイルでの設定値がconfigモジュールに格納されているのでインポートして利用します。
1 2 3 4 5
import config from 'mc/config' let socket = new Client({ host: config.host, port: 8080 })
WiFiへの接続はアプリケーションの起動時に、WebSocketサーバへの接続はインスタンスが作成されたタイミングで自動的に実行されます。接続が確立した時や接続が切れた時の処理は、共通のコールバック関数(socket.callback)を起点に行います。
1 2 3 4 5 6 7
socket.callback = function (message, value) { switch (message) { case Client.connect: trace('socket connect\n') timer = Timer.repeat(loop, 1000 / FPS) break // ...
このときコールバック関数の引数に「今どの処理を行っているか」を示す値(message)も渡されます。このmessageに基づいて処理を分岐させればよいです。サンプルではWebSocket接続が確立した時に距離の取得と音程送信を行うループ処理を開始しています。Timer.repeat(function, interval)は第1引数に与えた関数を、第2引数ミリ秒毎に繰り返し実行します。ブラウザJavaScriptのsetIntervalと同じ役割です。
2つ目に、距離センサから距離を取得します。M5Stackの距離センサユニットはVL53L0Xというセンサが内蔵されています。このセンサとはI2Cインタフェースで通信を行います。ModdableにはI2Cモジュール、そしてI2Cをベースにより拡張的な機能を持つSMBusモジュールが用意されています。今回はSMBusを使って、VL53L0Xのドライバを実装しました。
ドライバ実装の詳細は割愛しますが、センサのドライバを自作する場合はM5Stack公式のArduinoライブラリから(Moddableの書き方に合わせて)移植をするのが最も近道です。センサの種類にもよりますが、移植はそれほど難しくはありません。例えば距離センサユニットから距離を読み出すには「センサの特定のアドレスに値0x01を書き込んで距離を更新し、その後別のアドレスから値を読み出す」という処理が必要ですが、高々3行程度のコードで実現できます。
1 2 3 4 5
get value() { this.writeByte(REGISTERS.SYSRANGE_START, 0x01); this.readBlock(REGISTERS.RESULT_RANGE_STATUS, 12, this.#buf); return this.#view.getUint16(10, false) }
3つ目に、取得した距離を音程に変換して送信します。音の周波数は「1オクターブで2倍になる」という特徴を持ちます。最低音440Hz(ラの音)から最高音880Hz(高いラの音)まで移動するために、440を基数として、その乗数が1から2に線形に動くような関数を組み立てればよいです。このとき、2のべき乗の計算にMath.pow関数を使います。Node.jsやブラウザJavaScriptでも数値計算のためにMathクラスがよく使われますが、ModdableはECMAScriptに対応しているので、三角関数や乱数の生成など、Mathクラスのほぼ共通のメソッドが使えます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
const MIN_DISTANCE = 50 const MAX_DISTANCE = 500 const KEY_A = 440 const a = -1 / 450 const b = 10 / 9 function clamp (value, min, max) { return Math.floor(Math.min(max, Math.max(value, min))) } function getTone (mm) { const d = clamp(mm, MIN_DISTANCE, MAX_DISTANCE) return KEY_A * Math.pow(2, d * a + b) }
変換した音程の情報をWebSocket経由で送信します。これは先程作成したWebSocketインスタンスのwriteメソッドを呼び出すだけです。
1 2 3 4 5
function loop () { const mm = sensor.value const message = String(getTone(mm)) socket.write(message) }
以上がM5Stackのコードの説明です。最後にM5Stackアプリケーションのスクリプト全体を次に示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
/* global trace */ import { Client } from 'websocket' import Timer from 'timer' import ToF from 'vl53l0x' import config from 'mc/config' const FPS = 15 const MIN_DISTANCE = 50 const MAX_DISTANCE = 500 const KEY_A = 440 const a = -1 / 450 const b = 10 / 9 let socket = new Client({ host: config.host, port: 8080 }) let sensor = new ToF() let timer = null function clamp (value, min, max) { return Math.floor(Math.min(max, Math.max(value, min))) } function getTone (mm) { const d = clamp(mm, MIN_DISTANCE, MAX_DISTANCE) return KEY_A * Math.pow(2, d * a + b) } function loop () { const mm = sensor.value const message = String(getTone(mm)) socket.write(message) } socket.callback = function (message, value) { switch (message) { case Client.connect: trace('socket connect\n') timer = Timer.repeat(loop, 1000 / FPS) break case Client.handshake: trace('websocket handshake success\n') break case Client.receive: trace(`websocket message received: ${value}\n`) break case Client.disconnect: trace('websocket close\n') if (timer != null) { Timer.clear(timer) timer = null } break } }
WebSocket APIは、クライアントとサーバー間で対話的な通信セッションを開くことができる機能です。ソケットを通じてリアルタイムに双方向での情報のやりとりが可能になります。今回はNode.js製サーバであるExpress、WebSocketを扱えるようにするためのミドルウェアとしてws-expressを使用しました。今回は特に複雑な処理は無く、M5Stackから発信されたメッセージをブラウザに中継するだけです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
const Express = require('express') const app = Express() require('express-ws')(app) let sockets = [] // publicディレクトリ配下を配信する app.use(Express.static('public')) // WebSocketエンドポイントの設定 app.ws('/', function (socket) { sockets.push(socket) socket.on('message', function (message) { // 他のコネクションにメッセージを送る sockets.forEach(s => { s.send(message) }) }) socket.on('close', () => { // 閉じたコネクションを取り除く sockets = sockets.filter(s => { return s !== socket }) }) }) // ポート8080で接続を待ち受ける app.listen(8080)
最後はブラウザ側の実装です。Web Audio APIを使うとブラウザから自在に音を生成し、エフェクトを加えて再生できるようになります。次の2点について説明します。
- 音源の生成から再生
- 音程の変更
1つ目、音源の生成から再生です。M5テルミンでは正弦波をひとつ生成して鳴らします。Web Audioを使う流れは大まかに次のステップから構成されます。
- オーディオコンテキストを作成します。
1
const context = new AudioContext()
- オーディオタグ()、オシレーター、ストリームなどの音源を作成します。
1
const oscillator = context.createOscillator()
音声にエフェクトをかけたい場合はリバーブなどのエフェクトノードを作成します(今回は使いません)。
ノードを出力先に接続します。PCのスピーカーから音を鳴らすにはAudioContext.destinationの入力と紐つければよいです。
1
oscillator.connect(context.destination)
音声の再生を開始します。
1
oscillator.start()
注意点として、音声の再生はユーザの入力イベント(キーボード入力やクリック)を起点として開始しなくてはいけません。これは「ページを開いた瞬間に大音量で音楽が鳴る」ような、著しくユーザビリティに欠ける実装を防ぐための制約です。M5テルミンでは、ユーザがブラウザの画面を開いたときは音声はミュートになっており、「ミュートボタンをクリックした時に」startメソッドが呼ばれて音が出始めるように工夫しています。
このアプリケーションではWebSocket経由で受け取った音程の情報に従ってオシレーターの周波数を変更します。オシレーターには音程を示すfrequencyというプロパティがありますが、この値を直接変更しても反映されません。代わりに専用のメソッド setValueAtTime を使います。
最後にブラウザ側のコード全体を示します。WebAudio関連のAPIをOscillatorクラスを作ってラップしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65
/* global location, WebSocket, AudioContext */ class Oscillator { constructor () { this.ctx = new AudioContext() this.osc = this.ctx.createOscillator() this.osc.connect(this.ctx.destination) this.playing = false } // 音を鳴らす start () { if (this.playing) { return } this.osc.start() this.playing = true } // 音を止める stop () { if (!this.playing) { return } this.osc.stop() this.playing = false } // 音程を変える setFrequency (freq) { // ポイント2:frequencyを直接書き換えず、setValueAtTimeなどのメソッドを使う this.osc.frequency.setValueAtTime(freq, this.ctx.currentTime + 1 / 15) } } document.addEventListener('DOMContentLoaded', () => { const oscillator = new Oscillator() const heltz = document.querySelector('.frequency-value') const muteButton = document.getElementById('muteButton') muteButton.addEventListener('click', async () => { const muted = muteButton.classList.contains('muted') if (muted) { // ポイント1:クリックを起点に音声を再生開始する oscillator.start() muteButton.classList.remove('muted') } else { oscillator.stop() muteButton.classList.add('muted') } }) const socket = new WebSocket(`ws://${location.host}/`) socket.onopen = (e) => { console.log('connected') } socket.onmessage = (event) => { const tone = Number(event.data) oscillator.setFrequency(tone) heltz.innerText = tone.toFixed(1) } socket.onclose = (e) => { console.log('disconnected') } })
以上がコードの説明です。
出来上がった楽器を子供に遊んでもらったところ「手を近づけたり、話したりすると音が変わる」というギミックはすぐに理解してもらえました。曲を演奏するのはなかなか難しいですが、手をデタラメに動かして変な音になるのが楽しい様子で何度も遊んでいました。演奏のサポート機能がなどあるとさらに楽しめそうです。
M5テルミンをはじめ、Moddableを使ったM5StackアプリケーションのサンプルをGitHubで公開しています。
https://github.com/meganetaaan/moddable-examples
JavaScriptでの組み込み開発に興味がある/可能性を感じる方はぜひ触ってみてください!