編集履歴一覧に戻る
rioilのアイコン画像

rioil が 2021年05月15日09時41分26秒 に編集

初版

タイトルの変更

+

ObnizのBLE機能で簡易テルミンを作る

タグの変更

+

obniz

+

BLE

本文の変更

+

# デモ動画 # 部品 - obniz Board 1Y - BLEに対応したAndroid機器 # 動作の仕組み 1. obnizとAndroid機器をBLEで接続 1. obniz側から一定時間ごとにキャラクタリスティクスの値の更新通知を出す 1. 更新通知を受けたAndroid機器側がRSSI(電波強度)の値を読む 1. RSSIの値に応じて音の周波数を変える # ソースコード ## obniz側 ``` HTML <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機器側 ``` Kotlin: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" const val ReAD_RSSI_INTERVAL = 1000 /** スキャン時間 */ private const val SCAN_PERIOD: Long = 10000 } 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 private val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { TODO("Not yet implemented") } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) // Register the permissions callback, which handles the user's response to the // system permissions dialog. Save the return value, an instance of // ActivityResultLauncher. You can use either a val, as shown in this snippet, // or a lateinit var in your onAttach() or onCreate() method. val requestPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestPermission() ) { isGranted: Boolean -> if (isGranted) { // Permission is granted. Continue the action or workflow in your // app. } else { // Explain to the user that the feature is unavailable because the // features requires a permission that the user has denied. At the // same time, respect the user's decision. Don't link to system // settings in an effort to convince the user to change their // decision. } } when { ContextCompat.checkSelfPermission( applicationContext, android.Manifest.permission.ACCESS_FINE_LOCATION ) == PackageManager.PERMISSION_GRANTED -> { // You can use the API that requires the permission. } shouldShowRequestPermissionRationale(android.Manifest.permission.ACCESS_FINE_LOCATION) -> { // In an educational UI, explain to the user why your app requires this // permission for a specific feature to behave as expected. In this UI, // include a "cancel" or "no thanks" button that allows the user to // continue using your app without granting the permission. //showInContextUI(...) } else -> { // You can directly ask for the permission. // The registered ActivityResultCallback gets the result of this request. requestPermissionLauncher.launch( android.Manifest.permission.ACCESS_FINE_LOCATION ) } } btnScan.setOnClickListener { scanObniz() } btnConnect.setOnClickListener { startPlay() } btnStop.setOnClickListener { stop() } audioTrack = soundGenerator.audioTrack /* // 音生成デバッグ val assetManager = assets val assetFileDescriptor = assetManager.openFd("440hz_pcm16.wav"); // byte配列を生成し、音声データを読み込む val audioData = ByteArray(assetFileDescriptor.length.toInt()) val inputStream = assetFileDescriptor.createInputStream() inputStream.read(audioData) inputStream.close() val testSound = soundGenerator.generate(440.0, 1.0) thread { if(audioTrack!!.playState != AudioTrack.PLAYSTATE_PLAYING){ audioTrack!!.play() } for(i in 1..1){ audioTrack!!.write(testSound, 0, testSound.size) } //val headLen = 46 //audioTrack!!.write(audioData, headLen, audioData.size - headLen) audioTrack!!.stop() } */ } override fun onDestroy() { super.onDestroy() //unregisterReceiver(receiver) } /** * 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 //bleScanner.startScan(buildScanFilters(), buildScanSettings(), BleScanCallback()) /* val pairedDevices: Set<BluetoothDevice> = bluetoothAdapter.bondedDevices var deviceName: String? = null var deviceHardwareAddress: String? = null for (device in pairedDevices) { if (device.name == OBNIZ_DEVICE_NAME) { deviceName = device.name deviceHardwareAddress = device.address txvDeviceAddress.text = deviceHardwareAddress bluetoothGatt = device.connectGatt(applicationContext, false, bluetoothGattCallback) break } } // ペアリング済みになければ探索 val filter = IntentFilter(BluetoothDevice.ACTION_FOUND) registerReceiver(receiver, filter) txvDeviceAddress.text = "Starting discovery..." bluetoothAdapter.startDiscovery() */ } /** * 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() // TODO 要調整 while (!isStopRequested) { val data: RssiData? = receivedRssis.poll() if(data != null){ lastFreq = 440.0 + data.rssi * 2 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() // Comment out the below line to see all BLE devices around you //val uuid = UUID(0x0000_1063_0000_1000, 0x8000_0080_5f9b_34fb_UL.toLong()) //builder.setServiceUuid(ParcelUuid(uuid)) 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) //builder.setCallbackType(ScanSettings.CALLBACK_TYPE_FIRST_MATCH) 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(); /* ここでサービスは取れない,discoverServiceで先にサービスリストを取ってくる必要がある val service = gatt.getService(UUID(0x0000_1063_0000_1000, 0x8000_0080_5f9b_34fb_UL.toLong())) val characteristic = service.getCharacteristic(UUID(0x0000_7777_0000_1000, 0x8000_0080_5f9b_34fb_UL.toLong())) gatt.setCharacteristicNotification(characteristic, true) val descriptor = characteristic.getDescriptor(UUID(0x0000_2902_0000_1000, 0x8000_0080_5f9b_34fb_UL.toLong())) descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE gatt.writeDescriptor(descriptor) */ } } 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) /* TODO * Queueに貯めていって再生は別スレッドで行う方がよさそう * 古すぎるものは破棄したり,新しいデータが届かなかったときは同じ音を再生し続けるとかの処理を入れるといいかも */ receivedRssis.add(RssiData(rssi, Date())) /* val freq = 440.0 + rssi * 2 GlobalScope.launch(Dispatchers.Main) { txvRssi.text = rssi.toString() txvTime.text = Date().toString() txvFrequency.text = freq.toString() } val soundData = soundGenerator.generate(freq, 0.5) audioTrack?.flush() audioTrack?.write(soundData, 0, soundData.size) */ } } } /** * 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() ) } } ``` ``` Kotlin:RssiData.kt package dev.rioil.ble_theremin import java.util.* data class RssiData(val rssi: Int, val date: Date) ``` ``` Kotlin: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 } } ``` ``` XML: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を普段書かない自分でもすぐにできて,とても簡単でした.Androidアプリはほとんど知識が無かった上に,調子に乗って書いたことのないKotlinを使おうとしたので作るのにとても時間がかかりました.obnizもAndroidもブロックプログラミングにすれば,もっと楽にできるかもしれないので試してみようと思います(Androidだと[MIT App Inventor](https://appinventor.mit.edu/)が使えそう).