Vue.jsでメトロノームなWebアプリ

「次はこれ書く」ってあんまり言わない方がいい気がしてきました。
久しぶりで、全然違う話題を書いてきます。

## お題
「メトローム実家だわ」という嫁の一言で、Webアプリ作ってみることにしました。
いわゆるメトロームな感じで定間隔で音を鳴らすやつ。

作れと言われたわけではないですが、そういうのをWebで作るのってどうすんのかな、という単純な動機と、最近はめっきりWebに興味持ってきてた為です。

Google検索したら速攻Googleのメトローム出てきて心が折れかけましたが、なんとか完成させられました。

## 仕様
Wikipediaの「機械式メトロノーム」を参考にしました。
https://ja.wikipedia.org/wiki/メトロノーム

1. 定間隔で音を鳴らす
- テンポ100の場合、1分間で100回鳴らす
- テンポは 40 - 208 まで変更できる
    1. 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` のインストール

```bash
$ npm i vue-cli -g
```

- Vueプロジェクトの作成

```bash
$ vue init webpack hogehoge
```

色々質問聞かれますが、他で検索してまねして下さい。
私は、ほぼほぼここ真似しました。良記事ありがとうございます。
https://qiita.com/Satachito/items/4a00b402970d657a88f3

特にフォルダ構成がここと丸々いっしょです。

- Vueプロジェクトのデバッグ

```bash
$ npm run dev
```

`localhost:8080/` にローカルサーバーが立ち上がるので、修正しながら開発しましょう。
`vue` ファイルは即時反映ですが、多分 `index.html` が違う気がする。

- Vueプロジェクトのビルド

```bash
$ npm run build
```

ビルドすると `dist` フォルダにファイルが生成されます。
これが出来上がらないとデプロイはできないので注意。

### Vuetify
アプリエンジニアがWebページ作るときに一番悩むのって、絶対見た目の部分だと思うんです。
だって自由すぎて怖い。
CSSフレームワークも、色々あるのはわかるけど多すぎて選べない。怖い。

そこでVuetify。
完全にマテリアルデザインの再現なので、アプリエンジニアも安心。
タグを設置するだけなのもイメージがしやすい。
地味にマテリアルデザインのアイコンがそのまま使えるのも最高でした。
https://vuetifyjs.com/ja/getting-started/quick-start

ただ、カラーテーマだけは悩みました。
デザイナーじゃないとやっぱ色味とかわかりませんよほんと。
今回は以下のようにしてます。

```js
  theme: {
    primary: '#039BE5',
    secondary: '#3949AB',
    accent: '#FF5252',
    error: '#F44336',
    warning: '#FBC02D',
    info: '#90A4AE',
    success: '#4caf50'
  }
```

Vueのプロジェクトに入れるのは少し手間です。

- vuetifyをインストール

```bash
$ npm install vuetify --save
```

- Vueプロジェクトの `src/main.js` にインポート

```js:main.js
import Vuetify from 'vuetify'

Vue.use(Vuetify)
```

- `src/stylus/main.styl` ファイルを作成してインポート

```js:main.styl
@import '~vuetify/src/stylus/main'
```

```js:main.js
import './stylus/main.styl'
```

- `index.html` の `<head>` にリソースをリンク

```html:index.html
  <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 をインストール

```bash
$ npm install -g firebase-tools
```

- Firebaseにログイン

```bash
$ firebase login
```

- Firebaseプロジェクトを準備

- Firebase Hosting と Storage を初期化

```bash
$ firebase init
```

色々聞かれた後、作成したプロジェクトを選択すると、なんのFirebaseサービスを使うか聞かれるので、そこで `Hosting` と `Storage` を選びましょう。

- デプロイ

```bash
$ firebase deploy
```

、、、なのですが、Vueのプロジェクトのビルドしてからじゃないと意味ないで、やってからにしましょう。
あと、Storage にファイル設置する場合、今回のように誰でも見れるようにするなら、ルールを変更してくださいね。

### Vueのコード
SPAっていうかシングルページなので、ページ構成としてはこんなもんです。

```
App
└── Home
```

が、せっかくVueなのでコンポーネント化してみようってことで、HomeにMetronomeのコンポーネントを設置する構成にしました。

```
App
└── Home
     └── Metronome
```

#### Homeコンポーネント

ヘッダとフッタとMetronomeコンポーネントから構成してます。
わからないながら、頑張ってレスポンシブしてますが微妙です。

```vue:Home.vue
<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` で読んでるので無駄はしてないつもり。

```vue:Metronome.vue
<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


以上、この辺りをクリアしたら、なんとかできました。
次は振り子をスライダーじゃなくてもっと振り子っぽくしてみたいと思います。（また言ってる）

以上です。