【ESP32,Android】物理フリック入力キーボードの開発が1段落しました!

こんにちは。
最近、寒くて在宅作業を布団の中で行なっているのですが、すぐに寝落ちしてしまうume-boshiです。


さて、ずっと開発してきた物理フリック入力キーボードの作品を完成させて、GUGEN2020という電子工作コンテストヒーローズ・リーグに応募しました!

皆さんの「ほしいね!」の投票が審査にも関わるようなので、このデバイスを欲しいと思った方はぜひ ↓ のサイトから「ほしいね!」をクリックしていただけますと幸いです! gugen.jp

紹介動画もあります ↓
youtu.be


GUGENヒーローズ・リーグとは、日本最大級の開発コンテストのことです。2年前にもこの作品 で応募していましたが、今回は完全に個人名義で応募しました(今も昔も全て1人で開発しましたが…)。

いつの間にか4か月間も開発を続けていたこの作品について、本記事では技術面をメインにお話ししたいと思います。

作品概要

とはいえ、技術の前にちょっと作品紹介させてください。

f:id:ume-boshi:20201104055353p:plain:w400
物理フリック入力キーボード「FFKB」についてGUGEN2020に応募しました!

近年、前方不注意で危険な「歩きスマホ」がはびこっています。私は、歩行中にメモしたいときに立ち止まるまで放置するのですが、そのタイミングまでに内容を忘れてしまうことが多々ありました。その経験から、歩行中でも前を見ながら安全に入力できるデバイスの実現を考えるようになりました。

そのために、下記の3点の要素を兼ね備えたデバイスを考えました。

  • バイスを見ずに入力できる直感性の高さ
  • 両手の自由さによる安全さ
  • スマホを見なくても入力内容が確認できる機能

これらを同時に実現できるデバイス「FFKB(Future Flicking KeyBoard)」は、スマホフリック入力を物理世界に導入したデバイスです。 物理的・機能的に左右に分割されたキーボードを用いて、左手側で「あかさたな...」を指定し、右手側で「あいうえお」の選択と決定・変換・削除を入力することが可能です。

f:id:ume-boshi:20201104065920p:plain:w400
入力機能のイメージ

スマホ入力に慣れたユーザにとって技術の習得が直感的であり、ブラインドタッチの習得も容易であると推測できます。

また、両手が自由に動かせられ安全性を確保できます。例えば歩行中に躓きそうになった際も、両手をとっさに動かせて安全に対応可能です。

そして専用のAndroidアプリを用いることで「入力した内容を音声出力する」機能を拡張可能です。イヤホンを用いて音声を聞ける状態であれば、スマホの画面を見なくとも入力内容を把握できます。


実装について

前回の記事と比較して、基板の作成と、両手の2デバイス間の通信と、文字変換への対応、3Dケース作成、Bluetoothキーボードデバイスとして認識・音声出力方法を変更しました。これらについて、ハードウェアの{基板・ケース}と、ソフトウェアの{2デバイス間の通信・BleKeyboard・入力内容処理アルゴリズム・音声出力変更 }の順番で説明していこうと思います。

システムの処理過程は下図のようになっており、2デバイス間をEPS-NOWで通信してキーの入力内容を共有し、キー入力内容を右手側のデバイスで処理して、BluetoothAndroid端末に送信する形になっております。

f:id:ume-boshi:20201104072724p:plain:w400
システムの処理過程



ほぼすべてのハードウェアとソースコードGitHub上で公開していますので、詳細を見たい方はどうぞご覧ください。

  • stl_filesフォルダ内にケース用の3Dモデル?があります。
  • futureKeyboardPCB内の.kicad_pcbや.schファイルがメインの基板・回路内容です。
  • leftHand.inoとrightHand2.inoがマイコン関連のコードです。
  • futureFlickKeyboardAppのフォルダ内がAndroid関連のコードです。

github.com


ハードウェア

基板

作ってから時間がたっているのですっかり忘れていましたが、ブログで紹介するのは初めてです。なんなら既に1回寸法を間違えて発注しているので、現在がversion2なのです。

基板は右手・左手共に2段構成にしております。上段下段の基板接合において、今回は表面実装のピンヘッダを採用しました。 2段構成の基板は強度が落ちる問題があるため、2か所のピンヘッダで接合するように工夫してあります。

f:id:ume-boshi:20201104094852j:plain:w400
左手上段(左上)、右手上段(右上)、下段裏(左下)、下段表(右下)

f:id:ume-boshi:20201104094717j:plain:w400
実装後の左右の基板。2段構成になっている。

今回のこだわった点としては、下段基板を左右で(ほぼ)同じ部品配置にし、ピンの割り当てを一致させたことです。ただし、上段のキースイッチの配置問題で、左右の下段基板を完全一致させることはできませんでした。しかし、これらを完全一致させることで

  • マイコンの死亡時などの緊急時の取り換えや
  • 左右の機能を1つのプログラムに落とし込むことでソースコードを変えることなく両デバイスで兼用できる

などの可能性があります。なんで諦めてしまったんだ。。。


基板のデータについては、GitHubよりもInventHubからの方がわかりやすくご覧いただけます。電気系の知識がある方はご自由に改善していってください。

https://inventhub.io/umeboshi/futureKeyboard/blob/master/futureKeyboardPCB%2FfutureKeyboard%2FfutureKeyboard.kicad_pcb?branchName=master&repoId=5f4f46e506d89e54d1104e8c&dirHash=&hash=711fa054e8b8aa75eecf6d97fc13ef43f89ac975&path=futureKeyboardPCB%2FfutureKeyboard%2FfutureKeyboard.kicad_pcb&size=undefined&isGitHub=true&cacheLib=futureKeyboard-cache.lib&cacheLibHash=8254113abb4243246bbafc433c82efcf70a12208inventhub.io


使用部品は下記のとおりです。



ケース

ケースに関しては、持ちやすさをメインに考えて作成しました。

右手側についてはフリック機能により安定しづらいため、oculus questのコントローラ風の持ち手を設け、力強くデバイスをホールドできるようにしました。
左側については、遠くのキーにアクセスできる必要があるため、持ち替えがしやすいように手のひら側のみにシンプルなグリップを付けた形になっております。
どちらの手に関しても親指のみで操作するようなイメージです。

f:id:ume-boshi:20201104073538j:plain:w250f:id:ume-boshi:20201104073534j:plain:w250
モデリングした3Dモデル

3Dモデルの生成は2回目で2年ぶりなので、復習しながら適当に行いました。
基本的には次の手順で設計を進めました。

  1. 基板の概形とモデルを生成(2段構成の基板の場合、2つのスケッチで作るべき)。
  2. 基板上の部品を配置し、それっぽい高さに押し出しする。この際にちょっと大きめに雑に作ってもいい気がする。
  3. 基板の形状を基に、ケースのスケッチをxyzの3方向から作成。
  4. 押し出しと論理積を活用し、ケースの立体形状を作成。
  5. 基板とケースの3Dモデルが交わっていないかを確認するために論理差を取る。
  6. ケース側の3Dモデルを滑らかに調整していく。
  7. 3Dプリントや組み立てしやすいように、いくつかの面に沿ってボディを分離する。


あまり経験がないため、モデル生成時の反省は多々ありました。

  • 基板上の部品の再現をさぼったため(ESPやSMDのスイッチなど)、組み立て時に衝突する箇所が多々あり削る羽目になった
  • 組み立て後の使い勝手を十分に考慮できておらず、プログラムを書き換えるためにケースを分解しなければならない
  • 電源スイッチが飛び出るようにしたが、ケースが分厚すぎて操作しづらい
  • 言うほど持ちやすくない(致命的!)

ただ、今回のケース作成で慣れてきたのか3Dモデリング心理的ハードルが下がったので、気に入ったものについては今後もケース作成したいと思います。


実際にプリントした3DモデルのデータはGitHub上のstl_filesフォルダにあります。いくらか削る部分があり不完全ですが、ただ作りたいだけの人はこのまま印刷してしまってもいいかと思います。


ソフトウェア

この章ではソースコードの一部を抜粋して説明します。完全版のソースコードGitHubを参照してください。

右手側はESP-NOWでwi-fiを使い、デバイスと端末間の通信でBluetoothも使用するためメモリが足りなくなります。これを解決するためには、Arduino IDEの「ツール」→「Partition Schema」→「NO OTA」や「Huge APP」などを選択してメモリの使用領域を指定する必要があります。

2デバイス間の通信

左右のデバイス間の通信は、ESP-NOWというESP系列マイコンの独自通信プロトコル?を使いました。意外にもリアルタイムに通信可能であり、キー入力情報を送信するくらいでは全く問題なく動作します。

左手側がServerで、右手側がClientです(逆のイメージなんだけどなぁ)。


左手のServer関連のソースコード

#include <esp_now.h>
#include <WiFi.h>

uint8_t key=0;
uint8_t slaveMAC[6] = {0x24, 0x0a, 0xc4, 0x11, 0xf8, 0xb1}; // 事前にMACアドレスを保存しておく

// Global copy of slave
esp_now_peer_info_t slave;
#define CHANNEL 0  // 自動割り当てしてくれるらしい
#define PRINTSCANRESULTS 0
#define DELETEBEFOREPAIR 0

// Init ESP Now with fallback
void InitESPNow() {
  WiFi.disconnect();
  if (esp_now_init() == ESP_OK) {
    Serial.println("ESPNow Init Success");
  } else {
    Serial.println("ESPNow Init Failed");
    ESP.restart();
  }
}

// 可能なら消し飛ばしたい
bool manageSlave() {
  if (slave.channel == CHANNEL) {
    if (DELETEBEFOREPAIR) {
      deletePeer();
    }
    Serial.print("Slave Status: ");

    // check if the peer exists
    // ここでslave.peer_addrが存在しているからOKなんだと思う
    bool exists = esp_now_is_peer_exist(slave.peer_addr);
    if ( exists) {
      Serial.println("Already Paired");
      return true;
    } else {
      // Slave not paired, attempt pair
      esp_err_t addStatus = esp_now_add_peer(&slave);
      if (addStatus == ESP_OK) {
        Serial.println("Pair success");
        return true;
      } else {
        Serial.println("ESPNOW manageSlave Error");
        return false;
      }
    }
  } else {
    // No slave found to process
    Serial.println("No Slave found to process");
    return false;
  }
}

// この辺無くていいと思う
void deletePeer() {
  esp_err_t delStatus = esp_now_del_peer(slave.peer_addr);
  Serial.print("Slave Delete Status: ");
  if (delStatus == ESP_OK) Serial.println("Success");
  else {Serial.println("ESPNOW deletePeer Error");
}

// データ送信用
void sendData() {
//  const uint8_t *peer_addr = slave.peer_addr;
  const uint8_t *peer_addr = slaveMAC; // 事前に登録しておいたアドレスを代入
  Serial.print("\nSending: "); Serial.println(key);
  esp_err_t result = esp_now_send(slaveMAC, &key, sizeof(key)); // 送りたい内容をglobal変数で直接指定する糞仕様
  Serial.print("Send Status: ");
  if (result == ESP_OK) Serial.println("Success");
  else {"Some Error is happened");
}

// データ受信用(今回は使用しない)
void OnDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) {
  char macStr[18];
  snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x:%02x:%02x:%02x",
           mac_addr[0], mac_addr[1], mac_addr[2], mac_addr[3], mac_addr[4], mac_addr[5]);
  Serial.print("Last Packet Sent to: "); Serial.println(macStr);
  Serial.print("Last Packet Send Status: "); Serial.println(status == ESP_NOW_SEND_SUCCESS ? "Delivery Success" : "Delivery Fail");
}


void setup() {
  Serial.begin(115200);

  WiFi.mode(WIFI_STA);
  Serial.print("STA MAC: "); Serial.println(WiFi.macAddress());
  InitESPNow();
  esp_now_register_send_cb(OnDataSent);

  w[0]=0; w[1]=0; // 入力内容を初期化
}


void loop() {
  //どのボタンが押されているかを認識しkey変数に代入(省略)

  memcpy(slave.peer_addr, slaveMAC, 6);  // 事前に入力したslaveのアドレスをコピー
  slave.channel = CHANNEL; // channel 0
  slave.encrypt = 0; // no encryption(暗号化?)
//  slaveFound = 1;

  if (key != prevKey && slave.channel == CHANNEL) { // check if slave channel is defined
    bool isPaired = manageSlave();
    if (isPaired) {
      sendData();
    } else {
      Serial.println("Slave pair failed!");
    }
  }
  delay(10);
}

Server側のサンプルでは、通信をするたびに毎回ScanForSlave()という関数を呼び出しているのですが、この関数をloop関数内で用いると実行速度が非常に遅くなってしまいます。そのため、通信相手であるClientのMACアドレスをl.5のように事前に登録しておき、sendData()関数内やloop()関数内の3行目あたりのように、無理やり指定します。
ちなみに通信相手のMACアドレスは、ESP32のリセット時に表示される文字列に書かれていますので、それをソースコードに記述していきます。

sendData()関数でデータを送信します。今回はkeyに左手側のキー入力情報が保持されており、それを送信しています。



右手のClient関連のソースコード

#include <WiFi.h>
#include <esp_now.h>

#define CHANNEL 0 // 自動で決めてくれるらしい
int key = 0;

// Init ESP Now with fallback
void InitESPNow() {/*server側と同じ(省略)*/}

// config AP SSID
void configDeviceAP() {
  const char *SSID = "Slave_rightHand"; // なんで名前つけてるか忘れた
  bool result = WiFi.softAP(SSID, "ffkb_rightHand", CHANNEL, 0); // 同上
  if (!result) {
    Serial.println("AP Config failed.");
  } else {
    Serial.println("AP Config Success. Broadcasting with AP: " + String(SSID));
  }
}

// callback when data is recv from Master
// 別スレッド的なので、Serverからデータが受信されるたびに勝手に実行してくれる
void OnDataRecv(const uint8_t *mac_addr, const uint8_t *data, int data_len) {
  char macStr[18];
  snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x:%02x:%02x:%02x",
           mac_addr[0], mac_addr[1], mac_addr[2], mac_addr[3], mac_addr[4], mac_addr[5]);
  Serial.print("Last Packet Recv from: "); Serial.println(macStr);
  Serial.print("Last Packet Recv Data: "); Serial.println(*data);
  Serial.println("");

  key = *data;
}

void setup() {
  Serial.begin(115200);

  WiFi.mode(WIFI_AP);
  configDeviceAP();  // configure device AP mode
  Serial.print("AP MAC: "); Serial.println(WiFi.softAPmacAddress());  // This is the mac address of the Slave in AP Mode
  InitESPNow();  // Init ESPNow with a fallback logic
  esp_now_register_recv_cb(OnDataRecv);  // Once ESPNow is successfully Init, we will register for recv CB to get recv packer info.

  key=0;
}

void loop() {
}

Client側はServer側からデータが来るたびにスレッド処理が発生し、半自動的にkeyにデータが入ります。MACアドレスの指定もServer側と違ってする必要がなく、比較的シンプルなコードです。

スレッド内で取得した左手側の入力情報「key」の値を、以降の処理に利用しています。


BleKeyboard

T-vKさんのESP32-BLE-Keyboardというライブラリを使用しました。このライブラリを使用することで、BLEキーボードを簡単に実装できます。過去の自作レーザーポインタもこの人のライブラリ(BLEマウス版)を使用していました。

こちらのライブラリを使用する場合、sampleコードで十分に理解できると判断したので、本節ではソースコードの抜粋は省略します。

github.com

このライブラリを使用する場合、Bluetoothバイス名をBleKeyboard.hのl.101の「deviceName」を変更できます。長い文字列は無理な可能性?


さて、FFKBでは入力内容を日本語として認識させるために、受信端末側では日本語入力用のIME(例えば日本語版Microsoft IME)を事前設定することにしています。そして、FFKB側から1~3文字のアルファベットを連続で送信することで日本語として認識させています。例えば、「a」や「ge」や「xtu」といった具合です。

FFKBから送信する主な内容は下記の通りです。↓ の内容以外でも、BleKeyboard.h内で定義されているキー情報を送信することが可能です(SHIFTキーやF5キーなど)。

  • 文字の送信(bleKeyboard.write('a'); などで送信)
  • 改行(bleKeyboard.write('KEY_RETURN');)
  • 変換(0x20を送信)
  • 削除(KEY_BACKSPACEを送信)
  • 十字キー(KEY_LEFT_ARROWなどを送信。UP, DOWN, RIGHTも同様)


入力内容処理アルゴリズム

前回の記事と比較して、小文字や濁点、記号入力の処理を追加しました。

f:id:ume-boshi:20201104065920p:plain:w400
入力機能のイメージ(再掲)

右手側の入力内容処理アルゴリズム部分の抜粋

#include "BleKeyboard.h" //https://github.com/T-vK/ESP32-BLE-Keyboard

#define LEDs 13
#define button1 32
#define button2 33
#define spk 25
#define enter 16
#define conv 17
#define back 4
#define jx 34
#define jy 35
#define pi 3.141592

BleKeyboard bleKeyboard;

int lines[4]={0};
char w[2]={0};
char beforew[2]={0}; //長押しによる連続入力を避けるための変数
char beforeInput[2]={0}; //ゴミ変数名(前回の送信内容で、濁点や小文字に対応する)
int key = 0;
bool smallW = false;
bool pushed = false;

void setup() {
  Serial.begin(115200);
  bleKeyboard.begin();
  delay(1000);

  pinMode(jx,INPUT); //ジョイスティック
  pinMode(jy,INPUT);
  pinMode(button1,INPUT);
  pinMode(button2,INPUT);
  pinMode(back,INPUT); // 消す
  pinMode(enter,INPUT); // 改行 or あ列
  pinMode(conv,INPUT); //スペース

  w[0]=0; w[1]=0; // 入力内容を初期化  
  key=0;
}


void loop() {
  if(bleKeyboard.isConnected()){ //接続されていた時だけキーを出力する
    printKey(key);
  }
  delay(50); 
}


void printKey(int key){ // 糞みたいなネーミング
  int x = analogRead(jx); // ジョイスティックの値を取得(0~4095)
  int y = analogRead(jy);
  bool output = false;

  int wordIndex = 0;

  if(key!=0){ // 左手側が入力されているとき
    if(sqrt((x-2048)*(x-2048) + (y-2048)*(y-2048)) > 1250){ // ジョイスティックがある一定以上傾いていたとき(値の範囲に注意)
      float direc = (2.0*(atan2(2048-y,2048-x)+0.785398))/pi; // ジョイスティックの角度を求め、45度ごとに値が1ずつ変化するように変換(値の範囲に注意)

      if(direc>=0){ // ジョイスティックの上側
        switch((int)direc){
          case 0:  Serial.print("<-"); w[1]='i'; wordIndex = 1; break; // ←
          case 1:  Serial.print("!"); w[1]='o'; wordIndex = 4; break; // ↓
          default:  Serial.print("->"); w[1]='e'; wordIndex = 3; break; // →
        }
        output = true;
      }else{ // ジョイスティックの下側(-1以上0未満の値がintにした際に0になっちゃうので)
        switch((int)direc){
          case -1: Serial.print("->"); w[1]='e'; wordIndex = 3; break; // →
          default: Serial.print("i"); w[1]='u'; wordIndex = 2; break; // ↑
        }
        output = true;
      }
    }else if(!digitalRead(enter)){ // フリックしない文字(あ行)はEnterキーで入力
      w[1]='a'; 
      wordIndex = 0; 
      output = true;
    }else{
      w[1]=0;
    }

    switch(key){
      case 1:w[0]=w[1];w[1]=0;break; //1文字なのでw[1]には0を
      case 2:w[0]='k';smallW=false;break;
      case 3:w[0]='s';smallW=false;break;
      case 4:w[0]='t';smallW=false;break;
      case 5:w[0]='n';smallW=false;break;
      case 6:w[0]='h';smallW=false;break;
      case 7:w[0]='m';smallW=false;break;
      case 8:w[0]='y';smallW=false;break;
      case 9:w[0]='r';smallW=false;break;
      case 10:w[0]='c';break; // 小文字と濁点の変換
      case 11:w[0]='w';smallW=false;break;
      case 12:w[0]='q';smallW=false;break; // 記号。なぜqかは聞かないで…
    }
    wordIndex += (key-1)*5; // 左手の入力値を代入する

    if(w[0]=='q'){ // 右下スイッチは記号入力
      switch(w[1]){
        case 'a': w[0]=','; w[1]=0; break; 
        case 'i': w[0]='.'; w[1]=0; break; 
        case 'u': w[0]='?'; w[1]=0; break; 
        case 'e': w[0]='!'; w[1]=0; break; 
        case 'o': w[0]=0x60; w[1]=0; break; // `:バッククォーテーション(PCでは日本語と英語キーボードを切り替えられる)
      }
    }
    if(w[0]=='y'){ // ヤ行の左右はカッコを入力
      switch(w[1]){
        case 'i': w[0]='('; w[1]=0; break;
        case 'e': w[0]=')'; w[1]=0; break;
      }
    }
    if(w[0]=='w'){ // ワ行の入力が変化
      switch(w[1]){
        case 'i': w[1]='o'; break;
        case 'u': w[0]='n'; w[1]='n'; break;
        case 'e': w[0]='-'; w[1]=0; break;
        case 'o': w[0]=0x40; w[1]=0; break; // @
        default: break;
      }
    }


    if(w[0]=='c'){ //濁点や小文字の変更
      char sub = 'l'; // 小文字の場合は、基本的にl(エル)を頭につける
      if(!pushed){
        // 濁点にする場合、w[0]を変更
        // 小文字にする場合、smallWをtrueにし、頭に"l"などを付ける
        // output=trueをデフォにしておけば良かった…
        switch(beforeInput[0]){ 
          case 'k':w[0]='g'; w[1]=beforeInput[1]; output=true; break; // か行
          case 'g':w[0]='k'; w[1]=beforeInput[1]; output=true; break;
          case 's':w[0]='z'; w[1]=beforeInput[1]; output=true; break; // さ行
          case 'z':w[0]='s'; w[1]=beforeInput[1]; output=true; break;
          case 't': // た行
            if(beforeInput[1]=='u' && smallW==false){ //つ の場合
              w[0]='t';
              w[1]='u';
              smallW = true;
              sub = 'x'; //っの場合のみ"ltu"で小文字にならず、"xtu"で小文字にする
              output=true; 
            }else if(beforeInput[1]=='u'){ //っ の場合
              w[0]='d';
              w[1]='u';
              smallW = false;
              output=true; 
            }else { //「つ」 以外
              w[0]='d';
              w[1]=beforeInput[1];
              output=true; 
             }
            break; //つの時だけ小文字にする
          case 'd':w[0]='t'; w[1]=beforeInput[1]; output=true; break;
          case 'h':w[0]='b'; w[1]=beforeInput[1]; output=true; break; // は行
          case 'b':w[0]='p'; w[1]=beforeInput[1]; output=true; break;
          case 'p':w[0]='h'; w[1]=beforeInput[1]; output=true; break;
          case 'y': // や行
            if(beforeInput[1]!='i' || beforeInput[1]=='e'){ //やゆよ のみ小文字反転
              smallW=!smallW; w[0]=beforeInput[0]; w[1]=beforeInput[1]; 
              output=true; 
            }
            break;
          case 'w':if(beforeInput[1]=='a'){smallW=!smallW; output=true;} break; // わ行は「わ」のみ小文字化できる
          
          case 'a':smallW=!smallW; w[0]=beforeInput[0]; w[1]=beforeInput[1]; output=true; break; // 小文字かそうでないかを反転させる 1文字目が変化するため,あ行は厄介
          case 'i':smallW=!smallW; w[0]=beforeInput[0]; w[1]=beforeInput[1]; output=true; break; // 小文字かそうでないかを反転させる
          case 'u':smallW=!smallW; w[0]=beforeInput[0]; w[1]=beforeInput[1]; output=true; break; // 小文字かそうでないかを反転させる
          case 'e':smallW=!smallW; w[0]=beforeInput[0]; w[1]=beforeInput[1]; output=true; break; // 小文字かそうでないかを反転させる
          case 'o':smallW=!smallW; w[0]=beforeInput[0]; w[1]=beforeInput[1]; output=true; break; // 小文字かそうでないかを反転させる
  
          default: output=false; Serial.println("cannot change a small word"); break;
        }
  
        if(output){ // 小文字の場合は通常とは別処理と考えて文字出力を行なう.    
          pushed = true;
          bleKeyboard.write(KEY_BACKSPACE); //前回入力した1文字を削除
          delay(5);
          if(smallW==true){ // ヘッダーが存在するときはそれも入力する ex)xtu や lya などの小文字の入力に用いる
            bleKeyboard.write(sub); delay(5);
          }
          bleKeyboard.write(w[0]); delay(5);
          bleKeyboard.write(w[1]);
          beforeInput[0]=w[0];
        }
        output = false; //先に入力して
      }
    }

    // 出力
    if(output && (beforew[0]!=w[0] || beforew[1]!=w[1])) {
      Serial.print(w[0]); Serial.println(w[1]);
      bleKeyboard.write(w[0]); delay(5);
      if(w[1]!=0)
      bleKeyboard.write(w[1]); 
      beforeInput[0]=w[0]; //過去の出力内容を保存(≠長押し回避)
      beforeInput[1]=w[1];
      pushed = true;
    } // 連続入力を避ける

  }else { // 左手側が入力されていないとき
    if(sqrt((x-2048)*(x-2048) + (y-2048)*(y-2048)) > 1250){ // ジョイスティックがある一定以上傾いていたとき(値の範囲に注意)
      w[0]=0;
      if(pushed==false){
        float direc = (2.0*(atan2(2048-y,2048-x)+0.785398))/pi; // ジョイスティックの角度を求め、45度ごとに値が1ずつ変化するように変換(値の範囲に注意)
        if(direc>=0){ // ジョイスティックの上側
          switch((int)direc){
            case 0:w[0]='<'; wordIndex = 1; break; // ←
            case 1:w[0]='!'; wordIndex = 4; break; // ↓
            default:w[0]='>'; wordIndex = 3; break; // →
          }
        }else{ // ジョイスティックの下側(-1以上0未満の値がintにした際に0になっちゃうので)
          switch((int)direc){
            case -1:w[0]='>'; wordIndex = 3; break; // →
            default:w[0]='I'; wordIndex = 2; break; // ↑
          }
        }
        w[1]=0;
        output = true;
      }
    }else {
      pushed=false;
      if(!digitalRead(enter)) {w[0]='\n'; w[1]=0; wordIndex=60; output=true;} // Enter長押し時に、改行されてしまう問題があるため、w[1]=0を入れる
      else if(!digitalRead(back)) {w[0]='\b'; w[1]=0; wordIndex=59; output=true;} // 画面をクリア
      else if(!digitalRead(conv)) {w[0]=' '; w[1]=0; wordIndex=61; output=true;} // スペースを空ける
      else {w[0]=0;}
    }

    if(output && (beforew[0]!=w[0] || beforew[1]!=w[1])) { // 連続入力を避ける
      Serial.println(w[0]);

      switch(w[0]){
        case '\n':bleKeyboard.write(KEY_RETURN); break;
        case ' ':bleKeyboard.write(KEY_BACKSPACE); delay(75); w[0]=0; break; //\bだけ連続入力可能にする
        case '\b':bleKeyboard.write(0x20);break;
        case '<':bleKeyboard.write(KEY_LEFT_ARROW);break;
        case 'I':bleKeyboard.write(KEY_UP_ARROW);break;
        case '>':bleKeyboard.write(KEY_RIGHT_ARROW);break;
        case '!':bleKeyboard.write(KEY_DOWN_ARROW);break;
      }
    }
  }
  
  beforew[0]=w[0]; // 前回の入力を記憶(長押しで同じ文字が入力されいないようにする)
  beforew[1]=w[1];
}

左手側が入力されている場合、文字として扱えるかを判断します。左手側からのキー入力情報は「key」に1~12の値で保存されています。それぞれ、「あかさたな」に対応しており、左下と右下のみ「小文字と濁点変換用」と「記号用」に対応付けられています。文字として判断する基準は ↓ の3条件です。

  1. 右手側のジョイスティックが傾いているか
  2. 右手側の決定が押されているか(普通のフリック入力と違い、決定スイッチを押したときに あ行 として扱う)
  3. 左手側が左下のキー(10)であるか


1と2に関しては単純な処理内容で、前回記事から変化していないため説明は省略します。3について、ちょっと考慮する点が多かったので、こちらは説明していこうかなと思います。

まず、濁点がつけられる文字は「かさたは」列であり、返還後は「がざだば」列になります。この変換は単純で、平仮名をアルファベットで表したときの1文字目を、「k → g, g → k」というように変換してあげます。「は」列は半濁音もあるので、「h → b, b → p, p → h」という感じで変換します。


次いで、小文字にする文字は「あいうえおつやゆよわ」の10文字あります。「つ」以外は単純で、文字頭に「l」を付けるだけで小文字にできます。小文字にできる文字だった場合は、smallWをtrueにしています。
小文字に変換する場合でも、beforeInput[0]からは大小を判断できないため、別の文字を入力するまでsmallWの内容は保持します(糞コードだから)。 「つ」だった場合は1度「っ」に変換し、その次に「づ」に変換します。さらに、私が使用したスマホでは、「ltu」で「ぅ」と入力されてしまい、「xtu」でないと「っ」と入力できなかったりしました。そのため、変換部の分岐が少し複雑になっています。


これらの判断を基に、文字出力できる場合はBleKeyboard.hのライブラリを用いて端末に送信します。普通の入力だった場合は、w[0]とw[1]に保持した内容をそのまま送信します。小文字や濁点の場合は、前回入力文を1文字を削除(bleKeyboard.write(KEY_BACKSPACE);)してから次の文字を送信します。このとき、小文字だった場合はlやxを頭につけます。



続いて、左手側の入力がされていなかった場合、右手側における{決定・変換(space)・削除・十字キー}の入力を受け付けます。削除だけは長押しで連続入力できるようにしました(連続入力を避けるw[0]の値を適当に変更)。割と単純な処理ですね。

こんな感じで入力内容処理アルゴリズムが実装されています。


音声出力方法の変更

BleKeyboardライブラリを用いたことでBluetoothSerialの処理を介せなくなり、前回記事で紹介した実装よりも簡単になりました。

run()メソッド以外ほぼ変わっていませんが、音声出力関連のコード抜粋を下記に提示します。

/* import 略 */

public class editor2 extends AppCompatActivity implements Runnable, View.OnClickListener, TextToSpeech.OnInitListener{
    private static final String TAG = "Future Flicking KeyBoard";

    /* 各種変数の初期化(一部省略) */
    private String currentText = ""; // 現在の入力内容
    private String previousText = ""; // 前回の入力内容

    private TextView mStatusTextView;    /*** ステータス.*/
    private TextView mInputTextView;    /*** Bluetoothから受信した値.*/
    private static final int VIEW_INPUT = 1;    /*** Action(取得文字列).*/

    private Thread mThread;    /* Thread */
    private boolean isRunning;    /* Threadの状態を表す */
    private boolean isMute = false;
    private AudioManager am;
    private TextToSpeech tts;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.editor2);
        /* mInputTextView, mTitleTextView, mStatusTextViewの初期化(省略)*/
        /* メニュー画面から画面遷移時の引数受け取り(省略)*/
        /*copyButton, saveButton, volumeButtonの初期化(省略)*/
        /* ツールバーの初期化(省略)*/

        //音声出力用
        am = (AudioManager)getSystemService(Context.AUDIO_SERVICE);
        tts = new TextToSpeech(this, (TextToSpeech.OnInitListener) this);

        // スレッド(無限ループで音声出力に対応する) 
        mThread = new Thread(this);
        mThread.start();
    }

    public boolean onCreateOptionsMenu(Menu menu) {/* 省略 */}
    public boolean onOptionsItemSelected(MenuItem item) {/* 省略 */}

    // 別のアクティビティが起動した場合の処理
    protected void onPause() {
        super.onPause();
        fileWrite();  //中身省略
    }

    // スレッド処理
    @Override
    public void run() {
        while(true){ // 入力されるのを一生待つ
            //テキストビューから現在の入力内容を取得
            currentText = mInputTextView.getText().toString();
            Log.i(TAG, "value=" + currentText);

            // 以前の入力内容よりも文字列が長かった場合、音声を出力する
            if (currentText.length() > previousText.length()) {
                String addition = currentText.substring(previousText.length(), currentText.length());
                Log.i(TAG, "addition=" + addition);
                speechText(addition);
            }

            // 現在の文字列を保持
            previousText = currentText;

            try { // ビジーにならないように0.5秒待つ(合ってる?)
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    // ボタン押下時の処理
    @Override
    public void onClick(View v) {
        if(v.equals(saveButton)) { // 保存ボタン
            fileWrite(); //中身省略
        }else if(v.equals(volumeButton)) { // ミュートボタン
            if ( isMute ) {
                isMute = false;
                am.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_UNMUTE, 0);
                Toast.makeText(this, "ミュート解除", Toast.LENGTH_SHORT).show();
            }else {
                isMute = true;
                am.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_MUTE, 0);
                Toast.makeText(this, "ミュート", Toast.LENGTH_SHORT).show();
            }
        }else if(v.equals(copyButton)) { // clipBoardに入力内容をコピー
            /*省略*/
        }
    }

    /* 描画処理はHandlerでおこなう */
    Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            int action = msg.what;
            String msgStr = (String) msg.obj;
            if (action == VIEW_INPUT) {
                mInputTextView.setText(msgStr);
            } else {/*省略*/}
        }
    };

    public void onInit(int status) {
        if (TextToSpeech.SUCCESS == status) {
            //言語選択
            Locale locale = Locale.JAPAN;
            if (tts.isLanguageAvailable(locale) >= TextToSpeech.LANG_AVAILABLE) {
                tts.setLanguage(locale);
            } else {
                Log.d("Error", "Locale");
            }
        } else {
            Log.d("Error", "Init");
        }
    }

    private void speechText(String readMsg) {
        CharSequence cs = readMsg;
        tts.speak(cs, TextToSpeech.QUEUE_ADD, null, "id:1");
    }
};

説明することはほぼないですが、BleKeyboard.hのライブラリを使用したことで、アプリからの介入ができなくなり、TextViewから取得した文字列を利用しています。

単純に、スレッド内(run())の無限ループが回るたびに、TextViewの内容が追加されていないかを判断し、追加がある場合はその文字を音声出力するだけです。

現在、音声が出力途中に途切れてしまう問題や、アルファベットだけを(「か」ではなく「k」だけ)出力するなどの問題が発生しています。前者はspeechText()メソッドをいじくることで解決し、後者はBleKeyboard.hで日本語をそのままprintすることで解決するのではないかと予想しています。


今回の開発での発見

今回のFFKBの開発にあたり、最後の1か月ほどでもたくさんの発見がありました。やはり、コンテストなどの目標を定めた開発のほうが、通常のグダグダよりも身が入りますね。

メモ程度に列挙します。読まなくてええよ。

  • 3Dモデルはその場の考えで設計するよりも、事前にある程度寸法を決めてから進めること(滑らかにするときに、エッジがめちゃくちゃでバグが生じる)
  • ソフト開発のためにケースを毎回分解していると基板が弱ってくるため、開発者にも基板にも優しいケース作りをするべき。つまり丁寧にやれ!
  • 3Dプリントしやすい形状を考慮(例えばジョイスティックのパッドはサポートが取り出せなかった)
  • ケース設計の際は、メイン基板のみの状態で多くの人に見てもらい、どんな持ち方がよさそうかヒアリングすること
  • 基板関連のモデルを作成するときは、裏表に注意しながら上下関係までしっかり見直して作ることが重要。
  • ケースの形状を事前に見越した基板設計を行なうこと
  • グリップするのではなく手首にバンドで固定できるような拡張を設け、複数指による高速入力を図る(様々な入力スタイルを評価すべき)

  • ESP-NOWとかは公式のサンプルが難しいので、ネットで簡単なのを拾ってくるべし

  • 以外にバッテリーは長持ちする。その分、HWの動作不良時には早期に電池不足を疑うべし
  • 小型化を進めるためには、キーキャップを自作することが近道(市販のキーキャップはキースイッチよりも広めに面積を取っている)
  • キースイッチはPCBの穴位置に注意(様々な企画が存在する…)
  • TTSの機能について、1文字ずつ出力する場合途切れる問題が多々あった。そして発音もめちゃくちゃになってしまっていた。tts関連の実装知識をもう少しつけたい


普段、スマホで仮想フリックキーボードを使っていますが、日本語ということもあり実装内容の違いがちらちら見えてくることがありました。

  • 小文字の切り替えや、物理キーだと入力のために2文字分を送信しなければならない
  • 文字変換の際の1文字削除をどのように実装しているか('\b' + 'あ') 的な?
  • おそらくスマホ側では日本語でも1文字でデータ送信している
  • 元のBluetoothSerial方式だと十字キー操作や、文字列の変換ができないと勝手に判断したが実装する術がある?


おわりに

繰り返しになりますが、皆さんの「ほしいね!」の投票が審査にも関わるようなので、このデバイスを欲しいと思った方はぜひ ↓ のサイトから「ほしいね!」をクリックしていただけますと幸いです!

gugen.jp

GUGENには落ちましたが、ヒーローズ・リーグの方でもなんかよろしくお願いします!

今後も、この世に存在しないデバイスを作成していきたいと考えていますので、お付き合いのほどよろしくお願いします :)

過去の製作過程

ume-boshi.hatenablog.jp

ume-boshi.hatenablog.jp

ume-boshi.hatenablog.jp