PR

生成AIで「ArduinoによるCWデコーダ」を作らせる

ここ一週間くらい、生成AIの尻を叩きまくってCWデコーダを作らせている。ようやく割と安定してデコードできる所まで来た。ただし、送信側は機械出力で安定しているという理想条件での話。

システム概要

送信側(符号出力側)はArduino UNO R4 Minimaで、DAC出力を使って700Hzの正弦波を出力。これも生成AIに作らせた。

受信側はArduino UNO R3(互換機)。こちらが今回のメイン。

両者の接続は、下の図のとおり。

UNO R4のDACの出力を簡単なRC LPFを通して階段状のノイズを除去。UNO R3のアナログ入力A0にバイアスを掛けて、そこにC経由で信号を突っ込む。

その波形(受信側の入力波形)がこれ。

周波数は700Hzで、電圧の中心は2.3Vくらいかな?その中心から約3Vppで振っている。受信信号としては、かなり良い条件。送信速度は20WPM。

実際の動作

動作の様子はビデオで。

ツールの都合で2分しか録画できない。その後も、こんな具合にちゃんと動いている。まぁ、同じものの繰り返しなので見てもしょうがないけど。

ソースコード

送信側

こちらは簡単な内容なので、ソースコード内のコメントを見ればわかる。

送信文字の表示はLCD。

受信側

こちらは大変。コードの解説もAIにやらせる。長くなるので、後で。

デコード結果の表示はシリアルモニタ。当初はLCDに出そうとしたのだけど、デバグの都合でシリアルモニタに出すことにした。

これらのコード、書いたのは生成AIだけど、書かせたのは私。ああしろ、こうしろ、ここがおかしい、と指摘して動くレベルまで持っていったのも私だけど、その作業を行ったのは生成AI。これ、「私が作った」って言っていいのかなぁ?ちょっと憚られる。

まとめ

理想環境でのデコードという、まだ最初の一歩にようやく到達しただけ。実使用に耐えるレベルに仕上げるのはこれから。本当にできるのか?

ほとんどのコードは生成AIに作らせた。アルゴリズムの実装も含めて。ただ、それが上手く動かない。結局、デバグ用のログを吐き出させて、それを見ながら、ああしろこうしろと、逐一指示しなければならない。

要求したことは概ねちゃんとやってくれるのだけど、違うことをやったり、余計なことをやったりもする。それも見つけて逐一直させる。中にはどうしてもできな行こともあり、結局、こちらがコードを直してやらなきゃいけないこともある。

生成AIでちょいちょいとやれればと思ったのだけど、そんなに簡単なものじゃない。こちらがちゃんと理解できないとまともなものには仕上がらない。「物知りで仕事は早いが、ちょっと(かなり?)抜けている部下」って感じ。ただ、私の自力だけではこんなに早く実装できないし、それどころか動くものは作れなかったかもしれない。面倒なことも黙々とやってくれるので助かる。

余談ながら、ブレッドボードって、本当に大変。こんな単純なものでも、動いたり動かなかったり。要は接触不良が多くて、何をやっているだかわからなくなる。各ブロックがピンコード(?)でつながっているのも問題。大きめのボードにでも貼り付けて動かないようにすれば、もうちょっとマシにはなりそうなきもする。

受信側ソースコード解説

アルゴリズム全体の流れ

  1. 信号取得・トーン判定
     GoertzelアルゴリズムでAUDIO_PINの音声信号から特定周波数成分(CWトーン)を検出し、「ON(トーンあり)」または「OFF(トーンなし)」を判定。
  2. エッジ検出とタイミング計測
     ON→OFF、OFF→ONの信号遷移(エッジ)ごとに、その区間の長さ(ms)を計測。
  3. ノイズ除去とバッファ管理
     短すぎるON/OFF(0.2dot未満)はノイズとしてpendingOnValidをリセットし、点・線としてもバッファとしても記録しない。
  4. 点・線バッファリング
     有効なON区間がOFFで区切られた時、長さに応じて「.」または「-」をmorseBufferに追加。
  5. スペース判定
     OFF区間の長さに応じて文字間スペース(2.2dot以上)・単語間スペース(6dot以上)を認識。
  6. デコードとバッファクリア
     スペース判定時にmorseBufferが1文字分たまっていればテーブル照合でデコードし、出力。その後バッファをクリア。
  7. 適応的ドット長推定
     最近の短いON区間の中央値でadaptiveDotLenを自動調整し、速度変動に追従。

重要ポイントとバッファ管理

  • morseBufferは「.」「-」で1文字分のモールス符号を記録
     スペース判定時(OFF区間が2.2dot以上)にデコードする。
  • pendingOnDuration/pendingOnValidでON区間を一時保存し、短いON/OFFノイズが来た時はバッファ確定せずリセット。
  • スペース判定はOFF区間の長さで行い、デコードタイミングを安定化

詳細解説(最終コードに即した解説)

1. 信号取得・トーン判定

float power = goertzel(AUDIO_PIN);
bool tone = (power >= THRESHOLD * 2);
  • Goertzelでパワー計算・閾値判定。

2. エッジ検出とタイミング計測

if (lastTone && !tone) {
  handleOnToOffEdge(now, ...);
} else if (!lastTone && tone) {
  handleOffToOnEdge(now, ...);
}
  • ON→OFF、OFF→ONごとにエッジ処理関数を呼ぶ。

3. ON→OFFエッジ:点・線バッファリングとノイズ除去

void handleOnToOffEdge(...) {
  unsigned long onDuration = ...;
  if (onDuration < 0.2 * adaptiveDotLen) {
    pendingOnValid = false; // ノイズONはバッファリセット
    return;
  }
  if (pendingOnValid) {
    pendingOnDuration += onDuration;
  } else {
    pendingOnDuration = onDuration;
    pendingOnValid = true;
  }
  lastOffTime = now;
  spaceStartTime = now;
}
  • 短いONはノイズとしてバッファリセット
  • 有効なONはpendingOnDurationに蓄積。

4. OFF→ONエッジ:バッファ確定・スペース判定・デコード

void handleOffToOnEdge(...) {
  unsigned long offDuration = ...;
  if (offDuration < 0.2 * adaptiveDotLen) {
    pendingOnValid = false; // ノイズOFFもバッファリセット
    return;
  }
  lastOnTime = now;
  lastOffTime = now;

  // 点・線バッファ確定
  if (pendingOnValid) {
    if (pendingOnDuration < 1.5 * adaptiveDotLen) {
      updateAdaptiveDotLen(pendingOnDuration);
      if (morseLen < MAX_MORSE_LEN) morseBuffer[morseLen++] = '.';
    } else {
      if (morseLen < MAX_MORSE_LEN) morseBuffer[morseLen++] = '-';
    }
    pendingOnValid = false;
  }

  // スペース判定とデコード
  if (spaceStartTime > 0) {
    unsigned long spaceLen = now - spaceStartTime;
    if ((spaceStartTime == 0 || spaceLen > 12 * adaptiveDotLen) && morseLen == 1) {
      clearMorseBuffer();
    } 
    else if (spaceLen >= 6 * adaptiveDotLen) {
      if (morseLen > 0) decodeAndClearBuffer();
      Serial.print(' ');
    } 
    else if (spaceLen >= 2.2 * adaptiveDotLen) {
      if (morseLen > 0) decodeAndClearBuffer();
    }
  }
  spaceStartTime = 0;
}
  • 短いOFFはノイズとしてバッファリセット
  • 有効なOFFでpendingOnDurationを「.」または「-」としてmorseBufferに追加。
  • OFF区間の長さでスペース(単語間・文字間)を判定し、そのタイミングでmorseBufferをデコード

5. デコード処理

void decodeAndClearBuffer() {
  morseBuffer[morseLen] = '\0';
  if (morseLen >= 1 && morseLen <= 6) {
    char decoded = decodeMorse(morseBuffer);
    Serial.print(decoded);
  }
  clearMorseBuffer();
}
  • スペース判定時(OFF区間が2.2dot以上)にmorseBufferをテーブル照合でデコードし、出力。

6. 適応的ドット長推定

void updateAdaptiveDotLen(float pulse) {
  if (pulse > 30 && pulse < 1.5 * adaptiveDotLen) {
    dotBuf[dotIdx] = pulse;
    dotIdx = (dotIdx + 1) % ADAPTIVE_BUF_LEN;
    if (dotCount < ADAPTIVE_BUF_LEN) dotCount++;
    adaptiveDotLen = median(dotBuf, dotCount);
  }
}
  • 最近の短いON区間の中央値でadaptiveDotLenを自動調整。

まとめ

  • 文字間・単語間スペースを認識したタイミングでmorseBufferの内容をデコードする
  • 短いON/OFFノイズはpendingバッファをリセットし、点・線としても記録しない
  • バッファ管理・スペース認識・デコードタイミングが安定しており、ノイズ耐性と実用性に優れる

このアルゴリズムは、リアルタイムCWデコーダとして理想的な構造と実用的な堅牢性を両立しています

ソースコードに関する補足

重要な定数(#define)の解説

定数名説明
DEBUGデバッグモードの有効化。シリアル出力で詳細ログを表示
WPMモールス符号の速度(Words Per Minute)
GOERTZEL_NGoertzelアルゴリズムのサンプル数
THRESHOLDGoertzelアルゴリズムの検出閾値
LED_PINLEDの接続ピン番号
SAMPLE_RATEサンプリング周波数(Hz)
TARGET_FREQ検出対象の周波数(Hz)
AUDIO_PIN音声入力のアナログピン番号
MAX_MORSE_LENモールス符号の最大長(1文字あたり)
ADC_READ_USADC読み取りにかかる時間(マイクロ秒)
LOOP_DELAY_MSループの遅延時間(ミリ秒)
VARIABLE_DELAY_COEFF可変遅延係数(タイミング補正用)
ADAPTIVE_BUF_LENドット長適応用のバッファサイズ

グローバル変数の解説

  • char morseBuffer[MAX_MORSE_LEN + 1];
    1文字分のモールス符号(「.」「-」)を蓄積するバッファ。
  • uint8_t morseLen;
    morseBufferに現在蓄積されている符号数。
  • unsigned long lastOnTime, lastOffTime, spaceStartTime;
    直近のON/OFFエッジやスペースの開始時刻(ミリ秒単位)。
  • bool lastTone;
    前回のON/OFF状態。
  • float dotBuf[ADAPTIVE_BUF_LEN];
    ドット長適応用のバッファ(最近の短いON区間長を保存)。
  • uint8_t dotIdx, dotCount;
    dotBufのインデックスと有効データ数。
  • float adaptiveDotLen;
    適応的に推定された現在のドット長(ミリ秒単位)。
  • float pendingOnDuration;
    ON区間の長さを一時保存するバッファ。
  • bool pendingOnValid;
    pendingOnDurationが有効かどうかのフラグ。

すべての関数の解説

  • float goertzel(uint8_t pin);
    Goertzelアルゴリズムで特定周波数成分の強度(power)を算出し、CW信号のON/OFF判定に使う。
  • char decodeMorse(const char* code);
    morseBufferに溜まった「.」「-」列を英数字に変換する。
  • void clearMorseBuffer();
    morseBufferをクリアし、morseLenを0にリセット。
  • void decodeAndClearBuffer();
    morseBufferをデコードして出力し、クリアする。
  • void updateAdaptiveDotLen(float pulse);
    最近の短いON区間長をdotBufに追加し、adaptiveDotLenを中央値で更新。
  • void handleOnToOffEdge(…);
    ON→OFFエッジで呼ばれる。ON区間長をpendingバッファに追加し、短いONならリセット。
  • void handleOffToOnEdge(…);
    OFF→ONエッジで呼ばれる。OFF区間長を判定し、短いOFFならpendingをリセット。有効なOFFならpendingバッファを点・線としてmorseBufferに追加。スペース長に応じてデコードや単語区切りも行う。
  • void handleLongOff(…);
    非常に長いOFF(信号途切れ時)でバッファを強制デコード。
  • void setup();
    初期化処理。ピン設定、シリアル開始、変数初期化など。
  • void loop();
    メインループ。GoertzelでON/OFF判定し、エッジごとに上記関数を呼び出し、リアルタイムでデコードを進める。

その他の重要事項

  • ノイズ除去の仕組み
    ON/OFF区間がドット長の0.2倍未満ならノイズとみなし、pendingOnValid/pendingOnDurationをリセット。これにより、誤った点・線や分割が記録されない。
  • スペース判定
    OFF区間の長さが2.2dot以上なら文字間スペース、6dot以上なら単語間スペースとしてバッファを確定・デコード。
  • 適応的ドット長推定
    ドット長は固定値ではなく、受信中の短いON区間の中央値で逐次更新されるため、速度が変動しても追従可能。
  • バッファ管理
    1文字分のモールス符号はmorseBufferに溜め、スペースや長いOFFで確定・デコード。pendingOnDuration/pendingOnValidでエッジ間のON長を一時保存し、ノイズ除去もここで行う。

このように、リアルタイムなノイズ耐性・速度適応・スペース判定・バッファ管理を組み合わせて、実用的なCWデコーダを実現しています。

コメント