
ここ一週間くらい、生成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でちょいちょいとやれればと思ったのだけど、そんなに簡単なものじゃない。こちらがちゃんと理解できないとまともなものには仕上がらない。「物知りで仕事は早いが、ちょっと(かなり?)抜けている部下」って感じ。ただ、私の自力だけではこんなに早く実装できないし、それどころか動くものは作れなかったかもしれない。面倒なことも黙々とやってくれるので助かる。
余談ながら、ブレッドボードって、本当に大変。こんな単純なものでも、動いたり動かなかったり。要は接触不良が多くて、何をやっているだかわからなくなる。各ブロックがピンコード(?)でつながっているのも問題。大きめのボードにでも貼り付けて動かないようにすれば、もうちょっとマシにはなりそうなきもする。
受信側ソースコード解説
アルゴリズム全体の流れ
- 信号取得・トーン判定
GoertzelアルゴリズムでAUDIO_PINの音声信号から特定周波数成分(CWトーン)を検出し、「ON(トーンあり)」または「OFF(トーンなし)」を判定。 - エッジ検出とタイミング計測
ON→OFF、OFF→ONの信号遷移(エッジ)ごとに、その区間の長さ(ms)を計測。 - ノイズ除去とバッファ管理
短すぎるON/OFF(0.2dot未満)はノイズとしてpendingOnValidをリセットし、点・線としてもバッファとしても記録しない。 - 点・線バッファリング
有効なON区間がOFFで区切られた時、長さに応じて「.」または「-」をmorseBufferに追加。 - スペース判定
OFF区間の長さに応じて文字間スペース(2.2dot以上)・単語間スペース(6dot以上)を認識。 - デコードとバッファクリア
スペース判定時にmorseBufferが1文字分たまっていればテーブル照合でデコードし、出力。その後バッファをクリア。 - 適応的ドット長推定
最近の短い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_N | Goertzelアルゴリズムのサンプル数 |
THRESHOLD | Goertzelアルゴリズムの検出閾値 |
LED_PIN | LEDの接続ピン番号 |
SAMPLE_RATE | サンプリング周波数(Hz) |
TARGET_FREQ | 検出対象の周波数(Hz) |
AUDIO_PIN | 音声入力のアナログピン番号 |
MAX_MORSE_LEN | モールス符号の最大長(1文字あたり) |
ADC_READ_US | ADC読み取りにかかる時間(マイクロ秒) |
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デコーダを実現しています。
コメント