友人と入った喫茶店で、耳になじむのに曲名が出てこない音楽が流れていて、二人でしばらく唸っていたことがありました。スマートフォンをかざせば一発で分かるあの体験を、自分の小さなアプリの一機能として持てたら面白いのに、とそのとき思いました。個人開発でユーティリティ系のアプリをいくつか作ってきましたが、「かざすと分かる」という手触りには、理屈抜きの気持ちよさがあります。
その入口になるのが ShazamKit です。Apple の音楽認識をアプリに組み込める仕組みで、周囲の音や自分のアプリの再生音から、曲を照合できます。かつては録音の面倒を自分で見る必要がありましたが、iOS 17 以降の SHManagedSession を使えば、その大半を任せられます。Rork Max がネイティブ Swift を生成するようになったことで、この API を個人開発でも素直に組み込めるようになりました。実際に「かざすと分かる」小さなアプリを作ってみて分かったことを、順を追ってまとめます。
手回しの旧来型か、任せる SHManagedSession か
ShazamKit には、大きく二つの組み方があります。ここを最初に選んでおくと、後の設計が決まります。
| 観点 | SHSession(手回し) | SHManagedSession(iOS 17+) |
|---|---|---|
| 録音の管理 | AVAudioEngine を自分で組む | フレームワークに任せられる |
| マイク権限 | 自分で要求・確認する | セッションが面倒を見る |
| 結果の受け取り | デリゲート | async の結果列(results()) |
| 状態の可視化 | 自前で管理 | idle / prerecording / matching を観測できる |
| 向いている用途 | 再生音との突き合わせなど細かい制御 | 周囲の曲をかざして認識する定番用途 |
「周囲で流れている曲をかざして当てる」という王道の用途なら、迷わず SHManagedSession を選びます。録音とマイク権限の面倒を肩代わりしてくれるので、書く量が目に見えて減ります。私自身、最初は勉強のつもりで AVAudioEngine を手で組みましたが、権限まわりとバッファの取り回しで思いのほか時間を食い、SHManagedSession に切り替えたら本質だけが残りました。
状態を SwiftUI に映す
SHManagedSession は Observable に準拠しているので、状態の変化を SwiftUI がそのまま拾えます。状態は三つです。
idle:録音も照合もしていない、待機の状態prerecording:照合に必要な準備が整い、先んじて録りためている状態matching:実際に照合を試みている状態
この三状態をそのままボタンの見た目に反映すると、ユーザーに「いま何をしているか」が伝わります。以下は、状態に応じて表示を変える最小の画面です。
import SwiftUI
import ShazamKit
@MainActor
final class RecognizerModel: ObservableObject {
let session = SHManagedSession()
@Published var title: String?
@Published var artist: String?
@Published var isWorking = false
/// かざして照合する。結果は results() の非同期列から受け取る。
func recognize() async {
isWorking = true
defer { isWorking = false }
// 照合を1回試み、最初の結果で打ち切る
for await result in session.results {
switch result {
case .match(let match):
if let item = match.mediaItems.first {
self.title = item.title
self.artist = item.artist
}
return // 当たったら止める
case .noMatch:
self.title = "一致なし"
return
case .error(let error, _):
self.title = "エラー: \(error.localizedDescription)"
return
@unknown default:
return
}
}
}
}
struct RecognizeView: View {
@StateObject private var model = RecognizerModel()
var body: some View {
VStack(spacing: 24) {
if let title = model.title {
Text(title).font(.title2).bold()
if let artist = model.artist {
Text(artist).foregroundStyle(.secondary)
}
} else {
Text(model.isWorking ? "聴いています…" : "タップして曲を認識")
.foregroundStyle(.secondary)
}
Button {
Task { await model.recognize() }
} label: {
Image(systemName: "shazam.logo.fill")
.font(.system(size: 64))
}
.disabled(model.isWorking)
}
.padding()
}
}session.results は照合結果の非同期列を返します。当たった時点で return して抜けているのは、一曲当てられれば十分な用途だからです。連続して当て続けたいなら、抜けずに列を回し続ければ、流れる曲が変わるたびに結果が届きます。