はじめに
こちらの記事は「VCI Advent Calendar 2022」の8日目の記事です!
何ができるVCIを作るのか?
① 単音と長音の2オクターブ分の触ると音が出る球が並ぶ
② 録音モードにすると、上記オブジェクトを触った順番を記録する
③ 再生モードにすると、記録された順番に音を再生する
「VRで音楽を演奏・録音し、再生するVCIを作ろう!」というものです
作業手順
- 音源を用意する
- オブジェクトを並べる
- Luaを書く
- いざ実際に弾いてみる
1.音源を用意する
今回は、AppleのGarageBandからピアノ音源をBPM120の全音、
四分分の長さの「C2(ラ)」~「G4(ソ)」までの約2オクターブ分46音の音源をwavで用意しました。
2.オブジェクトを並べる
Unityで作業をします。
※ VCIを作成できる環境は整っていることを前提にします。
VCI作成環境の整備などは、直近のしろひげさんの記事が参考になります。
1つの音の高さに対して1つのオブジェクトを用意し、ただの球体を23個用意しました。
音の高さに合わせて、なんとなく階段状に演奏者を取り巻くように円形に配置します(Keyboard1~Keyboard23)。
※円形に配置する位置を考えたいときは正多角形の頂点座標のようなサイトが便利です。
また、音を鳴らす予定のオブジェクトにAudio Sourceをつけて、音源のwavファイルを指定しておきます。
触れた時にLuaから操作をしたいので、VCISubItemをオブジェクトに付与し、重力はOFF、IsKinematic、Grabbable、IsTriggerはONにします。
また、演奏モード/録音モード/再生モードを切り替えるための白キューブ「ModeController」を用意します。
さらに、録音開始や再生開始を制御するための水色キューブ「StartController」を用意します。
最後に、録音した情報を初期化する黒キューブ「InitializationObject」を用意します。
キューブも触れた時にLuaから操作をしたいので、VCISubItemをオブジェクトに付与し、重力はOFF、IsKinematic、Grabbable、IsTriggerはONにします。
3.Luaを書く
3-1. 音がなるようにする
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
上記のコードでは、球体に触れると、その球体に紐づけている高さの短い音がなるようになります。
3-2. 左手は長い音、右手は短い音にする
長い音と短い音をどう切り替えるかは迷いました。
今回は右で触れたら長い音、左で触れたら短い音にしてみます。
コードは以下のようにします。
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につくります。
まずは、どのモード家を示すだけの変数を用意します。
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. 録音モードをつくる
演奏した内容を記録するための仕組みを用意します。
録音モードのときに、録音を開始すると録音が始まり、
録音を止めると録音が終わるようにします。
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を読み出して、フレームカウントに従って再生しなおすことで実現します。
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用のオブジジェクの配下に加えます。
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. リセットできるようにする
最後に録音したものを消したり、元の状態に戻すための機能をつけます。
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-?. コード全景
同期周りが良くなかったり、負荷をかけすぎだったりと不備もあります。
しかし、これで目的は無理やり達成できました。
コードを一応全部のせておきます。
-- 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.いざ実際に弾いてみる
なお、今回作った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月のことです。
当時作ったのは、今日説明した機能だけでなく、配信のコメントの文字列を楽譜として判定して音楽を弾くみたいなものも実装していました。
ただ、色々できて面白い一方、何でもできることで、「どうしたら楽器として良いものなのか?」「どうなっていたら面白いものなのかがわからない?」というような壁にあたりました。
未だに、このどうしたら?の答えは掴めていません。
誰か発明求む!
VRならではの、物理接触を無視しつつ操作可能な楽器という可能性はあると思いつつ、
自分はまだ、革新的ないい形を想像できていません。
でも、絶対あると思うのですよね・・・
なので、どなたか絵だけでも妄想を描ける方がいたら、それをわたしに教えてください。
この記事に書かれたやり方を真似していただいてもいいですし、
今後Vキャスに来る新たな機能でできることはもっと広がるかと思います。
自分で作れる方は、ぜひ作ってください!
空間操作時代の新しい楽器に思いを馳せて、記事はおわりにいたします!