Reactアプリの続きです。前回は配色ジェネレーターを作りました。今回からTone.jsを使った音楽アプリ編に入ります。以前CodePenで作成したビートプレイヤーをReact/Next環境に移植してみました。それではいきましょう!
【目次】
※参考:前回記事
【React】配色ジェネレーターを作った(HSBでメインカラー、アクセントカラー、ベースカラーを割り出す!) - クモのようにコツコツと
※参考:【React】ReactでWebアプリを作るシリーズまとめ
qiita.com
作ったもの
ビートプレイヤー
下記のような用途に活用できる。
- 楽器やボーカルの練習用のメトロノームとして使う
- 既存の曲のBPM(Beats Per Minute:テンポ、速度)を調べる
- 同じBPMの拍子(リズム)を変えるとどんなビートになるか確認できる
作ったアプリはこちら
※参考:ビートプレイヤー
アプリの使い方
ソースコード
※参考:GitHub - ryo-i/beat-player: Tone.jsを使って作ったビートプレイヤー
このアプリで実現したかったこと
以前、こちらのTone.jsを使ってCodePen上に作ったビートプレイヤー
※参考:【Tone.js】いろいろなリズムが鳴らせるビート・プレイヤーを作った(BPM切り替え可能) - クモのようにコツコツと
※参考:【Web Audio】Tone.jsを習得するためにやったことまとめ(随時更新) - Qiita
これをReact/Next環境で再現したい。前回の配色ジェネレーターと同じく、Nextスターターキットの環境をベースに作成する。
※参考:GitHub - ryo-i/next-app-started
※参考:【React】ReactでWebアプリを作るシリーズまとめ(随時更新) - Qiita
ソースコード
上記の挙動を実現しているソースコードはこちら。
※参考:GitHub - ryo-i/beat-player: Tone.jsを使って作ったビートプレイヤー
NCUインストール(アップデート確認)
GitHubの画面上で脆弱性からパッケージアップデートを勧められた
NCUインストール
$ npm install -g npm-check-updates
NCU実行
$ ncu -u
パッケージインストール
$ npm install
参考になった記事
※参考:Githubからセキュリティ警告が来たときの対応 – Cntlog
tone.jsのインストール
下記のコマンドでインストールできた
$ npm i tone
※参考:Tone.js
packge.jsonに追加された!
"dependencies": { "next": "11.1.1", "react": "17.0.2", "react-dom": "17.0.2", "styled-components": "^5.3.0", "tone": "^14.7.77" },
※参考:beat-player/package.json at main · ryo-i/beat-player · GitHub
ちなみにtone.jsonは「@type」は無いようだった。
※参考:TypeScript: Search for typed packages
データ:data.json
inner
キーの中にビートプレイヤー用のデータ(設定値)を入れている。
"inner": { "settings": { // フックの初期値 }, "beatParam": { // 各ビートの設定値 }, "synthParam": { // シンセの設定値 } },
settings
キーの中には後述するフックの初期値を入れている
"settings": { "beatPlay": "▶︎", "bpm": 120, "beatName": "beat8", "beatValue": "8拍子" },
beatParam
キーの中には各種ビートの設定値を入れている
"beatParam": { "beat1": { "value": "1拍子", "beatNumber": 1, "kickRhythm": [0], "snareRhythm": [], "shaffle": false }, "beat2": { "value": "2拍子", "beatNumber": 2, "kickRhythm": [0], "snareRhythm": [1], "shaffle": false }, "beat3": { "value": "3拍子", "beatNumber": 3, "kickRhythm": [0], "snareRhythm": [1, 2], "shaffle": false }, "beat4": { "value": "4拍子", "beatNumber": 4, "kickRhythm": [0, 2], "snareRhythm": [1, 3], "shaffle": false }, "beat5": { "value": "5拍子", "beatNumber": 5, "kickRhythm": [0], "snareRhythm": [3], "shaffle": false }, "beat6": { "value": "6拍子", "beatNumber": 6, "kickRhythm": [0], "snareRhythm": [3], "shaffle": false }, "beat7": { "value": "7拍子", "beatNumber": 7, "kickRhythm": [0, 4], "snareRhythm": [2, 6], "shaffle": false }, "beat8": { "value": "8拍子", "beatNumber": 8, "kickRhythm": [0, 4], "snareRhythm": [2, 6], "shaffle": false }, "beat12": { "value": "12拍子", "beatNumber": 12, "kickRhythm": [0, 6], "snareRhythm": [3, 9], "shaffle": false }, "beatShuffle": { "value": "シャッフル", "beatNumber": 12, "kickRhythm": [0, 6], "snareRhythm": [3, 9], "shaffle": true }, "beat16": { "value": "16拍子", "beatNumber": 16, "kickRhythm": [0, 8], "snareRhythm": [4, 12], "shaffle": false }, "beat24": { "value": "24拍子", "beatNumber": 24, "kickRhythm": [0, 12], "snareRhythm": [6, 18], "shaffle": false }, "beatSwing16": { "value": "ハネた16", "beatNumber": 24, "kickRhythm": [0, 12], "snareRhythm": [6, 18], "shaffle": true }, "beat32": { "value": "32拍子", "beatNumber": 32, "kickRhythm": [0, 16], "snareRhythm": [8, 24], "shaffle": false } },
synthParam
にはシンセのオプション設定値を入れている。
"synthParam": { "synthKickOpts": { "value": "キック", "envelope": { "attack": 0 , "decay": 0.5 , "sustain": 0 , "release": 0 }, "volume": 2 }, "synthSnareOpts": { "value": "スネア", "envelope": { "attack": 0 , "decay": 0.2 , "sustain": 0 , "release": 0 }, "volume": -10 }, "noiseSnareOpts": { "value": "スネア", "type": "white", "envelope": { "attack": 0 , "decay": 0.4 , "sustain": 0 , "release": 0 }, "volume": 0 }, "noiseHihatOpts": { "value": "ハイハット", "type": "brown", "envelope": { "attack": 0 , "decay": 0.03 , "sustain": 0, "release": 0 }, "volume": -10 } }
※参考:beat-player/data.json at main · ryo-i/beat-player · GitHub
Inner.tsx(全体)
Inner.tsxはビートプレイヤーの本体にあたるファイル
※参考:beat-player/Inner.tsx at main · ryo-i/beat-player · GitHub
全体的な構成はこうなっている。
import React, { useState } from 'react'; import styled from 'styled-components'; import Data from '../data/data.json'; import * as Tone from 'tone'; const innerJson = Data.inner; // CSS in JS const BeatPlayer = styled.section` // 中略 // Component function Inner() { // 中略(フック、Tone.js、JSXを設定) } export default Inner;
react
とフックuseState
をインポート- CSS in JS
styled-components
をインポート - 設定データ
data.json
をインポート - Tone.js
tone
をインポート - 変数
innerJson
でdata.json
からInnerコンポーネントのデータを取得 - 関数
Inner()
でInnerコンポーネントを設定(中でフック、Tone.js、JSXを設定) - Innerコンポーネントをエクスポート
BeatPlayerコンポーネント(CSS in jS)
BeatPlayer
コンポーネントにstyled-components
によるCSS in JSの設定を書いている。section
タグになる。
const BeatPlayer = styled.section` { background: #333; font-family: "Hiragino Kaku Gothic ProN", "Hiragino Sans", "Helvetica Neue", Arial,Meiryo, sans-serif; padding: 0; h1, h2, p { color: #fff; text-align: center; } .pad { background: #333; padding: 10px; display: flex; justify-content: center; flex-wrap: wrap; margin: 0 auto; position: relative; .beat { margin: 5px; background: #666; border: 0px solid #333; width: 100px; height: 100px; font-family: inherit; font-size:30px; line-height: 100px; border-radius: 5px; text-align: center; display: inline-block; color: #fff; box-shadow: 5px 5px 5px rgba(0,0,0, 0.3); :hover { opacity: 0.7; cursor: pointer; } } } .setting { text-align: center; color: #fff; max-width: 350px; width: 100%; margin: 0 auto; .range { max-width: 300px; width: 100%; margin: 0 auto; :hover { cursor: pointer; } } label { margin: 0 3px 10px 0; display: inline-block; :hover { opacity: 0.7; cursor: pointer; } } } } `;
CodePenのCSSがSass(SCSS)的な書き方になりすっきり♪
Innerコンポーネント(全体)
Innerコンポーネントでフック、Tone.js、JSXを設定している。全体的な構成はこうなっている。
// Component function Inner() { // Hooks // 中略 // シンセ設定 let kickSynth, snareSynth, hihatSynth; const setSynth = () => { // 中略 }; // リズム取得 let getRhythmData = (className) => { // 中略 }; // キック、スネアリズム設定 const setRhythm = (beatLen, Array) => { // 中略 } // ハイハットリズム設定 const setHihatRhythm = (beatNmb, beatLen, shaffle) => { // 中略 }; // ビートリズム設定 const setBeatRhythm = (className) => { // 中略 }; // ビート再生設定 const playBeat = (kickRtm, snareRtm, hihatRtm) => { // 中略 }; // 再生ボタン let changeBeatPlay = () => { // 中略 }; // BPM変更 let changeBpm = (e: React.ChangeEvent<HTMLInputElement>) => { // 中略 } // リズム変更 const changeRythm = (e: React.ChangeEvent<HTMLInputElement>) => { // 中略 } // JSX return ( // 中略 ); }
フック設定(useState())
冒頭でフック設定をしている。
// Hooks const [beatPlay, setBeatPlay] = useState(innerJson.settings.beatPlay); const [bpmRange, setBpmRange] = useState(innerJson.settings.bpm); const [beatName, setBeatName] = useState(innerJson.settings.beatName); const [beatValue, setBeatValue] = useState(innerJson.settings.beatValue);
beatPlay
、bpmRange
、beatName
、beatValue
の4つ。初期値はinnerJson
のsettings
キーから読み取っている。
シンセ設定
次にシンセ設定
// シンセ設定 let kickSynth, snareSynth, hihatSynth; const setSynth = () => { const synthKick = new Tone.Synth(innerJson.synthParam.synthKickOpts).toDestination(); const synthSnare = new Tone.Synth(innerJson.synthParam.synthSnareOpts).toDestination(); const noiseSnare = new Tone.NoiseSynth(innerJson.synthParam.noiseSnareOpts).toDestination(); const noiseHihat = new Tone.NoiseSynth(innerJson.synthParam.noiseHihatOpts).toDestination(); kickSynth = () => { synthKick.oscillator.type = "sine"; synthKick.frequency.rampTo('C0', '0.1'); synthKick.triggerAttackRelease('C4', '0.5'); }; snareSynth = () => { synthSnare.oscillator.type = "sine"; synthSnare.triggerAttackRelease('F4', '0.5'); noiseSnare.triggerAttackRelease('0.5'); }; hihatSynth = () => { try { noiseHihat.triggerAttackRelease('0.1'); } catch (error) { console.log('error ->', error.message); } }; };
setSynth()
関数で処理を実行するが、結果を取得するkickSynth
, snareSynth
, hihatSynth
を再利用したいため、関数の外でlet
変数で設定した。
ドラムマシン808的な音にしたくて試行錯誤した。
※参考:Roland TR-808 (1980) - Famous Drum Beats - YouTube
synthKick
、synthSnare
、noiseSnare
、noiseHihat
でシンセを取得。CodePen時代はtoMaster ()
を実行していたが今はtoDestination()
が推奨されているようだ。
※参考:Destination
kickSynth()
でキック音の設定。CodePen時代はMembraneSynth(Tone.jsのキック&タム用のシンセ)を使っていたが結果、808的なキック音はTone.jsの基本シンセであるSynthで実現できた。
※参考:Synth
波形をサイン波にしたかった。波形の設定値はこちらで調べることができた。
※参考:TypeWithPartials
下記の参考記事のようにキックのアタック時にピッチを時間的変化(エンベロープ)させたかった。
※参考:エレクトロなキック
FrequencyEnvelope
でピッチの時間的変化を実現できた。ピッチがC4からC0に0.1秒かけて下がる。
snareSynth()
内でスネアの設定。CodePen時代はNoiseシンセの減衰音を鳴らしていた。
※参考:Noise
それだけだと物たりないため、アタック音に革が響く音を加えたかった。
※参考:エレクトロなスネア
snareSynth
でキックと同じSynthシンセを実行しnoiseSnare
のと一緒に鳴らした。ピッチはF4が808的な高さだった。
hihatSynth()
でハイハットの設定。スネアと同じNoiseシンセ。32ビートにしてBPM240にすると下記のエラーが頻発した。エラーはループの頭で発生し、若干繋ぎがズレる感じがした。
Uncaught Error: Start time must be strictly greater than previous start time
まったく同時に多くのtriggerAttackRelease()
を実行するのはよくないようだ。
※参考:javascript - Tone.js Error: 'Start time must be strictly greater than previous start time' - Stack Overflow
※参考:[Tonejs/Tone.js#594:title]
試行錯誤の結果、try...catchで例外設定すると繋ぎのズレはなくなった。(エラー自体はなくならないが)
※参考:try...catch - JavaScript | MDN
※参考:JavaScriptのtry~catch文で例外処理をする方法|TECH PLAY Magazine [テックプレイマガジン]
※参考:https://wa3.i-3-i.info/word1427.html
リズム取得
getRhythmData()
関数でリズムを取得
// リズム取得 let getRhythmData = (className) => { let data = innerJson.beatParam[className]; let beatLen = 4 / data.beatNumber; return { data: data, beatVal: data.value, beatNum: data.beatNumber, beatLen: beatLen, kickRhythm: data.kickRhythm, snareRhythm: data.snareRhythm, shaffle: data.shaffle } };
引数のclassName
を元にinnerJson
のbeatParam
キーからリズム設定値を取得。そこから先はCodePen時代と同じ。
キック、スネアリズム設定
setRhythm()
関数でキック、スネアリズムを設定
// キック、スネアリズム設定 const setRhythm = (beatLen, Array) => { let rhythm = []; for(let i = 0; i < Array.length ; i++) { rhythm.push('0:' + Math.floor((beatLen * Array[i]) * 1000) / 1000 + ':0'); } return rhythm; }
CodePen時代とちょっと変えたのはfor文の中。1000を掛けてから1000で割ってMath.floor
で小数2桁で切り捨てている。(エラーや音のズレと格闘しているときに原因の一つかな?と思って試した。結果は変わらなかったw)
ハイハットリズム設定
setHihatRhythm()
関数でハイハットリズムを設定
// ハイハットリズム設定 const setHihatRhythm = (beatNmb, beatLen, shaffle) => { let rhythm = []; for(let i = 0; i < beatNmb ; i++) { if (shaffle && i % 3 == 1) { // 鳴らさない } else { rhythm.push('0:' + Math.floor(beatLen * i * 1000) / 1000 + ':0'); } } return rhythm; };
shaffle
がtrueの場合は2番目のハイハットを鳴らさない。ここでも少数2桁で切り捨てにしている。
ビートリズム設定
setBeatRhythm()
関数でビートリズムを設定
// ビートリズム設定 const setBeatRhythm = (className) => { let data = getRhythmData(className); setBeatValue(data.beatVal); const beatNum = data.beatNum; const beatLen = data.beatLen; const kickRhythm = data.kickRhythm; const snareRhythm = data.snareRhythm; const shaffle = data.shaffle; return { kick: setRhythm(beatLen, kickRhythm), snare: setRhythm(beatLen, snareRhythm), hihat: setHihatRhythm(beatNum, beatLen, shaffle) } };
引数でclassName
を取得し先ほどのgetRhythmData()
の引数に入れて実行している。
ビートリズム設定
playBeat()
で関数ビートリズムを設定
// ビート再生設定 const playBeat = (kickRtm, snareRtm, hihatRtm) => { setSynth(); let hihatPart = new Tone.Part(hihatSynth, hihatRtm).start(); let kickPart = new Tone.Part(kickSynth, kickRtm).start(); let snarePart = new Tone.Part(snareSynth, snareRtm).start(); hihatPart.loop = true; kickPart.loop = true; snarePart.loop = true; };
setSynth()
でシンセを設定し、Tone.Part()
でキック、スネア、ハイハットを鳴らす。loop
でビートをループしている。
※参考:Part
再生ボタン
changeBeatPlay()
関数で再生ボタンの処理を実行
// 再生ボタン let changeBeatPlay = () => { if (beatPlay === "▶︎") { setBeatPlay("■"); Tone.Transport.stop(); Tone.Transport.cancel(); Tone.Transport.bpm.value = bpmRange; const beatRhythm = setBeatRhythm(beatName); playBeat(beatRhythm.kick, beatRhythm.snare, beatRhythm.hihat); Tone.Transport.start(); } else if (beatPlay === "■"){ setBeatPlay("▶︎"); Tone.Transport.stop(); Tone.Transport.cancel(); } };
フックbeatPlay
が「▶︎」だったらbeatPlay
を「■」に変更。Tone.Transport
でキック、スネア、ハイハットを同時に制御している。
※参考:Transport
「▶︎」だったら音を止めて設定をキャンセル。BPMをフックbpmRange
に。setBeatRhythm()
の引数にフックbeatName
を入れて実行し、その結果をplayBeat()
で再生する。
フックbeatPlay
が「■」だったらbeatPlay
を「▶︎」に変更。Tone.Transport
でキック、スネア、ハイハットをストップし、設定もキャンセルしている。
BPM変更
changeBpm()
関数でBPMを変更
// BPM変更 let changeBpm = (e: React.ChangeEvent<HTMLInputElement>) => { const BPMImput: number = Number(e.target.value); setBpmRange(BPMImput); Tone.Transport.bpm.value = BPMImput; }
引数はJSXのInputタグ。BPMImput
でinputタグのvalue
を取得し、フックbpmRange
にセット。Tone.Transport
のBPMも変更している。
リズム変更
changeRythm()
関数でリズムを変更
// リズム変更 const changeRythm = (e: React.ChangeEvent<HTMLInputElement>) => { const getClassName: string = String(e.target.className); const getValue: string = String(e.target.value); const beatRhythm = setBeatRhythm(getClassName); setBeatName(getClassName); setBeatValue(getValue); if(beatPlay === "■") { Tone.Transport.stop(); Tone.Transport.cancel(); Tone.Transport.start(); playBeat(beatRhythm.kick, beatRhythm.snare, beatRhythm.hihat); } else if (beatPlay === "▶︎") { Tone.Transport.cancel(); playBeat(beatRhythm.kick, beatRhythm.snare, beatRhythm.hihat); } }
こちらも引数はJSXのinputタグ。getClassName
でinputタグのclass名、getValue
でvalueを取得。
beatRhythm
でsetBeatRhythm()
を実行(引数にgetClassName
を入れる)。フックbeatName
にgetClassName
をセット。フックbeatValue
にgetValue
をセット。
フックbeatPlay
が「■」だったらTone.Transport
でキック、スネア、ハイハットを停止、キャンセル、再生。playBeat()
でリズム変更。引数はbeatRhythm
のキック、スネア、ハイハット。
フックbeatPlay
が「▶︎」だったらTone.Transport
でキック、スネア、ハイハットの設定をキャンセルし、playBeat()
でplayBeat()
でリズム変更。
JSX設定
JSX設定
// JSX return ( <> <BeatPlayer> <h1>Beat Player</h1> <ul className="pad"> <button className="beat" type="button" onClick={changeBeatPlay}>{beatPlay}</button> </ul> <div className="setting"> <section className="bpm"> <h2>BPM: <span className="val">{bpmRange}</span></h2> <input type="range" name="range" min="30" max="240" value={bpmRange} className="range" onChange={changeBpm} /> </section> <section className="rhythm"> <h2>リズム: <span className="val">{beatValue}</span></h2> <label><input type="radio" name="beat" className="beat1" value="1拍子" onChange={changeRythm} />1</label> <label><input type="radio" name="beat" className="beat2" value="2拍子" onChange={changeRythm} />2</label> <label><input type="radio" name="beat" className="beat3" value="3拍子" onChange={changeRythm} />3</label> <label><input type="radio" name="beat" className="beat4" value="4拍子" onChange={changeRythm} />4</label> <label><input type="radio" name="beat" className="beat5" value="5拍子" onChange={changeRythm} />5</label> <label><input type="radio" name="beat" className="beat6" value="6拍子" onChange={changeRythm} />6</label> <label><input type="radio" name="beat" className="beat7" value="7拍子" onChange={changeRythm} />7</label> <label><input type="radio" name="beat" className="beat8" value="8拍子" onChange={changeRythm} defaultChecked />8</label> <label><input type="radio" name="beat" className="beat12" value="12拍子" onChange={changeRythm} />12</label> <label><input type="radio" name="beat" className="beatShuffle" value="シャッフル" onChange={changeRythm} />Shuffle</label> <label><input type="radio" name="beat" className="beat16" value="16拍子" onChange={changeRythm} />16</label> <label><input type="radio" name="beat" className="beat24" value="24拍子" onChange={changeRythm} />24</label> <label><input type="radio" name="beat" className="beatSwing16" value="ハネた16" onChange={changeRythm} />Swing16</label> <label><input type="radio" name="beat" className="beat32" value="32拍子" onChange={changeRythm} />32</label> </section> </div> </BeatPlayer> </> ); }
タグの全体的な構成はCodePenと変わらない。class名はJSXのためclassName
にしている。
ユーザーの操作によってテキスト表示を変える部分は{bpmRange}
などのフックにしている。
また、buttonタグやinputタグのイベントはReactのonClick
やonChange
で関数を実行いしてる。
Headerコンポーネント(音を止める設定)
「About」ページに遷移してもビートの音が止まらなかった。ブラウザがリロードされていないためと思われる。
Headerコンポーネントの中で音を止める設定をした。
※参考:beat-player/Header.tsx at main · ryo-i/beat-player · GitHub
import Link from 'next/link'; import Router from 'next/router'; // 中略 import * as Tone from 'tone'; // 中略(Style設定など) // 再生停止 const stopBeatPlay = (url) => { // console.log('App is changing to: ', url); if (url === '/about') { Tone.Transport.stop(); Tone.Transport.cancel(); } }; Router.events.on('routeChangeStart', stopBeatPlay); // Component function Header() { return ( <HeaderTag> <div className="wrapper"> <h1>{ title }</h1> <p dangerouslySetInnerHTML={{ __html: text }}></p> <nav> <span>MENU:</span> <Link href="/"><a>Home</a></Link> <Link href="/about"><a>About</a></Link> </nav> </div> </HeaderTag> ); } export default Header;
Tone.jsをここでもインポートする。
stopBeatPlay()
関数でTone.Transport
でキック、スネア、ハイハットの停止、設定キャンセルを実行
aboutページのaタグでonClick
でstopBeatPlay()
を実行
next/routerのルーティングイベント(events.on)のrouteChangeStartでページ遷移時にstopBeatPlay()
を実行
(※2021/09/20修正:onClickだとブラウザの矢印アイコンで音が止まらないため、ルーティングイベントに変更)
※参考:Next.jsのルーティングイベントの発火の覚書 | RyotArchLog ※参考:next/router | Next.js
これでaboutページに遷移したときに音が止まるようになった♪
課題(ハイハットがズレる)
2021/09/14現在、ハイハットの音がズレるという課題が残っている。特に「はねた16ビート」が顕著に感じる。「チッキチッキチッキチッキ」がたまに「チキッチキッチキッチキッ」みたいな感じにズレる。
なんとなくループ処理の時になんらかの事情でタイムラグが生じている気がするが、Tone.jsのループは「hoge.loop = true」とプロパティ化しており、その先はライブラリ内で行われているため、解決方法が見つからない。
この問題はCodePen時代はあまり起こっていなかった(完全にないわけではなさそう)
See the Pen tone.js-BeatPlayer by イイダリョウ (@i_ryo) on CodePen.
※参考:【Tone.js】いろいろなリズムが鳴らせるビート・プレイヤーを作った(BPM切り替え可能) - クモのようにコツコツと
そのため、React/Next.js環境との相性の問題な気がいている。特にTone.jsはブラウザ側がメインで動き、Next.jsはサーバ側がメインで動くのでその相性が悪いように思う。
今回、React/Next.jsでTone.jsの音が鳴らせるか、というテーマで、それ自体は実現できたので、今後は課題が生じない範囲内で作っていくしか今のところなさそう。また解決策がわかったらここに追記していく予定。
最後に
とうことでReact/Next環境でTone.jsの音を鳴らすことができました〜。ハイハットの音がズレるなどの課題は残っています。。
早いBPMで起こる事象のため、React/Next環境でTone.jsを使うコンテンツはあまり早い速度で動かすと意図通りの音にならない可能性がありそうです。まあ自分はLo-fi Hip Hopなどゆったり目の速度の音楽が好きなのでたいていのことは実現できるんじゃないかな、という気はしています。
Tone.jsシリーズ、次は和音(コード)系のアプリを作る予定です。それではまた!
※参考:【React】ReactでWebアプリを作るシリーズまとめ
qiita.com