SPRESENSEを使ってジェスチャーで音楽を操作する「MusicGripper」

少し前ですが、ジェスチャーを使って音楽を操作する「GripBeats」なるものを知りました。
<br/>

<iframe width="560" height="315" src="https://www.youtube.com/embed/ilmzZmGTnyA" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

<br/>
うーん、これはなかなか面白ろかっこいい！でも、これってSPRESENSEで出来るんじゃね？とふつふつと製作意欲が湧いてきました。SPRESENSEで「[BeatBox](https://www.youtube.com/watch?v=HFjKwSGYeX8)」を作った者としては、これは挑戦状ととらえて、同じくジェスチャーで音楽を操作する「MusicGripper」を作ることにしました。パクリ丸出しの名前です。

イメージとしてはこんな感じ。手に装着したSPRESENSEからジェスチャーの情報をPCに送り、PCで再生する音楽のボリュームと再生スピードを制御するようにします。PCのプログラムはPythonで作ります。


![MusicGripperConcept.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/598183/60e1a05e-dc3f-bf6a-fa5c-2d065e79d2c4.png)


# SPRESENSE側のシステム
SPRESENSEには、６軸センサーとBLEを装着しました。６軸センサーで手の傾きを検出してその値をBLEでPCに送信します。


![MusicGripper.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/598183/e7ede153-d674-c35e-9441-91db8ad1bc6b.jpeg)

スケッチを掲載します。傾きを検出するのにジャイロと加速度センサーを使った補完フィルターによるセンサー・フュージョンを用いています。傾き情報は200ミリ秒毎に送ることにしました。もう少し頻繁に送りたかったのですが、PC側のPythonプログラムが受け取ってくれないので仕方なく200ミリ秒としました。


``` Spresense_MusicGripper.cpp
#include <BMI160Gen.h>
#include <float.h>
#include <MK71251.h>
MK71251 mk71251;

const int sense_rate = 200; /* Hz */
const int gyro_range = 500; /* 度/秒 */
const int accl_range = 2;   /* G */
const float alpha = 0.94;  /* 補完フィルター数値 */

float rpm_roll[sense_rate];
float rpm_pitch[sense_rate];

/* 16bit整数(±32768)を浮動小数点に変換する */  
inline float convertRawGyro(int gRaw) {
  return gyro_range * ((float)(gRaw)/32768.0);
}

/* 16bit整数(±32768)を浮動小数点に変換する */  
inline float convertRawAccel(int aRaw) {
  return accl_range *((float)(aRaw)/32768.0);
}

/* ラジアン(rad)から角度(°)に変換 */
inline float rad_to_deg(float r) {
  return r*180./M_PI;
}

void setup() {
  Serial.begin(115200);

  BMI160.begin(BMI160GenClass::I2C_MODE);
  BMI160.setGyroRange(gyro_range);
  BMI160.setAccelerometerRange(accl_range);
  BMI160.setGyroRate(sense_rate);
  BMI160.setAccelerometerRate(sense_rate);

  mk71251.init();
}

void loop() {
  static unsigned long last_msec = 0;
  int rollRaw, pitchRaw, yawRaw;
  int accxRaw, accyRaw, acczRaw; 
  static float gyro_roll = 0;
  static float gyro_pitch = 0;
  static float gyro_yaw = 0;
  static float comp_roll = 0;
  static float comp_pitch = 0;
  static float last_comp_roll = 0;
  static float last_comp_pitch = 0;

  unsigned long curr_msec = millis();
  float dt = (float)(curr_msec - last_msec)/1000.0;
  last_msec = curr_msec;
  if (dt > 0.1 || dt == 0.) return;
  
  BMI160.readGyro(rollRaw, pitchRaw, yawRaw);
  BMI160.readAccelerometer(accxRaw, accyRaw, acczRaw);

  float omega_roll  = convertRawGyro(rollRaw);
  float omega_pitch = convertRawGyro(pitchRaw);
  float omega_yaw   = convertRawGyro(yawRaw); 
  float accel_x = convertRawAccel(accxRaw);
  float accel_y = convertRawAccel(accyRaw);
  float accel_z = convertRawAccel(acczRaw);

  float acc_roll  = rad_to_deg(atan2(accel_y, accel_z));
  float acc_pitch = rad_to_deg(atan2(-accel_x, sqrt(accel_y*accel_y+accel_z*accel_z)));

  /* 補完フィルター */
  comp_roll  = alpha*(comp_roll  + omega_roll*dt)  + (1-alpha)*acc_roll;
  comp_pitch = alpha*(comp_pitch + omega_pitch*dt) + (1-alpha)*acc_pitch;

  static char send_buff[32];
  memset(send_buff, NULL, 32);
  /* ロールとピッチをJSONデータにエンコード */
  sprintf(send_buff, "{\"r\":%.2f,\"p\":%.2f}\n", comp_roll, comp_pitch);
  char* p = send_buff;
  while (*p != NULL) {
    mk71251.write((unsigned char*)p);
    Serial.print(*p);
    ++p;
  }

  usleep(200000); /* sleep 200msec */
} 
```


#PC側のPythonプログラム
PCのPythonの実装は、BLEの受信にBleakライブラリを使用し、オーディオの処理にはPyAudioを使っています。オーディオをバックグラウンドで流し続けるために Threading も使ってみました。

BLEで傾き（Roll, Pitch）を受信して、音楽再生のボリュームと再生速度をできるだけリアルタイムに反映するようにしています。200ミリ秒単位なので、少し反応にディレイがあります。ここが改善ポイントですね。

``` MusicGripper.py
import threading
import pyaudio
import wave
import numpy as np
import aioconsole
import asyncio
import json
from bleak import BleakClient

address = "FE:F2:EF:F8:A8:4C"
UUID = "0179BBD1-5351-48B5-BF6D-2167639BC867"
json_data=""
d_length=0
roll=0.0
pitch=0.0
stop=False

def notification_handler(sender, data):
    global json_data
    global d_length
    global roll
    global pitch
    sdata = data.decode()
    sdata = sdata.replace('\n','')
    n = len(sdata)
    d_length = d_length + n
    json_data = json_data + sdata
    if d_length > 20:
        try:
            index = json_data.find("}")
            obj_data = json_data[:index+1]
            json_data = json_data[index+1:]
            d_length = len(json_data)
            dic = json.loads(obj_data)
            roll = dic['r']
            pitch = dic['p']
            #print("roll=" + str(roll) + ",pitch=" + str(pitch))
        except:
            print("Error:" + obj_data)

async def run(address, loop):
  global stop
  async with BleakClient(address, loop=loop) as client:
    x = await client.is_connected()
    print("Connected: {0}".format(x))
    print("Services:")
    for service in client.services:
      print(service)
      for char in service.characteristics:
        print(" Charcteristcs: {0}".format(char))
        for disc in char.descriptors:
          print("   Descriptors: {0}".format(disc))
    await client.start_notify(UUID, notification_handler)
    stdin, stdout = await aioconsole.get_standard_streams()
    async for line in stdin:
      if line == b'q\n':
        print ("disconnect")
        await client.stop_notify(UUID)
        await client.disconnect()
        stop=True
        return 
      else:
        await client.write_gatt_char(UUID, line)

def play():
    global roll
    global pitch
    global stop

    CHUNK_SIZE=1024

    p = pyaudio.PyAudio()
    wf = wave.open('guitar.wav', 'rb')
    channels = wf.getnchannels()
    depth = wf.getsampwidth()
    rate = wf.getframerate()
    x0 = np.linspace(0.0, CHUNK_SIZE*channels-1.0, int(CHUNK_SIZE*channels))

    stream = p.open(format=pyaudio.paInt16,
                    channels=channels,
                    rate=rate,
                    output=True)
         
    data = wf.readframes(CHUNK_SIZE)
    n = 0
    while len(data) > 0 and stop == False:
        volume = 1.0 + roll/20
        factor = 1.0 + pitch/90 
        if n % 10 == 0:
            svol = '{:.3f}'.format(volume)
            sfac = '{:.3f}'.format(factor)
            print("volume=" + svol + ",factor=" + sfac)
        chunk = np.frombuffer(data, dtype='int16')
        f_data = chunk.astype(float)
        f_data = volume*f_data
        x1 = np.linspace(0.0, CHUNK_SIZE*channels-1.0, int(CHUNK_SIZE*channels*factor))
        if len(f_data) < CHUNK_SIZE:
            x0 = x0[:int(len(f_data))]
            x1 = x1[:int(len(f_data)*factor)]
        inp_data = np.interp(x1, x0, f_data).astype(np.int16)
        output = inp_data.tobytes()
        stream.write(output)
        data = wf.readframes(CHUNK_SIZE)
        n = n+1
 
    stream.stop_stream()
    stream.close()
    p.terminate()

print("start music")
thread1 = threading.Thread(target=play)
thread1.start()
print("wait for connection...")
loop = asyncio.get_event_loop()
loop.run_until_complete(run(address, loop))
thread1.join()

```

# SPRESENSEで音楽を操作してみる
SPRESENSEで音楽を操作した様子の動画が次になります。やっぱり、ディレイが気になりますね…
<br/>
<iframe width="560" height="315" src="https://www.youtube.com/embed/nklvteuaRBk" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>



# 作ってみた感想
操作してから反応するまでのディレイがかなり気になりました。PC側をPythonで実装したので仕方ないところはありますが、改善の余地ありです。今回はPCを使用しましたが、再生もSpresenseでやってしまえば、もっとレスポンスがよくなるし、BLEも不要になり面白い楽器ができそうだなと思いました。スピーカーをどうするかですね。

あとエフェクトもボリュームと再生速度変更じゃつまらないですね。せっかくなら、エコーやリバーブなどにするともっと面白くなりそうです。音声データの処理の仕方はだいぶ分かってきたので、今後いろいろなアプリケーションを作ってみたいと思います！

技術的な詳しい内容は、今後ブログに随時あげていきたいと思います。


# 参考情報
Coming Soon…