【GAS】ジャズの練習用にそれっぽいコード進行をリアルタイムに自動生成するアプリを作ってみた【Marcov連鎖】
はじめに
この記事は個人的なWebアプリの開発記録です。再現性は保つように注意していますが、開発環境によって異なる場合があることをご理解ください。
また、これは私の初投稿の記事になります。拙い部分などあるかと思いますが、ご容赦ください!
概要
- ジャズピアノの練習をしていてそれっぽいコード進行を自動で生成するツールが欲しくなった
- 軽く調べたところリアルタイムに生成してくれるアプリが見つからなかった
- スプレッドシートとGASを使用してウェブアプリとして実装した
動機
みなさんは何か楽器を演奏されますか?私は最近ジャズピアノを練習しているのですが、クラシックと違った難しさに四苦八苦しています...(上手くいくと楽しいんですけどね)。
ジャズピアノの難しさの一つに、アドリブがあります。コード進行をもとに自分でいい感じの伴奏とメロディーを即興で演奏しなければならないのですが、これがとても難しい!曲としての整合性を取りつつ個性を出さなければならないので、ちょっとやそっとの練習では全く歯が立たないわけです。しかも一つの曲で上手くできるようになっても、曲が変わればコード進行も全く別物になるので、また1から練習が必要になるんですね。
これを克服するための方法はいろいろ考えられるかとは思いますが、今回私は考えました。
リアルタイムに自動生成されるコード進行に、その場で対応できるようになれば無敵じゃないかと。
そこでリアルタイムにコード進行を生成するツールを探したのですがすぐには見つからなかったので、作ってしまえということで開発したWebアプリの記録がこの記事になります。
目的とデザイン
まず初めに、今回制作するウェブアプリに必要な要件を整理します。
- リアルタイムにコード進行を生成できること
- 生成する進行は完全にランダムではなく、ある程度整合性のとれたものにすること(調性など)
- アプリはデバイスによらず、任意のブラウザ上で実行できること(スマホで見ながら演奏するため)
- キー設定が可能なこと
これらの要件を満たすため、今回はGoogle Apps Script(GAS)とMarcov連鎖を使用して実装を行いました。
Google Apps Script(GAS)
公式サイトによると、
Apps Script は Google ドライブを活用したクラウドベースの JavaScript プラットフォームで、Google プロダクト全体でタスクの統合と自動化を可能にします。Google Workspace
だそうです。今回は
Googleの各種サービスと簡単に連携できて、簡単に公開できるウェブアプリ開発ツール(無料)
として使います。
詳しい機能や使い方についてはインターネットに無限に情報が転がっているので、そちらに譲ります。
Marcov連鎖
現在の状態と次の状態への遷移確率を予め対応付け、それをもとに確率的に状態を遷移させるモデルです。原始的な文章生成AI的側面を持ち、比較的シンプルな構造の再現に適しています。
コード進行のバリエーションは複雑化させようと思えばいくらでもできますが、基本的なトライアドの進行はある程度パターンが決まっています(Ⅱm7-Ⅴ7-Ⅰとか)。
今回は状態=コードとし、今のコードから次にきそうなコードを予測するモデルを構築することを目指します。
数学的な解説はこのサイトが分かりやすいです。
フローチャート
今回は次のような実装にしました。(スプレッドシート→SS)
実装
スプレッドシート(SS)の準備
はじめにデータ記録用にSSを準備します。それぞれ記録用・処理用・出力用に3つのシートを用意してください。今回は、データの入力から遷移確率行列の計算まで全てSS上で行いたいと思います。
記録用シート
記録用シートには、進行を度数表記で記録します。今回は単純化のため行・列のヘッダーは設けず、データのみを記録する形にしました。
A | B | C | D | ... | |
---|---|---|---|---|---|
1 | I | IV | V | I | |
2 | I | IIm7 | V7 | I | |
3 | IV | V | #Vdim | VIm | |
... | ... |
コード進行は全て手打ちしてもよいのですが、バリエーションを持たせるためにはある程度データ数が必要です。そこで今回はこのサイトからコード進行の例をお借りして、元データとしました。ただオンコードまで律義に入力してしまうとコードの多様性が増えすぎて遷移のバリエーションが生まれにくくなってしまう気がしたので、今回はオンコードは無しとしました。スクレイピングとかで大量のデータを用意できる方は対応しても良いかもしれません。
余談
サイトのテキストデータをSSに貼り付ける形式にするのには、ChatGPTを利用しました。
「次のテキストをスプレッドシートにペーストできる形にしてください」とお願いするといい感じに変形してくれます。GPTくんさすがですね。
注意点として、ループする系のコードを記録する場合、必ず最後尾に先頭のコードを入れるようにしてください。(I⇀IV⇀Vの場合I⇀IV⇀V⇀Iまで入力する)今回の実装上、こうしないとMarcov連鎖に「最後尾⇀先頭」の遷移確率が記録されず、最後尾のコードからどこにも進めなくなってしまいます。
また同様の理由から、必ず全てのコードが少なくとも1つ遷移先を持つようにしてください(この問題は最後尾に必ず先頭のコードを置くようにすれば基本的には起きないです)。
処理用シート
処理用シートでは、対応する記録用シート上のセルとその右隣のセルのデータを文字列として結合した値を各セルに保存します。SSには指定範囲を一つのセルでまとめて処理できるARRAYFORMULA()
という関数があるので、今回はこれを利用します(Excelでいうスピルのようなものです)。正直私はこの関数を100%理解しているわけではないのですが、これでいい感じに動いているのでこれでよしとさせてください。
=ARRAYFORMULA(CONCAT(CONCAT(progress!A:AA, " "),progress!B:AB))
この関数を処理用シートのA1セルに置くと、目的を満たしたデータが配置されます。具体的には次のような見た目になるはずです:
A | B | C | D | ... | |
---|---|---|---|---|---|
1 | I IV | IV V | V I | I | |
2 | I IIm7 | IIm7 V | V I | I | |
3 | IV V | V #Vdim | #Vdim VIm | VIm | |
... | ... |
ここで最左端には1コード単体がはいってしまっていますが、これは次の処理で無視されるので問題ありません。
補足
上述の関数で範囲がA:AAやA:ABとなっているのは、データ領域をカバーするのに十分な範囲とするためです。例えば進行のデータが4以下なら、A:D・A:Eとすれば十分です。
出力用シート
このシートは、そのまま遷移確率行列を表します。1行・A列にそれぞれ存在する全コードのリストがあり、そのクロス位置に[行インデックス]から[列インデックス]へ遷移する事象数を表示します。ここから遷移確率を求めることができます。例えばI度のコードからの遷移確率を取得したい場合は、2行目の値の総和で各セルの値を割れば、それがAから各コードへの遷移確率になります。
以下、入力する関数一覧:
- A2セル
=UNIQUE(TOCOL(Flatten(progress!A1:H),1))
- B1セル
=TRANSPOSE(UNIQUE(TOCOL(Flatten(progress!A1:H),1)))
- B2以降
=COUNTIF(FILTER(TOCOL(concat!$A:$AA,0),TOCOL(concat!$A:$AA,0)=CONCAT(CONCAT($A2, " "),B$1)),"<>#N/A")
(これをB2セルにコピペ後右下ドラッグで全体にコピー)
上手く動作していれば、次の表のようになるはずです:
A | B | C | D | ... | |
---|---|---|---|---|---|
1 | I | II | IV | ||
2 | I | 1 | 1 | 5 | |
3 | II | 0 | 0 | 2 | |
4 | IV | 2 | 0 | 0 | |
... | ... |
GAS
GASはChatGPT4oに書いてもらいました。"marcov"が出力用シートの名前です。やっていることはSSから配列を引っ張ってくるだけなので、説明は割愛します。
function doGet() {
return HtmlService.createHtmlOutputFromFile('index');
}
// スプレッドシートをリンクで取得し、"marcov"シートのデータを配列として返す
function getMarcovSheetData() {
try {
// ① スプレッドシートをリンクで取得
const ss_id = "[シートのid(SSのリンクのd/と/edit?の間)]";
const ss = SpreadsheetApp.openById(ss_id);
// ② "marcov"シートを選択
const sheet = ss.getSheetByName("marcov");
if (!sheet) {
throw new Error("'marcov'という名前のシートが見つかりません。");
}
// ③ シート全体を配列として取得
const data = sheet.getDataRange().getValues();
return data; // 配列として返す
} catch (error) {
Logger.log("エラー: " + error.message);
throw new Error("スプレッドシートのデータ取得中にエラーが発生しました: " + error.message);
}
}
JS&HTML
jsは結果的にかなり複雑になってしまったので、分割して解説したいと思います。
GASの仕様上JSやCSSを分離するのがやや面倒なのと、今回は個人用の小規模なプロジェクトのため、HTMLにまとめて書く仕様にしました(横着)。
htmlの全体像
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f4f4f9;
color: #333;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
}
#settings, #control {
margin: 10px;
display: flex;
gap: 10px;
}
select, input, button {
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
background-color: #fff;
font-size: 16px;
cursor: pointer;
transition: all 0.3s;
}
button:hover {
background-color: #007BFF;
color: white;
}
#display {
margin-top: 20px;
display: flex;
flex-direction: column;
align-items: center;
}
.chords {
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
margin-top: 20px;
}
.chords div {
padding: 10px 20px;
border: 2px solid #ddd;
border-radius: 5px;
background-color: #fff;
font-size: 18px;
transition: all 0.3s;
}
#current {
background-color: #007BFF;
color: white;
font-weight: bold;
font-size: 20px;
transform: scale(1.2);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
#previous, #next1, #next2 {
background-color: #f0f0f0;
color: #666;
}
#beat_display {
font-size: 24px;
font-weight: bold;
color: #007BFF;
}
@media (max-width: 600px) {
.chords {
flex-direction: column;
}
.chords div {
margin: 5px 0;
}
}
</style>
</head>
<body>
<div id="settings">
<select id="key">
<option value="C">C</option>
<option value="C#">C#</option>
<option value="D">D</option>
<option value="D#">D#</option>
<option value="E">E</option>
<option value="F">F</option>
<option value="F#">F#</option>
<option value="G">G</option>
<option value="Ab">Ab</option>
<option value="A">A</option>
<option value="Bb">Bb</option>
<option value="B">B</option>
</select>
<select id="beat">
<option value="4">4/4</option>
<option value="3">3/4</option>
<option value="2">2/4</option>
</select>
<input id="bpm" type="number" min="10" max="250" value="100">
</div>
<div id="control">
<button id="start">Start</button>
<button id="pause">Pause</button>
<button id="stop">Stop</button>
</div>
<div id="display">
<div id="beat_display"></div>
<div class="chords">
<div id="previous">-</div>
<div id="current">-</div>
<div id="next1">-</div>
<div id="next2">-</div>
</div>
</div>
<script>
//元データ
let data = [];
let degNames = [];
//DOM要素
const key = document.getElementById('key');
const beat = document.getElementById('beat');
const bpm = document.getElementById('bpm');
const startButton = document.getElementById('start');
const pauseButton = document.getElementById('pause');
const stopButton = document.getElementById('stop');
const beatDisplay = document.getElementById('beat_display');
const previous = document.getElementById('previous');
const current = document.getElementById('current');
const next1 = document.getElementById('next1');
const next2 = document.getElementById('next2');
//繰り返し処理
let previousDeg = null;
let degsQueue = []
let timeoutId = null;
let isPlaying = false;
let currentBeat = 0;
const start = () => {
if (timeoutId === null) {
//初期化
if(!isPlaying){
previousDeg = null;
degsQueue = [];
degsQueue.push("I");
degsQueue.push(chooseNext(degsQueue[0]));
degsQueue.push(chooseNext(degsQueue[1]));
//ディスプレイの更新
currentBeat = 0;
beatDisplay.innerHTML = "";
previous.innerHTML = "-";
current.innerHTML = "-";
next1.innerHTML = degreeToChord(degsQueue[0],key.value);
next2.innerHTML = degreeToChord(degsQueue[1],key.value);
isPlaying = true;
}
loop();
}
}
function loop() {
let d = 60 / bpm.value * 1000;
currentBeat++;
beatDisplay.innerHTML = "・".repeat(currentBeat);
timeoutId = setTimeout(() => {
if(currentBeat >= beat.value){
//配列の更新
degsQueue.push(chooseNext(degsQueue[degsQueue.length-1]));
console.log(degsQueue);
//ディスプレイの更新
previous.innerHTML = current.innerHTML;
current.innerHTML = degreeToChord(degsQueue.shift(),key.value);
next1.innerHTML = degreeToChord(degsQueue[0],key.value);
next2.innerHTML = degreeToChord(degsQueue[1],key.value);
currentBeat %= beat.value;
}
loop();
}, d);
}
const pause = () => {
clearTimeout(timeoutId);
timeoutId = null;
};
const stop = () => {
clearTimeout(timeoutId);
timeoutId = null;
isPlaying = false;
next1.innerHTML = "";
next2.innerHTML = "";
};
startButton.addEventListener('click', start);
pauseButton.addEventListener('click', pause);
stopButton.addEventListener('click', stop);
//データの読み込み
function loadData(callback) {
google.script.run.withSuccessHandler(onLoaded).getMarcovSheetData();
}
function onLoaded(response) {
console.log('GAS function was called successfully');
console.log(response);
data = response;
//コードの一覧を取得
degNames = [...response[0]];
degNames.shift();
}
function getProbs(degName){
if(!degNames.includes(degName)){
alert("Invalid degName");
return;
}
let probs = [...data.find(e => e[0] == degName)];
probs.shift();
//console.log(probs);
return probs;
}
//マルコフ遷移
function chooseNext(degName){
let probs = getProbs(degName);
let total = probs.reduce((acc, current) => acc + current);
let probs_cumulative = [...probs.map((sum => value => sum += value)(0))] //確率を累積和に
let r = Math.random() * total;
let index = probs_cumulative.findIndex(e => e >= r);
index = index != -1 ? index : 0;
return degNames[index];
}
// トニック(キー)に対応するコードの配列を作成します。
function generateScale(tonic, flat = false) {
const scale = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "Ab", "A", "Bb", "B"];
const scale_display = flat ? ["C", "Db", "D", "Eb", "Fb", "F", "Gb", "G", "Ab", "A", "Bb", "Cb"] : ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
const tonicIndex = scale.indexOf(tonic);
if (tonicIndex === -1) {
throw new Error("Invalid tonic");
}
// トニックから始まる12音階を生成
return [...scale_display.slice(tonicIndex), ...scale_display.slice(0, tonicIndex)];
}
// ディグリーをコードに変換する関数
function degreeToChord(degree, tonic = "C") {
// 調のシャープ系/フラット系
let isSharp = tonic.includes("#");
let isFlat = tonic.includes("b");
const scale = generateScale(tonic, isFlat);
// メジャースケール上のディグリーに対応するインデックス
const degreeMap = {
"I": 0,
"II": 2,
"III": 4,
"IV": 5,
"V": 7,
"VI": 9,
"VII": 11
};
const match = degree.match(/(b|#)?(VII|VI|V|IV|III|II|I)(m)?(7|M7|6)?(dim|aug|\(b5\)|sus4|sus2)?(add9|add13|add9,13)?/);
if (!match) {
throw new Error(`Invalid degree: ${degree}`);
}
const [_, accidental, baseDegree, minor, tension, extension, add] = match;
let index = degreeMap[baseDegree];
// インデックスを0〜11に収める
index = (index + 12) % 12;
let chord = scale[index];
if (accidental === "b") {
chord += "b";
} else if (accidental === "#") {
chord += "#";
}
if(chord.includes("b#") || chord.includes("#b")){
chord = chord.replace("b","");
chord = chord.replace("#","");
}else if(chord.includes("##")){
chord = chord.replace("##","×");
}else if(chord.includes("bb")){
// ダブルフラットはそのまま
}
// マイナーコード
if (minor) {
chord += "m";
}
// セブンス
if (tension) {
if (tension === "7") {
chord += "7";
} else if (tension === "M7") {
chord += "M7";
} else if (tension === "6") {
chord += "6";
}
}
// コードタイプを追加
if (extension) {
if (extension === "dim") {
chord += "dim";
} else if (extension === "aug") {
chord += "aug";
} else if (extension === "(b5)") {
chord += "(b5)";
} else if (extension === "sus4") {
chord += "sus4";
} else if (extension === "sus2") {
chord += "sus2";
}
}
//add
if (add) {
if (add === "add9"){
chord += "add9";
} else if (add === "add13"){
chord += "add13";
} else if (add === "add9,13"){
chord += "add9,13";
}
}
return chord;
}
loadData();
</script>
</body>
</html>
遷移確率データのロード
//元データ
let data = [];
let degNames = [];
//データの読み込み
function loadData(callback) {
google.script.run.withSuccessHandler(onLoaded).getMarcovSheetData();
}
function onLoaded(response) {
console.log('GAS function was called successfully');
console.log(response);
data = response;
//コードの一覧を取得
degNames = [...response[0]];
degNames.shift();
}
データの読み込みはシンプルです。GASの関数を呼び出して、コールバックから記録しているだけです。degNamesには、データの1行目(=コードのリスト)を代入しています。
確率の取得
function getProbs(degName){
if(!degNames.includes(degName)){
alert("Invalid degName");
return;
}
let probs = [...data.find(e => e[0] == degName)];
probs.shift();
return probs;
}
getProbs()
では、行列の中で指定されたコードを表す行を返しています。
次のコードのランダム取得
//マルコフ遷移
function chooseNext(degName){
let probs = getProbs(degName);
let total = probs.reduce((acc, current) => acc + current);
let probs_cumulative = [...probs.map((sum => value => sum += value)(0))] //確率を累積和に
let r = Math.random() * total;
let index = probs_cumulative.findIndex(e => e >= r);
index = index != -1 ? index : 0;
return degNames[index];
}
chooseNext()
では、getProbs()
で取得した一覧をもとに、乱数で次のコードを決めてそれを返しています。
度数表記からコード表記への変換
// トニック(キー)に対応するコードの配列を作成します。
function generateScale(tonic, flat = false) {
const scale = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "Ab", "A", "Bb", "B"];
const scale_display = flat ?
["C", "Db", "D", "Eb", "Fb", "F", "Gb", "G", "Ab", "A", "Bb", "Cb"]
: ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
const tonicIndex = scale.indexOf(tonic);
if (tonicIndex === -1) {
throw new Error("Invalid tonic");
}
// トニックから始まる12音階を生成
return [...scale_display.slice(tonicIndex), ...scale_display.slice(0, tonicIndex)];
}
// ディグリーをコードに変換する関数
function degreeToChord(degree, tonic = "C") {
// 調のシャープ系/フラット系
let isSharp = tonic.includes("#");
let isFlat = tonic.includes("b");
const scale = generateScale(tonic, isFlat);
// メジャースケール上のディグリーに対応するインデックス
const degreeMap = {
"I": 0,
"II": 2,
"III": 4,
"IV": 5,
"V": 7,
"VI": 9,
"VII": 11
};
const match = degree.match(/(b|#)?(VII|VI|V|IV|III|II|I)(m)?(7|M7|6)?(dim|aug|\(b5\)|sus4|sus2)?(add9|add13|add9,13)?/);
if (!match) {
throw new Error(`Invalid degree: ${degree}`);
}
const [_, accidental, baseDegree, minor, tension, extension, add] = match;
let index = degreeMap[baseDegree];
// インデックスを0〜11に収める
index = (index + 12) % 12;
let chord = scale[index];
if (accidental === "b") {
chord += "b";
} else if (accidental === "#") {
chord += "#";
}
if(chord.includes("b#") || chord.includes("#b")){
chord = chord.replace("b","");
chord = chord.replace("#","");
}else if(chord.includes("##")){
chord = chord.replace("##","×");
}else if(chord.includes("bb")){
// ダブルフラットはそのまま
}
// マイナーコード
if (minor) {
chord += "m";
}
// セブンス
if (tension) {
if (tension === "7") {
chord += "7";
} else if (tension === "M7") {
chord += "M7";
} else if (tension === "6") {
chord += "6";
}
}
// コードタイプを追加
if (extension) {
if (extension === "dim") {
chord += "dim";
} else if (extension === "aug") {
chord += "aug";
} else if (extension === "(b5)") {
chord += "(b5)";
} else if (extension === "sus4") {
chord += "sus4";
} else if (extension === "sus2") {
chord += "sus2";
}
}
//add
if (add) {
if (add === "add9"){
chord += "add9";
} else if (add === "add13"){
chord += "add13";
} else if (add === "add9,13"){
chord += "add9,13";
}
}
return chord;
}
degreeToChord()
はキーを指定して度数表記をコード表記に変換する関数、generateScale()
は変換の過程で使用する内部関数です。かなり冗長な記述に見えますがやっていることは比較的単純で、基本的にはキーから度数表記の度数分だけずらした位置のコードをgenerateScale()
で生成したスケールとdegreeMap
を使って取得し、String.prototype.match()
を使用してテンションや臨時記号を検出、検出された記号に応じてベースとなるトライアドにぺたぺた貼り付けているイメージです。
(全部のパターンを正しく網羅できている自信がないのと、もっと簡潔な書き方がある気がしているので、有識者の方いましたらコメントで指摘いただけますと幸いです...)
コード進行の表示
//DOM要素
const key = document.getElementById('key');
const beat = document.getElementById('beat');
const bpm = document.getElementById('bpm');
const startButton = document.getElementById('start');
const pauseButton = document.getElementById('pause');
const stopButton = document.getElementById('stop');
const beatDisplay = document.getElementById('beat_display');
const previous = document.getElementById('previous');
const current = document.getElementById('current');
const next1 = document.getElementById('next1');
const next2 = document.getElementById('next2');
//繰り返し処理
let previousDeg = null;
let degsQueue = []
let timeoutId = null;
let isPlaying = false;
let currentBeat = 0;
const start = () => {
if (timeoutId === null) {
//初期化
if(!isPlaying){
previousDeg = null;
degsQueue = [];
degsQueue.push("I");
degsQueue.push(chooseNext(degsQueue[0]));
degsQueue.push(chooseNext(degsQueue[1]));
//ディスプレイの更新
currentBeat = 0;
beatDisplay.innerHTML = "";
previous.innerHTML = "-";
current.innerHTML = "-";
next1.innerHTML = degreeToChord(degsQueue[0],key.value);
next2.innerHTML = degreeToChord(degsQueue[1],key.value);
isPlaying = true;
}
loop();
}
}
function loop() {
let d = 60 / bpm.value * 1000;
currentBeat++;
beatDisplay.innerHTML = "・".repeat(currentBeat);
timeoutId = setTimeout(() => {
if(currentBeat >= beat.value){
//配列の更新
degsQueue.push(chooseNext(degsQueue[degsQueue.length-1]));
console.log(degsQueue);
//ディスプレイの更新
previous.innerHTML = current.innerHTML;
current.innerHTML = degreeToChord(degsQueue.shift(),key.value);
next1.innerHTML = degreeToChord(degsQueue[0],key.value);
next2.innerHTML = degreeToChord(degsQueue[1],key.value);
currentBeat %= beat.value;
}
loop();
}, d);
}
const pause = () => {
clearTimeout(timeoutId);
timeoutId = null;
};
const stop = () => {
clearTimeout(timeoutId);
timeoutId = null;
isPlaying = false;
next1.innerHTML = "";
next2.innerHTML = "";
};
startButton.addEventListener('click', start);
pauseButton.addEventListener('click', pause);
stopButton.addEventListener('click', stop);
コード進行の表示は、setTimeout()
を使用してリアルタイムに行えるようにしました。拍子を設定できるようにし、設定した拍ごとにコードが切り替わるようにしています。
完成形
完成形のイメージをCodePenで貼っておきます。GASは使えないのでコード進行のデータは暫定的に配列でjs内に用意することで対応しています。なおCodePenにはおまけとしてサウンドの再生機能も付けてみました。
おわりに
今回GASとMarcov連鎖を使用することで、かなり理想形のアプリが作れました。度数→コード表記の変換が思った以上に複雑になってしまったのは反省点です。機会があればここは作り直してみたいなと思っています。
今回のケースに限らず、GASはちょっと簡単なWebアプリを作りたいというときに非常に重宝するので、まだ使ったことがないよ~という方はぜひ、この機会に使ってみてください!
以上、つきふらでした~🌙
参考文献
Discussion