rioilのアイコン画像
rioil 2021年05月15日作成 (2022年03月06日更新) © MIT
製作品 製作品 閲覧数 1258
rioil 2021年05月15日作成 (2022年03月06日更新) © MIT 製作品 製作品 閲覧数 1258

obnizとAndroidスマートフォンで簡易テルミンを作る

obnizとAndroidスマートフォンで簡易テルミンを作る

デモ動画

ここに動画が表示されます

部品

  • obniz Board 1Y
  • BLEに対応したAndroid機器

動作の仕組み

obniz側

  1. Android機器とBLEで接続
  2. 一定時間ごとにキャラクタリスティクスの値の更新通知を出す

Android側

  1. obnizとBLEで接続
  2. キャラクタリスティクスの更新通知を購読する
  3. 更新通知を受ければRSSI(電波強度)の値を読む
  4. RSSIの値に応じて音の周波数を変える

動作中の画面

ソースコード

obniz側

<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link
      rel="stylesheet"
      href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
    />
    <script src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
    <script
      src="https://unpkg.com/obniz@3.x/obniz.js"
      crossorigin="anonymous"
    ></script>
  </head>
  <body>
    <div id="obniz-debug"></div>
    <script>
     var obniz = new Obniz("OBNIZ_ID_HERE");

     // called on online
     obniz.onconnect = async function () {
       // 画面にタイトルを表示
       obniz.display.print("BLE Theremin");

       // BLEを初期化
       await obniz.ble.initWait();

       obniz.ble.peripheral.onconnectionupdates = function (data) {
         if (data.status === "connected") {
           console.log("connected from remote device ", data.address);
         } else if (data.status === "disconnected") {
           console.log("disconnected from remote device ", data.address);
         }
       };

       // サービス・キャラクタリスティック登録
       const service = new obniz.ble.service({ uuid: "1063" });
       const characteristic = new obniz.ble.characteristic({
         uuid: "7777",
         data: [1, 2, 3], // 適当な値
         properties: ["read", "notify"],
         descriptors: [
           {
             uuid: "2902", //CCCD
             data: [0x00, 0x00], //2byte
           },
         ],
       });
       service.addCharacteristic(characteristic);
       obniz.ble.peripheral.addService(service);

       // advertisementのデータを設定
       obniz.ble.advertisement.setAdvData(service.advData);
       obniz.ble.advertisement.setScanRespData({
         localName: "BLE Theremin",
       });

       // advertisement送信開始
       obniz.ble.advertisement.start();
       obniz.display.print("Advertising...");

       // 250ms毎に更新通知
       setInterval(async () => {
         await characteristic.writeWait([0xff]);
         characteristic.notify();
         console.log("notify");
       }, 250);
     };
    </script>
  </body>
</html>

Android機器側

メインアクティビティ

動作に必要な権限の取得処理は省略しています.

MainActivity.kt

package dev.rioil.ble_theremin import android.bluetooth.* import android.bluetooth.le.* import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.media.AudioTrack import android.os.Bundle import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import kotlinx.android.synthetic.main.activity_main.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import java.util.* import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.Executors import kotlin.collections.ArrayList import kotlin.collections.HashSet import kotlin.concurrent.thread class MainActivity : AppCompatActivity() { companion object { const val REQUEST_ENABLE_BT = 1 const val OBNIZ_DEVICE_NAME = "BLE Theremin" } private val executorService = Executors.newFixedThreadPool(4) private var bluetoothGatt: BluetoothGatt? = null val bleDevices = HashSet<BluetoothDevice>() val soundGenerator = SoundGenerator(44100); val receivedRssis = ConcurrentLinkedQueue<RssiData>() var bleScanner: BluetoothLeScanner? = null var bleScanCallback: ScanCallback? = null var audioTrack: AudioTrack? = null @Volatile var isStopRequested: Boolean = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) btnScan.setOnClickListener { scanObniz() } btnConnect.setOnClickListener { startPlay() } btnStop.setOnClickListener { stop() } audioTrack = soundGenerator.audioTrack } /** * Obnizのスキャンを行います */ private fun scanObniz() { val bluetoothAdapter: BluetoothAdapter = BluetoothAdapter.getDefaultAdapter() ?: return // デバイスがBluetoothをサポートしていないときは終了 // Bluetoothが有効でなければダイアログを出して有効化 if (!bluetoothAdapter.isEnabled) { val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT) } bleScanner = bluetoothAdapter.bluetoothLeScanner bleScanCallback = buildBleScanCallback() txvStatus.text = "Scanning Obniz..." executorService.execute { bleScanner?.startScan(buildScanFilters(), buildScanSettings(), bleScanCallback) } btnScan.isEnabled = false } /** * BLEデバイスと接続し,音の再生を開始します */ private fun startPlay() { if (bleDevices.count() > 0) { txvStatus.text = "Connecting to " + bleDevices.first().name + " ..." connect(this, bleDevices.first()) txvStatus.text = "Connected to " + bleDevices.first().name btnConnect.isEnabled = false btnStop.isEnabled = true // 音再生スレッドを開始 isStopRequested = false thread { var lastFreq: Double = Double.NaN audioTrack?.play() while (!isStopRequested) { val data: RssiData? = receivedRssis.poll() if(data != null){ lastFreq = 440.0 - data.rssi * 3 val soundData = soundGenerator.generate(lastFreq, 0.5) audioTrack?.flush() audioTrack?.write(soundData, 0, soundData.size) GlobalScope.launch(Dispatchers.Main) { txvRssi.text = data.rssi.toString() txvTime.text = Date().toString() txvFrequency.text = lastFreq.toString() } } else if(lastFreq != Double.NaN){ val soundData = soundGenerator.generate(lastFreq, 0.5) audioTrack?.flush() audioTrack?.write(soundData, 0, soundData.size) } } } } } /** * BLEデバイスとの接続を切断し,音の再生を停止します */ private fun stop() { isStopRequested = true txvStatus.text = "Disconnected from " + bluetoothGatt?.device?.name audioTrack?.flush() audioTrack?.stop() bluetoothGatt?.disconnect() btnScan.isEnabled = true btnConnect.isEnabled = false btnStop.isEnabled = false } /** * Return a List of [android.bluetooth.le.ScanFilter] objects to filter by Service UUID. */ private fun buildScanFilters(): List<ScanFilter>? { val scanFilters: MutableList<ScanFilter> = ArrayList() val builder = ScanFilter.Builder() builder.setDeviceName(OBNIZ_DEVICE_NAME) scanFilters.add(builder.build()) return scanFilters } /** * Return a [android.bluetooth.le.ScanSettings] object set to use low power (to preserve * battery life). */ private fun buildScanSettings(): ScanSettings? { val builder = ScanSettings.Builder() builder.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) return builder.build() } /** * BLEスキャンコールバックを作成します */ private fun buildBleScanCallback(): ScanCallback { return object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { super.onScanResult(callbackType, result) if (callbackType == ScanSettings.CALLBACK_TYPE_ALL_MATCHES && result.device != null) { bleDevices.add(result.device) txvDeviceAddress.text = result.device.address txvTime.text = Date().toString() bleScanner?.stopScan(bleScanCallback) btnConnect.isEnabled = true; } } } } /** * GATTコールバックを作成します */ private fun buildGattCallback(): BluetoothGattCallback { return object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { super.onConnectionStateChange(gatt, status, newState); // 接続成功し、サービス取得 if (newState == BluetoothProfile.STATE_CONNECTED) { bluetoothGatt = gatt; GlobalScope.launch(Dispatchers.Main) { txvStatus.text = "Connected to " + gatt.device.name; } gatt.readRemoteRssi() discoverService(); } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { super.onServicesDiscovered(gatt, status); // 更新通知を購読 val service = gatt.getService(createUUID(0x1063)) if (service != null) { val characteristic = service.getCharacteristic(createUUID(0x7777)) gatt.setCharacteristicNotification(characteristic, true) val descriptor = characteristic.getDescriptor(createUUID(0x2902)) descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE gatt.writeDescriptor(descriptor) } } override fun onCharacteristicChanged( gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic? ) { gatt?.readRemoteRssi() } override fun onReadRemoteRssi(gatt: BluetoothGatt?, rssi: Int, status: Int) { super.onReadRemoteRssi(gatt, rssi, status) receivedRssis.add(RssiData(rssi, Date())) } } } /** * GATTへの接続要求を出します */ private fun connect(context: Context?, device: BluetoothDevice) { bluetoothGatt = device.connectGatt(context, false, buildGattCallback()) bluetoothGatt?.connect() } /** * サービスを取得要求を出します */ private fun discoverService() { bluetoothGatt?.discoverServices() } /** * UUID作成ヘルパーメソッド */ private fun createUUID(bleUUID: Short): UUID { return UUID( 0x0000_0000_0000_1000_UL.toLong() or (bleUUID.toLong() shl 32), 0x8000_0080_5f9b_34fb_UL.toLong() ) } }

RSSIのデータクラス

RssiData.kt

package dev.rioil.ble_theremin import java.util.* data class RssiData(val rssi: Int, val date: Date)

指定された周波数の音声データを作成するクラス

SoundGenerator.kt

package dev.rioil.ble_theremin import android.media.AudioAttributes import android.media.AudioFormat import android.media.AudioManager import android.media.AudioTrack import kotlin.math.PI import kotlin.math.ceil import kotlin.math.sin class SoundGenerator(private val sampleRate: Int) { val audioTrack: AudioTrack get() { return mAudioTrack } private val mAudioTrack: AudioTrack private val bufferSize: Int init { val audioTrackBuilder: AudioTrack.Builder = AudioTrack.Builder() audioTrackBuilder.setAudioFormat( AudioFormat.Builder() .setEncoding(AudioFormat.ENCODING_PCM_16BIT) .setChannelIndexMask(AudioFormat.CHANNEL_OUT_MONO) .setSampleRate(sampleRate) .build() ) audioTrackBuilder.setTransferMode(AudioTrack.MODE_STREAM) bufferSize = AudioTrack.getMinBufferSize( sampleRate, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT ) audioTrackBuilder.setBufferSizeInBytes(bufferSize) audioTrackBuilder.setAudioAttributes( AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA).build() ) mAudioTrack = AudioTrack( AudioManager.STREAM_MUSIC, sampleRate, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT, bufferSize, AudioTrack.MODE_STREAM ) //mAudioTrack = audioTrackBuilder.build() } fun generate(frequency: Double, lengthSecond: Double): ShortArray { val buffer = ShortArray(ceil(sampleRate * lengthSecond).toInt()) for (i in buffer.indices) { val t: Double = i / (sampleRate / frequency) * (PI * 2) buffer[i] = (sin(t) * Short.MAX_VALUE).toInt().toShort() } return buffer } }

画面

activity_main.xml

<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:layout_marginStart="10dp" android:layout_marginTop="10dp" android:layout_marginEnd="10dp" android:orientation="vertical" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center_horizontal" android:orientation="horizontal"> <Button android:id="@+id/btnScan" style="@style/Widget.AppCompat.Button.Borderless" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/scan" /> <Button android:id="@+id/btnConnect" style="@style/Widget.AppCompat.Button.Borderless" android:layout_width="wrap_content" android:layout_height="wrap_content" android:enabled="false" android:text="@string/connect" /> <Button android:id="@+id/btnStop" style="@style/Widget.AppCompat.Button.Borderless" android:layout_width="wrap_content" android:layout_height="wrap_content" android:enabled="false" android:text="@string/stop" /> </LinearLayout> <TableLayout android:layout_width="match_parent" android:layout_height="match_parent" android:layout_marginStart="10dp" android:layout_marginTop="10dp" android:layout_marginEnd="10dp"> <TableRow android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android:id="@+id/lblDeviceAddress" android:layout_width="wrap_content" android:layout_height="wrap_content" android:labelFor="@id/txvDeviceAddress" android:text="@string/mac_address" /> <TextView android:id="@+id/txvDeviceAddress" android:layout_width="wrap_content" android:layout_height="wrap_content" /> </TableRow> <TableRow android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android:id="@+id/lblLabelRssi" android:layout_width="wrap_content" android:layout_height="wrap_content" android:labelFor="@id/txvRssi" android:text="@string/rssi" /> <TextView android:id="@+id/txvRssi" android:layout_width="wrap_content" android:layout_height="wrap_content" /> </TableRow> <TableRow android:layout_width="match_parent" android:layout_height="match_parent"> <Space android:layout_width="wrap_content" android:layout_height="wrap_content" /> <TextView android:id="@+id/txvTime" android:layout_width="wrap_content" android:layout_height="wrap_content" /> </TableRow> <TableRow android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android:id="@+id/lblFrequency" android:layout_width="wrap_content" android:layout_height="wrap_content" android:labelFor="@id/txvFrequency" android:text="@string/frequency" /> <TextView android:id="@+id/txvFrequency" android:layout_width="wrap_content" android:layout_height="wrap_content" tools:text="440" /> </TableRow> <TableRow android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android:id="@+id/lblStatus" android:layout_width="wrap_content" android:layout_height="wrap_content" android:labelFor="@id/txvStatus" android:text="@string/status" /> <TextView android:id="@+id/txvStatus" android:layout_width="wrap_content" android:layout_height="wrap_content" tools:text="debug" /> </TableRow> </TableLayout> </LinearLayout> </androidx.constraintlayout.widget.ConstraintLayout>

感想

obnizでBLEを使うのは普段JavaScriptを書かない自分でもすぐにできて,とても簡単でした.当初,セントラル側はWeb Bluetoothを使って作ろうとしたのですが,うまく動かず結局断念しました.そこで,Androidアプリとして作ることにしたのですが,ほとんど知識が無かった上に,調子に乗って書いたことのないKotlinを使おうとしたので作るのにとても時間がかかりました.obnizもAndroidもブロックプログラミングにすれば,もっと楽にできるかもしれないので試してみようと思っています(AndroidだとMIT App Inventorが使えそう).

参考にしたサイト

【クリスマスだし】Androidで8ビット音を生成してジングルベルを奏でてみる【25日目の1】

ログインしてコメントを投稿する