kasysのアイコン画像
kasys 2024年10月30日作成 (2024年10月31日更新) © GPL-3.0+
製作品 製作品 閲覧数 537
kasys 2024年10月30日作成 (2024年10月31日更新) © GPL-3.0+ 製作品 製作品 閲覧数 537

木苺式クイズ早押し機【PIOを使用した高性能・低コストタイミング検出】

木苺式クイズ早押し機【PIOを使用した高性能・低コストタイミング検出】

概要

木苺式クイズ早押し機はRaspberry PI Pico Wを使用した10入力クイズ用早押し検知装置です。Raspberry PI Picoシリーズの特徴的な機能である PIO(Programmable I/O)を使用することで、理論最高検出性能が24nsと非常に高速にも関わらず、低コストで作成可能になっています。

製作動機

Raspberry Pi PicoシリーズにはPIOという非常に面白い機能が搭載されています。PIOは、通常のGPIOの制御を超えて、ハードウェアのように1クロック単位でタイミングを制御することができるプログラマブルな入出力ブロックです。この機能により、通常のマイコンでは難しい複雑かつ高速な入出力操作を実現できます。これにより、ユーザーは独自のシリアル通信プロトコルやパルス幅測定など、通常のマイコンでは難しい複雑な入出力操作を実現できます。特に、タイミングの精度が要求されるタスクや複数のピンの協調動作が必要なシチュエーションにおいて、その力を発揮します。

私は大学の卒業研究でタイミング検出(Time of Flight: ToF)に関する研究を行っていたため、PIOの持つ高精度なタイミング制御機能に強く興味を持ち、活用してみたいと考えました。

ところで、早押しクイズをご存知でしょうか?早押しクイズは、複数の参加者が問題に対していち早くボタンを押し、回答権を得る競技です。そのため、どの参加者が最初にボタンを押したかを正確に判定することが求められます。この判定精度がクイズの公平性を保つ重要な要素となります。PIOは、複数の入力を同時に監視しながら、どの入力が最初にトリガーされたかを非常に短い遅延で判断することができます。この優れたリアルタイム性能が、早押しクイズのような用途に非常に適しています。

そこで、普段は早押しクイズをする機会はありませんが、PIOの特性を活かしてどこまで高性能かつ低コストな早押しクイズ判定機を制作できるかに挑戦しました。

従来の早押し機

一般に有名な早押し機としては「早稲田式クイズ早押し機」があります。これは、クイズ系YouTuberであるQuizKnockが使用していることでも有名です。入力端子も多く、非常に高性能とされていますが、具体的な検知精度に関する情報は公式には公表されていません。ただし、使用者のレビューによると、非常に速いレスポンスが得られることから、実際のクイズ大会での利用にも十分耐える性能があるとされています。しかし、1セットあたり8万円程度らしく非常に高価です。

検索したところ他の早押し機などもありますが、一般販売があるものは大抵数万円するため高価です。また、自作している人も少なくありませんが、多くの場合は低速な通常のGPIOを使用していたり、リレーを組み合わせたものが多く、これらはどうしても検知精度が数μs の範囲に留まることが多いと考えられます。そのため、ns オーダーの精度を実現しているものはほとんど見つかりませんでした。

設計(システム構成)

簡易なシステム構成図

木苺式クイズ早押し機の早押し判定機は各GPIOにボタンを繋ぐだけの非常に簡単な構成になっています。また、早押しの結果やボタンのリセットなどはWeb Bluetooth API(BLE)を経由してパソコンやタブレットなどの端末から行います。これにより、早押し判定機には従来の自作早押し機のようなアナログ検知回路(リレーなど)やオーディオ出力機能、LED表示機能などが不要となるため、非常に低コストに抑えられます。ボタンとの接続には3.5mm4極ミニジャックを使用しています。これは、間違えてオーディオ機器などの別用途の機器を接続しても問題ないピンアサインにするためです。

早押しボタンの制作

早押しボタンは格安なモーメンタリスイッチを使用して作りました。構造は極めて単純でスイッチにケーブルとコネクタをはんだ付けして3Dプリンタ製のケースに入れただけです。3DモデルはBlenderを使用して作成しました。色々なカラーの早押しボタンを計9個作成しました。

早押しボタン

早押しボタン(裏面)

色々なカラーの早押しボタン

早押しボタンのケースの3Dモデル

早押し判定機の制作

早押し判定機のハードウェアもシンプルで、ユニバーサル基板にRaspberry Pi Pico Wと3.5mm4極ミニジャックコネクタを実装しただけの構造です。GPIO0~GPIO9と10個のコネクタがそれぞれ接続されています。また、タイミング検出速度向上のために250MHzにオーバークロックすることにしました。それを考慮して、気休めですがヒートシンクも装着しました。GPIOは内蔵プルアップ抵抗を有効にしてあります。

早押し判定機

裏面

プログラム環境

早押し判定機のプログラムにはarduino-pico(Arduino IDE)環境、WebUIはVisual Studio Codeを使用して開発しました。

[早押し判定機]
開発環境:Arduino IDE
ボード定義:arduino-pico v4.1.1
追加ライブラリ:なし

[WebUI]
開発環境:Visual Studio Code
追加ライブラリ(サードパーティリソース含む):Material IconsMaterialize CSSOtoLogic

タイミング検出の流れ

タイミング検出の流れを説明します。まずPIOを使用してGPIOから使用するすべての入力情報を一気に取得します。その後1ループ前のGPIO入力情報と比較し、差があればPIOの深さ8のRX FIFO(PIOの出力用のメモリのようなもの)に入力情報を格納します。

理論最高検出性能
PIOの一連の動作は6クロック(24ns)で完了します。そのため、連続して計測可能な早押しの時間差の理論最高検出性能は24nsとなります。

GPIO入力数
理論上、PIOを使用したGPIO入力は同時に最大32まで検出可能なので、検証はしていないものの、コネクタを取り付けて早押し判定やWebUIプログラムの簡単な修正を行えば更に入力数を拡張可能です。(実はこの記事に載せている早押し判定コードでは16入力同時に取得するコードになっています。)

その後、FIFOに入った全入力データはDMA(Direct Memory Access)によってメモリ内に順次転送されます。メモリ内のデータをメインプログラムがループごとにチェックし、入力を検知した時点で順番とタイムスタンプ(マイクロ秒)を記録します。現実的にほぼありえませんが、24nsの間に同時に押されて同着となった場合は乱数を使って決定します。また、並行してデータをBLE経由で送信できるように待ち受けます。

結果表示機のブラウザ上で動くJavaScriptプログラムはWeb Bluetooth API経由で250msごとに現在の早押し順位情報とタイムスタンプを取得しています。そして、取得したデータに変動があれば画面に順位やタイムスタンプを表示するようになっています。

タイミングチャート

検証

実際にどのくらいの時間差が検出できているかを検出するために、別のRaspberry Pi PicoのPIOを使用して時間差付きのパルスを出力してテストを行いました。所持しているオシロスコープの最大分解能が20nsなので、とりあえず20nsの時間差パルスを以下のMicroPythonコードで出力して計測しました。

MicroPython

import rp2 import machine # クロックの設定 #FREQ = int(100_000_000) # ns換算で10ns FREQ = int(50_000_000) # ns換算で20ns #FREQ = int(42_000_000) # ns換算で24ns #FREQ = int(25_000_000) # ns換算で40ns machine.freq(FREQ) @rp2.asm_pio(set_init=(rp2.PIO.OUT_HIGH, rp2.PIO.OUT_HIGH), out_init=(rp2.PIO.OUT_HIGH, rp2.PIO.OUT_HIGH)) def button_simulator(): set(pins, 0b11) # 全ボタンを離した状態(HIGH) nop() [5] # 遅延 set(pins, 0b10) # ボタン1 (GPIO0) を押す set(pins, 0b00) # ボタン2も離す(GPIO1) nop() [12] # 遅延 set(pins, 0b11) # 全ボタンを離した状態(HIGH) nop() [18] #遅延 wrap() # ループ(オシロで計測する時用) # 以下は1ショットで実行する場合 # jmp("edge1") # label("edge1") # nop() [1] # jmp("edge1") # 無限ループ # PIOの初期化 sm = rp2.StateMachine(0, button_simulator, freq=int(FREQ), set_base=machine.Pin(0)) # ステートマシンをアクティブにしてボタン押下をシミュレート sm.active(1) # ステートマシンが終了するまで待機 while sm.active(): pass

上記のコードでGPIOを出力し、オシロスコープで計測した結果、以下の図のようなオシロスコープの波形を計測できました。オシロスコープの分解能ギリギリなので正確性は若干怪しいですが、設定どおりの結果を得られたのでこのプログラムを使用して10ns、20ns、24ns、40nsの時間差パルスをGPIOに入力しました。

実験の様子

実験の結果以下のような結果を得られました。20nsより長い遅延であれば正確に判定できていることがわかります。

パルス間遅延 [ns] クロック [MHz] 結果
10 100 同着判定
20 50 設定順通り
24※ 42 設定順通り
40 25 設定順通り

※正確には23.8ns程度

また、24ns遅延の時のログを以下に示します。このログからは、入力ビットの下3桁が「110」→「100」→「111」となっていることが読み取れます。これは、複数回実行しても正確に入力ビットの判定が行えている事を示しています。したがって、PIOを利用したタイミング検出はうまく行っていると考えられます。

出力ログ

00:43:31.077 -> [INFO]Button states have been reset. 00:43:33.919 -> [PRESS]write_pointer=51, read_pointer=48, 00000000000000001111111111111110 00:43:33.919 -> [PRESS]id=1,time=2.868636 00:43:33.919 -> [PRESS]write_pointer=51, read_pointer=49, 00000000000000001111111111111100 00:43:33.919 -> [PRESS]id=2,time=2.869099 00:43:33.919 -> [PRESS]write_pointer=51, read_pointer=50, 00000000000000001111111111111111 00:43:39.154 -> [PRESS]write_pointer=54, read_pointer=51, 00000000000000001111111111111110 00:43:39.154 -> [PRESS]write_pointer=54, read_pointer=52, 00000000000000001111111111111100 00:43:39.186 -> [PRESS]write_pointer=54, read_pointer=53, 00000000000000001111111111111111 00:43:40.492 -> [PRESS]write_pointer=57, read_pointer=54, 00000000000000001111111111111110 00:43:40.492 -> [PRESS]write_pointer=57, read_pointer=55, 00000000000000001111111111111100 00:43:40.492 -> [PRESS]write_pointer=57, read_pointer=56, 00000000000000001111111111111111 00:43:41.983 -> [PRESS]write_pointer=60, read_pointer=57, 00000000000000001111111111111110 00:43:41.983 -> [PRESS]write_pointer=60, read_pointer=58, 00000000000000001111111111111100 00:43:41.983 -> [PRESS]write_pointer=60, read_pointer=59, 00000000000000001111111111111111

WebUI

クイズを管理するためのUIはWebベースで作成しました。これは、マルチプラットフォームにしやすい点と、Web Bluetooth APIを使用できるためです。接続には主にChromeを使用します。iOS/iPadOSの場合はBluefyというBLEが使用できる特殊なブラウザを使用します。

実際に作ったUIは以下の通りです。このUIでは最初にボタンを押した人の表示や各プレイヤーの押下時間及びタイムスタンプの表示、正解/不正解判定、早押し機のリセットなどが行えます。また、正解、不正解をプレイヤーごとにカウントしたり、効果音の発生なども行っています。全員分のタイミングを常に監視しているため、不正解の場合は次の回答者に移行することもできます。タイミング検出とは独立しているため、クイズのルールに合わせた様々な拡張もハードウェアを変更せずに行うことができます。

WebUIの様子

接続の様子

WebUIはページ下部に記載したソースコードを自前でホスティングした環境か、私が用意したページを使用できます。自前でやる場合はlocalhost環境かhttps環境でなければ動作しないため、注意が必要です。

テスト動画

ここに動画が表示されます

※効果音はオトロジック(CC BY 4.0)様のものを使用させていただきました。

部品表

私の場合は10チャンネル構成で約5000円くらいでした。3Dプリンタがない場合はもう少しかかると思われます。

早押し判定機(10入力)

部品 参考価格 備考
Raspberry PI Pico W 1 1200円程度 Pico WHも可
ヒートシンク 1 数十円 オーバークロック用、無くても可
ピンヘッダ(20P) 2 30円程度 Pico WHの場合不要
ピンソケット(20P) 2 50円程度
ユニバーサル基板 1 数百円
3.5mm4極ミニジャックコネクタ 10 10個で600円程度 理論上はGPIOの数まで拡張可能
簡易ケース 1 不明 3Dプリンタで印刷

早押しボタン(一つ)

部品 参考価格 備考
モーメンタリスイッチ 1 5個で700円程度 Amazonでの価格
2芯ケーブル 適量 20mで800円程度 Amazonでの価格
3.5mm4極ミニジャックプラグ 1 10個で数百円
スイッチ用ケース 1 不明 3Dプリンタで印刷

結果表示機

部品 参考価格 備考
パソコン/タブレット 1 ブラウザ(Chrome / Bluefy)が使える端末であれば可

ソースコード

★早押し判定機のコード
以下のソースコードとPIOプログラムをarduino-pico環境で同一プロジェクト内に配置してRaspberry Pi Pico Wに書き込むことで早押し判定機を作成可能です。

rpi_hayaoshi.ino(早押し検知機のプログラム:arduino-pico)

#include <Arduino.h> #include <BTstackLib.h> #include <SPI.h> #include "hardware/pio.h" #include "hardware/dma.h" #include "button_monitor.pio.h" // 自作したPIOプログラムを読み込み #define NUM_BUTTONS 16 // ボタン数 #define NUM_SAMPLES 1 // 1サンプルずつ転送する(エッジ検出ごと) #define MAX_FIFO_LEVEL 8 // FIFOの最大レベル #define RESET_COMMAND 'R' // BLE用のリセットコマンド #define MAX_HISTORY 16 // 最大履歴数 #define ENTRY_SIZE 5 // 各エントリのサイズ(5バイト) #define TIMESTAMP_MAX 4294967295 // マイクロ秒の最大値(4バイトで表現) #define BUFFER_SIZE 1000 // DMA転送先のメモリのサイズ #define CLK 250000 //kHZ クロック void reset_button_states(); // BLEHandler クラス定義 class BLEHandler { public: BLEHandler() {} void setup() { Serial.begin(9600); // BLEコールバックの設定 BTstack.setBLEDeviceConnectedCallback(deviceConnectedCallback); BTstack.setBLEDeviceDisconnectedCallback(deviceDisconnectedCallback); BTstack.setGATTCharacteristicRead(gattReadCallback); BTstack.setGATTCharacteristicWrite(gattWriteCallback); // GATTサービスと特性の設定 BTstack.addGATTService(new UUID("B8E06067-62AD-41BA-9231-206AE80AB551")); characteristic_handle = BTstack.addGATTCharacteristicDynamic( new UUID("f897177b-aee8-4767-8ecc-cc694fd5fce0"), ATT_PROPERTY_READ | ATT_PROPERTY_WRITE | ATT_PROPERTY_NOTIFY, 0); // characteristic_handleからBLECharacteristicを生成 characteristic = BLECharacteristic(gatt_client_characteristic_t{ characteristic_handle }); // Bluetoothの起動と広告の開始 BTstack.setup(); BTstack.startAdvertising(); } void loop() { BTstack.loop(); } // ボタン押下通知と履歴の更新 void notifyButtonPress(int button_id, uint32_t timestamp) { // ボタンIDとタイムスタンプを履歴に保存 button_press_history[history_index][0] = button_id; button_press_history[history_index][1] = (timestamp >> 24) & 0xFF; // タイムスタンプ上位 button_press_history[history_index][2] = (timestamp >> 16) & 0xFF; button_press_history[history_index][3] = (timestamp >> 8) & 0xFF; button_press_history[history_index][4] = timestamp & 0xFF; // タイムスタンプ下位 // 履歴のインデックスを更新(最大数に達したら0に戻る) history_index = (history_index + 1) % MAX_HISTORY; // クライアントに通知 characteristic_data = button_id + '0'; BTstack.writeCharacteristicWithoutResponse( nullptr, &characteristic, (uint8_t *)&characteristic_data, sizeof(characteristic_data)); } // コールバック:デバイス接続 static void deviceConnectedCallback(BLEStatus status, BLEDevice *device) { (void)device; if (status == BLE_STATUS_OK) { Serial.println("Device connected!"); } } // コールバック:デバイス切断 static void deviceDisconnectedCallback(BLEDevice *device) { (void)device; Serial.println("Disconnected."); } // コールバック:GATTリード static uint16_t gattReadCallback(uint16_t value_handle, uint8_t *buffer, uint16_t buffer_size) { if (buffer && value_handle == characteristic_handle) { // 履歴を一括送信(最大80バイト = 16エントリ x 5バイト) uint16_t length = MAX_HISTORY * ENTRY_SIZE; if (buffer_size < length) { length = buffer_size; // バッファサイズに収まるよう調整 } memcpy(buffer, button_press_history, length); return length; } return 0; } // コールバック:GATTライト static int gattWriteCallback(uint16_t value_handle, uint8_t *buffer, uint16_t size) { if (value_handle == characteristic_handle && size > 0) { if (buffer[0] == RESET_COMMAND) { resetButtonStates(); Serial.println("Received reset command."); } return 0; } return -1; } private: static char characteristic_data; static uint16_t characteristic_handle; static BLECharacteristic characteristic; static uint8_t button_press_history[MAX_HISTORY][ENTRY_SIZE]; // ボタン押下履歴 static uint8_t history_index; // ボタン状態のリセット static void resetButtonStates() { memset(button_press_history, 0, sizeof(button_press_history)); // 履歴をクリア history_index = 0; Serial.println("Button states have been reset."); reset_button_states(); } }; // BLEHandlerの静的メンバ初期化 char BLEHandler::characteristic_data = 'N'; uint16_t BLEHandler::characteristic_handle; BLECharacteristic BLEHandler::characteristic; uint8_t BLEHandler::button_press_history[MAX_HISTORY][ENTRY_SIZE] = { 0 }; uint8_t BLEHandler::history_index = 0; // BLEHandlerインスタンス BLEHandler bleHandler; // グローバル変数 uint32_t button_press_times[NUM_BUTTONS]; // ボタンのタイムスタンプを記録 uint32_t button_state[BUFFER_SIZE]; // 読み出しポインタの初期化 uint32_t read_pointer = 0; // 読み込み位置を管理 uint32_t write_pointer = 0; // 書き込み位置を管理 volatile bool dma_complete = false; // DMA転送完了フラグ int dma_chan0 = 0; bool use_dma_chan0 = true; // どちらのDMAチャンネルを使うかを示すフラグ uint32_t reference_time = 0; uint sm; // ステートマシン番号をグローバル変数として宣言 // DMA転送再設定関数 void configure_dma_transfer(PIO pio, uint sm, int dma_channel) { dma_channel_config c = dma_channel_get_default_config(dma_channel); // 読み取り元は固定(PIOのFIFO) channel_config_set_read_increment(&c, false); // 書き込み先はインクリメント(バッファ内の次の位置に書き込む) channel_config_set_write_increment(&c, true); // PIOのDREQを使用(DMA要求信号) channel_config_set_dreq(&c, pio_get_dreq(pio, sm, false)); // リングバッファの設定 // 書き込みアドレスを循環させるよう設定(2^6 = 64バイトのリングバッファとして設定) channel_config_set_ring(&c, true, 6); // DMAの設定(1サンプルずつ転送) dma_channel_configure( dma_channel, &c, &button_state[write_pointer], // 現在の書き込み先アドレス(リングバッファの位置に従う) &pio->rxf[sm], // PIOのRX FIFOのソース 1, // 1サンプルずつ転送 true // DMA転送をすぐに開始 ); } // 32ビットの値をビット形式で表示する関数 void print_bits(uint32_t value) { for (int i = 31; i >= 0; i--) { Serial.print((value >> i) & 1); // 各ビットをシフトして取り出し } Serial.println(); } // 割り込みハンドラ内でフラグを設定し、次のDMA転送を再設定 void dma_handler0() { dma_channel_acknowledge_irq0(dma_chan0); // 割り込みをクリア dma_complete = true; // 書き込み位置の更新 write_pointer = (write_pointer + 1) % BUFFER_SIZE; // 次のDMA転送を再設定して開始 configure_dma_transfer(pio0, sm, dma_chan0); } // ボタンピン設定、GPIO初期化 void setup_buttons() { for (int i = 0; i < NUM_BUTTONS; i++) { pinMode(i, INPUT_PULLUP); // 各GPIOをプルアップ入力に設定 } } // ボタン状態をリセットする関数 void reset_button_states() { for (int i = 0; i < NUM_BUTTONS; i++) { button_press_times[i] = -1; // 全てのボタンのタイムスタンプをリセット } reference_time = micros(); // タイムスタンプを取得 Serial.println("[INFO]Button states have been reset."); } void button_monitor_program_init(PIO pio, uint sm, uint offset, uint pin_base, uint pin_count) { pio_sm_config c = button_monitor_program_get_default_config(offset); sm_config_set_in_pins(&c, pin_base); pio_sm_set_consecutive_pindirs(pio, sm, pin_base, pin_count, false); // ピンを入力に設定 sm_config_set_fifo_join(&c, PIO_FIFO_JOIN_RX); // FIFOジョインを有効にしてFIFO容量を拡張 pio_sm_init(pio, sm, offset, &c); // PIOステートマシン初期化 } void poll_usbserial_input_non_blocking() { static char buffer[128]; // 入力を格納するバッファ static int index = 0; // バッファ内の現在のインデックス // USBシリアルが接続されているか確認 if (Serial) { int c = Serial.read(); // 非ブロッキングでシリアルデータを取得 if (c != -1) { // データがある場合のみ処理 if (c == '\n' || c == '\r') { // 改行コードを受け取った場合 buffer[index] = '\0'; // 文字列の終端 Serial.printf("[CMD]Received USB Serial Input: %s\n", buffer); // "reset" コマンドを検出 if (strcmp(buffer, "reset") == 0) { reset_button_states(); // ボタン状態をリセット } else { Serial.printf("[ERROR]Unknown command: %s\n", buffer); } // 入力処理後にバッファをリセット index = 0; } else { // 文字をバッファに追加 if (index < sizeof(buffer) - 1) { buffer[index++] = c; } } } } } void printBinary(uint32_t number, int bits = 32) { for (int i = bits - 1; i >= 0; i--) { // 各ビットをチェックし、1なら'1'を、0なら'0'を表示 Serial.print((number & (1 << i)) ? '1' : '0'); } Serial.println(); // 改行を追加 } // メインプログラム void setup() { // シリアル通信初期化 Serial.begin(115200); // 少し待つ delay(1000); Serial.println("[INFO]Raspberry-pi-quiz-buzzer"); // システムクロックを250MHzに設定 Serial.print("[INFO]Setting system clock to 250MHz..."); set_sys_clock_khz(CLK, true); Serial.println(" done"); // ボタン配列を初期化 reset_button_states(); // ボタンGPIO初期設定 Serial.print("[INFO]Setting up buttons..."); setup_buttons(); Serial.println(" done"); // PIO初期化 Serial.print("[INFO]Initialising PIO..."); PIO pio = pio0; uint offset = pio_add_program(pio, &button_monitor_program); sm = pio_claim_unused_sm(pio, true); button_monitor_program_init(pio, sm, offset, 0, NUM_BUTTONS); // GPIO 0を基準に設定 // PIOステートマシンのクロックをシステムクロックに同期 pio_sm_set_clkdiv(pio, sm, 1.0f); // クロック分周を1に設定 // PIOステートマシンを有効化 pio_sm_set_enabled(pio, sm, true); Serial.println(" done"); // DMAチャンネルを取得 Serial.print("[INFO]Claiming DMA channels..."); dma_chan0 = dma_claim_unused_channel(true); Serial.println(" done"); // 初期DMA転送の設定 Serial.print("[INFO]Configuring DMA transfer..."); configure_dma_transfer(pio, sm, dma_chan0); // 初期はdma_chan0を使用 Serial.println(" done"); // DMAチャンネルの割り込みを有効化 Serial.print("[INFO]Enabling DMA channel interrupts..."); dma_channel_set_irq0_enabled(dma_chan0, true); irq_set_exclusive_handler(DMA_IRQ_0, dma_handler0); irq_set_enabled(DMA_IRQ_0, true); Serial.println(" done"); // タイムスタンプを取得 reference_time = micros(); // BLEをセットアップ bleHandler.setup(); // LEDピン(GPIO 25)を出力に設定 pinMode(LED_BUILTIN, OUTPUT); // LEDを点灯 digitalWrite(LED_BUILTIN, HIGH); Serial.println("[SUCCESS]Ready to press buttons!"); } void loop() { bleHandler.loop(); poll_usbserial_input_non_blocking(); // DMAの現在の書き込み位置を取得 uint32_t write_pointer = dma_channel_hw_addr(dma_chan0)->write_addr; write_pointer = (write_pointer - (uintptr_t)button_state) / sizeof(uint32_t); // 新しいデータを読み出し、処理する while (read_pointer != write_pointer) { dma_complete = false; // フラグをリセット uint32_t current_time = micros(); // タイムスタンプを取得 uint32_t state = button_state[read_pointer]; // DMAで転送されたボタン状態 int pressed_buttons[NUM_BUTTONS]; Serial.printf("[PRESS]write_pointer=%d, read_pointer=%d, ", write_pointer, read_pointer); printBinary(state); int pressed_count = 0; // ボタンの押下状態を確認 for (int j = 0; j < NUM_BUTTONS; j++) { if (!(state & (1 << j))) { // ボタンが押された場合 if (button_press_times[j] == -1) { pressed_buttons[pressed_count++] = j; } } } if (pressed_count > 0) { // 同着が発生した場合はランダムに選択 int selected_button; if (pressed_count > 1) { selected_button = pressed_buttons[random(pressed_count)]; Serial.printf("[TIE] Randomly selected button id=%d from %d simultaneous presses\n", selected_button + 1, pressed_count); } else { selected_button = pressed_buttons[0]; read_pointer = (read_pointer + 1) % BUFFER_SIZE; // 全部読み終わったら読み取りポインタをインクリメント } button_press_times[selected_button] = current_time - reference_time; // タイムスタンプを記録 float press_time = (current_time - reference_time) / 1000000.0f; Serial.printf("[PRESS]id=%d,time=%f\n", selected_button + 1, press_time); bleHandler.notifyButtonPress(selected_button + 1, (current_time - reference_time)); } else { read_pointer = (read_pointer + 1) % BUFFER_SIZE; // 新着情報がない場合も読み取りポインタをインクリメント(チャタリングなど) } // DMAのエラーステータスを確認 if (dma_channel_is_busy(dma_chan0) && dma_channel_get_irq0_status(dma_chan0)) { Serial.println("[Error]DMA0 transfer error detected!"); dma_channel_acknowledge_irq0(dma_chan0); // エラーフラグをクリア } } }

button_monitor.pio(pioのプログラム:使用するには変換が必要)

.program button_monitor ; 初期化 in pins, 16 ; 16ビットのピンの状態を読み取る(ISRにロード) in null, 16 ; 残りの16ビットを空データで埋める mov x, isr ; 読み取ったピンの状態をXに保存 start1: in pins, 16 ; 再度16ビットのピンの状態を読み取る in null, 16 ; 残りの16ビットを空データで埋める mov y, isr ; 読み取った状態をYに保存 jmp x!=y, edge1 ; XとYが異なっていればエッジ検出(XORで差異を判断) jmp start2 ; 状態が変わっていない場合は次のステートへ edge1: push ; エッジを検知したのでFIFOにプッシュ jmp start2 ; 次のステートへ start2: in pins, 16 ; 再度16ビットのピンの状態を読み取る in null, 16 ; 残りの16ビットを空データで埋める mov x, isr ; 読み取った状態をXに保存 jmp x!=y, edge2 ; XとYが異なっていればエッジ検出 jmp start1 ; 状態が変わっていない場合は最初に戻る edge2: push ; エッジを検知したのでFIFOにプッシュ jmp start1 ; 最初に戻る

button_monitor.pio.h(pioのプログラム:変換後)

// -------------------------------------------------- // // This file is autogenerated by pioasm; do not edit! // // -------------------------------------------------- // #pragma once #if !PICO_NO_HARDWARE #include "hardware/pio.h" #endif // -------------- // // button_monitor // // -------------- // #define button_monitor_wrap_target 0 #define button_monitor_wrap 16 static const uint16_t button_monitor_program_instructions[] = { // .wrap_target 0x4010, // 0: in pins, 16 0x4070, // 1: in null, 16 0xa026, // 2: mov x, isr 0x4010, // 3: in pins, 16 0x4070, // 4: in null, 16 0xa046, // 5: mov y, isr 0x00a8, // 6: jmp x != y, 8 0x000a, // 7: jmp 10 0x8020, // 8: push block 0x000a, // 9: jmp 10 0x4010, // 10: in pins, 16 0x4070, // 11: in null, 16 0xa026, // 12: mov x, isr 0x00af, // 13: jmp x != y, 15 0x0003, // 14: jmp 3 0x8020, // 15: push block 0x0003, // 16: jmp 3 // .wrap }; #if !PICO_NO_HARDWARE static const struct pio_program button_monitor_program = { .instructions = button_monitor_program_instructions, .length = 17, .origin = -1, }; static inline pio_sm_config button_monitor_program_get_default_config(uint offset) { pio_sm_config c = pio_get_default_sm_config(); sm_config_set_wrap(&c, offset + button_monitor_wrap_target, offset + button_monitor_wrap); return c; } #endif

★WebUIのコード
以下のソースコードを任意のhttps環境で公開するか、pythonやnode.jsなどを使用してlocalhost環境でアクセスできるようにすることで使用できます(Web Bluetooth APIはhttps環境かlocalhost環境でしか動作しないため)。音源データは付属していないため、適宜書き換えるか記事の最後にあるリンク先のリポジトリから取得してください。

index.html

<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>木苺式クイズ早押し機</title> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css" rel="stylesheet"> <style> body { display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background-color: #f5f5f5; } .container { width: 90vw; height: 90vh; display: grid; grid-template-columns: 33% 33% 33%; grid-template-rows: 15% 70% 15%; gap: 10px; } .header { grid-column: 1 / span 3; background-color: #eee; padding: 10px; text-align: center; font-weight: bold; box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1); border-radius: 8px; display: flex; flex-direction: column; justify-content: center; align-items: center; } .header-title { font-size: 2rem; margin-bottom: 10px; } .connection-button { margin-bottom: 10px; } .first-pressed { grid-row: 2 / span 1; grid-column: 2 / span 1; background-color: #e3f2fd; display: flex; flex-direction: column; justify-content: center; align-items: center; font-size: 12rem; color: #0d47a1; box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1); border-radius: 8px; } .first-pressed .description { font-size: 1.5rem; margin-bottom: 10px; } .pressed-list { grid-row: 2 / span 1; grid-column: 1 / span 1; background-color: #f1f8e9; padding: 20px; display: flex; flex-direction: column; align-items: center; box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1); border-radius: 8px; } .pressed-list .scroll-area { width: 100%; height: calc(100% - 50px); overflow-y: auto; } .pressed-list .description { font-size: 1.5rem; margin-bottom: 10px; text-align: center; } .score-board { grid-row: 2 / span 1; grid-column: 3 / span 1; background-color: #fff3e0; padding: 10px; display: flex; flex-direction: column; align-items: center; box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1); border-radius: 8px; } .score-board .scroll-area { width: 100%; height: calc(100% - 50px); overflow-y: auto; } .score-board .description { font-size: 1.5rem; margin-bottom: 10px; text-align: center; } .controller { grid-row: 3 / span 1; grid-column: 1 / span 3; background-color: #ffebee; display: flex; justify-content: space-around; align-items: center; box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1); border-radius: 8px; height: 60%; } .controller button { margin: 10px; width: 170px; } .controller button.connected { background-color: #4caf50 !important; color: white; } .controller button.disconnected { background-color: #f44336 !important; color: white; } table { width: 100%; border-collapse: collapse; } table, th, td { border: 1px solid #ccc; } th, td { padding: 5px; text-align: center; } .info-button { position: fixed; bottom: 10px; left: 10px; z-index: 1000; } </style> </head> <body> <div class="container"> <!-- ライセンス表示 --> <div class="info-button"> <button class="btn-floating btn-small blue modal-trigger" data-target="licenseModal"> <i class="material-icons">info</i> </button> </div> <div id="licenseModal" class="modal"> <div class="modal-content"> <h4>ライセンス情報</h4> <hr> <p> <a href="https://github.com/kasys1422/raspberry-pi-quiz-buzzer" target="_blank">raspberry-pi-quiz-buzzer</a> (GPLv3)<br> <a href="https://github.com/kasys1422/raspberry-pi-quiz-buzzer/blob/main/LICENSE" target="_blank">https://github.com/kasys1422/raspberry-pi-quiz-buzzer/blob/main/LICENSE</a><br><br> </p> <p> <strong>サードパーティライセンス</strong> <br> <hr> 1. Material Icons: Apache License Version 2.0<br> <a href="https://www.apache.org/licenses/LICENSE-2.0" target="_blank">https://www.apache.org/licenses/LICENSE-2.0</a><br><br> 2. Materialize CSS: MIT License<br> <a href="https://opensource.org/licenses/MIT" target="_blank">https://opensource.org/licenses/MIT</a><br><br> 3. OtoLogic: CC BY 4.0<br> <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank">https://creativecommons.org/licenses/by/4.0/</a><br><br> 4. arduino-pico: LGPL-2.1 License<br> <a href="https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html" target="_blank">https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html</a> </p> </div> <div class="modal-footer"> <a href="#!" class="modal-close waves-effect waves-green btn-flat">閉じる</a> </div> </div> <!-- タイトルとデバイス接続ボタン --> <div class="header"> <div class="header-title">木苺式クイズ早押し機</div> <button id="connectionButton" class="btn connection-button blue lighten-2 disconnected" onclick="app.toggleConnection()"><i class="material-icons left">bluetooth</i>Bluetooth接続(未接続)</button> </div> <!-- 早押しボタンを押した人を順番に表示するエリア --> <div class="pressed-list"> <div class="description">早押しボタンを押した順番</div> <div class="scroll-area"> <ul id="pressedList" class="collection"> <!-- <li class="collection-item">1️⃣ - 0.453秒</li> <li class="collection-item">3️⃣ - 0.478秒</li> <li class="collection-item">2️⃣ - 0.512秒</li> --> </ul> </div> </div> <!-- 一番早く押した人の番号を表示するエリア --> <div class="first-pressed"> <div class="description">回答者</div> <div id="firstPressed">-</div> </div> <!-- プレイヤーごとの正解数と誤回答数 --> <div class="score-board"> <div class="description">プレイヤーのスコア</div> <div class="scroll-area"> <table id="scoreTable"> <thead> <tr> <th>プレイヤー</th> <th style="color: #ff4336;">正解数 ◯</th> <th style="color: #3647ff;">誤答数 ✕</th> </tr> </thead> <tbody> <tr> <td>プレイヤー1</td> <td>0</td> <td>0</td> </tr> <tr> <td>プレイヤー2</td> <td>0</td> <td>0</td> </tr> <tr> <td>プレイヤー3</td> <td>0</td> <td>0</td> </tr> <tr> <td>プレイヤー4</td> <td>0</td> <td>0</td> </tr> <tr> <td>プレイヤー5</td> <td>0</td> <td>0</td> </tr> <tr> <td>プレイヤー6</td> <td>0</td> <td>0</td> </tr> <tr> <td>プレイヤー7</td> <td>0</td> <td>0</td> </tr> <tr> <td>プレイヤー8</td> <td>0</td> <td>0</td> </tr> <tr> <td>プレイヤー9</td> <td>0</td> <td>0</td> </tr> <tr> <td>プレイヤー10</td> <td>0</td> <td>0</td> </tr> </tbody> </table> </div> </div> <!-- デバイスコントローラ --> <div class="controller"> <button class="btn red lighten-2" onclick="app.resetPressedList()"><i class="material-icons left">refresh</i>リセット</button> <button class="btn blue lighten-2" onclick="app.addPressedEntry()"><i class="material-icons left">play_arrow</i>出題</button> <button class="btn green lighten-2" onclick="app.updateScore('正解')"><i class="material-icons left">check</i>正解</button> <button class="btn red lighten-2" onclick="app.updateScore('不正解')"><i class="material-icons left">close</i>不正解</button> <button class="btn orange lighten-2" onclick="app.resetScores()"><i class="material-icons left">autorenew</i>スコアリセット</button> </div> </div> <script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script> <script> let time_interval = 250; let ranking; let ranking_index = 0; const circledNumbers = { 1: '①', 2: '②', 3: '③', 4: '④', 5: '⑤', 6: '⑥', 7: '⑦', 8: '⑧', 9: '⑨', 10: '⑩', 11: '⑪', 12: '⑫', 13: '⑬', 14: '⑭', 15: '⑮', 16: '⑯', 17: '⑰', 18: '⑱', 19: '⑲', 20: '⑳', 21: '㉑', 22: '㉒', 23: '㉓', 24: '㉔', 25: '㉕', 26: '㉖', 27: '㉗', 28: '㉘', 29: '㉙', 30: '㉚', 31: '㉛', 32: '㉜' }; class AudioPlayer { constructor(src, volume = 1.0, loop = false) { this.audio = new Audio(src); // 音声ファイルのパス this.audio.volume = volume; // 音量の初期値 this.audio.loop = loop; // ループの設定 } // 効果音を再生する play() { this.audio.currentTime = 0; // 再生位置をリセット this.audio.play(); } // 効果音を一時停止する pause() { this.audio.pause(); } // 効果音の再生を停止し、位置をリセット stop() { this.audio.pause(); this.audio.currentTime = 0; } // 音量を設定する setVolume(volume) { this.audio.volume = volume; } // ループの設定を変更する setLoop(loop) { this.audio.loop = loop; } // 効果音が終了したときの処理を設定 onEnded(callback) { this.audio.addEventListener("ended", callback); } } // 効果音 (適宜ソースを用意してください|sampleではOtoLogicの効果音を使用) // 正解音 const correctSound = new AudioPlayer("./sound/Quiz-Buzzer01-1.mp3"); // 不正解音 const incorrectSound = new AudioPlayer("./sound/Quiz-Wrong_Buzzer01-1.mp3"); // 回答音 const answerSound = new AudioPlayer("./sound/Quiz-Buzzer02-1.mp3"); // 出題音 const questionSound = new AudioPlayer("./sound/Quiz-Question03-1.mp3"); class PicoController { constructor() { this.picoDevice = null; this.characteristic = null; this.serviceUuid = "b8e06067-62ad-41ba-9231-206ae80ab551"; this.characteristicUuid = "f897177b-aee8-4767-8ecc-cc694fd5fce0"; this.pollingInterval = null; } async connect() { try { this.picoDevice = await navigator.bluetooth.requestDevice({ acceptAllDevices: true, optionalServices: [this.serviceUuid] }); const server = await this.picoDevice.gatt.connect(); const service = await server.getPrimaryService(this.serviceUuid); this.characteristic = await service.getCharacteristic(this.characteristicUuid); app.updateConnectionUI(true); this.startPolling(); } catch (error) { console.error("接続に失敗しました:", error); } } startPolling() { this.pollingInterval = setInterval(async () => { if (this.characteristic) { await this.getButtonPressHistory(); } }, time_interval); } async getButtonPressHistory() { try { const value = await this.characteristic.readValue(); this.displayButtonPressHistory(value); } catch (error) { console.error("ボタン履歴の取得に失敗:", error); } } displayButtonPressHistory(dataView) { let rankings = []; for (let i = 0; i < dataView.byteLength; i += 5) { const buttonId = dataView.getUint8(i); const timestamp = dataView.getUint32(i + 1, false); rankings.push({ buttonId, timestamp }); } ranking = rankings; app.displayRankings(rankings); } async sendResetCommand() { try { const resetCommand = new Uint8Array([0x52]); await this.characteristic.writeValue(resetCommand); } catch (error) { console.log(`リセットコマンドの送信に失敗:${error}\nリセットコマンドの再送信を行います`,); // エラーを伝搬 throw error; } } } class QuizApp { constructor() { this.picoController = new PicoController(); // 初期時間を設定 this.time_from_reset = new Date().getTime(); } async toggleConnection() { if (document.getElementById('connectionButton').classList.contains('disconnected')) { await this.picoController.connect(); } else { this.updateConnectionUI(false); } } updateConnectionUI(connected) { const button = document.getElementById('connectionButton'); if (connected) { button.classList.remove('disconnected'); button.classList.add('connected'); button.innerHTML = '<i class="material-icons left">bluetooth</i>Bluetooth接続(接続済み)'; } else { button.classList.remove('connected'); button.classList.add('disconnected'); button.innerHTML = '<i class="material-icons left">bluetooth</i>Bluetooth接続(未接続)'; } } // 押されたボタンリストをリセット async resetPressedList() { const pressedList = document.getElementById('pressedList'); pressedList.innerHTML = ''; document.getElementById('firstPressed').innerHTML = '-'; time_interval = 250; ranking_index = 0; this.time_from_reset = new Date().getTime(); try { await this.picoController.sendResetCommand(); // リセットコマンドをPicoに送信 } catch (e) { // 遅延の後に再送信 setTimeout(() => { this.resetPressedList(); }, 100); } } // ボタン押下順のエントリを追加 addPressedEntry() { questionSound.play(); this.resetPressedList(); // 早押しボタンを押した順番をリセット } // スコアを更新する関数(正解・不正解のタイプに応じて) updateScore(type) { // 早押しボタンが押されていない場合は何もしない if (ranking[ranking_index]['buttonId'] == 0) { return; } const scoreTable = document.getElementById('scoreTable').getElementsByTagName('tbody')[0]; const row = scoreTable.rows[Math.floor(document.getElementById('firstPressed').textContent - 1)]; const scoreCell = type === '正解' ? row.cells[1] : row.cells[2]; scoreCell.textContent = parseInt(scoreCell.textContent) + 1; // 効果音 if (type === '正解') { correctSound.play(); } else { incorrectSound.play(); } if (type === '不正解') { ranking_index++; let next_button = ranking[ranking_index]; if (next_button['buttonId'] != 0) { document.getElementById('firstPressed').textContent = next_button['buttonId']; time_interval = 1000; } else { document.getElementById('firstPressed').textContent = '-'; time_interval = 250; } } } // スコアをリセットする resetScores() { const scoreTable = document.getElementById('scoreTable').getElementsByTagName('tbody')[0]; for (let row of scoreTable.rows) { row.cells[1].textContent = '0'; row.cells[2].textContent = '0'; } } displayRankings(rankings) { // ランキング表示を更新 document.getElementById('pressedList').innerHTML = ''; rankings.forEach((rank, index) => { if (rank.buttonId != 0) { const item = document.createElement('li'); item.classList.add('collection-item'); item.textContent = `${circledNumbers[parseInt(rank.buttonId)]} - ${rank.timestamp / 1e6}秒`; document.getElementById('pressedList').appendChild(item); if (index === ranking_index) { if (document.getElementById('firstPressed').textContent != rank.buttonId) { // リセットボタンを押してから0.5秒以上経過していない場合は効果音を再生しない if (new Date().getTime() - this.time_from_reset > 500) { answerSound.play(); } } document.getElementById('firstPressed').textContent = rank.buttonId; time_interval = 1000 } } }); } } const app = new QuizApp(); document.addEventListener('DOMContentLoaded', function () { var elems = document.querySelectorAll('.modal'); M.Modal.init(elems); }); </script> </body> </html>

まとめ

PIOを使用して工夫することで圧倒的に高性能かつ低コストなクイズ早押し機を制作できました。安価で高性能な早押し機が欲しい方やタイミング検出に興味のある方は、ぜひPIOを活用して早押し機を作ってみてください!

完成した木苺式クイズ早押し機

関連リンク

GitHubリポジトリ

1
kasysのアイコン画像
趣味でpythonや3Dプリンターいじってます。たまにjavascriptやC++使ったり、電子工作したりしてます。大学院生(2024年時点)
ログインしてコメントを投稿する