Synthesizer V Pro用リップモーション生成プラグイン
投稿:2021-09-25、更新:2021-11-06
Synthesizer V Proで歌ってもらった曲にMMD(MikuMikuDance)でキャラクターが歌う映像を作る際、 キャラクターに口パク(リップモーション)をさせたい。 USTファイルを出力、UTAUで読みVSQを出力、MMDの「vsqによるリップシンク」で作ることができますけれど、手順が面倒なのとMMDのリップシンクはテンポ変更に対応しません。 Synthesizer V Proではプラグインを自作できますから、いきなりリップモーションを生成できればよい訳です。
2021-11-06 【更新情報】バージョン2です。 ・グループに対応 グループをライブラリからトラックにドロップしてリフレイン的に再利用する機能ですね。 前バージョンはグループを全無視していたので多用したトラックでは口が開きません。 ・口を閉じる子音 「ま」「ぱ」「ば」はフレーズ途中でも口を閉じるようにしました。 ・詳細デバッグ出力 処理中の発音情報をテキストファイルへ出力します。 場所はSynthesizer V Proのプログラムと同じフォルダです。
リップモーションを作りたいトラックを選択してプラグインを起動(メニュー|スクリプト|MMD|リップモーション生成(Lua)を選択)します。
プロジェクトファイルと同じフォルダにlipmotion.vmdファイルを出力しますが新規作成のプロジェクトであればパスがないのでSynthesizer V Proのプログラムと同じフォルダになってしまいます。
あとWindowsの場合はプラグインがutf8、処理中のファイル名はcp932(シフトJISの親戚)になっている都合で漢字のパスやファイル名を使えず、vmdファイルを作成できません。
ドライブのルートか、全てASCIIのパスにしてください。
母音ごとのウエイトはMMDの表情にある数字で0~1の範囲です。
あ~お、んのそれぞれをモデルで表示させて良い感じのウエイトを探してください。
MMDの「vsqによるリップシンク」で作ると大体0.7なのでデフォルトをそれに合わせてあります。
例えば公式弦巻マキSynthesizer V衣装モデルでは「あ=0.5、い=0.4、う=0.7、え=0.5、お=0.8、ん=1.0、接続=60%」で良さそうです。
同じ母音が続く際にウエイトが同じですから口が全く動きません。
すると1音が長く続いているように見えてしまいます。
そこで後続のウエイト直前に3フレームだけウエイトを若干下げて2音に見せるようにしました。
下端のチェックボックスはデバッグ用ですから特に使わないと思います。
もしモーションの動作が期待と違う場合に発生位置のチェックに使えるかも知れません。
せっかくリップモーションを改善したので、ついつい口元が映るアングルにしてしまいます。
バグがあるのが前提ですからSynthesizer V Proのプロジェクトを壊したり、vmdファイルの出力が止まらずに巨大なファイルが生成されたり、MMDで読み込むとMMDのプロジェクトを壊すかも知れません。 くれぐれもバックアップを確保してから作業してください。 プラグインの元ネタとして好きに書き替えてお使いください。 Synthesizer V Proのプラグインがもっと増えますように(-人-)
これです。
-- リップモーション生成
local plugin_name = "リップモーション生成(Lua)"
function getClientInfo()
return {
name = plugin_name,
category = "MMD",
author = "lemorin_jp",
versionNumber = 2,
minEditorVersion = 0
}
end
local fmt_long = "<I4"
local fmt_float = "<f"
local prefs = {
-- 母音毎
-- ウエイト
-- 接続ウエイト
a = { weight = 0.7, display = "あ" },
i = { weight = 0.7, display = "い" },
u = { weight = 0.7, display = "う" },
e = { weight = 0.7, display = "え" },
o = { weight = 0.7, display = "お" },
n = { weight = 0.7, display = "ん" }
}
local seam_ratio = 60
local verbose = false
local verbose_fh = nil
function main()
-- プロジェクトのパス
local path_func = string.gmatch(SV:getProject():getFileName(), ".+\\")
local project_path = path_func()
-- プロジェクトがない場合
if nil == project_path then
project_path = ""
end
-- ダイアログの内容
local myForm = {
title = plugin_name,
message = "母音毎のウエイト",
buttons = "OkCancel",
widgets = {
{
type = "TextBox",
label = "vmdファイル Windowsは漢字NG",
name = "vmdfile",
default = project_path .. "lipmotion.vmd",
},
}
}
local disp_order = {"a", "i", "u", "e", "o", "n", }
local i
local disp
for i, disp in pairs(disp_order) do
table.insert(myForm.widgets, {
type = "Slider",
label = prefs[disp].display,
name = "weight_" .. disp,
format = "%5.3f",
minValue = 0,
maxValue = 1,
interval = 0.01,
default = prefs[disp].weight
})
end
table.insert(myForm.widgets, {
type = "Slider",
label = "接続ウエイト比(同じ母音が続く際の境界)",
name = "seam_ratio",
format = "%3.0f %%",
minValue = 0,
maxValue = 100,
interval = 5,
default = seam_ratio
})
table.insert(myForm.widgets, {
type = "CheckBox",
text = "詳細デバッグ出力(verbose.log)",
name = "verbose",
default = false,
})
-- ダイアログ表示(モーダル)
local result = SV:showCustomDialog(myForm)
if result.status then
-- 設定内容を拾う
local vmdfile = result.answers.vmdfile
-- 出力先に同名ファイルがないか、上書き確認した場合
if not file_exists(vmdfile) or SV:showOkCancelBox(plugin_name, "vmdファイルが既にあります。上書きしてよいですか?\n" .. vmdfile) then
for i, disp in pairs(disp_order) do
prefs[disp].weight = result.answers["weight_" .. disp]
end
seam_ratio = result.answers.seam_ratio
verbose = result.answers.verbose
if verbose then
verbose_fh = io.open("verbose.log", "a")
end
trace("----------")
trace("デバッグ出力開始")
local lyric = grab_lyric()
local boin_shiin = lyric_list2boin_shiin_list(lyric)
local lip = boin_shiin2lip(boin_shiin)
local rc = write_vmd(vmdfile, lip)
trace("デバッグ出力終了")
trace("----------")
if verbose_fh then
verbose_fh:close()
verbose_fh = nil
end
if 1 == rc then
message_box("vmdファイルを出力しました")
else
message_box("vmdファイルを開けません\n" .. vmdfile)
end
end
end
-- スクリプト終了
SV:finish()
end
-- デバッグ出力
function trace(msg)
if nil ~= verbose_fh then
verbose_fh:write(msg .. "\n")
end
end
-- メッセージ表示
function message_box(msg)
SV:showMessageBox(plugin_name, msg)
trace(msg)
end
-- ファイルの有無チェック
function file_exists(file)
local result = false
local fh = io.open(file, "rb")
if nil ~= fh then
fh:close(fh)
result = true
end
return result
end
-- 歌詞収集
function grab_lyric()
trace("歌詞収集開始")
local timeaxis = SV:getProject():getTimeAxis()
local track = SV:getMainEditor():getCurrentTrack()
local lyric_list ={}
-- 選択中のトラックのグループ
local groupnum = track:getNumGroups()
trace("グループ数(1個目はメイン) " .. groupnum)
local group_index
for group_index = 1, groupnum do
trace("----------")
trace("グループ位置 " .. group_index)
-- グループ1個
local notegroup_ref = track:getGroupReference(group_index)
local offset_blick = notegroup_ref:getTimeOffset()
trace("オフセット " .. offset_blick)
local notegroup = notegroup_ref:getTarget()
-- グループ内音符
trace("開始blick 終了blick 開始フレーム 終了フレーム 歌詞")
local num_note = notegroup:getNumNotes()
local note_index
for note_index = 1, num_note do
-- 音符1個
local note = notegroup:getNote(note_index)
-- 歌詞
local lyric = {
start = offset_blick + note:getOnset(),
stop = offset_blick + note:getEnd(),
lyric = note:getLyrics()
}
local frame_start = math.floor(timeaxis:getSecondsFromBlick(lyric.start) * 30)
local frame_stop = math.floor(timeaxis:getSecondsFromBlick(lyric.stop) * 30)
trace(lyric.start .. " " .. lyric.stop .. " " .. frame_start .. " " .. frame_stop .. " " .. lyric.lyric)
table.insert(lyric_list, lyric)
end
trace("----------")
end
trace("発声位置順に並べ替え")
table.sort(lyric_list,
function(a, b)
return (a.start < b.start)
end
)
trace("開始blick 終了blick 開始フレーム 終了フレーム 歌詞")
trace("----------")
local i
for i = 1, #lyric_list do
local frame_start = math.floor(timeaxis:getSecondsFromBlick(lyric_list[i].start) * 30)
local frame_stop = math.floor(timeaxis:getSecondsFromBlick(lyric_list[i].stop) * 30)
trace(lyric_list[i].start .. " " .. lyric_list[i].stop .. " " .. frame_start .. " " .. frame_stop .. " " .. lyric_list[i].lyric)
end
trace("----------")
trace("歌詞収集終了")
return lyric_list
end
-- 歌詞から母音
function lyric_list2boin_shiin_list(lyric_list)
local timeaxis = SV:getProject():getTimeAxis()
local boin = ""
local boin_list = {}
local i
for i = 1, #lyric_list do
local word = lyric_list[i].lyric
-- いくつかの例外は直近の母音を継続
if not string.find("-ーっ", word) then
boin = lyric2boin(word)
end
if "" == boin then
message_box("変換できない歌詞(母音不明) 小節 " .. timeaxis:getMeasureAt(lyric_list[i].start) .. " 歌詞 '" .. word .. "'")
boin = "a"
local before_lyric = ""
local j
for j = i - 5, i do
if 0 < j then
before_lyric = before_lyric .. lyric_list[j].lyric
end
end
message_box("直近の歌詞 '" .. before_lyric .. "'")
end
local shiin = lyric2shiin(word)
-- 発声の開始/終了をopen/closeで
local lyric = {
open = math.floor(timeaxis:getSecondsFromBlick(lyric_list[i].start) * 30),
close = math.floor(timeaxis:getSecondsFromBlick(lyric_list[i].stop) * 30),
boin = boin,
shiin = shiin
}
table.insert(boin_list, lyric)
end
return boin_list
end
function lyric2boin(lyric)
-- 先頭の文字
local capital = utf8_sub(lyric, 1, 1)
-- 母音が分かる文字を含む
local boin
if string.find(lyric, "ぁ") or string.find(lyric, "ァ") or string.find(lyric, "ゃ") or string.find(lyric, "ャ") then
boin = "a"
elseif string.find(lyric, "ぃ") or string.find(lyric, "ィ") then
boin = "i"
elseif string.find(lyric, "ぅ") or string.find(lyric, "ゥ") or string.find(lyric, "ゅ") or string.find(lyric, "ュ") then
boin = "u"
elseif string.find(lyric, "ぇ") or string.find(lyric, "ェ") then
boin = "e"
elseif string.find(lyric, "ぉ") or string.find(lyric, "ォ") or string.find(lyric, "ょ") or string.find(lyric, "ョ") then
boin = "o"
-- 母音
elseif string.find("あアかカがガさサざザたタだダなナはハばバぱパまマやヤらラわワ", capital) then
boin = "a"
elseif string.find("いイきキぎギしシじジちチぢヂにニひヒびビぴピみミりリ", capital) then
boin = "i"
elseif string.find("うウくクぐグすスずズつツづヅぬヌふフぶブぷプむムゆユるル", capital) then
boin = "u"
elseif string.find("えエけケげゲせセぜゼてテでデねネへヘべベぺペめメれレ", capital) then
boin = "e"
elseif string.find("おオこコごゴそソぞゾとトどドのノほホぼボぽポもモよヨろロをヲ", capital) then
boin = "o"
-- ん
elseif string.find("んン", capital) then
boin = "n"
-- 変換できない
else
boin = ""
end
return boin
end
function lyric2shiin(lyric)
-- 先頭の文字
local capital = utf8_sub(lyric, 1, 1)
-- 子音
local shiin
if string.find("あいうえおアイウエオ", capital) then
shiin = ""
elseif string.find("かきくけこカキクケコ", capital) then
shiin = "k"
elseif string.find("がぎぐげごガギグゲゴ", capital) then
shiin = "g"
elseif string.find("さしすせそサシスセソ", capital) then
shiin = "s"
elseif string.find("ざじずぜぞザジズゼゾ", capital) then
shiin = "z"
elseif string.find("たちつてとタチツテト", capital) then
shiin = "t"
elseif string.find("だぢづでどダヂヅデド", capital) then
shiin = "d"
elseif string.find("なにぬねのナニヌネノ", capital) then
shiin = "n"
elseif string.find("はひふへほハヒフヘホ", capital) then
shiin = "h"
elseif string.find("ばびぶべぼバビブベボ", capital) then
shiin = "b"
elseif string.find("ぱぴぷぺぽパピプペポ", capital) then
shiin = "p"
elseif string.find("まみむめもマミムメモ", capital) then
shiin = "m"
elseif string.find("やゆよヤユヨ", capital) then
shiin = "y"
elseif string.find("らりるれろラリルレロ", capital) then
shiin = "r"
elseif string.find("わワ", capital) then
shiin = "w"
-- 変換できない
else
shiin = ""
end
return shiin
end
-- string.subのutf8版
function utf8_sub(str, head, tail)
local head_offset = utf8.offset(str, head)
local tail_offset = utf8.offset(str, tail + 1) - 1
return string.sub(str, head_offset, tail_offset)
end
-- 母音と子音からリップモーション
function boin_shiin2lip(boin_shiin)
-- 予備動作start、余韻動作stopを追加
-- 全体の順番 start open close stop
local i
for i = 1, #boin_shiin do
boin_shiin[i].start = boin_shiin[i].open - 2
boin_shiin[i].stop = boin_shiin[i].close + 2
boin_shiin[i].start_weight = 0
boin_shiin[i].open_weight = prefs[boin_shiin[i].boin].weight
boin_shiin[i].close_weight = prefs[boin_shiin[i].boin].weight
boin_shiin[i].stop_weight = 0
end
for i = 1, #boin_shiin do
-- 口を閉じる子音の前は必ず口を閉じる
if string.find("pbm", boin_shiin[i].shiin) then
-- start open close stop start open close stop
local j = i - 1
while 1 <= j and boin_shiin[i].start <= boin_shiin[j].stop do
if boin_shiin[i].start < boin_shiin[j].start then
boin_shiin[j].start_weight = -1
boin_shiin[j].open_weight = -1
boin_shiin[j].close_weight = -1
boin_shiin[j].stop_weight = -1
elseif boin_shiin[i].start < boin_shiin[j].open then
boin_shiin[j].open = boin_shiin[i].start
boin_shiin[j].open_weight = 0
boin_shiin[j].close_weight = -1
boin_shiin[j].stop_weight = -1
elseif boin_shiin[i].start < boin_shiin[j].close then
boin_shiin[j].close = boin_shiin[i].start - 2
boin_shiin[j].stop = boin_shiin[i].start
boin_shiin[j].stop_weight = 0
elseif boin_shiin[i].start < boin_shiin[j].stop then
boin_shiin[j].stop = boin_shiin[i].start
boin_shiin[j].stop_weight =0
end
j = j - 1
end
else
-- 同じ母音がラップする場合に調整
-- 前へ向かって終了と開始がラップする間スキャン
local j = i - 1
while 1 <= j and boin_shiin[i].start <= boin_shiin[j].stop do
-- 同じ母音
if boin_shiin[j].boin == boin_shiin[i].boin then
-- 発声が切れている場合
if boin_shiin[j].close < boin_shiin[i].open then
-- start open close (stop) start/small open close stop
-- 前の終了を削除
boin_shiin[j].stop_weight = -1
--今の開始を少し閉じ気味して継続
boin_shiin[i].start_weight = boin_shiin[i].start_weight * seam_ratio / 100
else
-- start open close stop/small (start) open close stop
-- 発声が継続する場合
-- 前の開けにぶつからない場合
if boin_shiin[j].open < boin_shiin[i].open - 4 then
-- 前の閉じを今の開けの手前に移動
boin_shiin[j].close = boin_shiin[i].open - 4
-- 前の終了を今の開けの手前へ移動
boin_shiin[j].stop = boin_shiin[i].open - 2
else
-- 前の開けにぶつかる場合は詰め詰め
-- 前の閉じを今の開けの手前に移動
boin_shiin[j].close = boin_shiin[i].open - 2
-- 前の終了を今の開けの手前へ移動
boin_shiin[j].stop = boin_shiin[i].open - 1
end
-- 一旦閉じ気味にする
boin_shiin[j].stop_weight = boin_shiin[j].close_weight * seam_ratio / 100
-- 今の開始を削除
boin_shiin[i].start_weight = -1
end
end
j = j - 1
end
end
end
-- リップモーション生成
-- ウエイトが-1の場合は生成しない
local lip = {}
for i = 1, #boin_shiin do
if 0 <= boin_shiin[i].start_weight then
local lip_start = {
weight = boin_shiin[i].start_weight,
frame = boin_shiin[i].start,
boin = boin_shiin[i].boin
}
table.insert(lip, lip_start)
end
if 0 <= boin_shiin[i].open_weight then
local lip_open = {
weight = boin_shiin[i].open_weight,
frame = boin_shiin[i].open,
boin = boin_shiin[i].boin
}
table.insert(lip, lip_open)
end
if 0 <= boin_shiin[i].close_weight then
local lip_close = {
weight = boin_shiin[i].close_weight,
frame = boin_shiin[i].close,
boin = boin_shiin[i].boin
}
table.insert(lip, lip_close)
end
if 0 <= boin_shiin[i].stop_weight then
local lip_stop = {
weight = boin_shiin[i].stop_weight,
frame = boin_shiin[i].stop,
boin = boin_shiin[i].boin
}
table.insert(lip, lip_stop)
end
end
return lip
end
function write_vmd(vmdfile, lip)
local rc = 0
local fh = io.open(vmdfile, "wb")
if fh then
-- ヘッダ
fh:write(padding_0x00("Vocaloid Motion Data 0002", 30))
-- バージョン
fh:write(padding_0x00("Lip Motion by SynthV", 20))
-- ボーン個数
fh:write(string.pack(fmt_long, 0))
-- ここからリップモーション
-- スキン個数
fh:write(string.pack(fmt_long, #lip))
message_box("モーション個数 " .. #lip)
local bonename = {
a = "\x82\xa0", -- あ
i = "\x82\xa2", -- い
u = "\x82\xa4", -- う
e = "\x82\xa6", -- え
o = "\x82\xa8", -- お
n = "\x82\xf1" -- ん
}
-- その他(想定外)
bonename[""] = "\x82\xa0"
local boin
local name
for boin, name in pairs(bonename) do
bonename[boin] = padding_0x00(name, 15)
end
local frame_min = 99999
local frame_max = -1
for i = 1, #lip do
if lip[i].frame < frame_min then
frame_min = lip[i].frame
end
if frame_max < lip[i].frame then
frame_max = lip[i].frame
end
if lip[i].frame < 0 then
message_box("フレーム位置がマイナス " .. i .. " " .. lip[i].frame)
end
end
message_box("最小フレーム位置 " .. frame_min .. " 最大 " .. frame_max)
for i = 1, #lip do
-- 表情
fh:write(bonename[lip[i].boin])
-- フレーム位置
fh:write(string.pack(fmt_long, lip[i].frame))
-- ウエイト
fh:write(string.pack(fmt_float, lip[i].weight))
end
-- ここまでリップモーション
-- カメラ個数
fh:write(string.pack(fmt_long, 0))
-- 照明個数
fh:write(string.pack(fmt_long, 0))
-- セルフ影個数
fh:write(string.pack(fmt_long, 0))
-- モデル表示個数
fh:write(string.pack(fmt_long, 0))
fh:close()
rc = 1
end
return rc
end
function padding_0x00(str, byte)
local pad = ""
local i
for i = 1, byte do
pad = pad .. "\0"
end
return string.sub(str .. pad, 1, byte)
end