ギターのスケール図を生成する Angular アプリを作った
趣味の融合。
最近めっきり弾かなくなったものの、ギターを趣味としているので、Angular アプリでギターのスケール表の一つや二つ作れないといかんぞ (?) ってなって、作った。その名も Guitar Scale Generator。
任意のルート音とスケール名を指定して「Generate」ボタンを押すと、ギター指板を模したテーブルに、そのスケールの構成音を描画する。
フレット数、弦の数 (最近は8弦ギターとかあるんで…)、各弦のチューニングは変更可能にした。また、スケール図は複数描画できるので、曲のコード進行に合わせていくつかのスケールを表示しておく、といったことも可能だ。
選択できるスケールの種類が少ないと思うが、これからちまちま追加していこうと思う。
以下、コレを作るにあたって大変だったこととか語る。
目次
ビジネスロジックが辛かった…
「この音はなんか気持ちいいなぁ」とか「コレはなんか外してる感あるなぁ」とかいうノリでギターをやってきたので、音楽理論もへったくれも知らず、どうやってスケール表を作るか、というビジネスロジック部分に悩んだ。
音階名は、フラット表記 (Db) とシャープ表記 (C#) があるので、両方に対応できるよう、音階名の定数配列を作った。
スケールについては、ルート音から半音ごとに、その度数の音が構成音か否かを Boolean で示す配列を作った。メジャースケールならこんな感じ。
{
key: 'major',
name: 'Major Scale',
// Tonic M2/9 M3 P4/11 P5 M6/13 M7
// 全 全 半 全 全 全 半
// C D E F G A B (C)
scale: [ true , false, true , false, true , true , false, true , false, true , false, true ],
}
ココは頑張って自分で作っていくしかないのだが、コレを書いているうちに「マイナーサードとかいうのってそういうことかぁ」みたいなことが分かってきた。いまさら…。
弦ごとのチューニングを入力フォームで指定してもらうので、コレで開放弦の音階が分かる。まずは開放弦の音階が、指定のスケールの構成音かどうかを調べ、そこからフレットの数だけループして、1弦あたりの「構成音か否か」を控えていく。コレを指定の弦の数だけループすれば、スケール表の出来上がり。
// 擬似コードで表現
// スケール図にするデータ。配列で1弦ごとのデータを持つ
const scale = [];
for( 指定の弦の数だけループする ) {
// 1弦あたりのデータ。フレットごとのデータを持つ
const line = [];
// 指定のチューニング情報から、その弦の0フレット = 開放弦の音階を調べる
const zeroFret = detectZeroFret();
for( 指定のフレットの数だけループする ) {
line.push( そのフレットの音階が指定のスケールの構成音なら、音階名をセットする );
}
// 1弦のデータをスケール図に追加する
scale.push(line);
}
// スケール図のデータの出来上がり
return scale;
さきほどのスケール定義 (true
・false
が並ぶ配列) は1オクターブ分のデータしか蓄えていないので、フレットの数だけループする時に、スケール定義からデータを取り出す添字を途中でリセットしたりする必要がある。この辺は定義したデータに合わせてよしなに…。
結構冗長な実装になってしまったのだが、弦数・フレット数・チューニングをどのように変えても動作するようにはできた。入力フォームとしては用意していないが、10弦の60フレット、みたいな指板も生成できる。
バカなりに頑張った。w
指板を表現する CSS
スケール図としてギターの指板を再現するために、「ギター弦」の表現と、「ポジションマーク」の再現を頑張った。
ギターの弦を再現する : 行の垂直中央を横切る「横線」の作り方
まず「ギター弦」の再現だが、コレはどういうことかというと、「この弦を押さえて!」を表現するには、弦の上に音階名を配置する必要があった。
例えば、5弦 (A 弦) の3フレット・5フレットを押さえて、C 音・D 音を表現するには、以下のように、「C」と「D」の文字が横線に串刺しにされているようなビジュアルにする必要があるのだ。
E |--|--|--|-------|--|-------|--|
B |--|--|--|-------|--|-------|--|
G |--|--|--|-------|--|-------|--|
D |--|--|--|-------|--|-------|--|
A |--|--|--|-[ C ]-|--|-[ D ]-|--|
E |--|--|--|-------|--|-------|--|
0 1 2 3 4 5 6 ...
コレを実現するためには、通常の border
は使えないので、横線を表現した linear-gradient
を作成することで対応した。
td {
/* サイズを固定する */
width: 2em;
height: 2em;
/* フレットを示す左右の罫線は border で引く */
border-right: 1px solid #ccc;
border-left: 1px solid #ccc;
/* 太さ 1px の横線を作り、垂直中央に配置した */
background: linear-gradient(to bottom, #ccc, transparent 1px) 0% 100% / 100% 50% no-repeat, transparent;
}
background
のショートハンド、なかなか覚えない…。position-x position-y / size-x size-y
の順番!
ポジションマークの再現 : nth-of-type
を使う
次に、ギター指板のポジションマークを再現する。6弦ギターの一般的なドットマークは、
- 3・5・7・9F に1つ・3弦と4弦の間
- 12F に2つ・それぞれ2弦と5弦に重なる位置
と配置される。そしてコレが 13F 以降もループするので、15・17・19・21F に1つ、24F に2つ、と配置される。
ポジションマーク自体は ::before
擬似要素の content: "●";
で雑に再現するとして、
- 12フレットごとにループするにはどうするか
- 弦と弦の間 = 今回の場合、スケール図を
table
で作っているので、行 (tr
) を飛び越えたセルとセル (td
) の間に、どうやってドットを配置するか
というところが課題だった。
まず12フレットごとのループだが、コレは td:nth-of-type(12n + 4)
といった書き方で再現できた。
次に、弦と弦にドットを配置する方法は、transform: translateY(-50%)
でズラすことで対処した。
/*
* ポジションマーク
* - 3・5・7・9F : 3・4弦の間
* - 12F : 本来は2・5弦に重なるが、それだとポジションマークに重なる音階名が見づらいので、今回は2・3弦と4・5弦の間に置く
*/
tr:nth-child(4) td:nth-of-type(12n + 4)::before, /* 3F・15F … 3・4弦の間 */
tr:nth-child(4) td:nth-of-type(12n + 6)::before, /* 5F・17F … 3・4弦の間 */
tr:nth-child(4) td:nth-of-type(12n + 8)::before, /* 7F・19F … 3・4弦の間 */
tr:nth-child(4) td:nth-of-type(12n + 10)::before, /* 9F・21F … 3・4弦の間 */
tr:nth-child(3) td:nth-of-type(12n + 13)::before, /* 12F・24F その1 … 2・3弦の間 */
tr:nth-child(5) td:nth-of-type(12n + 13)::before /* 12F・24F その2 … 4・5弦の間 */ {
content: "●";
color: rgba(0, 0, 0, .5);
/* td 要素内の邪魔をしないようフロートさせる */
position: absolute;
top: 0;
left: 0;
z-index: -1; /* td 要素内のコンテンツの裏に重なるようにする */
/* 実際は4弦 (12F の場合は3弦と5弦) の行に「●」を配置しているので、1つ上の弦との間に配置されるよう、上にズラす */
transform: translateY(-50%);
}
translate
最高だな、position: absolute
でのズラし以上に柔軟性がある。もう「どこでも配置モード」の悪夢は起こさないぞ…!
ReactiveForms と「連動して内容が変化する項目」の作り方
スケール作成時のフォームは Angular の ReactiveForms で作成したが、
- 弦の数を変えると「チューニング」欄にある「各弦のチューニング」項目を増減させる
- 「チューニングプリセット」を変えると、それに合わせて「各弦のチューニング」を変更する
- 「各弦のチューニング」を変えると、そのチューニングがプリセットと合致するかチェックして「チューニングプリセット」項目を変更する
というように、ある項目の変化によって他の項目も変更する、という処理が必要だった。
「項目 A が変わった時に項目 B を操作する」という一方的な操作なら、以下のように valueChanges
を subscribe()
することで対応できる。
this.myForm.get('inputA').valueChanges.subscribe((value) => {
// 項目 A の値が、変数「value」の値に変わった
// これに応じて項目 B の値を変える
this.myForm.get('inputB').setValue(value * 2);
});
しかし今回は、「チューニングプリセット」と「各弦のチューニング」が相互に監視して値を書き換え合ってしまうので、コレをそのまま実装すると Maximum call stack size exceeded
というエラーが発生してしまう。
- 項目 A が変わったので項目 B を変える → 項目 B が変わったので項目 A を変える → 項目 A が変わったので項目 B を変える…
…という再帰的な呼び出しが大量に発生してしまうことが原因だ。
コレのうまい解決策が見つからず、結局、ある値が変化した時、一旦他の項目を unsubscribe
してから処理を行い、再度 subscribe
するという実装にした。先程のコードでいうとこんな感じだ。
// 項目ごとの Subscription を控えるメンバ変数を作っておく
private subscribes = {
inputA: null,
inputB: null
};
// [★] 項目 A の変更時 : Subscription をメンバ変数に控えておく
this.subscribes.inputA = this.myForm.get('inputA').valueChanges.subscribe((value) => {
// 項目 B の監視を一時的に止める
this.subscribes.inputB.unsubscribe();
this.subscribes.inputB = null; // null に戻す
// 項目 A の変化に応じて項目 B の値を変える
this.myForm.get('inputB').setValue(value * 2);
// 項目 B の監視処理を再度設定する : [☆] 部分の実装と全く同じ
this.subscribes.inputB = this.myForm.get('inputB').valueChanges.subscribe( /* 省略 */ );
});
// [☆] 項目 B の変更時
this.subscribes.inputB = this.myForm.get('inputB').valueChanges.subscribe((value) => {
// コチラは逆に、項目 A の監視を一時的に止める
this.subscribes.inputA.unsubscribe();
this.subscribes.inputA = null; // null に戻す
// 項目 B の変化に応じて項目 A の値を変える
this.myForm.get('inputA').setValue(value / 2);
// 項目 A の監視処理を再度設定する : [★] 部分の実装と全く同じ
this.subscribes.inputA = this.myForm.get('inputA').valueChanges.subscribe( /* 省略 */ );
});
このような作りにするため、unsubscribe()
部分の処理と、再度 subscribe()
する部分の処理とを、別々の関数に抜き出して対処した。
なんかココはもっと上手いやり方がある気がするんだよなぁ…。でもとりあえず動くモノが作りたくて、勢いで作ってしまった。「FIXME」な実装でろう…。
FormArray と FormControl の操作が迷った
以前も FormArray について解説記事を書いたが、その時は、FormGroup を配列で持つ構成にしていた。
// こういう構成のモノは記事で紹介したことがある
this.myForm = new FormGroup({
someArray: new FormArray([
new FormGroup({ name: new FormControl('A'), age: new FormControl(25) }),
new FormGroup({ name: new FormControl('B'), age: new FormControl(31) }),
new FormGroup({ name: new FormControl('C'), age: new FormControl(42) })
])
});
今回、弦ごとのチューニング情報をフォームで保持するにあたって、FormArray 内に FormGroup を作るのは冗長と思い、FormArray 直下に直接 FormControl を置いてみることにした。
// こんな構成にしたい
this.myForm = new FormGroup({
tuning: new FormArray([
new FormControl('E'),
new FormControl('B'),
new FormControl('G'),
new FormControl('D'),
new FormControl('A'),
new FormControl('E')
])
});
Typescript 側のコードは、この作りで問題ない。問題はコレを HTML 側でループ表示する方法がちょっと手間取った。
FormArray の制約上、型変換が必要になる部分があるので、まずは TypeScript 側で以下のような Getter メソッドを作っておく必要がある。
// 先程作った myForm 内の tuning プロパティを返す Getter。FormArray 型であることを明示する
get myFormTuing(): FormArray {
return this.myForm.get('tuning') as FormArray;
}
続いて HTML 側。試行錯誤した結果、以下の構成で上手くいった。
<form [formGroup]="myForm">
<ng-container formArrayName="tuning" *ngFor="let control of myFormTuning.controls; index as i">
<select [formControlName]="i">
<option>適当にオプション項目を用意する</option>
</select>
</ng-container>
</form>
formArrayName
で、this.myForm.get('tuning')
と等価になるよう名前を指定する。
この FormArray をループする時に、Getter メソッドである myFormTuning
が必要になる。それを使っているのが *ngFor
部分。しかし、myFormTuning.controls
から FormControl を1つずつ取り出して、変数 control
に入れたものの、今回は使用しない。必要なのは添字の方なので、index as i
(もしくは let i = index
) が重要。
この添字を、*ngFor
でループする内部の要素の [formControlName]
に渡す。すると、formArrayName="tuning"
で取得できる FormArray 内の、「0番目の FormControl」「1番目の FormControl」を特定できるようになり、この場合 select
要素のバインディングが出来るようになる。
この辺の構築が少々難儀だが、その分、FormArray に対して動的に FormControl を追加したり、あるいは逆に削除したり、といった複雑な操作も容易にできるようになる。
以上
ベースとなるロジックはできたので、あとはスケール定義を増やしたり、指定が便利になるようにチューニングのプリセットを増やしたりできれば良いかな、というところ。