Spotify APIを使って楽曲レコメンドアプリを作る

# 前置き
こちらの記事はソニックガーデン 若手プログラマ Advent Calendar 2024の6日目の記事です。

# はじめに
社内で月一で開催されている若手ハッカソンで「YourDJ」という楽曲レコメンドアプリを作成しました。
楽曲を入力すると、同じアーティストの楽曲を似ている順に並べてお勧めしてくれるアプリです。
これから解説に入るのですが、まずは一度触ってみて欲しいです。
https://music-suggestion-shy-cherry-6079.fly.dev/


## <font color="HotPink">※注意</font>
2024/11/27のAPIの仕様変更によって、SpotifyAPIではAudio Featureの取得ができなくなってしまいました。
https://developer.spotify.com/blog/2024-11-27-changes-to-the-web-api
このアプリへの影響は以下のとおりです。
- 楽曲情報の取得はできるので、TOPページから楽曲検索までは可能です
- 楽曲選択以降の機能が、Audio Featureを用いているのでエラーが出るようになってしまっています

communityでは以下のように、今回制限された機能の復活を求める声が多数投稿されています。
https://community.spotify.com/t5/Spotify-for-Developers/Changes-to-Web-API/td-p/6540414


もし、この声が反映され機能制限が解除されることがあれば、追記という形で修正をしていこうと考えています。
昔のSpotify APIではこのようなことができた、という感じでご覧いただければ幸いです。

### ありし日の挙動を画像で紹介

1, 検索したい楽曲名を入力します
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2265362/52fe6a33-db12-16ef-9638-e1a898330526.png" width=50%>

2, 候補の中から楽曲を選び「この曲にする」ボタンを押す
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2265362/6d2160ff-3573-2a16-40fe-d4ca18c36387.png" width=50%>

3, 同じアーティストの似ている楽曲をDJが紹介してくれる！
(アーティストが同名の楽曲を別バージョンで登録していると、今回のケースのように同名楽曲が出てくることがあります。)
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2265362/86edd7aa-cea6-d4b2-f77c-be9630844f12.png" width=50%>
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2265362/9437c9bc-b4e2-cf46-e69b-6917a6a940b1.png" width=50%>



# 背景
私は普段Spotifyを使って音楽を聴いています。
ただ、好きなアーティストを知った時に、そのアーティストの他の好みの曲を探すことが難しいという困り事がありました。
Spotifyではこのような時に、「人気曲」で人気順に同アーティストの曲を聴くくらいしか手段がなく、次の手段としては「アルバムジャケットでピンときたものを聴く」が来るくらいです。
確かにSpotifyにはオススメ機能はあるにはあるのですが、アーティスト関係なしに似ている曲をお勧めしてくる感じで、今回の要望にはうまく使えません。
そこで、手軽に同じアーティストの似ている曲を探すアプリは作れないかということで、今回のアプリ制作に至りました。

# 仕組み
## 使用技術
- Ruby on Rails
- rspotify

## SpotifyAPIについて
SpotifyではAPIを利用して楽曲の検索やデータの取得ができるようになっています。
そしてSpotifyは楽曲ごとにパラメータを持っています。
例えば次の楽曲の場合、以下のようなパラメータを持っています。

<iframe width="560" height="315" src="https://www.youtube.com/embed/LIlZCmETvsY?si=4goHJiPwlZdwqAY5" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>


| **属性**            | **値**                                                                                  |
|---------------------|------------------------------------------------------------------------------------------|
| **Acousticness**     | 0.0013                                                                                  |
| **Analysis URL**     | https://api.spotify.com/v1/audio-analysis/4a48lWUd64bZgHUDx0GZlj    |
| **Danceability**     | 0.551                                                                                   |
| **Duration (ms)**    | 305000                                                                                  |
| **Energy**           | 0.919                                                                                   |
| **External URLs**    | None                                                                                     |
| **Href**             | None                                                                                     |
| **ID**               | 4a48lWUd64bZgHUDx0GZlj                                                                   |
| **Instrumentalness** | 0.00638                                                                                 |
| **Key**              | 7                                                                                        |
| **Liveness**         | 0.352                                                                                   |
| **Loudness**         | -3.484                                                                                  |
| **Mode**             | 0                                                                                        |
| **Speechiness**      | 0.074                                                                                   |
| **Tempo**            | 157.989                                                                                 |
| **Time Signature**   | 4 |
| **Track URL**        | https://api.spotify.com/v1/tracks/4a48lWUd64bZgHUDx0GZlj                    |
| **Type**             | audio_features                                                                            |
| **URI**              | spotify:track:4a48lWUd64bZgHUDx0GZlj                                                     |
| **Valence**          | 0.652                                                        |

今回私はこの中でも以下のパラメータに着目しました

| **属性**            | **説明**                                                                                  |
|---------------------|------------------------------------------------------------------------------------------|
tempo| トラックの全体的なテンポの推定値(１分あたりのビート数 (BPM))。
energy|energyは 0.0 から 1.0 までの尺度で、強度と活動の知覚的尺度を表します。通常、energyのあるトラックは速く、大きく、騒々しいと感じられます。たとえば、デスメタルはenergyが高く、バッハのプレリュードはスケール上で低いスコアになります。この属性に寄与する知覚的特徴には、ダイナミックレンジ、知覚される音量、音色、オンセットレート、および一般的なエントロピーが含まれます。
valence|トラックによって伝えられる音楽的なポジティブさを表す 0.0 から 1.0 までの尺度。高い値を持つトラックはよりポジティブに聞こえ (例: 幸せ、陽気、多幸感)、低い値を持つトラックはよりネガティブに聞こえます (例: 悲しい、落ち込んだ、怒っている)。
danceability|danceabilityは、テンポ、リズムの安定性、ビートの強さ、全体的な規則性などの音楽要素の組み合わせに基づいて、トラックがダンスにどれだけ適しているかを表します。値が 0.0 の場合、danceabilityは最も低く、値が 1.0 の場合、danceabilityは最も高くなります。

(https://developer.spotify.com/documentation/web-api/reference/get-audio-features)
から和訳

ここで私は、これらの値が似ている楽曲を同じアーティストの楽曲から取得すれば、好みの曲に素早く出会えるのではと考えました。

## 実装
今回はRSpotifyというgemを用いて、そのAPIをrubyで使っていくことにします。
https://github.com/guilhermesad/rspotify
RSpotifyは[Spotify Web API](https://developer.spotify.com/documentation/web-api)のwrapperです。

### 全体像
アプリのメイン機能が集約されているTrackモデルの全体像が以下です。
```ruby
class Track < ApplicationRecord
  require 'rspotify'

  def self.get_track(track_name)
    RSpotify.authenticate("<your_client_id>", "<your_client_secret>")
    RSpotify::Track.search(track_name, limit: 20)
  end
  
  def self.get_related_track_ids(artist_name, base_track_id)
    RSpotify.authenticate("<your_client_id>", "<your_client_secret>")
    base_track = RSpotify::AudioFeatures.find(base_track_id)
    target_tracks = RSpotify::Base.search("artist:#{artist_name}", 'track', limit: 50)
    track_ids = target_tracks.map {|track| track.id}
    audio_features = RSpotify::AudioFeatures.find(track_ids)
    (audio_features.map {|track| [track.id, Track.calc_difference(base_track, track)]}).sort_by{|x| x[1] }
  end

  def self.calc_difference(base, target)
    difference = 0
    difference += (base.tempo - target.tempo).abs / [base.tempo, target.tempo].max
    difference += (base.energy - target.energy).abs
    difference += (base.valence - target.valence).abs
    difference += (base.danceability - target.danceability).abs

    difference / 4.0 * 100
  end
end
```


### 認証
RSpotifyでは一部のデータにアクセスするには認証が必要です。以下のページからclient_id, client_secretを取得しましょう。
https://developer.spotify.com/my-applications

そしてこのように記述することで、認証が完了します
```ruby
RSpotify.authenticate("<your_client_id>", "<your_client_secret>")
```


### 楽曲の検索
下記のコードで楽曲を検索することができます。今回は20件の候補を取得すれば十分なので、limit: 20としておきます。
```ruby
RSpotify::Track.search(track_name, limit: 20)
```

### 楽曲の表示
そして、spotifyでは共有用の埋め込みが用意されています。
今回は詳しく説明しませんが、viewで以下のようなiframeを表示し、srcの値に楽曲のIDを動的に渡してあげれば、簡単にプレビューやジャケット写真を見せることができます。
```haml
%iframe{allow: "autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture", allowfullscreen: "", frameborder: "0", height: "80", loading: "lazy", src: "https://open.spotify.com/embed/track/[楽曲のID]?utm_source=generator", style: "border-radius:12px", width: "100%"}
```


### 楽曲データの取得
ユーザが選んだ楽曲のアーティスト名をartist_nameとして次のようなコードを実行します。
本当はアーティストのすべての楽曲から取得したいのですが、API制限があり、一回で50曲までしか取得できません。そのため、```RSpotify::Base.search```ではlimit: 50として限界量まで楽曲を取得することとします。
そして、結果から楽曲のidを取得し、```RSpotify::AudioFeatures.find```でそれらの楽曲データを取得します。
```ruby
target_tracks = RSpotify::Base.search("artist:#{artist_name}", 'track', limit: 50)
track_ids = target_tracks.map {|track| track.id}
audio_features = RSpotify::AudioFeatures.find(track_ids)
```

### 似ている楽曲順にソートする
ユーザが選んだ楽曲のIDをbase_track_idとして、次のコードを実行します。
類似度の判定にはTrackモデルに定義した```calc_difference```メソッドを用います。
baseにはユーザが選んだ比較元の楽曲を、targetには取得してきた比較したい楽曲を渡します。
パラメータごとに差を絶対値で取って、違いの度合いを計算します。
tempo以外の項目は0~1なのですが、tempoはそれよりも大きい値となるので、影響度を考慮して0~1の値をとるように処理します。
そして、平均値を取り、%の形で示せるように100倍にします。
これで、「不一致度」の値が取れたので、値が小さい（一致している）順に並び替えます。
```ruby
base_track = RSpotify::AudioFeatures.find(base_track_id)
(audio_features.map {|track| [track.id, Track.calc_difference(base_track, track)]}).sort_by{|x| x[1] }
```

```track.rb
def self.calc_difference(base, target)
  difference = 0
  difference += (base.tempo - target.tempo).abs / [base.tempo, target.tempo].max
  difference += (base.energy - target.energy).abs
  difference += (base.valence - target.valence).abs
  difference += (base.danceability - target.danceability).abs

  difference / 4.0 * 100
end
```

### 結果の表示
これで、同アーティストの中で似ている楽曲を取得できました。
実はこのロジックだと、ユーザが選んだ楽曲と同じ曲が100%一致として出てきてしまうので、100%一致している楽曲を弾いて、それ以降の楽曲をお勧め楽曲として並べます。


## 終わりに
このロジックで楽曲レコメンドアプリを作ることができました。
説明してしまえばそこまで大した仕組みではないのですが、それなりの精度で似ている曲をお勧めしてくれると思います。