「次はこれ書く」ってあんまり言わない方がいい気がしてきました。
久しぶりで、全然違う話題を書いてきます。
お題
「メトローム実家だわ」という嫁の一言で、Webアプリ作ってみることにしました。
いわゆるメトロームな感じで定間隔で音を鳴らすやつ。
作れと言われたわけではないですが、そういうのをWebで作るのってどうすんのかな、という単純な動機と、最近はめっきりWebに興味持ってきてた為です。
Google検索したら速攻Googleのメトローム出てきて心が折れかけましたが、なんとか完成させられました。
仕様
Wikipediaの「機械式メトロノーム」を参考にしました。
https://ja.wikipedia.org/wiki/メトロノーム
- 定間隔で音を鳴らす
- テンポ100の場合、1分間で100回鳴らす
- テンポは 40 - 208 まで変更できる
- 40 - 60 は2刻み
- 60 - 72 は3刻み
- 72 - 120 は4刻み
- 120 -144 は6刻み
- 144 - 208 は8刻み
- 2拍ごと、3拍ごと、4拍ごと、6拍ごとに、小節の区切りとして別の音を鳴らす(オプション)
画面は、適当にテンポと拍を配置して、ボタンとかで変更して、再生ボタンで開始する。
あと振り子みたいなの設置して、なんか動かす。
そんなのをイメージしました。
音源はここのを使わせていただきました。ありがとうございます。
http://musicisvfr.com
開発内容
構成は以下にしました。
- Vue.js
- Vuetify
- Firebase Hosting
- Firebase Storage
いわゆる高速ホスティングとかってやつですかね。
確かにほんとに早かったです。プラス無料で終わる。最高。
Storage には音楽ファイルを入れました。
Vue.js
言わずと知れたJavascriptのWebフレームワークです。SPAの構築に自信ありなようです。
流行ってるようですが、基本iOSエンジニアなのでようわからんってのが正直なとこでした。
簡単らしいよっていう言葉だけで使ってみたんです。
ただ、触ってみると非常にアプリっぽい構成なので、アプリエンジニアにはとっつきやすい気がしました。
ドキュメントが充実しまくってるのもポイント高しでした。
https://jp.vuejs.org/v2/guide/
日本語は誰が書いてんですかね。
はじめ方は超簡単です。
-
vue-cli
のインストール
$ npm i vue-cli -g
- Vueプロジェクトの作成
$ vue init webpack hogehoge
色々質問聞かれますが、他で検索してまねして下さい。
私は、ほぼほぼここ真似しました。良記事ありがとうございます。
https://qiita.com/Satachito/items/4a00b402970d657a88f3
特にフォルダ構成がここと丸々いっしょです。
- Vueプロジェクトのデバッグ
$ npm run dev
localhost:8080/
にローカルサーバーが立ち上がるので、修正しながら開発しましょう。
vue
ファイルは即時反映ですが、多分 index.html
が違う気がする。
- Vueプロジェクトのビルド
$ npm run build
ビルドすると dist
フォルダにファイルが生成されます。
これが出来上がらないとデプロイはできないので注意。
Vuetify
アプリエンジニアがWebページ作るときに一番悩むのって、絶対見た目の部分だと思うんです。
だって自由すぎて怖い。
CSSフレームワークも、色々あるのはわかるけど多すぎて選べない。怖い。
そこでVuetify。
完全にマテリアルデザインの再現なので、アプリエンジニアも安心。
タグを設置するだけなのもイメージがしやすい。
地味にマテリアルデザインのアイコンがそのまま使えるのも最高でした。
https://vuetifyjs.com/ja/getting-started/quick-start
ただ、カラーテーマだけは悩みました。
デザイナーじゃないとやっぱ色味とかわかりませんよほんと。
今回は以下のようにしてます。
theme: {
primary: '#039BE5',
secondary: '#3949AB',
accent: '#FF5252',
error: '#F44336',
warning: '#FBC02D',
info: '#90A4AE',
success: '#4caf50'
}
Vueのプロジェクトに入れるのは少し手間です。
- vuetifyをインストール
$ npm install vuetify --save
- Vueプロジェクトの
src/main.js
にインポート
import Vuetify from 'vuetify'
Vue.use(Vuetify)
-
src/stylus/main.styl
ファイルを作成してインポート
@import '~vuetify/src/stylus/main'
import './stylus/main.styl'
-
index.html
の<head>
にリソースをリンク
<link href='https://fonts.googleapis.com/css?family=Roboto:300,400,500,700|Material+Icons' rel="stylesheet">
以上ですが、今改めて調べてみると他にもっと簡単な方法載ってますね。(Vue CLI-3 Install っての)
今度調べます。
Firebase Hosting
語り尽くされてるので割愛。
とにかく高速でデプロイできるので便利ですね。
Auth とか Realtime Database とか Storage にも簡単にアクセスできるので、ほとんどこれだけで完結するのが魅力。
手順としては以下です。
- firebase tools をインストール
$ npm install -g firebase-tools
- Firebaseにログイン
$ firebase login
-
Firebaseプロジェクトを準備
-
Firebase Hosting と Storage を初期化
$ firebase init
色々聞かれた後、作成したプロジェクトを選択すると、なんのFirebaseサービスを使うか聞かれるので、そこで Hosting
と Storage
を選びましょう。
- デプロイ
$ firebase deploy
、、、なのですが、Vueのプロジェクトのビルドしてからじゃないと意味ないで、やってからにしましょう。
あと、Storage にファイル設置する場合、今回のように誰でも見れるようにするなら、ルールを変更してくださいね。
Vueのコード
SPAっていうかシングルページなので、ページ構成としてはこんなもんです。
App
└── Home
が、せっかくVueなのでコンポーネント化してみようってことで、HomeにMetronomeのコンポーネントを設置する構成にしました。
App
└── Home
└── Metronome
Homeコンポーネント
ヘッダとフッタとMetronomeコンポーネントから構成してます。
わからないながら、頑張ってレスポンシブしてますが微妙です。
<template>
<div>
<v-toolbar
dark
color="primary"
>
<v-toolbar-title white--text>
Metronomo
</v-toolbar-title>
</v-toolbar>
<v-card class="transparent">
<v-container
fluid
grid-list-xs
>
<v-layout
row
wrap
>
<v-flex
xs12 md6 lg4 xl4
offset-md3 offset-lg4 offset-xl4
>
<v-card>
<v-card-text>
<metronome />
</v-card-text>
</v-card>
</v-flex>
</v-layout>
</v-container>
</v-card>
<v-footer
color="info"
>
<v-layout text-xs-center>
<v-flex
class="soundLink"
grey--text
text--lighten-3
>
Sound by
<strong>
<a href="http://musicisvfr.com" target="_blank">
Music is VFR
</a>
</strong>
</v-flex>
</v-layout>
</v-footer>
</div>
</template>
<script>
import Metronome from '@/components/Metronome'
export default {
name: 'Home',
components: {
'metronome': Metronome
},
data () {
return {
}
}
}
</script>
<style scoped>
.soundLink a {
color: white
}
</style>
Metronomeコンポーネント
- テンポは+-で変更。
- 拍はチェックボックスで有効無効を指定、ラジオボタンで数字を変更。
- 再生ボタンクリックで、タイマーで音鳴らします。
- タイマーに合わせてスライダーが右左に動きます。(振り子のイメージ、、、? 無理があるか)
- スライダーが真ん中で一回止まるのは、振り子が真ん中で音出すからです。
- 音源は
mounted
で読んでるので無駄はしてないつもり。
<template>
<v-container>
<v-layout
row
wrap
>
<v-flex xs12>
<v-slider
ref="pendurumSlider"
:value="playState.now"
:min="0"
:max="scaleLimit"
readonly
color="primary"
/>
</v-flex>
<v-flex xs12>
<v-card
flat
color="transparent"
>
<v-card-title>
Tempo
</v-card-title>
<v-card-text>
<v-layout
row
wrap
align-center
>
<v-flex text-xs-right>
<v-btn
fab
small
color="secondary"
:disabled="isPlaying"
@click="stepScaleMinus"
>
<v-icon>
remove
</v-icon>
</v-btn>
</v-flex>
<v-flex
text-xs-center
headline
grey--text
text--darken-2
>
{{ playCondition.scale }}
</v-flex>
<v-flex text-xs-left>
<v-btn
fab
small
color="secondary"
:disabled="isPlaying"
@click="stepScalePlus"
>
<v-icon>
add
</v-icon>
</v-btn>
</v-flex>
</v-layout>
</v-card-text>
</v-card>
</v-flex>
<v-flex xs12>
<v-card
flat
color="transparent"
>
<v-card-title>
<v-checkbox
label="Beat"
color="accent"
:readonly="isPlaying"
v-model="playCondition.enableBeat"
/>
</v-card-title>
<v-card-text>
<v-layout>
<v-flex
offset-xs1 offset-sm4 offset-md3 offset-xl4
>
<v-radio-group
v-model="playCondition.beat"
row
:disabled="!playCondition.enableBeat || isPlaying"
>
<v-radio
color="secondary"
v-for="(item, index) in beatItems"
:key="index"
:label="item.toString()"
:value="item"
@click="changeBeat(item)"
/>
</v-radio-group>
</v-flex>
</v-layout>
</v-card-text>
</v-card>
</v-flex>
<v-flex
xs12
text-xs-center
>
<template v-if="!isPlaying">
<v-btn
dark
color="primary"
@click.prevent="startPlay"
>
<v-icon>
music_note
</v-icon>
</v-btn>
</template>
<template v-else>
<v-btn
dark
color="accent"
@click.prevent="stopPlay"
>
<v-icon>
music_off
</v-icon>
</v-btn>
</template>
</v-flex>
</v-layout>
</v-container>
</template>
<script>
import { Howl } from 'howler'
const scaleRange = {
min: 40,
max: 208
}
const beatRange = [2, 3, 4, 6]
const soundUrls = {
tempo: 'https://firebasestorage.googleapis.com/v0/b/metronomo-22db2.appspot.com/o/admin%2FCastanets01-1.mp3?alt=media&token=cb5d31b2-6c76-4521-adc9-14a39df9e480',
beat: 'https://firebasestorage.googleapis.com/v0/b/metronomo-22db2.appspot.com/o/admin%2FOnmtp-Inspiration01-1.mp3?alt=media&token=8ec0630c-465d-4be2-947e-166ce5778d8d'
}
export default {
name: 'Metronome',
data () {
return {
playCondition: {
scale: 60,
enableBeat: false,
beat: 4
},
playInterval: null,
playState: {
direction: true,
now: 0,
limit: 0,
count: 0
},
audio: {
tempo: null,
beat: null
}
}
},
computed: {
scaleLimit () {
const allValue = scaleRange.max - scaleRange.min
const value = this.playCondition.scale - scaleRange.min
return allValue - value + 1
},
beatItems () {
return beatRange
},
isPlaying () {
return this.playInterval !== null
}
},
methods: {
stepScale (scale) {
if (scale < 60) return 2
if (scale < 72) return 3
if (scale < 120) return 4
if (scale < 144) return 6
return 8
},
stepScaleMinus () {
this.playCondition.scale -= this.stepScale(this.playCondition.scale - 1)
if (this.playCondition.scale <= scaleRange.min) {
this.playCondition.scale = scaleRange.min
}
},
stepScalePlus () {
this.playCondition.scale += this.stepScale(this.playCondition.scale + 1)
if (this.playCondition.scale >= scaleRange.max) {
this.playCondition.scale = scaleRange.max
}
},
changeBeat (value) {
this.playCondition.beat = value
},
playSilent (audio) {
audio.mute(true)
audio.play()
},
prepareAudio () {
this.playSilent(this.audio.tempo)
this.playSilent(this.audio.beat)
},
playAudio (audio) {
audio.seek(0)
audio.mute(false)
audio.play()
},
startPlay () {
this.prepareAudio()
this.playState.limit = this.$refs.pendurumSlider.max
const time = ((60 / this.playCondition.scale) / 2) * 1000
this.playInterval = setInterval(() => {
if (this.playState.now === (this.playState.limit / 2)) {
if ((this.playCondition.enableBeat) && (((this.playState.count + 1) % this.playCondition.beat) === 0)) {
this.playAudio(this.audio.beat)
} else {
this.playAudio(this.audio.tempo)
}
}
if (this.playState.direction) {
this.playState.now += this.playState.limit / 2
} else {
this.playState.now -= this.playState.limit / 2
}
let end = false
if (this.playState.direction) {
end = (this.playState.now >= this.playState.limit)
} else {
end = (this.playState.now <= 0)
}
if (end) {
this.playState.direction = !this.playState.direction
this.playState.count++
}
}, time)
},
stopPlay () {
clearInterval(this.playInterval)
this.playInterval = null
this.playState = {
direction: true,
now: 0,
limit: 0,
count: 0
}
this.audio.tempo.stop()
this.audio.beat.stop()
}
},
mounted () {
this.audio.tempo = new Howl({ src: [soundUrls.tempo] })
this.audio.beat = new Howl({ src: [soundUrls.beat] })
},
beforeDestroy () {
this.stopPlay()
}
}
</script>
<style scoped>
</style>
見た目に関しては、Vuetifyに限らないでしょうが、グリッドシステムが唯一悩むところでした。
概念や数字の意味(12とか)を理解するのが難しくて、こういうの簡単にするGUIツール誰か作って欲しいです。レスポンシブも内包して。
出来上がり
ここです。
https://metronomo-22db2.firebaseapp.com/
一応動作はしてます。
スマホでも使えますが、マナーモード解除しないと聞こえないです。
あんまり再生されると課金されるかもなのでご注意。
大変だったところ
簡単だろって思ってたわけではないです。もちろん。
Webはバックエンド少しできるぐらいだし、Javascriptも半端者なので。
ただ、予想外のとこに詰まって困りました。
Safariで音を鳴らすのが大変だったこと
最初音鳴ってたはずなんですが、どこからか全然鳴らなくなったので頭抱えました。
問題はユーザー操作だったとは。ここら辺に詳しいです。
https://qiita.com/pentamania/items/2c568a9ec52148bbfd08
ユーザー操作を伴わない場合はメディア再生を許可しない、とのこと。
最初の方だとまだタイマーやってなかったので気づかなかったんですね。
仕様知らないと絶対気づかない部分です。
今回の対処法は、上記と同じく再生ボタンクリック時に、無音再生して無理やり許可取る方法にしました。
テンポと拍の音源2種類とも無音再生してしまってます。
MobileSafariで無音ができなかったこと
上記修正の後、「あーできた」といざiPhoneで聞いたら、最初に音がでっかく「ぽぽーん」と鳴ってしまいました。無音再生してるとこです。
このタイミングでは、HTML5の audio
タグを使って開発してたんですが、volumeプロパティが制御できないとのことです。
https://ja.stackoverflow.com/questions/6838/iosでaudioタグを使い音量を制御したいです
あほかと。ここら辺でiOSエンジニアでありながら、iPhoneに怒りがでてきた次第です。
とりあえず代替手段ってことで色々検索。
二、三日悩んで、 howler.js
というライブラリを使用することにしました。
https://github.com/goldfire/howler.js
設置も使い方もシンプルなので最高でした。
マルチチャンネルの再生とかもできそうなので、もっとすごいの作るときにまた活用したいですね。
Firebase Storage でCORSが有効になっていないこと
さあ終わりだと、最後に何気なくコンソールでデバッグログ見たら、なんか大量にエラー。
「Access-Control-Allow-Origin のなんとかかんとかー」とのこと。
全然わからんので調査した結果、CORS(オリジン間リソース共有)の問題だとわかりました。
ドメイン違うとこを直接参照してるので警告する、という認識ですが、良いですかね。
確かに言われたらそりゃそうですな。
で、Firebase Storage でそれを設定する方法があったので、ポチッとやりました。
https://qiita.com/niusounds/items/383a780d46ee8551e98c
Google Cloud の Firebase Storage 管理領域に設定ファイルを追加するという作業です。
gcloud
のインストールはもしかすると、こっちの方がわかりやすいかも。
https://qiita.com/kentarosasaki/items/2232113b44b016a56adc
以上、この辺りをクリアしたら、なんとかできました。
次は振り子をスライダーじゃなくてもっと振り子っぽくしてみたいと思います。(また言ってる)
以上です。