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?

キャッチアップ専用ガジェット(キャッチーちゃん)をclaude agent sdkを活用して作ってみたはなし

1
Posted at

こんにちは!マヌカです。
今回は研究室に放置されていたM5Stack Core2を使って、質問をレコーディングして送信ボタンを押すと、AIが色々調べて調査レポートをPDFとして送ってくれるというガジェットを作ってみた。

わざわざM5Stack使わないで定期実行させたら良くない?と思うのだが、、「ガジェット」というロマンが邪魔をしました。

最後にリポジトリを公開しているので良かったら活用してみてください。

<動作概要>

  1. 音声のレコーディング(Start Recordingを1回タップ)
    IMG_0116.jpg

  2. 10秒間質問を録音
    IMG_0117.jpg

  3. 音声データをサーバーに送信(Send AIをタップ)(取り直し・録音のキャンセル・録音した音声の再生機能も搭載)
    IMG_0118.jpg

  4. Discordに調査レポートPDFが返ってくる
    {B3E990B1-67CA-432B-994D-B6C817CB58A1}.png

ちゃんと調査をまとめたレポートが返ってきた!!
(文章がちょっと読みづらかったりしたので、調査用のプロンプトなどの修正は必要そう…)

<環境と使用した機器類>

・M5Stack Core2 by AWS (下記に全く同じ商品のリンク貼っておきます)
M5Stack Core2 - Amazon.co.jp

・Arduino IDE
・PC(Windows11)(下記スペック)
{EDFA2759-B5E7-4FD5-89CD-A7DD8C0B0C65}.png

・Claude Code Proプラン(今回はサブスク内のトークンを利用する)
・Docker
・Python
・使用ライブラリにおいてはGitHubのリポジトリのrequirements.txtを適宜参考に(下記が内容)
{F5574338-C25C-4613-BBD8-8E92A268404D}.png

・Google Gemini モデル -> gemini-3.8-flash

<モデルのアーキテクチャ>

音声入力 → 文字起こし → 文章整形(フィラー除去+調査依頼の構造化) → エージェントによるWeb調査 → PDF化 → Discordに送信
の一本道です。

各ステップの役割

# ステップ 使っているもの やっていること 出力
① 文字起こし faster-whisper(small, CPU, int8, 日本語) 録音した音声をテキストに変換 transcription.json
② 文章整形 Gemini Flash(gemini-3.8-flash) 誤認識の補正(例:「エジェント」→「エージェント」)、フィラー(えー、あのー)の除去、調査テーマ・期間・検索キーワード候補・比較対象の抽出 research_request.json
③ Web調査 Claude Agent SDK 検索 → 読む → 追加で検索…を自律的に繰り返して情報を集め、整理・比較する research_report.json
④ PDF生成 fpdf2 調査結果を日本語PDFのレポートに組み立てる research_report.pdf
⑤ 通知 Discord Webhook PDFを添付して、テーマ・要約と一緒に送信 —

設計で意識したこと

1. Gemini と Claude の役割をはっきり分けた
Gemini には調査をさせず、「音声をちゃんとした調査依頼に整える」ことだけを任せています。
話し言葉はどうしても曖昧になるので、ここで JSON Schema に沿った構造化データにしてから Claude に渡すことで、調査の精度とブレの少なさを上げています。軽い処理は安くて速い Flash に任せる、という分担。

2. 調査の中心は Claude Agent SDK
Claude に渡すツールは WebSearch と WebFetch の2つだけに絞っています(ファイル操作などはさせない)。そのうえで、プロンプトで次のような多段階の調査手順を指示している。

  1. 日本語・英語でキーワードを複数作る
  2. X / Reddit / Qiita / Zenn / Hacker News / 公式ドキュメント / GitHub / ニュース / 論文などを横断して検索する
  3. 見つけた情報をもとに、さらに追加で検索する
  4. 複数の情報源を比較し、情報の新しさを確認する
  5. 各主張を FACT(事実)/ OPINION(意見)/ USER_REPORT(ユーザーの体験談)/ INFERENCE(推測) に分類する

結果は構造化出力(output_format: json_schema)で受け取るので、出典付きのレポートをそのまま PDF に流し込めます。
ちなみに認証は Claude Pro のサブスク枠(claude setup-token で発行したトークン)を使っているので、API の従量課金なしで動かせています。

3. ステップ同士は JSON だけでつなぐ
各ステップの入出力は Pydantic のモデルで定義していて、ステップ同士はこの JSON ファイルだけでつながっています。そのおかげで、

  • 途中の JSON が残っていれば、そのステップはスキップして再実行できる(Discord 送信だけ失敗しても、高い調査をやり直さなくて済む)
  • 「Whisper が何を聞き取ったか」「Gemini がどう解釈したか」を後から確認できる
  • STT エンジンや通知先(Slack など)を差し替えやすい

というメリットがあります。

4. サーバーはすぐ応答して、調査は裏で回す
調査には数分〜数十分かかるので、サーバーは音声を保存した時点で M5Stack にすぐ応答を返し、調査はバックグラウンドのワーカーで1件ずつ順番に処理しています。M5Stack 側は「受け付けました」を表示するだけで OK で、結果は Discord に届くのを待つだけです。

<参考>

(Arduino IDE内コード)

#include <M5Unified.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <driver/i2s.h>

#include "config.h"

// ==========================================
// 音声・録音の設定(10秒録音用)
// ==========================================

#define CONFIG_I2S_BCK_PIN     -1
#define CONFIG_I2S_LRCK_PIN     0
#define CONFIG_I2S_DATA_IN_PIN 34

#define SAMPLE_RATE     16000
#define RECORD_TIME     10

// 16kHz × 2byte × 10秒
#define PCM_DATA_SIZE \
    (SAMPLE_RATE * sizeof(int16_t) * RECORD_TIME)

// WAVヘッダー
#define WAV_HEADER_SIZE 44

// WAVヘッダー + PCMデータ
#define TOTAL_BUFFER_SIZE \
    (PCM_DATA_SIZE + WAV_HEADER_SIZE)

// ==========================================
// 音声データ用バッファ
// ==========================================

uint8_t* audio_buffer = nullptr;
size_t recorded_pcm_bytes = 0;
String upload_message;

enum class UiState {
    home,
    review,
    result
};

UiState ui_state = UiState::home;

// ==========================================
// I2Sマイクの初期化
// ==========================================

void initI2SMicrophone() {
    i2s_config_t i2s_config = {
        .mode = (i2s_mode_t)(
            I2S_MODE_MASTER |
            I2S_MODE_RX |
            I2S_MODE_PDM
        ),
        .sample_rate = SAMPLE_RATE,
        .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
        .channel_format = I2S_CHANNEL_FMT_ONLY_RIGHT,
        .communication_format = I2S_COMM_FORMAT_STAND_I2S,
        .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
        .dma_buf_count = 6,
        .dma_buf_len = 512,
        .use_apll = false
    };

    i2s_pin_config_t pin_config = {
        .bck_io_num = CONFIG_I2S_BCK_PIN,
        .ws_io_num = CONFIG_I2S_LRCK_PIN,
        .data_out_num = I2S_PIN_NO_CHANGE,
        .data_in_num = CONFIG_I2S_DATA_IN_PIN
    };

    i2s_driver_install(
        I2S_NUM_0,
        &i2s_config,
        0,
        NULL
    );

    i2s_set_pin(
        I2S_NUM_0,
        &pin_config
    );

    i2s_set_clk(
        I2S_NUM_0,
        SAMPLE_RATE,
        I2S_BITS_PER_SAMPLE_16BIT,
        I2S_CHANNEL_MONO
    );
}

// ==========================================
// WAVヘッダー作成
// ==========================================

void createWavHeader(uint8_t* header, uint32_t pcm_data_size) {
    uint32_t total_data_len = pcm_data_size + 36;
    uint32_t bitrate = SAMPLE_RATE * 2;

    // RIFF
    header[0] = 'R';
    header[1] = 'I';
    header[2] = 'F';
    header[3] = 'F';

    header[4] = (total_data_len & 0xff);
    header[5] = ((total_data_len >> 8) & 0xff);
    header[6] = ((total_data_len >> 16) & 0xff);
    header[7] = ((total_data_len >> 24) & 0xff);

    // WAVE
    header[8] = 'W';
    header[9] = 'A';
    header[10] = 'V';
    header[11] = 'E';

    // fmt
    header[12] = 'f';
    header[13] = 'm';
    header[14] = 't';
    header[15] = ' ';

    // SubChunk1Size = 16
    header[16] = 16;
    header[17] = 0;
    header[18] = 0;
    header[19] = 0;

    // AudioFormat = PCM
    header[20] = 1;
    header[21] = 0;

    // NumChannels = Mono
    header[22] = 1;
    header[23] = 0;

    // SampleRate
    header[24] = (SAMPLE_RATE & 0xff);
    header[25] = ((SAMPLE_RATE >> 8) & 0xff);
    header[26] = ((SAMPLE_RATE >> 16) & 0xff);
    header[27] = ((SAMPLE_RATE >> 24) & 0xff);

    // ByteRate
    header[28] = (bitrate & 0xff);
    header[29] = ((bitrate >> 8) & 0xff);
    header[30] = ((bitrate >> 16) & 0xff);
    header[31] = ((bitrate >> 24) & 0xff);

    // BlockAlign
    header[32] = 2;
    header[33] = 0;

    // BitsPerSample
    header[34] = 16;
    header[35] = 0;

    // data
    header[36] = 'd';
    header[37] = 'a';
    header[38] = 't';
    header[39] = 'a';

    // PCMデータサイズ
    header[40] = (pcm_data_size & 0xff);
    header[41] = ((pcm_data_size >> 8) & 0xff);
    header[42] = ((pcm_data_size >> 16) & 0xff);
    header[43] = ((pcm_data_size >> 24) & 0xff);
}

// ==========================================
// Wi-Fi経由でWAVファイルをPOST送信
// ==========================================

int sendAudioPost(uint8_t* data, size_t length) {
    if (WiFi.status() != WL_CONNECTED) {
        upload_message = "Wi-Fi disconnected";
        return -1;
    }

    HTTPClient http;
    if (!http.begin(server_url)) {
        upload_message = "HTTP begin failed";
        return -1;
    }

    http.addHeader("Content-Type", "audio/wav");

    int httpResponseCode = http.POST(data, length);
    if (httpResponseCode > 0) {
        upload_message = "HTTP " + String(httpResponseCode);
    } else {
        upload_message = http.errorToString(httpResponseCode);
    }

    http.end();
    return httpResponseCode;
}

// ==========================================
// setup
// ==========================================

void drawButton(int x, int y, int w, int h, const char* label, uint16_t color) {
    M5.Display.fillRoundRect(x, y, w, h, 8, color);
    M5.Display.drawRoundRect(x, y, w, h, 8, TFT_WHITE);
    M5.Display.setTextColor(TFT_WHITE, color);
    M5.Display.setTextSize(2);
    M5.Display.setCursor(x + 10, y + (h - 16) / 2);
    M5.Display.print(label);
    M5.Display.setTextColor(TFT_WHITE, TFT_BLACK);
}

void drawHome() {
    ui_state = UiState::home;
    M5.Display.clear(TFT_BLACK);
    M5.Display.setTextColor(TFT_WHITE, TFT_BLACK);
    M5.Display.setTextSize(2);
    M5.Display.setCursor(20, 35);
    M5.Display.println("Voice Recorder");
    M5.Display.setCursor(20, 78);
    M5.Display.println("Tap to record");
    M5.Display.setTextSize(1);
    M5.Display.setCursor(20, 112);
    M5.Display.printf("Duration: %d sec", RECORD_TIME);
    int w = M5.Display.width() - 40;
    drawButton(20, M5.Display.height() - 65, w, 48, "Start Recording", TFT_BLUE);
}

void drawReview() {
    ui_state = UiState::review;
    M5.Display.clear(TFT_BLACK);
    M5.Display.setTextColor(TFT_WHITE, TFT_BLACK);
    M5.Display.setTextSize(2);
    M5.Display.setCursor(12, 10);
    M5.Display.println("Recording ready");
    M5.Display.setTextSize(1);
    M5.Display.setCursor(12, 40);
    M5.Display.printf("Duration: %u sec", (unsigned)RECORD_TIME);

    int screenW = M5.Display.width();
    int screenH = M5.Display.height();
    int buttonW = (screenW - 30) / 2;
    int x1 = 10;
    int x2 = x1 + buttonW + 10;
    int y1 = screenH - 112;
    int y2 = screenH - 56;
    drawButton(x1, y1, buttonW, 46, "Play", TFT_BLUE);
    drawButton(x2, y1, buttonW, 46, "Retake", TFT_ORANGE);
    drawButton(x1, y2, buttonW, 46, "Cancel", TFT_DARKGREY);
    drawButton(x2, y2, buttonW, 46, "Send AI", TFT_GREEN);
}

void drawResult(int status) {
    ui_state = UiState::result;
    M5.Display.clear(TFT_BLACK);
    M5.Display.setTextColor(TFT_WHITE, TFT_BLACK);
    M5.Display.setTextSize(2);
    M5.Display.setCursor(16, 35);
    M5.Display.println(status >= 200 && status < 300 ? "Upload complete" : "Upload failed");
    M5.Display.setTextSize(1);
    M5.Display.setCursor(16, 75);
    M5.Display.println(upload_message);
    M5.Display.setCursor(16, 112);
    M5.Display.println("Tap to continue");
}

void recordAudio() {
    M5.Display.clear(TFT_BLACK);
    M5.Display.setTextColor(TFT_WHITE, TFT_BLACK);
    M5.Display.setTextSize(2);
    M5.Display.setCursor(16, 35);
    M5.Display.printf("Recording %d sec...", RECORD_TIME);

    size_t bytes_read = 0;
    size_t total_pcm_bytes = 0;
    uint8_t* pcm_ptr = audio_buffer + WAV_HEADER_SIZE;

    while (total_pcm_bytes < PCM_DATA_SIZE) {
        esp_err_t err = i2s_read(
            I2S_NUM_0,
            pcm_ptr + total_pcm_bytes,
            PCM_DATA_SIZE - total_pcm_bytes,
            &bytes_read,
            portMAX_DELAY
        );
        if (err == ESP_OK && bytes_read > 0) {
            total_pcm_bytes += bytes_read;
        }
    }

    recorded_pcm_bytes = total_pcm_bytes;
    createWavHeader(audio_buffer, recorded_pcm_bytes);
    drawReview();
}

void playRecording() {
    M5.Display.clear(TFT_BLACK);
    M5.Display.setTextSize(2);
    M5.Display.setCursor(16, 35);
    M5.Display.println("Playing recording...");

    i2s_stop(I2S_NUM_0);
    i2s_driver_uninstall(I2S_NUM_0);

    bool speakerStarted = M5.Speaker.begin();
    if (speakerStarted) {
        M5.Speaker.setVolume(200);
        M5.Speaker.playRaw(
            reinterpret_cast<const int16_t*>(audio_buffer + WAV_HEADER_SIZE),
            recorded_pcm_bytes / sizeof(int16_t),
            SAMPLE_RATE,
            false
        );
        while (M5.Speaker.isPlaying()) {
            M5.update();
            delay(1);
        }
        M5.Speaker.end();
    } else {
        M5.Display.clear(TFT_BLACK);
        M5.Display.setCursor(16, 35);
        M5.Display.println("Speaker unavailable");
        delay(1200);
    }

    initI2SMicrophone();
    drawReview();
}

bool hitButton(int x, int y, int buttonX, int buttonY, int buttonW, int buttonH) {
    return x >= buttonX && x < buttonX + buttonW
        && y >= buttonY && y < buttonY + buttonH;
}

void setup() {
    // ==========================================
    // M5Unified初期化
    // ==========================================

    auto cfg = M5.config();
    // The sketch manages microphone I2S and speaker playback itself, one at a time.
    cfg.internal_mic = false;
    cfg.internal_spk = false;
    M5.begin(cfg);

    // ==========================================
    // LCD設定
    // ==========================================

    M5.Display.setTextSize(2);
    M5.Display.clear();

    // ==========================================
    // 環境表示
    // ==========================================

    M5.Display.println("Starting...");

    if (PRODUCTION_MODE) {
        M5.Display.println("Environment: PRODUCTION");
    } else {
        M5.Display.println("Environment: TEST");
    }

    // ==========================================
    // Wi-Fi接続
    // ==========================================

    M5.Display.printf("Connecting to %s ", ssid);

    WiFi.begin(ssid, password);

    while (WiFi.status() != WL_CONNECTED) {
        delay(500);
        M5.Display.print(".");
    }

    M5.Display.println("\nWi-Fi Connected!");

    // ==========================================
    // M5StackのIPアドレス
    // ==========================================

    M5.Display.print("M5 IP: ");

    M5.Display.println(WiFi.localIP());

    // ==========================================
    // 接続先サーバー
    // ==========================================

    M5.Display.print("Server: ");
    M5.Display.println(server_url);

    // ==========================================
    // PSRAM確保
    // ==========================================

    audio_buffer = (uint8_t*)ps_malloc(TOTAL_BUFFER_SIZE);

    if (audio_buffer == nullptr) {
        M5.Display.println("PSRAM Allocation Failed!");

        while (1) {
            delay(1000);
        }
    }

    // ==========================================
    // I2Sマイク初期化
    // ==========================================

    initI2SMicrophone();

    // ==========================================
    // 待機
    // ==========================================

    drawHome();
}

// ==========================================
// loop
// ==========================================

void loop() {
    M5.update();
    auto touchDetail = M5.Touch.getDetail();
    if (!touchDetail.wasPressed()) {
        delay(20);
        return;
    }

    int x = touchDetail.x;
    int y = touchDetail.y;

    if (ui_state == UiState::home) {
        recordAudio();
        return;
    }

    if (ui_state == UiState::review) {
        int screenW = M5.Display.width();
        int screenH = M5.Display.height();
        int buttonW = (screenW - 30) / 2;
        int x1 = 10;
        int x2 = x1 + buttonW + 10;
        int y1 = screenH - 112;
        int y2 = screenH - 56;
        int buttonH = 46;

        if (hitButton(x, y, x1, y1, buttonW, buttonH)) {
            playRecording();
        } else if (hitButton(x, y, x2, y1, buttonW, buttonH)) {
            recordAudio();
        } else if (hitButton(x, y, x1, y2, buttonW, buttonH)) {
            recorded_pcm_bytes = 0;
            drawHome();
        } else if (hitButton(x, y, x2, y2, buttonW, buttonH)) {
            M5.Display.clear(TFT_BLACK);
            M5.Display.setTextSize(2);
            M5.Display.setCursor(16, 35);
            M5.Display.println("Sending to AI...");
            int status = sendAudioPost(audio_buffer, recorded_pcm_bytes + WAV_HEADER_SIZE);
            drawResult(status);
        }
        return;
    }

    recorded_pcm_bytes = 0;
    drawHome();
}

(リポジトリ)

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?