1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

一度失敗した自作AIラジオを、教訓ごとMacで作り直した話

1
Posted at

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

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?