U.F.O SAをPythonistaで操作する方法（csvファイルとの対応）

前回は、PythonistaでU.F.O. SAを操作することができた。

しかし、U.F.O SAの真価は、音声や動画との連動において発揮される。

今回は、音声ファイルとそれに対応するcsvファイルを用いて、U.F.O SAを音声連動させる方法を考える。

#### 準備するもの
- U.F.O. SA
- iPhone or iPad
- Pythonista （有料アプリ）

#### 参考にしたサイト

[騎士の物語](https://yihuishou.github.io/2020/10/10/2/)
中国語のサイトです。Google翻訳を駆使してください。

[Pythonista cb module](https://omz-software.com/pythonista/docs/ios/cb.html)
Pythonista公式のcb moduleのチュートリアル。

[Pythonista sound module](http://omz-software.com/pythonista/docs/ios/sound.html)
Pythonista公式のsound moduleのチュートリアル。

# ディレクトリ構成
ディレクトリ構成は以下の通りとします。

root/
  ├─ main.py
  ├─ music/
  │　　└─ hoge.mp3
  └─ csv/
  　　　└─ hoge.csv

音声ファイルは、標準のファイルアプリ、または、icloudから、Pythonistaフォルダに入れるなど、頑張って入れてください。（うまく移せないときがあった気がしますが、どうやって移せるようにしたか忘れました。）

```python:man.py
music_path = "/music/hoge.mp3"
csv_path = "/csv/hoge.csv"
```

# 音声ファイルの再生
Pythonistaには、音声ファイルを再生する[soundモジュール](http://omz-software.com/pythonista/docs/ios/sound.html)が存在する。

そのため、簡単に音声ファイルを実行することができる。

音声ファイルの再生は以下のコードでできます。

```python:
import sound

audio = sound.Player(music_path)
audio.play()
```

さらに、`audio.current_time =`で秒数を代入することで、再生開始時間を変更することができます。

# csvファイルを読み解く

一般的にU.F.O SA用に配布されているcsvファイルの中身は以下のようになっています。

| Time / ms | rotation direction | rotation speed |
|:-:|:-:|:-:|
| 10 | 0 | 20 |
| 30 | 1 | 30 |
| 90 | 0 | 10 |

:::note warn
ヘッダーは、見やすさのために着けただけで、実際のファイルには書いてありません。
:::

- 時間: スタートからの経過時間。単位が[ms]であることに注意。
- 回転方向: 0が右回転、1が左回転。
- 回転速度: 0–100の整数値。

上の例では、10 ms経ってから1つ目の命令（回転速度20で右回転）を実行します。
次にスタートから30 ms経った時点で2つ目の命令（回転速度30で左回転）を実行します。
1つ目と2つ目の間の20 msは、1つ目の命令が実行され続けます。

これをリストに格納するためにcsvモジュールを用います。

```python:main.py
with open(csv_path) as f:
	reader = csv.reader(f)
	csv_data = [map(int, r) for r in reader]
```

# 命令をU.F.O SAに送る

前回と同様に、cbモジュールを用いて、命令を送信します。
まずは、用いるモジュールをインポートします。

```python:main.py
import csv
import time
import cb
import sound
```

再生時間の取得にaudio.current_timeを用いたいのですが、秒数を整数型で返すので、大雑把です。
したがって、時間取得のためにtimeモジュールを入れています。

`cb.set_central_delegate()`の引数となるクラスのメソッドである`did_discover_characteristics()`に以下を前回同様に以下のようにします。

```python:main.py
def did_discover_characteristics(self, s, error):
    print('Did discover characteristics...')
    for c in s.characteristics:
        print(c.uuid)
        if c.uuid == CHAR_SSID:
            self.peripheral.set_notify_value(c, True)
            self.send_rotation_commands(c)
```

そして、`send_rotation_commands()`の中身を次のように書きます。

```python:main.py
def send_rotation_commands(self, c):
    audio = sound.Player(path)
    audio_start_time = 0　# 再生開始時間 / seconds
    audio.current_time = audio_start_time
    audio.play()
    start = time.time()
    for t, d, s in csv_data:
        while time.time()-start <= t/10 - audio_start_time:
            pass
        if time.time()-start <= t/10 - audio_start_time+0.1:
        	s = int(s*0.8)
          	b = b'\x02\x01'+(([0x00, 0x01][d] << 7) | s).to_bytes(1, byteorder='big')
          	self.peripheral.write_characteristic_value(c, b, True)
           	print(t, b)
    while audio.playing:
      	pass
```

再生時間を`audio_start_time`に代入し、`audio.current_time`に格納することで、再生開始時間を設定します。
再生開始時に`time.time()`で開始時間を取得しています。

```python:main.py
for t, d, s in csv_data:
    while audio_start_time + time.time() - start <= t/10:
        pass
    if audio_start_time + time.time() - start <= t/10 + 0.1:
       	b = b'\x02\x01'+(([0x00, 0x01][d] << 7) | s).to_bytes(1, byteorder='big')
       	self.peripheral.write_characteristic_value(c, b, True)
       	print(t, b)
```

for文でt, d, sにそれぞれ、時間と回転方向、回転速度を代入します。

while節では、（再生開始時間 + 経過時間） <= （命令の時間）である限り、待機します。
tは[ms]なので、t/10で秒に直してあげる必要があります。

それをわずかに超えた、（再生開始時間 + 経過時間） <= （命令の時間）+ 0.1を満たす場合のみif節の中身を実行します。
つまり、（命令の時間） < （再生開始時間 + 経過時間） <= （命令の時間）+ 0.1のときのみ実行するということです。

途中から再生を始めた際に、（再生開始時間 + 経過時間） <= （命令の時間）+ 0.1の条件がないと、再生開始時間前に時間設定されている命令が一気に実行されるため、ラグが発生します。この条件により、命令の時間が再生時間より0.1秒以上短い命令は除去しています。

これによって、音声ファイルとそれに対応するcsvファイルを用いて、U.F.O SAを音声連動させることができます。

# コード全文
```python:main.py
import csv
import time
import cb
import sound


SERVISE_SSID = '40EE1111-63EC-4B7F-8CE7-712EFD55B90E'
CHAR_SSID = '40EE2222-63EC-4B7F-8CE7-712EFD55B90E'

music_path = "/music/hoge.mp3"
csv_path = "/csv/hoge.csv"

AUDIO_START_TIME = 0

with open(csv_path) as f:
	reader = csv.reader(f)
	csv_data = [map(int, r) for r in reader]

class UFOSAManager (object):
    def __init__(self):
        self.peripheral = None

    def did_discover_peripheral(self, p):
        print(p.name)
        if p.name and 'UFOSA' in p.name and not self.peripheral:
            self.peripheral = p
            print('Connecting UFO SA...')
            cb.connect_peripheral(p)

    def did_connect_peripheral(self, p):
        print('Connected:', p.name)
        print('Discovering services...')
        p.discover_services()

    def did_fail_to_connect_peripheral(self, p, error):
        print('Failed to connect: %s' % (error,))

    def did_disconnect_peripheral(self, p, error):
        print('Disconnected, error: %s' % (error,))
        self.peripheral = None

    def did_discover_services(self, p, error):
        for s in p.services:
            if s.uuid == SERVISE_SSID:
                print('Discovered UFO SA servise, discovering characteristitcs...')
                p.discover_characteristics(s)

    def did_discover_characteristics(self, s, error):
        print('Did discover characteristics...')
        for c in s.characteristics:
            print(c.uuid)
            if c.uuid == CHAR_SSID:
                self.peripheral.set_notify_value(c, True)
                self.send_rotation_commands(c)
                
    def did_write_value(self, c, error):
        print('Did enable UFO SA')
        cb.reset()
        exit()
        
    def did_update_value(self, c, error):
        print('test')

    def send_rotation_commands(self, c):
        audio = sound.Player(path)
        audio.current_time = AUDIO_START_TIME
        audio.play()
        start = time.time()
        for t, d, s in csv_data:
            while audio_start_time + time.time() - start <= t/10:
                pass
            if audio_start_time + time.time() - start <= t/10 + 0.1:
              	b = b'\x02\x01'+(([0x00, 0x01][d] << 7) | s).to_bytes(1, byteorder='big')
              	self.peripheral.write_characteristic_value(c, b, True)
               	print(t, b)
        while audio.playing:
      	    pass
    

mngr = UFOSAManager()
cb.set_central_delegate(mngr)
print('Scanning for peripherals...')
cb.scan_for_peripherals()

try:
    while True: pass
except KeyboardInterrupt:
    # Disconnect everything:
    cb.reset()
```

# 最後に

これで、iPhoneから気軽にU.F.O SAの音声連動が可能になりました。
基本的にサイクロンSAやピストンSAでも方法は同じであると考えられます。

細かいところを詰めるとさらに快適に使えるはずですので、適宜、修正してください。

Pythonistaは無限の可能性を秘めているアプリですので、もっともっと活用していけたらと思います。