SpotifyをWeb Playback SDKで再生して、BPMとメトロノームも表示する

# はじめに

音楽配信サービスのSpotifyのAPIがオーディオ分析も取得できて面白いので作りました。
それをツイートしたらやり方教えてほしいと言われたので、作り方をまとめてQiitaに投稿します。
Qiita初投稿です。


# 完成物
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">SpotifyのAPIにオーディオ分析もあったので遊んでたら謎プレーヤーができた <a href="https://t.co/htGOGmfayn">pic.twitter.com/htGOGmfayn</a></p>&mdash; ヨット (@yotto_) <a href="https://twitter.com/yotto_/status/1191352722030645249?ref_src=twsrc%5Etfw">November 4, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

Spotifyの曲がブラウザで再生され、テンポ（BPM）とメトロノームが表示されます。ノリノリです。 :headphones: :sunglasses: :thumbsup:
UIは作っていないので、SpotifyアプリからSpotify Connectで接続して操作します。

~~CodePenに公開しましたが何故か音が出ないので、 :thinking: 
Debug mode でお楽しみください。
https://cdpn.io/yotto/debug/GRRXejb/~~
Debug modeは作者本人かPROメンバーでないと使えませんでした。

デモページをGitHub Pagesで公開しました。
https://yotto4.github.io/SpotifyBpm/


# 用意するもの

- Spotifyアプリ
- Spotifyのアカウント

Spotify Connectで接続するため、PCまたはスマホでSpotifyアプリを使用します。
またはウェブプレイヤーも使用できます。
https://open.spotify.com/browse/featured

また、Spotifyを再生するためにはアカウントが必要です。フリープランもあります。
Spotifyにログインして再生できる状態にしましょう。


# Web Playback SDK で再生する

Web Playback SDK Quick Start というチュートリアルがあるのでまずはそれを読んで、Spotifyを再生するシンプルなWebアプリを作ります。
https://developer.spotify.com/documentation/web-playback-sdk/quick-start/

最短ルートは下記の通りです。 :dash: 

1. 緑色の「GET YOUR WEB PLAYBACK SDK ACCESS TOKEN」ボタンをクリックしてアクセストークンを取得
2. ページ一番下の「Source Code」をindex.htmlにコピペして、ブラウザで開く
3. Spotifyアプリから「Web Playback SDK Quick Start Player」に接続
4. 曲を再生すると、ブラウザから曲が流れます :musical_note: 

アクセストークンの有効期限は1時間なので、期限切れで再生できなくなったら再度アクセストークンを取得しましょう。


# 現在の再生位置を計算する
再生位置はWebPlaybackStateオブジェクトのpositionプロパティにあります。
それを取得できるのは下記タイミングのみです。 :thinking: 

- getCurrentStateメソッドの非同期処理が完了したとき
- player_state_changedイベント発火のとき

上記以外のタイミングで現在の再生位置が欲しい場合、特に1フレームごとにアニメーションするタイミングで非同期処理も待たずに欲しい場合などは下記のように計算すると良いでしょう。 :thumbsup: 

現在の再生位置
= position + ステータス取得からの経過時間
= position + (ステータスのタイムスタンプ - 現在のタイムスタンプ)

<details>
<summary>現在の再生位置を表示するソースコード</summary>
<div>

```html:HTML
<input type="range" id="position">
```

```javascript:JavaScript
let lastState;
// ステータス更新イベント
player.addListener('player_state_changed', state => {
  lastState = state;
});

/** @type {HTMLInputElement} */
const positionElm = document.getElementById('position');
let position;
// 毎フレーム
window.requestAnimationFrame(updatePosition);
function updatePosition() {
  window.requestAnimationFrame(updatePosition);

  if (!lastState) {
    // 何も再生していない場合
    return;
  }

  // 再生中の場合は最後のステータス更新からの経過時間を加算する
  position = lastState.position + (lastState.paused ? 0 : Date.now() - lastState.timestamp);

  // 再生位置を表示する
  positionElm.value = position;
  positionElm.max = lastState.duration;
}
```

</div>
</details>

ついでにWebPlaybackStateオブジェクトから曲名やアーティストも取得できます。


# 再生中の曲のオーディオ分析を取得する
再生中の曲のidをWeb APIのaudio-analysisに投げてオーディオ分析を取得します。
https://developer.spotify.com/documentation/web-api/reference/tracks/get-audio-analysis/

```javascript:idを渡してオーディオ分析を取得する
fetch(
  `https://api.spotify.com/v1/audio-analysis/${id}`,
  {
    headers: {
      Accept: 'application/json',
      Authorization: `Bearer ${token}`,
    }
  }
);
```

<details>
<summary>再生中の曲のオーディオ分析を取得するソースコード</summary>
<div>

```javascript:JavaScript
const audioAnalysisList = {};

// ステータス更新イベント
player.addListener('player_state_changed', async state => {
  if (!state) {
    // 何も再生していない場合
    return;
  }

  // 再生中の曲のidを取得する
  const { id } = state.track_window.current_track;
  if (audioAnalysisList[id]) {
    // オーディオ分析を取得済みまたは取得中の場合
    return;
  }

  // オーディオ分析を取得する
  const promise = fetch(
    `https://api.spotify.com/v1/audio-analysis/${id}`,
    {
      headers: {
        Accept: 'application/json',
        Authorization: `Bearer ${token}`,
      }
    }
  );
  // オーディオ分析を取得中
  audioAnalysisList[id] = promise;
  try {
    const response = await promise;
    if (!response.ok) {
      throw response;
    }
    // オーディオ分析を取得済み
    const audioAnalysis = await response.json();
    audioAnalysisList[id] = audioAnalysis;
    console.log(audioAnalysis);
  } catch (error) {
    // オーディオ分析を未取得
    audioAnalysisList[id] = null;
    console.error(error);
  }
});
```

</div>
</details>

504エラー `Service didn't reply before timeout` がよく出るので、 :thinking: 
運良く取得成功したらidごとに連想配列に格納するように実装しました。


# BPMとメトロノームを表示する
取得したオーディオ分析のうち、beatsをメトロノーム表示に、sectionsをBPM表示に使用します。

```json:ドキュメントから抜粋.json
{
  "beats": [
    {
      "start": 251.98282
    }
  ],
  "sections": [
    {
      "start": 237.02356,
      "tempo": 98.253
    }
  ]
}
```

メトロノームはbeatsのstartの位置でリズムを取るようにアニメーションします。
BPM表示はsectionsのstartの位置でtempoの値を表示します。

<details>
<summary>BPMとメトロノームを表示するソースコード</summary>
<div>

```html:HTML
<div id="beat"></div>
<div id="tempo"></div>
```

```css:CSS
#beat {
  position: fixed;
  z-index: -1;
  top: 0;
  left: -50px;
  width: 100px;
  height: 100%;
  border-radius: 50%;
  background-color: blue;
}

#tempo::after {
  content: ' BPM';
}
```

```javascript:JavaScript
const beatElm = document.getElementById('beat');
const tempoElm = document.getElementById('tempo');

// 毎フレーム
window.requestAnimationFrame(animationBeat);
function animationBeat() {
  window.requestAnimationFrame(animationBeat);

  if (!lastState) {
    // 何も再生していない場合
    tempoElm.textContent = null;
    return;
  }

  // 再生中の曲のidを取得する
  const { id } = lastState.track_window.current_track;
  const audioAnalysis = audioAnalysisList[id];
  if (!audioAnalysis) {
    // オーディオ分析を取得できなかった場合
    tempoElm.textContent = '(error)';
    return;
  }
  if (audioAnalysis instanceof Promise) {
    // オーディオ分析を取得中の場合
    tempoElm.textContent = '(Loading...)';
    return;
  }

  // オーディオ分析を取得済みの場合
  const { beats, sections } = audioAnalysis;
  const sec = position / 1000;

  // ビートを表示する
  const lastBeat = findLastPosition(beats, sec);
  const scale = lastBeat
    ? Math.max(0, 1 - (sec - lastBeat.start) / 0.5)
    : 0;
  beatElm.style.transform = `scaleX(${scale})`;

  // BPMを表示する
  const lastSection = findLastPosition(sections, sec);
  const tempo = lastSection ? lastSection.tempo.toFixed(3) : null;
  tempoElm.textContent = tempo;
}

/**
  * @param {array} list
  * @param {number} sec
  */
function findLastPosition(list, sec) {
  const nextIndex = list.findIndex(item => sec < item.start);
  const lastIndex = (nextIndex !== -1 ? nextIndex : list.length) - 1;
  const lastItem = list[lastIndex];
  return lastItem;
}
```

</div>
</details>


# アクセストークンを取得する

チュートリアルでアクセストークン直書きしたガバガバ実装をCodePenに公開するのは流石にまずいので、 :innocent:
Spotifyにログインしてアクセストークンを取得する機能を実装しました。

チュートリアルでgetOAuthTokenが出てきましたが、その使い方が謎でした。 :thinking:

```javascript:WebPlaybackSDKQuickStartから抜粋
const token = '[My Spotify Web API access token]';
const player = new Spotify.Player({
  name: 'Web Playback SDK Quick Start Player',
  getOAuthToken: cb => { cb(token); }
});
```

実はgetOAuthTokenの意味が思ってたのと180度違ってました。

:x: getOAuthTokenでトークンがもらえる:hugging:
:o: getOAuthTokenメソッドを用意しろ！トークンよこせオラァ！:sunglasses:

アクセストークンの取得には認可フローがあります。
3つの認可フローから好きなものを1つ選びましょう。

Authorization Guide
https://developer.spotify.com/documentation/general/guides/authorization-guide/

サーバー側で実行せずクライアント側のみで実行するなら Implicit Grant 一択ですね。
ただしトークンの期限が切れたら再度認可フローを通して取得する必要があります。

Implicit Grant では下記の手順でトークンを取得します。

1. Webアプリは `https://accounts.spotify.com/authorize` にリダイレクトする
2. ユーザーはSpotifyにログインして、「同意する」ボタンをクリックする
3. SpotifyはWebアプリにリダイレクトする
4. WebアプリはURLの `#access_token=` からトークンを読み取る

手順1のURLにはクエリパラメータを付けます。
必須パラメータ3つと Web Playback SDK に必要なスコープを指定します。

|クエリパラメータ |値           |
|--------------|-------------|
|client_id     |クライアントID |
|response_type |"token"      |
|redirect_uri  |手順3のリダイレクト先URL |
|scope         |"streaming user-read-email user-read-private" |

スコープはstreamingだけでいい気がしますが、それだけでは再生できなかったので、 :thinking:
SDKのチュートリアルの記述通り3つ指定します。

クライアントIDはSpotifyのダッシュボードで作成します。
https://developer.spotify.com/dashboard/applications

リダイレクトURLはホワイトリスト方式なので、ダッシュボードでそのクライアントIDに対して登録します。


<details>
<summary>アクセストークンを取得するソースコード</summary>
<div>

```html:HTML
<div id="loginDiv">
  <a id="login" target="authorize">Login with Spotify</a>
</div>
```

```javascript:JavaScript
// ログインボタン作成
const params = new URLSearchParams();
params.set('client_id', 'ここにクライアントIDを入力');
params.set('response_type', 'token');
params.set('redirect_uri', location.href);
params.set('scope', 'streaming user-read-email user-read-private');
const url = `https://accounts.spotify.com/authorize?${params}`;
const login = document.getElementById('login');
login.href = url;

const loginDiv = document.getElementById('loginDiv');
let tokenCallback;
// トークンを要求されたとき
function getOAuthToken(callback) {
  // ログインボタンを再表示する
  loginDiv.style.display = 'block';
  tokenCallback = callback;
}

// URLにアクセストークンがある場合、親ウィンドウに送る
if (window.location.hash && window.opener) {
  const message = window.location.hash.slice(1);
  const targetOrigin = window.origin;
  window.opener.postMessage(message, targetOrigin);
  window.close();
}

let token;
// サブウィンドウからアクセストークンを受け取る
window.addEventListener('message', event => {
  if (event.origin !== window.origin) {
    return;
  }

  const params = new URLSearchParams(event.data);
  const accessToken = params.get('access_token');
  if (accessToken && tokenCallback) {
    token = accessToken;
    tokenCallback(accessToken);
    loginDiv.style.display = 'none';
    tokenCallback = null;
  }
});
```

</div>
</details>


# おわりに

Qiitaに投稿するためにMarkDownにまとめているうちにソースコードも機能ごとにまとまりました。
ソースコードはCodePenに公開すればそのまま実行できることに気づき、
CodePenに公開するにあたってSpotifyの認可フローも実装してしまいました。 :smile: