Spotify Premium 前提のアプリです。本記事は Premium 利用を前提に書いています。
ケイラボAIラジオとは
Mac のメニューバーに常駐し、AI の DJ が気ままに喋りながら、合間に Spotify の曲をかける「自律型ラジオ」を作りました。名前は ケイラボAIラジオ です。
仕組みはシンプルです。
- 声は VOICEVOX(ずんだもん / 四国めたん / 春日部つむぎ / 青山龍星)。
- セリフは Gemini(Google の LLM)が番組の文脈に合わせて生成。
- 曲は Spotify Web API で検索して再生。
- ニュースは Google News RSS、天気は 気象庁の予報 API(どちらも無料・キー不要)。
番組は「オープニング → 1曲目 → フリートーク → お便り → ニュースと天気 → ゲストコーナー → アーティスト特集 → エンディング」といったフォーマットを論理で進行しつつ、各コーナーの中身は LLM が毎回生成します。さらに、
- 曜日でメイン DJ が交代し、その日の担当が番組全体を仕切る
- 季節・曜日・記念日といった暦をプロンプトに注入して「今日はこどもの日なので…」と喋る
- 前回の放送のハイライトを要約して次回冒頭で振り返る長期記憶
まで持っていて、放っておくと延々とそれっぽいラジオが流れ続けます。
そして実はこれ、一度 Windows 版で作って失敗しているものを、教訓ごと作り直したものです。今日はその「失敗の中身」と「Mac 版で何を変えたか」を書きます。
実は一度、Windows版で失敗している
同じコンセプトを、先に C# / .NET + Avalonia で作っていました。
最初に言っておくと、当時立てた方針そのものは、今でも正しかったと思っています。
- ちゃんとテストを書く
- UI と機能(ロジック)を分離する
この 2 つは王道です。だから自信を持って進めました。……が、失敗しました。
理由は一言で言うと、実装を細かくしすぎて、方向性が見えなくなったからです。
「抽象化」「責務の分割」を “善” として突き進んだ結果、
- 小さなクラス・インターフェースが無限に増えていく
- 1 つの機能を足すのに、いくつもの層・部品を横断する
- テストは通る。でも「で、ラジオとして今これは何が動くんだっけ?」が分からなくなる
という状態に陥りました。設計図としては綺麗なのに、プロダクト(=実際に流れるラジオ)から目が離れていた。気づいたら「設計を整えること」が目的化していて、肝心の番組はちっとも前に進んでいませんでした。
Mac版で変えた4つのこと
Mac 版(Swift / メニューバー常駐)では、方針は捨てず、“効かせ方” を変えました。
1. 縦に薄いスライスで、毎回 “動くラジオ” を増やす
レイヤー(横)で作るのをやめ、機能(縦)で薄く切るようにしました。
-
S0(SwiftPM の土台+テスト土台)→S1(VOICEVOX で一声出す)→ … →S19(固有名詞の読み矯正)まで、各スライスが「ユーザーに見える 1 機能」を必ず “動く形” で足す。 - 完了条件は 2 つだけ:
swift testがグリーン+実機で聴いて確認(ライブ確認)。両方そろって初めてクローズ。
横に切ると「基盤を整えました(でも何も鳴らない)」が量産されます。縦に切ると、毎スライスの終わりに必ず “前より少し賢くなったラジオ” が手元にある。Windows 版で見失った「方向性」は、つまりこの「今、番組として何が増えたのか」が常に見える状態のことでした。
2. 仕様駆動で「何を作っているか」を毎回言語化する
スライスごとに、まず docs/specs/<機能>.md を 1 枚書いてレビュー → それから実装、という順番を徹底しました。
これがいちばん効きました。Windows 版で失った “方向性” の正体は、「今これは何のため?」を言語化していなかったことだったからです。
仕様を先に書くと、設計の細部に潜り込む前に「この変更で番組としてどう良くなるのか」へ何度でも立ち戻れます。実装中に迷子になりかけても、1 枚の仕様書が「お前が作ろうとしているのはこれだ」と引き戻してくれる。地味ですが、これが効きました。
3. 抽象は “目的のため” だけに引く(UIと機能の分離は維持)
「UI と機能を分ける」という方針自体は、Mac 版でもそのまま採用しました。具体的には 3 層構成です。
App … メニューバー UI・配線(依存は外→内)
Infra … VOICEVOX / Gemini / Spotify / News / 天気 の実装
Core … 純粋なドメインロジック(外部依存ゼロ)
外部サービスはすべて protocol(抽象)の裏に隠し、Core からは protocol 越しにしか触りません。
public protocol TTSBackend: Sendable {
func synthesize(text: String, speakerId: Int) async throws -> Data
}
public protocol LLMBackend: Sendable {
func generate(_ request: LLMRequest) async throws -> String
}
おかげで Core は完全に単体テスト可能で、テストでは本物の代わりに fake を差し込めます(Core の全ファイルが import Foundation だけ=プラットフォーム非依存)。
ただし Windows 版との違いは、“分離のための分離” をやめたこと。protocol は「テストで差し替えたい」「将来差し替えたい」境界にだけ引きます。「綺麗だから」で層を増やさない。抽象は手段であって目的ではない、と毎回自分に言い聞かせました。
4. やらないことを決める勇気(スコープを絞る)
「あったら良さそう」をどんどん足すと、Windows 版の二の舞です。なので、
- リスナー投稿フォーム / 曲リクエスト UI
- 番組フォーマットの複数化
- カオス度を切り替えるモード
…などを、意図的に「不採用」「後回し」として要件に明記しました。“やらないことを決める” のが、方向性を保つ最大のコツでした。
技術的な見どころ
ここから少しだけ中身の話を。
放送事故ゼロ:プレフライト+完全静寂
ラジオなので「紹介したのに曲が流れない」は致命的です。そこで DJ が「次はこの曲です」と紹介する前に、その曲が本当に再生可能かを Spotify API で確認します(プレフライトチェック)。ダメなら代替曲に差し替えてから喋る。これで放送事故を論理的に潰しています。
もう一つが「完全静寂」。停止したら、Spotify も LLM も TTS も完全に黙る。放送全体を 1 つの Task で回し、停止は Task.cancel()。各 await 点でキャンセルを伝播させ、defer で必ず Spotify を pause() する。鳴らしっぱなしの状態を構造的に作らないようにしています。
自律的な “番組らしさ”
- 統一テーマ/BGM 演出:オープニング・ニュース天気・エンディングが、同じ「タグライン → BGM → 音量を下げて DJ が喋る → 余韻 → 停止」というシーケンスを共有します。設定(YAML)でいじれて、ハードコードしません。
-
読み正確化:VOICEVOX のユーザー辞書に読みを登録して「栄光の架橋 → エイコウノカケハシ」のような誤読を直します。ここは実機の癖にだいぶハマりました。VOICEVOX は表記をサーバ側で全角に正規化して保存するので、
Mr.Childrenを素朴に突き合わせると毎回二重登録になる。NFKC で正規化してから比較する、半角カナの濁点は NFKC だけだと合成されず NFC を重ねて初めて「ド」になる……といった泥臭い調整が必要でした。
厳密テスト(実例)
テストは Swift Testing。外部依存はすべて fake に差し替えるので、ネットも音も無しで決定論的に回ります。たとえば「読み辞書同期は、カタカナでない読みを VOICEVOX に送らずスキップする」ことの検証はこんな具合です。
@Test("非カタカナ読み(ひらがな)は送らず failed")
func nonKatakanaRejected() async {
let fake = FakeHTTPClient { _ in Data("{}".utf8) } // 空の辞書を返す
let dict = VoicevoxUserDict(endpoint: "http://127.0.0.1:50021/", http: fake)
let summary = await dict.sync(entries: [
PronunciationEntry(surface: "栄光の架橋", pronunciation: "えいこう") // ひらがなはNG
])
#expect(summary.failed == 1) // 1件スキップ
#expect(summary.added == 0) // 登録は走らない
}
外部を protocol で切ってあるので、こういう「実機を起こさずに振る舞いを固定する」テストが書けます。この “テストしやすさ” のためにこそ抽象を引く、という線引きが、Windows 版との分かれ目でした。
数字で見るMac版
- 縦スライス:S0 〜 S19(20 余り、各スライスはライブ確認してクローズ)
- テスト:354 本(
swift testグリーンが各スライスの完了条件) - 外部依存パッケージ:Yams(YAML パーサ)1 つだけ
- Core 層:全ファイルが
import Foundationのみ=プラットフォーム非依存 - 仕様:機能ごとに 1 枚の仕様書(書いてから実装)
おわりに:失敗から持って帰ったもの
Windows 版で立てた方針 ――「テストを書く」「UI と機能を分ける」―― は、間違っていませんでした。間違っていたのは “刻み方” です。
設計を細かく整えることと、プロダクトが前に進むことは、別物だった。
Mac 版では、後者(=ラジオが実際に良くなっていくこと)を見失わない仕掛け ――縦スライス・仕様駆動・スコープを絞る―― を最初から組み込みました。すると、同じ「テスト」「分離」という方針が、今度はちゃんと機能してくれました。
同じように「設計は綺麗なのに進まない」で詰まったことのある人に、何か届けば嬉しいです。
リポジトリはこちらです:
https://github.com/tokumeishatyo/AIRadio_Mac