VCIで新たな楽器に挑戦してみる


## はじめに
こちらの記事は「[VCI Advent Calendar 2022](https://qiita.com/advent-calendar/2022/vci)」の8日目の記事です！



## 何ができるVCIを作るのか？

#### ①　単音と長音の2オクターブ分の触ると音が出る球が並ぶ<br>②　録音モードにすると、上記オブジェクトを触った順番を記録する<br>③　再生モードにすると、記録された順番に音を再生する<br>「VRで音楽を演奏・録音し、再生するVCIを作ろう！」というものです

## 作業手順
1. 音源を用意する
2. オブジェクトを並べる
3. Luaを書く
4. いざ実際に弾いてみる

## 1.音源を用意する
今回は、AppleのGarageBandからピアノ音源をBPM120の全音、  
四分分の長さの「C2(ラ)」～「G4(ソ)」までの約2オクターブ分46音の音源をwavで用意しました。

## 2.オブジェクトを並べる
Unityで作業をします。  
※　VCIを作成できる環境は整っていることを前提にします。
　　VCI作成環境の整備などは、直近のしろひげさんの[記事](https://qiita.com/sirohige/items/e4b19f8b7cb95910467e)が参考になります。

1つの音の高さに対して1つのオブジェクトを用意し、ただの球体を23個用意しました。  
音の高さに合わせて、なんとなく階段状に演奏者を取り巻くように円形に配置します（Keyboard1~Keyboard23）。  
※円形に配置する位置を考えたいときは[正多角形の頂点座標](https://keisan.casio.jp/exec/user/1334446988)のようなサイトが便利です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222644/4e4e8483-df6d-5958-eec0-43b5bc4f07bf.png)

また、音を鳴らす予定のオブジェクトにAudio Sourceをつけて、音源のwavファイルを指定しておきます。
触れた時にLuaから操作をしたいので、VCISubItemをオブジェクトに付与し、重力はOFF、IsKinematic、Grabbable、IsTriggerはONにします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222644/1a8e6154-ac5b-42dc-37d4-30fa2851496a.png)

また、演奏モード/録音モード/再生モードを切り替えるための白キューブ「ModeController」を用意します。  
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222644/c60637a0-1ef4-b60a-4e6a-711f5841c328.png)
さらに、録音開始や再生開始を制御するための水色キューブ「StartController」を用意します。  

最後に、録音した情報を初期化する黒キューブ「InitializationObject」を用意します。   
キューブも触れた時にLuaから操作をしたいので、VCISubItemをオブジェクトに付与し、重力はOFF、IsKinematic、Grabbable、IsTriggerはONにします。

これでオブジェクトの用意は完了です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222644/288296ab-a77d-baf9-fa25-c4f54cad6770.png)


## 3.Luaを書く

### 3-1. 音がなるようにする
```Lua
local SubItemList = {}
for i= 1, 23 do
   SubItemList["Keyboard"..tostring(i)]=vci.assets.GetTransform("Keyboard"..tostring(i))
end
SubItemList["ModeController"]=vci.assets.GetTransform("ModeController")
SubItemList["LengthController"]=vci.assets.GetTransform("LengthController")
SubItemList["InitializationObject"]=vci.assets.GetTransform("InitializationObject")

function onTriggerEnter(target,hit)
   if (hit == "RightHand" or hit == "LeftHand") then
      vci.assets._ALL_PlayAudioFromName(string.gsub(target, "Keyboard", "").."_S")
   end
end
```
上記のコードでは、球体に触れると、その球体に紐づけている高さの短い音がなるようになります。

![2022120819084363_検証ルーム_しきはふり.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222644/7d67ebe8-61b6-3ec1-de0d-ae4dc047d44a.jpeg)

### 3-2. 左手は長い音、右手は短い音にする
長い音と短い音をどう切り替えるかは迷いました。
今回は右で触れたら長い音、左で触れたら短い音にしてみます。

コードは以下のようにします。
```Lua
function onTriggerEnter(target,hit)
   if (hit == "RightHand") then
      vci.assets._ALL_PlayAudioFromName(string.gsub(target, "Keyboard", "").."_L")
   elseif (hit == "LeftHand") then
      vci.assets._ALL_PlayAudioFromName(string.gsub(target, "Keyboard", "").."_S")
   end
end
```
```if (hit == "RightHand") then```と```elseif (hit == "LeftHand") then```が触れた手で分岐する記述です。


### 3-3. 演奏モード/録音モード/再生モードの切り替えを作る

演奏できる状態、録音ができる状態、再生ができる状態
この3つのモードをVCIにつくります。
まずは、どのモード家を示すだけの変数を用意します。

```Lua
local mode = 1
vci.state.Set("mode",mode)

function onUnuse(use)
   if (use == "ModeController") then
      if vci.state.Get("mode") > 2 then
         mode = 1
         vci.state.Set("mode",mode)
      else
         mode = vci.state.Get("mode")+1
         vci.state.Set("mode",mode)
      end
      print(mode)
   end
end
```
ModeControllerオブジェクトをUseすると、モードが1,2,3と切り替わるようにしました。
3まで行ったら1に戻るようにしています。
これで、3つのモードが切り替わるようになりました。

なお、自分だけでなく、周りの人も同期するように、
```vci.state.Set("mode",mode)```でstate保存もします。

### 3-4. 録音モードをつくる
演奏した内容を記録するための仕組みを用意します。
録音モードのときに、録音を開始すると録音が始まり、
録音を止めると録音が終わるようにします。
```Lua
local recording_mem = {}
local rc = 0


-- tableを文字列に
function table_to_string(tbl)
   res = ""
   for k, v in pairs (tbl) do
      res = res..tostring(k)..":"..v..","
   end
   return res
end

function onUnuse(use)
   if (use == "ModeController" and recording == false) then
      -- 省略
   elseif (vci.state.Get("mode") == 2 and use == "StartController") then
      if recording == true then
         recording = false
         vci.state.Set("recording_mem",table_to_string(recording_mem))
         print(vci.state.Get("recording_mem"))
         rc = 0
      else
         recording_mem = {}
         recording = true
         vci.state.Set("recording_mem",recording_mem)
      end
   end
end

function onTriggerEnter(target,hit)
   if (target != "ModeController" and target != "StartController" and target != "InitializationObject") then 
      if (hit == "RightHand" or hit == "LeftHand") and vci.state.Get("mode") == 1 then
         -- 省略
      elseif (hit == "RightHand" or hit == "LeftHand") and vci.state.Get("mode") == 2 and recording == true then
         if (hit == "RightHand") then
            vci.assets._ALL_PlayAudioFromName(string.gsub(target, "Keyboard", "").."_L")
            recording_mem[rc] = string.gsub(target, "Keyboard", "").."_L"
         elseif (hit == "LeftHand") then
            vci.assets._ALL_PlayAudioFromName(string.gsub(target, "Keyboard", "").."_S")
            recording_mem[rc] = string.gsub(target, "Keyboard", "").."_S"
         end
      end
   end
end

function update()
   if recording == true then
      if rc > 1000 then
         recording = false
        vci.state.Set("recording_mem",table_to_string(recording_mem))
         print(recording_mem)
      else
         rc = rc+1
      end
   end
end
```
録音モードのときは最初、オブジェクトに触れても音が出ないようにしておきます。
そして、録音モードのときに、StartControllerをUseしたら、録音を開始状態（recording=trueに）します。

録音が開始状態になると、update関数でフレームカウントをスタートさせます。
そして、球体に触れると、その球体でならせた音をフレームカウントと一緒に保存します。
具体的にはkeyをフレームカウント、valueをならせた音の名前にした「recording_mem」というテーブルに情報を保存していきます。

そして、StartControllerをもう一度Useしたら、録音の開始状態から、停止状態に戻します。
同時に、Stateにrecording_memを保存します。
```vci.state.Set("recording_mem",table_to_string(recording_mem))```
※Stateにはテーブルが保存できないので、文字列にしてから保存しています。

なお、録音中に他のモードに移ると困るので、recording=trueのときはモード変更は不能にします。
また、録音時間が長すぎると困るので、1000フレーム過ぎたら自動でとまるようにしました。

これで録音モードができました。

### 3-5. 再生モードをつくる

再生モードのときは、演奏はできなくて良いので、球体に触っても音がならないようにします。
そして、再生の本体は、録音モードで保存されたrecording_memを読み出して、フレームカウントに従って再生しなおすことで実現します。

```Lua
local play = false

-- 文字列を特定文字を判定して切り分ける
function split(str, ts)
   if ts == nil then return {} end
 
   local t = {} ; 
   i=1
   for s in string.gmatch(str, "([^"..ts.."]+)") do
     t[i] = s
     i = i + 1
   end
   return t
 end

-- 文字列をtableに
function string_to_table(str)
   res = {}
   l = split(str,",")
   print(l)
   for k, v in pairs(l) do
      vi = split(v, ":")
      res[tonumber(vi[1])]=vi[2]
   end
   return res
end

-- テーブルにキーがあることを確認する
function table.in_key (tbl, key)
   for k, v in pairs (tbl) do
       if k==key then return true end
   end
   return false
end

-- useをしたときの処理
function onUnuse(use)
   if (use == "ModeController" and play == false and recording == false) then
      if vci.state.Get("mode") > 2 then
          -- 省略
      else
         mode = vci.state.Get("mode")+1
         rc = 0
         vci.state.Set("mode",mode)
         if mode == 3 then
            recording_mem = string_to_table(vci.state.Get("recording_mem"))
         end
      end
   elseif (vci.state.Get("mode") == 2 and use == "StartController") then
      -- 省略
   elseif (vci.state.Get("mode") == 3 and use == "StartController") then
      if play == true then
         play = false
      else
         play = true
      end
   end
end

function update()
   if recording == true then
      -- 省略
   elseif play == true then
      if rc > 1000 then
         play = false
         rc = 0
      else
         rc = rc+1
         if table.in_key (recording_mem, rc) then
            vci.assets._ALL_PlayAudioFromName(recording_mem[rc])
         end
      end
   end
end
```

関数が増えましたが、文字列をテーブルに戻すための関数関連が（string_to_table）が大部分です。

再生モードに切り替わるタイミングで、Stateから録音記録を取得し、文字列からテーブルに変換しておきます。```string_to_table(vci.state.Get("recording_mem"))```

再生モードのときに、StartControllerをUseしたら、録音の開始と同じように、再生を開始（play=true）にします。
再生が開始状態になると、update関数でフレームカウントをスタートさせます。
そして、フレームカウントと録音記録にあるkeyが一致する時に、そのkeyとセットに保存されている音名のAudioを鳴らします。

これで再生モードも完成しました。

### 3-6. 現在の状態を表示する
機能はできましたが、これでは、今何ができるモードか、再生中なのか停止中なのかもわかりません。
そこで、文字で表示するようにします。

UnityでTextを各Controller用のオブジジェクの配下に加えます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222644/c7b86145-3e4b-9942-123a-c35ecc57b89a.png)

Lua側で、モードの切替や、再生時に文字列が変わるように記述をします。
```Lua
function onUnuse(use)
   if (use == "ModeController" and play == false and recording == false) then
      if vci.state.Get("mode") > 2 then
         mode = 1
         rc = 0
         vci.state.Set("mode",mode)
         vci.assets._ALL_SetText("ModeControllerText","演奏")
      else
         mode = vci.state.Get("mode")+1
         rc = 0
         vci.state.Set("mode",mode)
         if mode == 3 then
            recording_mem = string_to_table(vci.state.Get("recording_mem"))
            vci.assets._ALL_SetText("ModeControllerText","再生")
         else
            vci.assets._ALL_SetText("ModeControllerText","録音")
         end
      end
   elseif (vci.state.Get("mode") == 2 and use == "StartController") then
      if recording == true then
         recording = false
         vci.state.Set("recording_mem",table_to_string(recording_mem))
         print(vci.state.Get("recording_mem"))
         rc = 0
         vci.assets._ALL_SetText("StartControllerText","停止")
      else
         recording_mem = {}
         recording = true
         vci.state.Set("recording_mem",recording_mem)
         vci.assets._ALL_SetText("StartControllerText","録音中")
      end
   elseif (vci.state.Get("mode") == 3 and use == "StartController") then
      if play == true then
         play = false
         vci.assets._ALL_SetText("StartControllerText","停止")
      else
         play = true
         vci.assets._ALL_SetText("StartControllerText","再生中")
      end
   end
end
```
これで現在のモード、録音、再生、停止が見えるようになりました。


### 3-7. リセットできるようにする

最後に録音したものを消したり、元の状態に戻すための機能をつけます。

```Lua
function initialization()
   mode = 1
   vci.state.Set("mode",mode)
   recording_mem = {}
   vci.state.Set("recording_mem",table_to_string(recording_mem))
   recording = false
   rc = 0
   play = false
   vci.assets._ALL_SetText("ModeControllerText","演奏")
   vci.assets._ALL_SetText("StartControllerText","停止")
end
-- useをしたときの処理
function onUnuse(use)
   if (use == "ModeController" and play == false and recording == false) then
      -- 省略
   elseif (vci.state.Get("mode") == 2 and use == "StartController") then
      -- 省略
   elseif (vci.state.Get("mode") == 3 and use == "StartController") then
      -- 省略
   elseif use == "InitializationObject" then
      initialization()
   end
end
```
初期化関数（initialization）を作り、InitializationObjectがUseされたら実行するようにします。
録音やモードの状態を削除するようにしました。

### 3-?. コード全景
同期周りが良くなかったり、負荷をかけすぎだったりと不備もあります。
しかし、これで目的は無理やり達成できました。

コードを一応全部のせておきます。
```Lua
-- modeは1が演奏モード、2が録音モード、3が再生モードとする
local mode = 1
local recording = false
local recording_mem = {}
local rc = 0
local play = false
vci.state.Set("mode",mode)

local SubItemList = {}
for i= 1, 23 do
   SubItemList["Keyboard"..tostring(i)]=vci.assets.GetTransform("Keyboard"..tostring(i))
end
SubItemList["ModeController"]=vci.assets.GetTransform("ModeController")
SubItemList["StartController"]=vci.assets.GetTransform("LengthController")
SubItemList["InitializationObject"]=vci.assets.GetTransform("InitializationObject")

-- 文字列を特定文字を判定して切り分ける
function split(str, ts)
   if ts == nil then return {} end
   local t = {} ; 
   i=1
   for s in string.gmatch(str, "([^"..ts.."]+)") do
     t[i] = s
     i = i + 1
   end
   return t
 end

-- tableを文字列に
function table_to_string(tbl)
   res = ""
   for k, v in pairs (tbl) do
      res = res..tostring(k)..":"..v..","
   end
   return res
end

-- 文字列をtableに
function string_to_table(str)
   res = {}
   l = split(str,",")
   print(l)
   for k, v in pairs(l) do
      vi = split(v, ":")
      res[tonumber(vi[1])]=vi[2]
   end
   return res
end

-- 初期化
function initialization()
   mode = 1
   vci.state.Set("mode",mode)
   recording_mem = {}
   vci.state.Set("recording_mem",table_to_string(recording_mem))
   recording = false
   rc = 0
   play = false
   vci.assets._ALL_SetText("ModeControllerText","演奏")
   vci.assets._ALL_SetText("StartControllerText","停止")
end

-- テーブルにキーがあることを確認する
function table.in_key (tbl, key)
   for k, v in pairs (tbl) do
       if k==key then return true end
   end
   return false
end

-- useをしたときの処理
function onUnuse(use)
   if (use == "ModeController" and play == false and recording == false) then
      if vci.state.Get("mode") > 2 then
         mode = 1
         rc = 0
         vci.state.Set("mode",mode)
         vci.assets._ALL_SetText("ModeControllerText","演奏")
      else
         mode = vci.state.Get("mode")+1
         rc = 0
         vci.state.Set("mode",mode)
         if mode == 3 then
            recording_mem = string_to_table(vci.state.Get("recording_mem"))
            vci.assets._ALL_SetText("ModeControllerText","再生")
         else
            vci.assets._ALL_SetText("ModeControllerText","録音")
         end
      end
   elseif (vci.state.Get("mode") == 2 and use == "StartController") then
      if recording == true then
         recording = false
         vci.state.Set("recording_mem",table_to_string(recording_mem))
         print(vci.state.Get("recording_mem"))
         rc = 0
         vci.assets._ALL_SetText("StartControllerText","停止")
      else
         recording_mem = {}
         recording = true
         vci.state.Set("recording_mem",recording_mem)
         vci.assets._ALL_SetText("StartControllerText","録音中")
      end
   elseif (vci.state.Get("mode") == 3 and use == "StartController") then
      if play == true then
         play = false
         vci.assets._ALL_SetText("StartControllerText","停止")
      else
         play = true
         vci.assets._ALL_SetText("StartControllerText","再生中")
      end
   elseif use == "InitializationObject" then
      initialization()
   end
end

function onTriggerEnter(target,hit)
   if (target != "ModeController" and target != "StartController" and target != "InitializationObject") then 
      if (hit == "RightHand" or hit == "LeftHand") and vci.state.Get("mode") == 1 then
         if (hit == "RightHand") then
            vci.assets._ALL_PlayAudioFromName(string.gsub(target, "Keyboard", "").."_L")
         elseif (hit == "LeftHand") then
            vci.assets._ALL_PlayAudioFromName(string.gsub(target, "Keyboard", "").."_S")
         end
      elseif (hit == "RightHand" or hit == "LeftHand") and vci.state.Get("mode") == 2 and recording == true then
         if (hit == "RightHand") then
            vci.assets._ALL_PlayAudioFromName(string.gsub(target, "Keyboard", "").."_L")
            recording_mem[rc] = string.gsub(target, "Keyboard", "").."_L"
         elseif (hit == "LeftHand") then
            vci.assets._ALL_PlayAudioFromName(string.gsub(target, "Keyboard", "").."_S")
            recording_mem[rc] = string.gsub(target, "Keyboard", "").."_S"
         end
      end
   end
end

function update()
   if recording == true then
      if rc > 1000 then
         recording = false
         vci.state.Set("recording_mem",table_to_string(recording_mem))
         rc = 0
      else
         rc = rc+1
      end
   elseif play == true then
      if rc > 1000 then
         play = false
         rc = 0
      else
         rc = rc+1
         if table.in_key (recording_mem, rc) then
            vci.assets._ALL_PlayAudioFromName(recording_mem[rc])
         end
      end
   end
end
```


## 4.いざ実際に弾いてみる
![2022120822033713_検証ルーム_しきはふり.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222644/11369bff-8387-f340-81a3-8a4d854c2cd1.jpeg)

なお、今回作ったVCIは、こちらの商品として出しています。
※ちょっと音が大きいかも
https://seed.online/products/ad3e8d87aa8296464d6d4d400ae856ea5d90aa55ef48adb493fd41bf83d8ba6d

もしよければ試してみてください。


## おまけ
### やろうと思った源流
時は4年以上前の・・・2018年6月頃  
Vキャス人気枠が一つ「放課後VR部活動」によく登場していた「雌落ち兄貴」が楽器を作っていました。  
VR内でデスクトップの画面を触ると音が出るというようなものです。

はふりは、この雌落ち兄貴の楽器にとても感動し、HITMLで音が出るだけのWebページを作り「メスピアノ」を作りました。
名前はあれですが、VR内でデスクトップモード画面を触り、音がなるというものです。

ここから、VRの世界の中で弾く楽器というものをつくってみたいと考えています。


### VCIの登場したので、VRならではを考えるが・・・
VCIはこれまでの記事にあるように、VirtualCast内でアレコレできる便利アイテムの規格です。
2018年6月には無かったものですが、これが年末に公開されます。

そこで、VR内楽器を作ってみることにしました。2019年8月のことです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222644/b1a5799b-f397-8b0d-6b5d-06243135a808.png)

当時作ったのは、今日説明した機能だけでなく、配信のコメントの文字列を楽譜として判定して音楽を弾くみたいなものも実装していました。
ただ、色々できて面白い一方、何でもできることで、「どうしたら楽器として良いものなのか？」「どうなっていたら面白いものなのかがわからない？」というような壁にあたりました。

未だに、このどうしたら？の答えは掴めていません。

### 誰か発明求む！

VRならではの、物理接触を無視しつつ操作可能な楽器という可能性はあると思いつつ、  
自分はまだ、革新的ないい形を想像できていません。  
でも、絶対あると思うのですよね・・・  

なので、どなたか絵だけでも妄想を描ける方がいたら、それをわたしに教えてください。  
この記事に書かれたやり方を真似していただいてもいいですし、  
今後Vキャスに来る新たな機能でできることはもっと広がるかと思います。  

自分で作れる方は、ぜひ作ってください！  

空間操作時代の新しい楽器に思いを馳せて、記事はおわりにいたします！