🧑‍💻

AI Podcast:HackerVoiceのリリース, その裏側

2025/02/16に公開

AI Podcast: HackerVoiceをリリースしました!

https://hackervoice.vercel.app/ja

デモ動画

https://www.youtube.com/watch?v=1lsP7VpOWi8

Y Combinatorが管理する海外の掲示板HackerNewsのトレンドをピックアップし、AIによってPodcastを自動生成しています。機能の概要としては以下のようになります。

  • HackerNewsの最新情報5選をコメント付きでお伝え
  • 日本語版、英語版どちらでも聴ける
  • Gemini+TTSで毎日自動更新
  • Spotify, Apple Podcastsに対応
  • RSS Feed対応

https://open.spotify.com/show/2I2KC9SRnJBSTEJW3rINor?si=LyESpAKYQPiJ9uZbH8mczg

https://podcasts.apple.com/us/podcast/hackervoice-ja/id1796645071

本稿では、このアプリの裏側をご紹介します。

🎙️ 開発の背景

HackerVoiceは、HackerNewsのトレンドを効率的にキャッチアップし、手軽に最新の技術ニュースを聴ける環境を提供することを目的に開発しました。

元々HackerNewsの存在自体は知っていて、Twitterの日本語訳タイトル紹介的なアカウントをフォローしていたのですが、(おそらく)XのAPI価格改定によってお亡くなりになってから全くHackerNewsを見ることは無くなりました。

その後、(Zennの作者でもある)catnoseさんの日本語まとめサイトを見ていました。

https://catnose.me/lab/hackernews-ja

その後、月曜日の憂鬱な高校の通学時間に「明治 presents 花澤香菜のひとりでできるかな?」を聞き始めたのをきっかけに、ラジオ・Podcastにハマりました (ぜひできるかな聞いてください)。

https://www.joqr.co.jp/qr/program/dekirukana/

そこから「自分がHackerNewsのトピックを話すPodcastを始めたらええやん!」と思いつくのですが、自分でそれをやるのは三日と続かないので、昨年に話題になったzenncastを思い出し、HackerNewsを取りまとめるAI Podcastを自分で作ることにしました。

https://zenn.dev/himara2/articles/db054d81b05d19

🛠 技術スタック

HackerVoiceの開発には、以下の技術を採用しています。TTS以外は無料枠で稼働していて、1話の費用は20-30円以下に収まっています。

  • データ収集: HackerNews API
  • Podcast原稿: Gemini
  • 音声合成+処理: OpenAI TTS + ffmpeg
  • フロントエンド: Next.js
  • データ保存: Cloudflare R2
  • ホスティング+定期実行: Vercel, GitHub Actions

🤖自動化されたポッドキャスト生成

HackerVoiceでは、HackerNewsの最新記事を自動収集し、音声変換から配信までを完全自動化する仕組みを構築しています。ここではその詳細なワークフローを説明します。

🌍コンテンツデータの取得

HackerNewsは公式にAPIが提供されています。HackerNewsはコメント、スレッド関係なく同一のidで管理されています。

https://github.com/HackerNews/API

例えば現在のTop 5を取得するために、以下のようなことを行なっています

BASE_URL = "https://hacker-news.firebaseio.com/v0"

def get_top_story(limit):
    """トップニュースの ID リストを取得"""
    logging.info("Fetching top stories")
    top_stories_url = f"{BASE_URL}/topstories.json"
    try:
        top_story_ids = requests.get(top_stories_url).json()
    except:
        logging.error(f"Fetched failed: {len(top_story_ids)}, Retrying...")
        time.sleep(1)
        top_story_ids = requests.get(top_stories_url).json()
    logging.info(f"Fetched {len(top_story_ids)} top stories")
    return top_story_ids[0:limit]

また、言及先のサイトについてもクロールを行なっており、Beautiful soupでbodyを取ってきていて

{
    "title": "言及先の記事のタイトル",
    "item_id": ハッカーニュースのid,
    "url": "言及先のURL",
    "body": "記事の内容",
    "comments": [
        {
            "text": "コメントの内容",
            "kids": [
              {
                "text": "コメントのリプライ"
                "kids": []
              }
            ]
        },
     
},

このようなデータ構造で管理しています。コメントについては、対話関係を取り上げたいため、コメントは3件までリプライを広い、全体では5件ピックアップしています。

📃原稿作成

トークン量の多さ、APIコストの点から言語モデルにはGemini 2.0 Flashを採用しました。

一度はsummary.jsonという、記事やコメントのサマリーを作る過程をGPT-4oで挟んでいましたが、コスト面、面白さ、許容トークン量の観点から、Gemini 2.0 Flashで原稿をそのまま作る方向にしました。

原稿プロンプト
────────────────────────────
Role & Context:

You are an experienced technology journalist and podcast host. Write a podcast script for “HackerVoice”, a tech podcast covering trending news from Hacker News. The script will be delivered by a single narrator as a monologue with two distinct roles: the host and the expert. The host (mc/司会) will handle the main narration, including news introductions and transitions. The expert will provide the commentary on Hacker News community insights. This script should feel like a story being told to the listener.

Audience & Tone:

• Target a broad tech-savvy audience, including Hacker News regulars as well as non-native English speakers who are learning the language. The language should be formal yet accessible, avoiding slang or overly complex words.

• Maintain a professional but engaging tone – authoritative and informative, yet friendly and lively. Do not oversimplify the content; assume listeners have some tech background, but provide enough context for newcomers or English learners to follow along.

• Address the listener directly in a conversational manner (using “you” or inclusive “we” where appropriate) to create connection. Keep the tone curious and engaging, but not overly casual.

Format & Length:

• The script should be about 1200 words (approximately 9 minutes of spoken content).

• Write in a style suitable for text-to-speech (TTS) delivery: use natural phrasing and clear pronunciation cues. Break up very long sentences and use commas or periods where a speaker would pause. Avoid tongue-twisters or complex alliterations that might trip up a TTS system.

• Ensure the script reads smoothly when spoken aloud, avoiding unnatural intonation or phrasing.

Structure & Flow:

1. Introduction (~150-200 words)
   - Start with:
     "Hello! Welcome to HackerVoice, the technology podcast that brings you the most talked-about stories from Hacker News in a way that’s clear, insightful, and engaging. Today, we’re diving into:"
   - List the news topics in an engaging way, ensuring a smooth flow.
   - Include an attention-grabbing hook (e.g., “Meta’s hyperscale infrastructure is a marvel of engineering, but is it sustainable in the long run? Let’s explore.”)
   - Set the tone for the episode and briefly explain what listeners can expect.
   - This entire introduction is handled by the host (mc/司会).

2. News Segments (5 stories, each ~200 words)
   - For each news item:
     1. Announce the title clearly.
     2. Summarize the article content in a smooth, engaging, and digestible way.
     3. Explain any technical terms briefly where needed (e.g., "Serverless architecture means developers don’t have to manage physical servers directly, but rather rely on cloud-based infrastructure").
     4. Incorporate 1-2 key community comments from Hacker News:
         - The host introduces the news and then hands off to the expert for community comments.
         - Instead of saying “A user said ‘This is amazing!’”, the expert will say something like:
           “One Hacker News commenter pointed out that Meta’s ability to move this fast is nothing short of remarkable.”
         - If there are differing perspectives, the expert should say:
           “While some users praised the speed, others argued that Threads felt rushed and lacked depth.”
     5. Ensure smooth transitions between stories with the host guiding the flow (e.g., “Meanwhile, in another part of the tech world…”).

3. Conclusion (~150-200 words)
   - Recap the covered topics: “That’s a wrap for today! We explored…” (list the topics briefly).
   - Offer a final thought or a small teaser for future discussions.
   - End with a clear sign-off: “Until next time, this was HackerVoice!”
   - The conclusion is delivered solely by the host.

Incorporating Hacker News Comments:

• Introduce community insights naturally. For example, after summarizing a news story, the host might say, “Let’s see what the Hacker News community had to say about this.” Then the expert provides the commentary.

• Balance different opinions by paraphrasing various perspectives, ensuring a natural and conversational style.

Engagement & Storytelling:

• Use storytelling techniques to make the news compelling. Frame topics as challenges or controversies, and highlight interesting or unexpected details.

• Use hooks and curiosity to keep listeners engaged, such as “Why did a simple software update crash thousands of servers overnight? The answer might surprise you.”

• Use examples and analogies to clarify complex ideas, ensuring the content remains accessible to both tech experts and newcomers.

Clarity & Technical Explanations:

• When introducing technical terms, provide concise definitions to ensure clarity without overwhelming non-expert listeners.

• Balance technical depth with accessibility, ensuring that explanations are brief yet informative.

Additional Guidelines:

• The script must be formatted in JSON with the following structure:

{
    "reciter": [
        "host", "expert"
    ],
    "script": [
        {
            "role": "host",
            "sentence": "Hello, this is HackerVoice...."
        },
        {
            "role": "expert",
            "sentence": "One Hacker News commenter noted..."
        },
        ...
    ]
}

• The host (mc/司会) is responsible for the overall narration, news introductions, and transitions.
• The expert is responsible only for presenting and commenting on Hacker News community insights.
• Use natural language suitable for TTS, ensuring smooth flow and clear pronunciation cues.
• When introducing technical terms, provide concise definitions to maintain clarity without overwhelming non-expert listeners.
• Keep the tone professional, engaging, and informative while directly addressing the listener.
• Ensure the overall script reads as a cohesive story, not as isolated news segments.

────────────────────────────

プロンプト生成にはDeepResearchを多用しました。この手のプロンプト生成には、「最新の論文を収集して、プロンプトプラクティスに従って、〜〜〜するプロンプトを作成してください」by o1-proにしておくと、そこには概ね良いプロンプトが出来上がっています。

サマリーのためのプロンプト
Translate the title and summarize the content of a news article based on its data (title, URL, article content, and comments), including the article's summary, and main discussion points from the comments in approximately 700 characters, and provide your opinion in English. Use a chain-of-thought approach to ensure thorough analysis and reasoning. Follow the template below.

Start by including the original title. Clearly discuss the opinions and flow of the discussion from the comments, including different perspectives, and more frankly summarize the comments' remarks in a conversational style. Incorporate emotional elements and tone of comments by quoting impactful phrases. Finally, present your opinion.

# Title
[Original Title Here]

## Article Content

- Summarize the main points and background of the article.

## Key Comment 1

- Highlight the main idea and tone of comment 1.
- Discuss any reasoning or logic presented.
- Use clear, conversational English with frankness.
- Quote impactful phrases that convey emotion.

## Key Comment 2

- Highlight the main idea and tone of comment 2.
- Discuss any reasoning or logic presented.
- Use clear, conversational English with frankness.
- Quote impactful phrases that convey emotion.

## Key Comment 3

- Highlight the main idea and tone of comment 3.
- Discuss any reasoning or logic presented.
- Use clear, conversational English with frankness.
- Quote impactful phrases that convey emotion.

## Key Comment 4

- Highlight the main idea and tone of comment 4.
- Discuss any reasoning or logic presented.
- Use clear, conversational English with frankness.
- Quote impactful phrases that convey emotion.

## Key Comment 5

- Highlight the main idea and tone of comment 5.
- Discuss any reasoning or logic presented.
- Use clear, conversational English with frankness.
- Quote impactful phrases that convey emotion.

## My Opinion (This refers to your perspective)

- Present your view, considering the discussions and comments.
- Provide reasoning for your opinion.

# Output Format

- Approximately 400 words for the summary and discussion.
- Organize using the provided template and headings.

# Steps

1. Begin with the original news article title
2. Summarize the article content focusing on the main points.
3. Analyze and describe key comments, emphasizing their impact and reasoning.
4. Present your own opinion with supported reasoning.

# Notes

- Ensure all parts are in English as the translation will occur in a separate phase.
- Encourage reasoning steps before conclusions are provided for clarity.

サマリー+原稿バージョンは以下のYoutubeに投稿しています。

https://www.youtube.com/watch?v=3RAGuwFy2vk

原稿は以下のようなデータ構造で管理しています。

{
    "script": [
        {
            "role": "host",
            "sentence": "Hello! Welcome to HackerVoice, the technology podcast that brings you the most talked-about stories from Hacker News in a way that’s clear, insightful, and engaging. Today, we’re diving into:"
        },
      ...
        {
            "role": "expert",
            "sentence": "In HackerNews Comment..."
        },
    ]
}

キャラクターごとにsentenceを保存しています。

🗣️ 音声合成技術の選定と最適化

自然で聞きやすい音声を生成するために、OpenAIのTTSを用いた合成技術を採用し、話し方や抑揚の調整方法について詳しく解説します。

先述した通り、TTSにはOpenAIのTTS-1を選択しました。合成音声としては他に以下のようなものを試しました。

ElevenLabsの音声が完成度が高いのですが、コストと自然さのバランスが良いTTS-1にしました。

https://elevenlabs.io/app/share/pglDvM4qD6r83Kse3UCR

また、原稿作成指示のプロンプトで、読みにくいと考えられる単語をカタカナで書いてもらうことで、多少聞き取りやすさを改善しています。ニュースはHostが、コメントはExpertが紹介する構成になっているので、TTSには以下の手順を踏んでいます。

def tts(paper, workdir):
    logging.info("TTS Started!")

    voice_mapping = {
        "host": "alloy",
        "expert": "coral"
    }

    client = OpenAI(api_key=os.environ["GPTAPIKEY"])
    
    script_entries = paper.get("script", [])
    
    for i, entry in enumerate(script_entries):
        sentence = entry.get("sentence", "")
        role = entry.get("role", "host") 
        voice = voice_mapping.get(role, "alloy")
 
        response = client.audio.speech.create(
            model="tts-1",
            voice=voice,
            input=sentence,
        )
        
        output_file = os.path.join(workdir, f"speech_temp_{i}.mp3")
        response.stream_to_file(output_file)
        logging.info(f"TTS chunk {i+1}/{len(script_entries)} finished using voice '{voice}'.")
    
    logging.info("TTS Finished!")

その後の音声処理で、番号順に連結して保存しています。

📈音声処理

音声処理にはffmpegを使っています。まず、speech_temp_[0-9].mp3を連番で連結します。その後、発話の音声が小さい関係上10db上げてから、BGMを繋げています。

BGMはBGMer様の再会の誓い, J4U - Liquid Bed 11PMを使っています。

💾 データ保存

音声データはCloudflare R2に保存しています。R2はストレージ容量が10GBまで無料で、特に下りの配信の限度量が大きいことが決め手になっています。

また、一話のメタデータは以下のようになっています(後から追加したので汚いが...)。

  {
      "id": 1,
      "date": "2025-02-13",
      "title": "Podcast Episode 1",
      "audioUrl": "https://hackervoice.app/2025-02-13T20:08:10.027387.mp3",
      "transcript": "原稿",
      "news": [
          {
              "title": "Why young parents should focus on building trust with their kids",
              "item_id": 43033463,
              "url": "https://desunit.com/blog/marshmallow-test-and-parenting/",
          },
        ...
      ],
      "uuid": "4304d123-5181-4072-81c8-b7fa9b271fab",
      "duration": 518,
      "episodelength": 3532772,
      "dateisofull": "2025-02-13T20:08:10.027387"
  },

PodcastをRSSで配信する際、タイトルや日付といった基本的なメタデータに加えて、GUID, エピソードの長さ, 音声ファイルの長さ, 音声ファイルのバイト数も提供する必要があるので注意です 。

⏳定期実行

GitHub Actionsのcron機能で定期実行を行っています。ただし、パッケージのインストールに加えてapiの呼び出しのための待機時間が長いため、月3000分の無料枠を使い切る恐れがありそうです...(一話あたり4-6分)

name: Podcast Auto Generation (ja)

on:
  schedule:
    - cron: '0 22 * * *'
  workflow_dispatch:

jobs:
  generate-podcast:
    runs-on: ubuntu-latest

    steps:
        - uses: actions/checkout@v4
  
        - name: Install uv
          uses: astral-sh/setup-uv@v5
  
        - name: Install the project
          run: uv sync --all-extras --dev
        
        - name: Install ffmpeg
          run: |
            sudo apt-get update
            sudo apt-get install -y ffmpeg
  
        - name: Generating Podcast
          env:
            GPTAPIKEY: ${{ secrets.GPTAPIKEY }}
            GGAPI: ${{ secrets.GGAPI }}
            AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
            AWS_ENDPOINT_URL: ${{ secrets.AWS_ENDPOINT_URL }}
            AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
            AWS_TOKEN: ${{ secrets.AWS_TOKEN }}
          run: uv run scraiping-ja.py

        - name: Commit changes using EndBug/add-and-commit
          uses: EndBug/add-and-commit@v9
          with:
            add: ./podcast-frontend/lib/episodes-ja.json
            message: "Update episodes.json via GitHub Actions"
            author_name: github-actions
            author_email: 41898282+github-actions[bot]@users.noreply.github.com
        
        - name: Delete all mp3 files in data subdirectories
          run: find ./data-ja -mindepth 2 -type f -name '*.mp3' -delete

🌐フロントエンドの設計と実装

フロントエンドは、ほとんど特別な実装はしていません。

紹介ページは、できるだけコンパクトにしながらも欲しい情報がまとまっているように作りました(ChatGPTが)。

エピソードコンポーネントでは基本的なメタデータに加えて、言及先のページとHackerNewsのリンク, 自動生成のために使った原稿が見れるようになっています。

🇬🇧i18n対応(はしていない)

本格的なi18n対応も途中まで行いましたが、あまりにも要件に対してオーバーであること、App Router + i18nが難しすぎるので、二つのページを用意することで対応しました。そのため、layout.tsxで以下のような情報を取得し、

const pathname = usePathname(); // 現在の URL パスを取得
const currentLanguage = pathname.startsWith('/ja') ? 'ja' : 'en'; // `/ja` で始まるかどうかで言語を判定

その言語情報をコンポーネントに送る形で対応しました。

<Header podcast={podcast} currentLanguage={currentLanguage} />

そのため、日本語版は/ja, 英語版は/というかたちで対応しています。

また、日本語版と英語版にそれぞれjsonファイルを作成し、この情報を取得するようになっています。そのため、共通コンポーネントは言語情報を渡す形で変更, page.tsx/app配下と/ja配下に(読み込むjsonファイル以外)全く同じものを配置しています。

{
  "title": "HackerVoice",
  "description": "Every day 8:00 A.M on PST, an LLM-powered host covers the top five trending topics from HackerNews in a fully automated podcast. This podcast is entirely automatically generated and may contain errors made by AI.",
  "link": "https://hackervoice.vercel.app/",
  "language": "en",
  "image": "https://hackervoice.vercel.app/image.png",
  "imagePodcastRule": "https://hackervoice.vercel.app/image-1400.jpg",
  "favicon": "/image.png",
  "copyright": "2025 HackerVoice",
  "logo":"/image.png",
  "feedUrl": "https://hackervoice.vercel.app/rss",
  "siteUrl": "https://hackervoice.vercel.app/",
  "ituens":{
    "explicit": "false",
    "author": "HackerVoice",
    "category": {
      "parent": "Technology",
      "child": "Technology"
    },
    "type": "episodic",
    "owner": {
      "name": "Tatsuhiko Akiyama",
      "email": "tatsuhiko.shigoto@gmail.com"
  }
  },
  "spotify": "https://open.spotify.com/show/2iSlf6WYOH1d24tct8HdqP?si=nCYYQwxqQGGvclgsGF9Thg",
  "applePodcast": "https://podcasts.apple.com/us/podcast/hackervoice/id1796644904"
}

📡 RSSフィードの自動更新

ポッドキャスト配信の核となるRSSフィードをどのように自動生成し、最新のエピソードをリスナーに届ける仕組みを説明します。

PodcastのためのRSS対応には、Apple Podcastsの形に対応することでほとんどのPodcastクライアントに対応することができます(ここ重要なのに全然情報がない...)。

https://help.apple.com/itc/podcasts_connect/#/itcb54353390

注意点としてSpotify Podcastで配信する際、登録のためメールアドレス情報をRSS上に載せる必要があります。

<itunes:owner>
  <itunes:name>名前</itunes:name>
  <itunes:email>email@example.com</itunes:email>
</itunes:owner>

また、Validatorがサードパーティで提供されており、私はCast Feed Validatorを利用しました。テストのためのPreview機能が提供されているため、非常に便利でした。

https://www.castfeedvalidator.com/

RSSの配信にはrssライブラリを使用しました。参考実装の情報が少ないため、公開します。

// src/app/ja/rss.ts
import RSS from 'rss';
import podcast from '@/lib/podcast-ja.json';
import episodes from '@/lib/episodes-ja.json';
import { DateTime } from 'luxon';

export async function GET() {
  const feed = new RSS({
    title: podcast.title,
    description: podcast.description,
    feed_url: podcast.feedUrl,
    site_url: podcast.siteUrl,
    image_url: podcast.imagePodcastRule,
    language: podcast.language,
    ttl: 60,
    custom_namespaces: {
      itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd',
      podcast: 'https://podcastindex.org/namespace/1.0'
    },
    custom_elements: [
      { 'itunes:image': { _attr: { href: podcast.imagePodcastRule } } },
      {
        'itunes:category': {
          _attr: { text: podcast.ituens.category.parent },
          'itunes:category': {
            _attr: { text: podcast.ituens.category.child }
          }
        }
      },
      {
        'itunes:owner': [
          { 'itunes:name': podcast.ituens.owner.name },
          { 'itunes:email': podcast.ituens.owner.email }
        ]
      },
      { 'itunes:explicit': podcast.ituens.explicit },
      { 'itunes:author': podcast.ituens.author },
      { 'itunes:title': podcast.title },
      { 'itunes:type': podcast.ituens.type },
      { copyright: podcast.copyright },
    ]
  });

  episodes.forEach((episode) => {
    const episodeUrl = `${podcast.siteUrl}#episode-${episode.id}`;
    const descriptionHTML = 
    `
    <description>
    <![CDATA[
      <p>取り扱った記事:</p>
      <ul>
        ${episode.news.map(news => `
          <li>
            <a href="${news.url}">${news.title}</a> - 
            <a href="https://news.ycombinator.com/item?id=${news.item_id}">Hacker News</a>
          </li>
        `).join('')}
      </ul>
    ]]>
    </description>
    `;

    const episodeTitle = `Episode #${episode.id}: ${episode.date}`;
    const rfc2822Date = DateTime.fromISO(episode.dateisofull).toRFC2822();

    feed.item({
      title: episodeTitle,
      date: String(rfc2822Date),
      description: descriptionHTML,
      url: episodeUrl,
      guid: episode.uuid,
      enclosure: {
        url: episode.audioUrl,
        type: 'audio/mpeg',
        size: episode.episodelength
      },
      custom_elements: [
        { 'itunes:episode': episode.id },
        { 'itunes:duration': episode.duration },
      ]
    });
  });

  const xml = feed.xml({ indent: true });

  return new Response(xml, {
    status: 200,
    headers: {
      'Content-Type': 'text/xml'
    }
  });
}

RSS Feedにおいて、予想外の要求がいくつかPodcastクライアントからあるため、A Podcaster’s Guide to RSSは一読することをお勧めします。

🚧 運用課題・今後の展望

一連の配信の仕組みや、自動生成のフロー等は完成した一方で、番組自体の面白さにはまだ向上点があると考えています。もっとコメントの内容を取り上げること、TTSの精度を高めるための原稿のチューニング、LLMのダブル原稿チェックなどを行うことを考えています。

また、情報源をHackerNewsに限らず、他の掲示版、ブログなどを取得してパーソナライズするなど、個人の趣味嗜好に合わせてPodcastをお届けする形を近いうちに提供したいと考えています。

📝 最後に

今回のために作った一連のソースコードはGitHubにて公開中です。o3-mini-highあたりに突っ込んでいい感じに指示させると多分自分だけのPodcast生成ができると思います。

https://github.com/gorira-tatsu/hackernews-podcast-public

ぜひHackerVoiceをよろしくお願いします!!!

https://hackervoice.vercel.app/

GitHubで編集を提案

Discussion