この記事は、Elixir Advent Calendar 2025その1 の22日目です
昨日は @t-yamanashi さんで 「Hugging Face形式をgguf形式にしてElixirでOllamaを使う」 でした
piacere です、ご覧いただいてありがとございます ![]()
先日、非力なノートPCで動くVRローカルLLM会話エージェント「piaちゃん」と一緒に登壇してきましたが、同じものを作るノウハウをコラム化しました
明日13時半の登壇、「『エッジ×分散生成AI』の技術と変わる産業+ITの未来」にて、VRローカルAIエージェント「piaちゃん」と共にセッションしますよー💁♂️https://t.co/TK3foOZFKb
— piacere@厨二病だがSFで現実を書き換えるエンジニア ▷ Elixir xデジタルツインxAI (@piacere_ex) December 18, 2025
LM StudioやローカルDify+ローカルLLM、ElixirでのAIエージェント開発もあれば、AIスロップ/ワークスロップのネタも😉 pic.twitter.com/5sk4bE0EE5
Ollama+Web Speech API+VOICEVOXによる会話AIエージェントを下記コラムにて作りましたが、そこにVRアバター「piaちゃん」によるビジュアル/挙動と、LINEやChatGPT、Geminiのような会話履歴(最新会話への追従スクロールも)を追加したものとなります
Elixirアドベントカレンダー、応援お願いします
今年もやっています
全体像
下記をLiveView上で実装します
- ①VRアバター「piaちゃん」がゆらゆら揺れながら待ち受ける
- ②Web Speech APIで音声入力を受け付け、喋りかけられるようにする
- ③喋りかけた内容をローカルLLMエンジン「Ollama」にて推論し、回答文を作る
- ④音声合成エンジン「VOICEVOX」でAI推論結果を喋らす
- ⑤喋る内容に合わせて、piaちゃんの口を動かす
- ⑥やり取りした履歴を会話形式UIで残す
コラムで使った環境は、下記CPU/GPU/メモリ/OSがセットアップされたノートPCです
- Ryzen 7 6800U with Redeon
- Windows 11
- WSL2 Ubuntu 24.04
- Erlang 28.1.1
- Elixir 1.19.3-otp-28
- Phoenix 1.8.1
- Chrome ※ブラウザがChromeかEdgeで無いと動きません
上記中、2025年11月時点の最新Elixir/ErlangとPhoenixのインストール方法は、下記コラムで解説しています
①VRアバター「piaちゃん」のロード/待ち受け
Resonite で使っているのと同じVRアバター「piaちゃん」のVRMデータをLiveView上でロードし、@t-yamanashi さん作のThree.jsとLiveViewを接続するJavaScript Hook(phx-hook)でHTML上描画や位置(position)と回転(rotation)の調整、ボーン(rotation_bone)の調整、口(set_blend_shape)の調整を行っています
なお、ロード時にpiaちゃんを常時ゆらゆら揺らしたり、瞬きさせるためのバックグラウンドタスク2つも起動します
<div id="threejs" phx-hook="threejs" phx-update="ignore" data-data={@data}></div>
@impl true
def mount(_params, _session, socket) do
pid = self()
socket = socket
|> assign(conversations: [])
|> assign(text: "")
|> assign(is_recording: false)
|> assign(prompt: "")
|> assign(old_sentence_count: 1)
|> assign(sentences: [])
|> assign(old_sentences: [])
|> assign(talking_no: 0)
|> assign(talking: false)
|> assign(max_talking_no: 0)
|> assign(pid: pid)
|> assign(data: 0)
|> load_model("test", "images/meiden.vrm")
{:ok, socket}
end
@impl true
def handle_event("load_model", %{"name" => "test", "status" => "completion"}, socket) do
socket =
socket
|> position("test", 0.0, -1.2, 4.85)
|> rotation("test", -0.1, 3.2, 0.0)
|> rotation_bone("test", "J_Bip_R_UpperArm", -1.0, 1.2, 0.5)
|> rotation_bone("test", "J_Bip_L_UpperArm", -1.0, -1.2, -0.3)
|> set_blend_shape("test", "aa", 0) # aa/ih/ou/ee/ohで口を開く、blinkで目を閉じる、happy、angry、sad、relaxed
Task.start_link(fn -> blink(socket.assigns.pid) end)
Task.start_link(fn -> move(socket.assigns.pid, 1) end)
{:noreply, socket}
end
piaちゃんを常時ゆらゆら揺らすのは下記コードで、:math.sin によるサインカーブでアバターを回転させることで実現しています
def move(pid, i) do
send(pid, {:move, i})
Process.sleep(50)
move(pid, i + 0.1)
end
@impl true
def handle_info({:move, v}, socket) do
sin = :math.sin(v) * 0.005
socket = socket
|> rotation("test", -0.1, 3.2, sin)
{:noreply, socket}
end
また瞬きも3秒に1回、行っています
def blink(pid) do
Process.sleep(100)
send(pid, {:blink, 1})
Process.sleep(100)
send(pid, {:blink, 0})
Process.sleep(3000)
blink(pid)
end
@impl true
def handle_info({:blink, v}, socket) do
socket = set_blend_shape(socket, "test", "blink", v)
{:noreply, socket}
end
②Web Speech APIで音声入力を受け付ける
ボタンを押すと、handle_event("start"(LiveView) → recognize(JS) → recognition.start(JS) → recognition.onresult(JS) → handle_event("finish"(LiveView) の順で処理されて、Web Speech APIでの音声入力を受け付け、後述する「③喋りかけた内容をOllamaで推論し、回答文を作る」の handle_info({"predict" に処理を渡します
なお、私のノートPCのマイク性能が悪いせいか、handle_event("finish" で音声認識結果の拾い間違えを補正しています
<button
class="btn btn-success text-white"
id="start" phx-click="start" phx-hook="Speech" disabled={@is_recording}>
<%= if @is_recording do %>
🔴 音声認識中…
<% else %>
🎙️ ここを押して喋ってください
<% end %>
</button>
@impl true
def handle_event("start", _value, socket) do
socket = assign(socket, is_recording: true, prompt: "", sentences: [])
{:noreply, push_event(socket, "recognize", %{})}
end
@impl true
def handle_event("finish", %{"transcript" => transcript}, %{assigns: assigns} = socket) do
transcript = transcript
|> String.replace("ひなちゃん", "piaちゃん")
|> String.replace("ひやちゃん", "piaちゃん")
|> String.replace("ぴあちゃん", "piaちゃん")
|> String.replace("里奈ちゃん", "piaちゃん")
|> String.replace("ヒロちゃん", "piaちゃん")
|> String.replace("いや、ちゃん", "piaちゃん")
|> String.replace("で、ちゃん", "piaちゃん")
|> String.replace("いやちゃん", "piaちゃん")
|> String.replace("リアンちゃん", "piaちゃん")
|> String.replace("平成", "生成")
|> String.replace("えっ。ずっ", "エッジ")
|> String.replace("ヘッジ", "エッジ")
|> String.replace("エッチ", "エッジ")
|> String.replace("レッド", "エッジ")
|> String.replace("ベッジ", "エッジ")
|> String.replace("れっき", "エッジ")
|> String.replace("えっと、", "エッジと")
|> String.replace("えっ?自分、", "エッジと")
|> String.replace("一度", "エッジと")
|> String.replace("一と", "エッジと")
|> String.replace("内と", "エッジと")
|> String.replace("ちょっと。", "エッジと")
|> String.replace("生成愛", "生成AI")
|> String.replace("性提案", "生成AI")
|> String.replace("性成員", "生成AI")
|> String.replace("性愛", "生成AI")
|> String.replace("訂正、", "生成AI")
|> String.replace("性性", "生成AI")
|> String.replace("性西安", "生成AI")
|> String.replace("性生成AI", "生成AI")
|> String.replace("分散性", "分散生成AI")
|> String.replace("分散性AI", "分散生成AI")
|> String.replace("分散1000円", "分散生成AI")
|> String.replace("物産生成AI", "分散生成AI")
|> String.replace("分散性やで。", "分散生成AIで")
|> String.replace("分散性。", "分散生成AIで")
|> String.replace("相談", "登壇")
|> String.replace("のこと", "の登壇")
|> String.replace("ローカルm", "ローカルLLM")
|> String.replace("料金", "領域")
|> String.replace("病気", "領域")
|> String.replace("努力", "領域")
|> String.replace("興味?", "興味ある?")
|> String.replace("邪魔。スリー", "Gemma3")
|> String.replace("エリクサー", "Elixir")
|> String.replace("ラグ", "RAG")
|> String.replace("恋愛", "AI")
socket = socket
|> assign(is_recording: false)
|> assign(talking_no: 0)
|> assign(conversations: assigns.conversations ++ [%{"prompt" => transcript, "answer" => "AI処理中…"}])
send(self(), {"predict", transcript})
{:noreply, socket}
end
import { LiveSocket } from "phoenix_live_view";
let Hooks = {};
Hooks.Speech = {
mounted() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
this.recognition = new SpeechRecognition();
this.recognition.lang = 'ja-JP';
this.recognition.continuous = false;
this.recognition.interimResults = false;
const liveHook = this;
this.handleEvent("recognize", () => {
try {
this.recognition.start();
} catch (e) {
liveHook.pushEvent("error", {error: "すでに音声認識が実行中です"});
}
});
this.recognition.onresult = (event) => {
const finalTranscript = event.results[0][0].transcript;
liveHook.pushEvent("finish", {transcript: finalTranscript});
};
this.recognition.onerror = (event) => {
liveHook.pushEvent("error", {error: event.error});
};
}
};
export default Hooks;
③喋りかけた内容をOllamaで推論し、回答文を作る
下記コード群でOllamaによって Gemma3:1b での推論を行い、回答文を作っています
Ollama.completion 実行時、stream: true を指定すると、回答チャンク生成の度に "done" が非同期で返ってくるので、それを handle_info(%{"done" で受け取るようにし、都度、後述の「④VOICEVOXでAI推論結果を喋らす」の speak_first に処理を回すことでスムースな受け答えを実現しています
なお、system_prefix と user_prefix によるプロンプト制御と、handle_info(%{"done" による回答文の調整が、会話に丁度良い回答を生み出すコツです
@impl true
def handle_info({"predict", transcript}, %{assigns: assigns} = socket) do
results = Enum.join(assigns.old_sentences, "、") #TODO: old_sentencesをold_textにすれば
system_prefix =
"あなたはpiaちゃんという名前です(名乗る必要はありません、挨拶も省略して)。" <>
"親切に語るアシスタントです。\n" <>
"前回のpiaちゃんの返答は以下の通りです。\n"
system_postfix = "答えは丁寧な口語で、2~3文程度で簡潔に答えてください。\n"
user_prefix = ""
user_postfix = "答えは丁寧な口語で、2~3文程度で簡潔に答えてください。\n"
system = system_prefix <> system_postfix
user = user_prefix <> "## コンテキスト情報\n" <> results <> "## ユーザーの質問\n" <> transcript <> user_postfix
pid = self()
socket = socket
|> assign(text: "")
|> assign(is_recording: false)
|> assign(prompt: system <> user)
|> assign_async(:ret, fn -> run(pid, user, system) end)
{:noreply, socket}
end
def run(pid_liveview, user, system) do
{_, task_pid} = Task.start_link(fn -> run_ollama(pid_liveview, user, system) end)
send(pid_liveview, {:task_pid, task_pid})
{:ok, %{ret: :ok}}
end
def run_ollama(pid_liveview, user, system) do
client = Ollama.init()
{:ok, stream} = Ollama.completion(client, model: "gemma3:1b", system: system, prompt: user, stream: true)
stream
|> Stream.each(&Process.send(pid_liveview, &1, [])) # 単語ごとに区切って"done" => falseを返し続ける
|> Stream.run()
end
@impl true
def handle_info(%{"done" => false, "response" => response}, %{assigns: assigns} = socket) do
old_sentence_count = assigns.old_sentence_count
text = assigns.text <> response
|> String.replace("PIAちゃんです。", "")
|> String.replace("PIAです。", "")
|> String.replace("こんにちは", "")
|> String.replace("こんにちは、", "")
|> String.replace("こんにちは!", "")
|> String.replace("こんにちは。", "")
|> String.replace("こんにちは!", "")
|> String.replace("お疲れ様です。", "")
|> String.replace("お疲れ様です!", "")
|> String.replace("エリクサー", "Elixir")
|> String.trim_leading("!")
|> String.trim_leading("。")
|> String.trim_leading("、")
sentences = String.split(text, ["。", "、", "?", "!"])
new_sentence_count = Enum.count(sentences)
socket = socket
|> assign(is_recording: false)
|> assign(sentences: sentences)
|> assign(old_sentences: assigns.sentences)
|> assign(old_sentence_count: new_sentence_count)
|> assign(conversations: List.update_at(assigns.conversations, -1, fn map ->
%{map | "answer" => String.replace(map["answer"], "AI処理中…", "") <> hd(sentences)} end))
|> speak_first(old_sentence_count, new_sentence_count, sentences)
{:noreply, socket}
end
@impl true
def handle_info(%{"done" => true}, %{assigns: assigns} = socket) do
max_talking_no = Enum.count(assigns.sentences) - 1
socket = socket
|> assign(max_talking_no: max_talking_no)
{:noreply, socket}
end
④VOICEVOXでAI推論結果を喋らす
下記コード群でVOICEVOXによるAI推論結果の再生を行っていますが、推論結果をチャンク化した文章を speak_first(LiveView) → synthesize_and_play(LiveView) → synthesize_and_play(JS) → speakText(JS) → handle_event("voice_playback_finished"(LiveView) → speak_next(LiveView) → synthesize_and_play(LiveView) → synthesize_and_play(JS) → speakText(JS) → handle_event("voice_playback_finished"(LiveView) → (以降、チャンクが無くなるまで speak_next を繰り返す)で順々に再生することで、細切れにVOICEVOX音声生成と再生を繰り返すことで、大きなVOICEVOX音声生成によるブロックを避け、滑らかな喋りを実現しています
なお、speakText(JS) では、再生内容のボリューム測定も行っており、後述の「⑤喋る内容でpiaちゃんの口を動かす」の元データも取得しています
<div class="p-4 w-[500px] h-[800px] overflow-y-auto" id="Voicex" phx-hook="Voicex">
…
</div>
defp speak_first(%{assigns: assigns} = socket, _old_sentence_count = 1, _new_sentence_count = 2, sentences) do
sentences
|> hd()
|> synthesize_and_play(socket)
|> assign(talking: true)
end
defp speak_first(socket, _, _, _sentences), do: socket
@impl true
def handle_event("voice_playback_finished", _, %{assigns: assigns} = socket) do
socket = socket
|> assign(old_sentences: assigns.sentences)
talking_no = assigns.talking_no + 1
sentences = assigns.sentences
text = Enum.at(sentences, talking_no)
socket = speak_next(socket, talking_no, 100, text)
{:noreply, socket}
end
defp speak_next(socket, talking_no, max_talking_no, text) when talking_no <= max_talking_no do
synthesize_and_play(text, socket)
|> assign(talking_no: talking_no)
|> assign(talking: true)
end
defp speak_next(socket, _talking_no, _max_talking_no, _text) do
assign(socket, talking_no: 0)
|> assign(max_talking_no: 0)
|> assign(talking: false)
end
defp synthesize_and_play(text, socket) do
push_event(socket, "synthesize_and_play", %{
"text" => text,
"speaker_id" => "20"
})
end
defp voice_volume(true, volume), do: volume * 10
defp voice_volume(false, _), do: 0
const VOICEVOX_URL = "http://localhost:50021";
Voicex = {
currentAudioPlayer: null,
currentAudioUrl: null,
audioContext: null,
analyser: null,
volumeCheckId: null, // requestAnimationFrame の ID
_volumeCount: 0,
mounted() {
this.handleEvent("synthesize_and_play", ({ text, speaker_id }) => {
const el = document.getElementById("Voicex");
el.scrollTop = el.scrollHeight;
this.stopPlayback();
this.speakText(text, speaker_id);
});
…
},
async speakText(text, speakerId) {
try {
const wavBlob = await this.synthesizeTextToBlob(text, speakerId);
const audioPlayer = new Audio();
const audioUrl = URL.createObjectURL(wavBlob);
audioPlayer.src = audioUrl;
this.currentAudioPlayer = audioPlayer;
this.currentAudioUrl = audioUrl;
// --- Web Audio API 初期化 ---
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
const source = this.audioContext.createMediaElementSource(audioPlayer);
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 512;
source.connect(this.analyser);
this.analyser.connect(this.audioContext.destination);
// --- ボリューム測定開始 ---
this.startVolumeMonitor();
await audioPlayer.play();
const cleanup = () => {
if (this.currentAudioPlayer === audioPlayer) {
this.stopVolumeMonitor();
URL.revokeObjectURL(audioUrl);
this.currentAudioPlayer = null;
this.currentAudioUrl = null;
this.pushEvent("voice_playback_finished", { status: "ok" });
}
};
audioPlayer.onended = cleanup;
audioPlayer.onerror = cleanup;
} catch (error) {
console.error("致命的なエラー:", error.message, error);
this.stopVolumeMonitor();
this.currentAudioPlayer = null;
this.currentAudioUrl = null;
}
},
async fetchSynthesis(audioQuery, speakerId) {
const synthesisParams = new URLSearchParams({ speaker: speakerId });
const synthesisUrl = `${VOICEVOX_URL}/synthesis?${synthesisParams}`;
const synthesisResponse = await fetch(synthesisUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(audioQuery)
});
if (!synthesisResponse.ok) {
throw new Error(`synthesis failed with status ${synthesisResponse.status}`);
}
return await synthesisResponse.blob();
},
async synthesizeTextToBlob(text, speakerId) {
const trimmedText = text.trim();
if (!trimmedText) throw new Error("Text input is empty.");
const audioQuery = await this.fetchAudioQuery(trimmedText, speakerId);
audioQuery.speedScale = 1.5;
const wavBlob = await this.fetchSynthesis(audioQuery, speakerId);
return wavBlob;
},
なお、再生中でも下記コード群により中断可能としており、ここの task_pid はOllamaタスクのPIDで、前述の run の中で取得したPIDを連携し、Process.exit で推論タスク毎、止めています
音声再生は stop_voice_playback でJS hookを呼び、止めています
<button class="btn btn-error" phx-click="stop">回答の中断</button>
@impl true
def handle_info({:task_pid, pid}, socket) do
{:noreply, assign(socket, task_pid: pid)}
end
@impl true
def handle_event("stop", _, %{assigns: assigns} = socket) do
if socket.assigns.task_pid do
Process.exit(socket.assigns.task_pid, :kill)
end
socket = socket
|> assign(is_recording: false)
|> assign(prompt: "")
|> assign(sentences: [])
|> assign(old_sentences: [assigns.prompt] ++ assigns.sentences)
|> assign(old_sentence_count: 1)
|> assign(talking_no: 0)
|> assign(task_pid: nil)
|> assign(talking: false)
|> stop_voice_playback()
|> set_blend_shape("test", "aa", 0)
{:noreply, socket}
end
defp stop_voice_playback(socket) do
push_event(socket, "stop_voice_playback", %{})
end
mounted() {
…
this.handleEvent("stop_voice_playback", () => {
this.stopPlayback();
});
また、会話文脈のリセットも下記コード群によって行っています
<button class="btn btn-accent" phx-click="reset">会話をリセット</button>
@impl true
def handle_event("reset", _, %{assigns: _assigns} = socket) do
socket = socket
|> assign(is_recording: false)
|> assign(prompt: "")
|> assign(sentences: [])
|> assign(old_sentences: [])
|> assign(old_sentence_count: 1)
|> assign(talking_no: 0)
|> assign(task_pid: nil)
|> assign(talking: false)
|> set_blend_shape("test", "aa", 0)
{:noreply, socket}
end
⑤喋る内容でpiaちゃんの口を動かす
上記で取得済みの再生内容ボリューム測定から、VRアバターの口の開きを制御しています
@impl true
def handle_event("voice_volume", %{"volume" => v}, socket) do
volume = voice_volume(socket.assigns.talking, v) * 7
socket = socket
|> set_blend_shape("test", "aa", volume)
{:noreply, socket}
end
⑥やり取りした履歴を会話形式UIで残す
assigns.conversations に、各会話回ごとの %{"prompt" => transcript, "answer" => "AI処理中…"} をリストし、それをHTML側で :for を使ってループ表示しています
なお、handle_info(%{"done" でOllamaからストリーミングされるたびに、発声する1文単位である hd(sentence) で会話履歴の回答文章をストリーミング更新し、el.scrollTop = el.scrollHeight により会話履歴の最新を追従スクロールします
<div class="p-4 w-[500px] h-[800px] overflow-y-auto" id="Voicex" phx-hook="Voicex">
<div class="chat-body" :for={conversation <- @conversations}>
<div>
<div class="message user" id="prompt" disabled={@is_recording}>{conversation["prompt"]}</div>
</div>
<div>
<div class="message assistant">{Phoenix.HTML.raw(conversation["answer"])}</div>
</div>
</div>
@impl true
def handle_info(%{"done" => false, "response" => response}, %{assigns: assigns} = socket) do
…
socket = socket
…
|> assign(conversations: List.update_at(assigns.conversations, -1, fn map ->
%{map | "answer" =>
String.replace(map["answer"], "AI処理中…", "") <> hd(sentences)} end))
mounted() {
this.handleEvent("synthesize_and_play", ({ text, speaker_id }) => {
const el = document.getElementById("Voicex");
el.scrollTop = el.scrollHeight;
会話UIの吹き出しは、下記CSSで形成しており、buddy_live.html.heex に追加してください(ココはChatGPTのプレビュー付きプロンプトで作りました)
<style>
:root {
--bg: #f5f7fb;
--panel: #ffffff;
--user: #00BAA6;
--user-text: #ffffff;
--assistant: #e5e7eb;
--assistant-text: #111827;
--muted: #6b7280;
--radius: 14px;
}
* { box-sizing: border-box; }
.chat {
width: 100%;
max-width: 420px;
height: 80vh;
background: var(--panel);
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0,0,0,.08);
display: flex;
flex-direction: column;
overflow: hidden;
}
.chat-header {
padding: 14px 18px;
border-bottom: 1px solid #e5e7eb;
font-weight: 600;
}
.chat-body {
flex: 1;
padding: 16px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.chat-body > div {
display: flex;
flex-direction: column;
}
.message {
max-width: 80%;
padding: 10px 14px;
border-radius: var(--radius);
font-size: 14px;
line-height: 1.6;
word-wrap: break-word;
}
.assistant {
background: var(--assistant);
color: var(--assistant-text);
align-self: flex-start;
border-top-left-radius: 0;
position: relative;
margin-left: 8px; /* 尖り分 */
}
.assistant::before {
content: "";
position: absolute;
left: -8px;
top: 0;
width: 8px;
height: 9px;
background: var(--assistant);
clip-path: polygon(0 0, 100% 0, 100% 100%);
}
.user {
background: var(--user);
color: var(--user-text);
align-self: flex-end;
border-top-right-radius: 0;
position: relative;
margin-right: 8px; /* 尖り分 */
}
.user::after {
content: "";
position: absolute;
right: -8px;
top: 0;
width: 8px;
height: 9px;
background: var(--user);
clip-path: polygon(100% 0, 0 0, 0 100%);
}
.timestamp {
font-size: 11px;
color: var(--muted);
margin-top: 2px;
}
.input-area {
display: flex;
gap: 8px;
padding: 12px;
}
.input-area input {
flex: 1;
border: 1px solid #d1d5db;
border-radius: 999px;
padding: 10px 14px;
font-size: 14px;
outline: none;
}
.input-area button {
border: none;
background: var(--user);
color: #fff;
border-radius: 999px;
padding: 0 16px;
font-size: 14px;
cursor: pointer;
}
.input-area button:hover {
opacity: .9;
}
</style>
更なる高速化
- 音声生成の並行化(バックプレッシャー化)
-
Broadwayを使うとイイ感じにできそう
-
終わりに
非力なノートPCで動くVRローカルLLM会話エージェント「piaちゃん」の作り方について解説しました
下記のような様々な技術/可能性として未来感を感じていただけたら幸いです
- 非力なPCやエッジデバイスでも動くElixir AIのサンプルとして
- 今年、充実してきたローカルLLMを使ったAIエージェントのサンプルとして
- Web Speech API+VOICEVOXを使った会話エージェントのサンプルとして
- LiveViewでVRアバターによるプレゼンテーションを行うのサンプルとして
明日は @kikuyuta さんで 「Elixirのプロセスを止める」 です