Reactアプリの続きです。前々回はビート・プレイヤー、前回はコードプレイヤーを作りました。今回は音楽系の第三弾、スケール・プレイヤーを作りました。これで音楽3要素(メロディ、ハーモニー、リズム)が揃います。今回もTone.jsを使っています。それではいきましょう!
【目次】
※参考:前回記事
【React & Tone.js】ビートプレイヤーを作った(ビートとBPMを変更可能) - クモのようにコツコツと
※参考:【React】ReactでWebアプリを作るシリーズまとめ
qiita.com
作ったもの
スケールプレイヤー
下記のような用途に活用できる。
- スケール(音階)の構成音をテキストと鍵盤色で確認できる
- 再生ボタンでスケールの実際の音を聴くことができる
- キーを変更すると、構成音がどう変わるかを確認できる
- スケールの種類を変えると同じキーでの構成音の違いを比較できる
作ったアプリはこちら
※参考:スケールプレイヤー
アプリの使い方
スケール一覧
ソースコード
※参考:GitHub - ryo-i/scale-player
このアプリで実現したかったこと
いろいろな種類のスケール(音階)の違い、さらにそのスケールのキーを変えたときの鍵盤上の黒鍵白鍵の位置の違いが調べられるアプリを作りたかった。
スケールの種類はWikipediaの「音階」の記事を参考にした。
※参考:音階 - Wikipedia
コードは前回作成した「コード・プレイヤー」をベースにして作成した。
※参考:【React & Tone.js】ビートプレイヤーを作った(ビートとBPMを変更可能) - クモのようにコツコツと
ただし、コード・プレイヤーは鍵盤からコードの音を鳴らす仕様だったが、スケール・プレイヤーは鍵盤自体は単音を鳴らすのみ、スケールやキーの種類を変えた場合の鍵盤上の色が変わるのみにした。
スケールの音は「ビート・プレイヤー」のときのように再生ボタンを押してならす仕様にした。
※参考:【React & Tone.js】ビートプレイヤーを作った(ビートとBPMを変更可能) - クモのようにコツコツと
ソースコード
上記の挙動を実現しているソースコードはこちら。
※参考:GitHub - ryo-i/scale-player
tone.jsのインストール
ビートプレイヤー、スケールプレイヤーと同じ手順。下記のコマンド
$ npm i tone
※参考:Tone.js
データ:data.json
inner
キーの中にスケールプレイヤー用のデータ(設定値)を入れている。
"inner": { "keyButtons": [ // 鍵盤タグの値 ], "scale": [ // スケール(音程)の一覧 ], "scaleTypes": [ // スケールの情報 ], "keyTypeButtons": [ // キー変更タグの情報 ], "scaleTypeButtons": { // スケール変更タグの情報 ] },
鍵盤タグの値。この内容をループして鍵盤部分のJSXのタグを作る
"keyButtons": [ {"value": "C4", "className": "w_key", "keyName": "C"}, {"value": "C#4", "className": "b_key", "keyName": "C#"}, // 中略 ],
スケール(音程)の一覧。スケールで使用する音程の名前。キーの開始位置とスケールの構成音を組み合わせて、ここから音階を取得する。
"scale": [ "null", "C4", "C#4", "D4", "D#4", "E4", "F4", "F#4", "G4", "G#4", "A4", "A#4", "B4", "C5", "C#5", "D5", "D#5", "E5", "F5", "F#5", "G5", "G#5", "A5", "A#5", "B5", "C6", "C#6", "D6", "D#6", "E6", "F6", "F#6", "G6", "G#6", "A6", "A#6", "B6" ],
スケールの情報。scaleValue
が一致するときに構成音であるscaleKeys
を取得する。
"scaleTypes": [ { "scaleValue": "major_scale", "scaleKeys": [1, 3, 5, 6, 8, 10, 12, 13] }, { "scaleValue": "natural_minor_scale", "scaleKeys": [1, 3, 4, 6, 8, 9, 11, 13] }, // 中略 ],
キー変更タグの情報。この内容をループしてキー変更ボタンのJSXのタグを作る
"keyTypeButtons": [ {"value": "C4", "keyTypeName": "C", "className": "w_key", "defaultChecked": true}, {"value": "C#4", "keyTypeName": "C#", "className": "b_key"}, // 中略 ],
スケール変更タグの情報。この内容をループしてスケール変更ボタンのJSXのタグを作る
"scaleTypeButtons": { "basicScale": [ { "scaleValue": "major_scale", "scaleName": "メジャー・スケール", "defaultChecked": true }, { "scaleValue": "natural_minor_scale", "scaleName": "ナチュラル・マイナー・スケール" }, // 中略 } },
Inner.tsx(全体)
Inner.tsxはコードプレイヤーの本体にあたるファイル
import React, { useState, useEffect, useRef } from 'react'; import styled from 'styled-components'; import { Tab, Tabs, TabList, TabPanel } from 'react-tabs'; import 'react-tabs/style/react-tabs.css'; import { inner } from '../data/data.json'; import * as Tone from 'tone'; // CSS in JS const keyWidth = '37px' const CoadPlayer = styled.div` // 中略 } `; // Component function Inner() { // 中略(フック、Tone.js、JSXを設定) } export default Inner;
import
はだいたい前回のコードプレイヤーと同じ(ReactからはuseState, useEffect, useRefをインポート、Tone.jsをインポートなど)。- 新たに追加インポートしたのは「react-tabs」のjsとcssで、タブ切り替え機能に使用した
- styled-componentsでCSS in JSを設定
- Innerコンポーネントにアプリの設定を書き、exportでエクスポートする
ScalePlayerコンポーネント(CSS in jS)
変数keyWidth
で鍵盤タグの幅を設定(後でcalc()の中で鍵盤数を掛けて鍵盤全体の幅を設定)
const keyWidth = '37px'
ScalePlayer
コンポーネントにstyled-componentsによるCSS in JSの設定を書いている。divタグになる。
const ScalePlayer = styled.div` color: #fff; #key { max-width: calc(${keyWidth} * 15); margin: 0 auto; overflow-x: scroll; .key_inner { background: #333; width: calc(${keyWidth} * 15); display: block; padding: 0 0 10px; position: relative; // 中略 } `;
コードプレイヤーの内容をベースにSass(SCSS)的な書き方で書く。
後述する「react-tabs」のスタイルも変更した。SPでは二行になったのでタブではなくボタン的なスタイルにした。
#scale_menu { margin: 0 auto; max-width: 700px; .react-tabs__tab-list { margin: 0 0 5px; border: none; font-size: 12px; .react-tabs__tab { margin: 0 5px 5px 0; border: 1px solid #333; background: #666; border-radius: 5px; &--selected { background: #fff; } } } }
PCでは横並び、SPでは縦並びにしたい場所があったが、styled-componentsのメディアクエリ設定はカンタンだった♪
.scale_type { dl { dd { @media (max-width: 400px) { display: block; } } } }
Innerコンポーネント(全体)
Innerコンポーネントでフック、Tone.js、JSXを設定している。全体的な構成はこうなっている。
// Component function Inner() { // Hooks const [synth, setSynth] = useState(null); // 他、中略 // オブジェクト設定 interface scaleTypes { // 中略 }; // 他、中略 // シンセ設定 useEffect(() => { // 中略 },[]); // 鍵盤リセット const resetKey = (): void => { // 中略 }; // 鍵盤カレント const currentKey = (currentScale): void => { // 中略 }; // 最新のスケール取得 const getScale = (key: string, scales: string[][]): string[] => { // 中略 }; // 鍵盤の構成音のテキスト取得 const scaleKeysText = (scale: string[]): string[] => { // 中略 }; // 鍵盤クリックイベント const clickKey = (e: React.MouseEvent<HTMLButtonElement>): void => { // 中略 }; // 再生ボタン let changeScalePlay = (): void => { // 中略 }; // スケールタイプ取得 const getScaleTypes = (getScaleValue: string): scaleTypes => { // 中略 }; // スケール取得 const getScales = (scaleTypes: scaleTypes): string[][] => { // 中略 }; // スケール構成音変更 const changeScaleInterval = (currentScales: string[][]): void => { // 中略 }; // スケール初期設定 useEffect(() => { // 中略 },[]); // キー変更イベント const keyTypeSelect = (e: React.ChangeEvent<HTMLInputElement>): void => { // 中略 } // スケール変更イベント const scaleTypeSelect = (e: React.ChangeEvent<HTMLInputElement>): void => { // 中略 } // JSX return ( <> <ScalePlayer> // 中略 </ScalePlayer> </> ); }
フック設定
冒頭でフック設定をしている。
// Component function Inner() { // Hooks const [synth, setSynth] = useState(null); const [scales, setScales] = useState([]); const [scale, setScale] = useState(['-']); const [keyValue, setKeyValue] = useState(inner.keyTypeButtons[0].value); const [keyType, setKeyType] = useState(inner.keyTypeButtons[0].keyTypeName); const [scaleInterval, setScaleInterval] = useState('-'); const [scaleValue, setScaleValue] = useState(inner.scaleTypes[0].scaleValue); const [scaleKeys, setScaleKeys] = useState(inner.scaleTypes[0].scaleKeys.join(', ')); const [scaleName, setScaleName] = useState(inner.scaleTypeButtons.basicScale[0].scaleName); const keyElement = useRef<HTMLInputElement>(null); const scaleTypeElement = useRef<HTMLInputElement>(null);
- 初期値は空だったりdata.jsonから読み込んだり
- 最後の
keyElement
とscaleTypeElement
はuseRef設定で子要素の情報を取得する。初期値は空。
オブジェクト設定
interface
でオブジェクト(連想配列)のキーごとにTypeScriptの型を設定
// オブジェクト設定 interface scaleTypes { scaleValue: string; scaleKeys: number[]; }; interface keyButtons { value: string; className: string; onClick: boolean; keyName: string; }; interface keyTypeButtons { value: string; keyTypeName: string; className: string; defaultChecked: boolean; }; interface scaleTypeButtons { scaleValue: string; scaleName: string; defaultChecked: boolean; };
シンセ設定
鍵盤のシンセ音はuseEffect()
でページ読み込み時に一回だけ設定することができた。
// シンセ設定 useEffect(() => { setSynth(new Tone.Synth().toDestination()); },[]);
(あとで出てくる再生ボタンから鳴らす音はこの方法だとうまく鳴らなくて、再生ボタンを押したときに毎回シンセ設定にした)
鍵盤リセット
スケールの音程に該当する鍵盤についているcurrent
というクラス名をいったん全部外してリセットする処理
// 鍵盤リセット const resetKey = (): void => { const keyElements: HTMLCollection = keyElement.current.children; for (let i = 0; i < keyElements.length; i++) { if (keyElements[i].classList.contains('current')) { keyElements[i].classList.remove('current'); } } };
スケールの設定を変更したときに最初にこの処理を実行する。
鍵盤カレント
スケールの音程に該当する鍵盤にcurrent
というクラス名をつけて、鍵盤の色を変える。
// 鍵盤カレント const currentKey = (currentScale): void => { const keyElements: HTMLCollection = keyElement.current.children; for (let i = 0; i < keyElements.length; i++) { const getKeyElement: HTMLButtonElement = keyElements[i] as HTMLButtonElement; const keyText: string = getKeyElement.value; if (currentScale.includes(keyText)) { keyElements[i].classList.add('current'); } } };
スケールの設定を変更したときに上記の「鍵盤リセット」の次にこの処理を実行する。
最新のスケール取得
引数に入ったキーとスケールを元に、キー音から始まるスケールの音程を配列に入れて返す処理
// 最新のスケール取得 const getScale = (key: string, scales: string[][]): string[] => { let getCurrentScale: string[]; for (let i = 0 ; i < scales.length; i++) { if (scales[i].indexOf(key) === 0) { getCurrentScale = scales[i]; } } return getCurrentScale; };
鍵盤の構成音のテキスト取得
Tone.jsの音階は「C4」「C#4」など末尾に何オクターブ目かを表す数字が入っている。この数字は画面表示上は不要なので、削除したテキストを取得する。
// 鍵盤の構成音のテキスト取得 const scaleKeysText = (scale: string[]): string[] => { let scaleKeysText: string[] = []; for (let i = 0; i < scale.length; i++) { const getScaleText: string = scale[i].slice(0, -1); scaleKeysText.push(getScaleText); } return scaleKeysText; };
鍵盤クリックイベント
鍵盤を押したときに、引数のinputタグのvalueの値から音程を取得して、Tone.jsのsynth
の引数に入れてを音を鳴らす。音の長さは0.4秒。
// 鍵盤クリックイベント const clickKey = (e: React.MouseEvent<HTMLButtonElement>): void => { const eventTarget: HTMLButtonElement = e.target as HTMLButtonElement; const KeyValue: string = eventTarget.value; synth.triggerAttackRelease(KeyValue, 0.4); };
再生ボタン
再生ボタンを押したときにもし再生中なら再生を停止。スケールの音程を取得してTone.Sequence
で順番に鳴らす。
// 再生ボタン let changeScalePlay = (): void => { Tone.Transport.stop(); Tone.Transport.cancel(); const currentScale = scale; const synth = new Tone.Synth().toDestination(); const seq = new Tone.Sequence((time, note) => { synth.triggerAttackRelease(note, '8n', time); }, currentScale).start(0); seq.loop = false; Tone.Transport.bpm.value = 80; Tone.Transport.start(); };
前回のコードプレイヤーの場合は「PolySynth」で一度に「ジャーン♪」と鳴らす仕様だったが、今回はスケールを位置ずつ順番に鳴らしたかった。いろいろ試した結果、「Tone. Sequence」によって実現できた。
※参考:Sequence
スケールタイプ取得
引数のスケール名を元に、data.jsonのスケール名と一致したらスケールタイプの情報を連想配列に入れて返す。
// スケールタイプ取得 const getScaleTypes = (getScaleValue: string): scaleTypes => { let getScaleTypes: scaleTypes; for (let i = 0; i < inner.scaleTypes.length; i++) { if (inner.scaleTypes[i].scaleValue === getScaleValue) { getScaleTypes = inner.scaleTypes[i]; } } return getScaleTypes; };
スケール取得
引数のスケールタイプを元に、キーごとのスケールの音程を配列に入れて返す。
// スケール取得 const getScales = (scaleTypes: scaleTypes): string[][] => { let getScales: string[][] = []; for (let i = 0 ; i < inner.keyButtons.length; i++) { getScales.push([]); for (var j = 0; j < scaleTypes['scaleKeys'].length; j++){ const key: string = inner.scale[i+scaleTypes['scaleKeys'][j]]; getScales[i].push(key); } } return getScales; };
スケール構成音変更
引数のスケール構成音(配列)を元にカレントのスケール構成音を取得し、鍵盤色に反映。スケールのカンマ区切りのテキストも作成してフックに設定。
// スケール構成音変更 const changeScaleInterval = (currentScales: string[][]): void => { const getCurrentScale: string[] = getScale(keyValue, currentScales); setScale(getCurrentScale); resetKey(); currentKey(getCurrentScale); const getScalesIntervalsArray: string[] = scaleKeysText(getCurrentScale); const getScalesIntervals: string = getScalesIntervalsArray.join(', '); setScaleInterval(getScalesIntervals); };
スケール初期設定
ページ読み込み時にuseEffect()
でスケールの初期設定(メジャースケールのCキー)を設定する。
// スケール初期設定 useEffect(() => { const getCurrentScaleTypes: scaleTypes = getScaleTypes(scaleValue); const getCurrentScales: string[][] = getScales(getCurrentScaleTypes); setScales(getCurrentScales); setScale(getCurrentScales[0]); changeScaleInterval(getCurrentScales); },[]);
キー変更イベント
キー変更ボタンでキーを変更したときに引数のinputタグを元にスケールの音程を変更して設定する。
// キー変更イベント const keyTypeSelect = (e: React.ChangeEvent<HTMLInputElement>): void => { const getKeyValue: string = e.target.value; setKeyValue(getKeyValue); const scaleArray: string[] = []; scaleArray.push(getKeyValue); const getKeyTypeName = scaleKeysText(scaleArray); const getkey = getKeyTypeName[0]; setKeyType(getkey); const getCurrentScale: string[] = getScale(getKeyValue, scales); setScale(getCurrentScale); resetKey(); currentKey(getCurrentScale); const getScalesIntervalsArray: string[] = scaleKeysText(getCurrentScale); const getKeyType: string = getScalesIntervalsArray[0]; const getScalesIntervals: string = getScalesIntervalsArray.join(', '); setKeyType(getKeyType); setScaleInterval(getScalesIntervals); }
スケール変更イベント
スケール変更ボタンでキーを変更したときに引数のinputタグを元にスケールの構成音を変更して設定する。
// スケール変更イベント const scaleTypeSelect = (e: React.ChangeEvent<HTMLInputElement>): void => { const getScaleValue: string = e.target.value; const getCurrentScaleTypes: scaleTypes = getScaleTypes(getScaleValue); const getScaleName: string = e.target.dataset.scaleName; setScaleValue(getCurrentScaleTypes.scaleValue); setScaleKeys(getCurrentScaleTypes.scaleKeys.join(', ')); setScaleName(getScaleName); const getCurrentScales: string[][] = getScales(getCurrentScaleTypes); setScales(getCurrentScales); changeScaleInterval(getCurrentScales); }
JSX
JSXタグをレンダリングする部分中はいくつかのブロックに分かれている。
// JSX return ( <> <ScalePlayer> // 中略 </ScalePlayer> </> ); }
鍵盤部分のタグ。data.jsonのinner.keyButtons
をmap()
でループして鍵盤のbuttonタグを作成している。clickKey
関数と紐づいている。ref
属性で子要素の情報を取得する。
<div id="key"> <div className="key_inner" ref={keyElement}> {inner.keyButtons.map((val: keyButtons) => <button key={val.value} value={val.value} className={val.className} onClick={clickKey}>{val.keyName}</button> )} </div> </div>
現在選択されているスケールやキーを表示しているテキスト部分。テキストはフックで変更している。「▶︎」の再生ボタンはchangeScalePlay
関数と紐づいている。
<section id="scale_text"> <h2 id="scale_name">{scaleName}({keyType})</h2> <p id="scale_keys">構成音:{scaleKeys}</p> <p id="scale_interval"><button value="start" className="start_button" onClick={changeScalePlay}>▶︎</button>音階:{scaleInterval}</p> </section>
キー変更ボタンの部分。data.jsonのinner.keyTypeButtons
をmap()
でループしてラジオボタンを作成している。keyTypeSelect
関数と紐づいている。
<div id="key_types"> <dl id="root"> <dt>キー</dt> {inner.keyTypeButtons.map((val: keyTypeButtons) => <dd><label key={val.value} className={val.className}><input key={val.value} type="radio" name="key_type" value={val.value} onChange={keyTypeSelect} defaultChecked={val.defaultChecked || null} />{val.keyTypeName}</label></dd> )} </dl> </div>
スケール変更ボタン部分だが、今回量が膨大で縦長ページになったので、4ブロックに分割してタブ形式にした。Tabs
コンポーネントの中でTabList
とTab
のコンポーネントがタブ切り替えボタン。TabPanel
コンポーネントが切り替わるタブの内容部分。
<Tabs> <nav id="scale_menu"> <TabList> <Tab>調性に基づく音階</Tab> <Tab>ヨーロッパ・アメリカ</Tab> <Tab>西アジア・南アジア</Tab> <Tab>東アジア・東南アジア</Tab> </TabList> </nav> <div id="scale_types"> <TabPanel> <section id="tonality" className="scale_type"> // 中略 </section> </TabPanel> <TabPanel> <section id="europe_america" className="scale_type"> // 中略 </section> </TabPanel> <TabPanel> <section id="west_south_asia" className="scale_type"> // 中略 </section> </TabPanel> <TabPanel> <section id="east_southeast_asia" className="scale_type"> // 中略 </section> </TabPanel> </div> </Tabs> </div>
タブ機能はReactの公式コミュニティが開発しているモジュール「react-tubs」によってカンタンに実現できた♪
※参考:GitHub - reactjs/react-tabs: An accessible and easy tab component for ReactJS.
スケール変更ボタンの中身。「調性に基づく音階」の「基本スケール」はdata.jsonのinner.scaleTypeButtons.basicScale
キーをmap()
でループしてラジオボタンを作成。scaleTypeSelect
関数と紐付け(他のブロックも同じ構造)
<section id="tonality" className="scale_type"> <h3>調性に基づく音階</h3> <dl id="basic_scale"> <dt>基本スケール</dt> {inner.scaleTypeButtons.basicScale.map((val: scaleTypeButtons) => <dd><label key={val.scaleValue}><input key={val.scaleValue} type="radio" name="scale_type" value={val.scaleValue} data-scale-name={val.scaleName} onChange={scaleTypeSelect} defaultChecked={val.defaultChecked || null} />{val.scaleName}</label></dd> )} </dl> // 中略 </section>
今後の課題
アラブの音階は「四分音」という1/4を上下にズラした音程があったが、Tone.jsの音程はピアノの十二音音階だったため、再現するのはシンプルではなさそうだった。個別の音にピッチを変えるエフェクトを加えるなどの検討が必要。
アラブの音階
※参考:音階 - Wikipedia
マカーム
※参考:マカーム - Wikipedia
四分音
※参考:四分音 - Wikipedia
ウード(フレットレスなアラブの弦楽器)
※参考:ピアノにはない微分音の音楽って? | スワラジ工房のブログ
ブルーノートも微分音
※参考:ブルーノート - SoundQuest
最後に
ピアノの鍵盤はCキー(ドの音)のメジャー・スケールのときはすべて白鍵だけど、別のキーになると黒鍵白鍵の組み合わせが変わってしまいます。また、別のスケールになるとCキーでも黒鍵白鍵の組み合わせが変わります。
キーやスケールが変わったときにどの鍵盤がスケールになるかを知りたかった。その音が実際に鳴らしてみてもキーが変わっただけで同じスケールになるかを確かめたかった。そして、西洋音楽以外にどんなスケールがあるかを知りたかった。ここらへんの目的を実現することができました。
これまで作ってきたビートプレイヤー、コードプレイヤー、スケールプレイヤー、個別にみると課題がいくつか残っています。でも自分の中では音楽の三要素「リズム、ハーモニー、メロディ」の基礎を知ることができ、ようやくスタートラインには立てた感があります。
さて次は、最近興味が出てきている数学の世界。三角関数が理解できる。アプリを作りたく思います。三角関数は図形、アニメーション、3Dなどいろいろな世界の入り口になっているのでちゃんと理解をしたく。それではまた!
※参考:【React】ReactでWebアプリを作るシリーズまとめ
qiita.com