音を利用する 3 〜Android デバイスでの音波通信:実装編〜
この記事の内容
前回の記事では Android デバイス間で音波通信を行うための準備として次のみっつの要素に目を向けました。
今回はこれらを素材として音声信号でビットデータを送受信してみます。四段階に分け順次機能を加えながら試作を行います。
2017-11-29 追記:本記事に掲載のコードは開発時に Android 5.x 環境を使用しており、Android 6.0 以降では android.permission.RECORD_AUDIO について実行時パーミッション取得が必要です。
- 実行時のパーミッション リクエスト - developer.android.com
- Support Library - 下位互換性 - developer.android.com
他のプロジェクトについても適宜加味して下さい。
1. ビットデータを音声信号で送受信する 1
想定した内容
まず最初の試作に際し次の内容を想定しました。
- 送信側は送信専用、受信側は受信専用とする
- 送信側は任意の文字列を所定の規則に添ってサイン波信号に変換しオーディオ出力する
- 受信側は集音中に所定の規則に添った音声信号を検知するとそこから文字列情報を復元し画面に表示する
- 開発上の便宜のために可聴音を使用する
以下についてはとりあえず後まわしです。
- 伝送効率
- 誤りへの対策
周波数変調(FSK)について
所定のデータを音声信号に変換するために周波数変調方式を利用することにしました。次の考え方です。
- ふたつの周波数 A, B に 0 と 1 を割り当てる
- 元データのビット並びを上記 A, B に置き換えそれらを所定の時間間隔で切り替える
- FSK - 「通信用語の基礎知識」より
正弦波に対してディジタル信号で変調を行なういわゆるディジタル変調の方式の一つで、ディジタル値を正弦波の周波数に対応させて伝送する方式のこと。具体的にはディジタル値の1/0に対応させて2つの周波数を決め、入力されるディジタル信号に応じてそれぞれの周波数の正弦波を交互に送出することで実現させる。
試作
処理のイメージ
ざっくり以下の要領で実装を試みました。
- 周波数変調においては 8000Hz を 0, 10000Hz を 1 として扱い 1 ビットあたりの発振持続時間を 100ミリ秒とする (よって伝送速度は 10bps)
- 信号出力に際しては単一バッファ上に終始の完結したデータを構築するのではなく生成済みの 8000Hz, 10000Hz 信号を再生バッファに順次直接書き込む
- 今回は参照していないが末尾に終端の符丁として 12000Hz の信号を 1秒間付与する
- 左:送信側 右:受信側
動作の様子
動画: 1分30秒 ※音量注意!
ソースコード
送信側: sonic05
- MainActivity.java
/** * * sonic05 * * 文字列をサイン波信号に置き換えて出力する * Lo -> 8000Hz, Hi -> 10000Hz * 対向の受信プログラムは sonic06 * */ package jp.klab.sonic05; import android.media.AudioFormat; import android.media.AudioRecord; import android.media.AudioManager; import android.media.AudioTrack; import android.os.Bundle; import android.os.Handler; import android.os.Message; import android.support.v7.app.AppCompatActivity; import android.support.v7.widget.Toolbar; import android.util.Log; import android.view.View; import android.widget.EditText; import android.widget.ToggleButton; import java.io.UnsupportedEncodingException; import java.util.Arrays; public class MainActivity extends AppCompatActivity implements Runnable, View.OnClickListener, Handler.Callback { private static final String TAG = "SNC"; private static final int SAMPLE_RATE = 44100; private static final float SEC_PER_SAMPLEPOINT = 1.0f / SAMPLE_RATE; private static final int AMP = 4000; private static final int ELMS_1SEC = SAMPLE_RATE; private static final int ELMS_100MSEC = SAMPLE_RATE/10; private static final int FREQ_LOW = 8000; private static final int FREQ_HI = 10000; private static final int FREQ_END = 12000; private static final int MSG_PLAY_START = 120; private static final int MSG_PLAY_END = 130; private static final byte [] BITS = new byte [] { (byte)0x80, (byte)0x40, (byte)0x20, (byte)0x10, (byte)0x08, (byte)0x04, (byte)0x02, (byte)0x01}; private Handler mHandler; private AudioTrack mAudioTrack = null; private ToggleButton mButton01; private EditText mEditText01; private short mPlayBuf1[]; private short mPlayBuf2[]; private short mPlayBuf3[]; private String mText; // サイン波データを生成 private void createSineWave(short[] buf, int freq, int amplitude, boolean doClear) { if (doClear) { Arrays.fill(buf, (short) 0); } for (int i = 0; i < ELMS_1SEC; i++) { float currentSec = i * SEC_PER_SAMPLEPOINT; // 現在位置の経過秒数 double val = amplitude * Math.sin(2.0 * Math.PI * freq * currentSec); buf[i] += (short)val; } } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.d(TAG, "onCreate"); mHandler = new Handler(this); setContentView(R.layout.activity_main); Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); setSupportActionBar(toolbar); mButton01 = (ToggleButton)findViewById(R.id.button01); mButton01.setOnClickListener(this); mEditText01 = (EditText)findViewById(R.id.editText01); int bufferSizeInBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT); mPlayBuf1 = new short[ELMS_1SEC]; mPlayBuf2 = new short[ELMS_1SEC]; mPlayBuf3 = new short[ELMS_1SEC]; createSineWave(mPlayBuf1, FREQ_LOW, AMP, true); // Low createSineWave(mPlayBuf2, FREQ_HI, AMP, true); // Hi createSineWave(mPlayBuf3, FREQ_END, AMP, true); // END // 再生用 mAudioTrack = new AudioTrack(AudioManager.STREAM_MUSIC, SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT, bufferSizeInBytes, AudioTrack.MODE_STREAM); } @Override public void onStop() { super.onStop(); Log.d(TAG, "onStop"); } @Override public void onDestroy() { super.onDestroy(); Log.d(TAG, "onDestroy"); try { Thread.sleep(2000); } catch (InterruptedException e) { } if (mAudioTrack != null) { if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) { Log.d(TAG, "cleanup mAudioTrack"); mAudioTrack.stop(); mAudioTrack.flush(); } mAudioTrack = null; } } @Override public void onClick(View v) { if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) { mAudioTrack.stop(); mAudioTrack.flush(); } if (mButton01.isChecked()) { mText = mEditText01.getText().toString(); if (mText.length() > 0) { new Thread(this).start(); } else { mButton01.setChecked(false); } } } @Override public boolean handleMessage(Message msg) { switch (msg.what) { case MSG_PLAY_START: Log.d(TAG, "MSG_PLAY_START"); break; case MSG_PLAY_END: Log.d(TAG, "MSG_PLAY_END"); mButton01.setChecked(false); break; } return true; } @Override public void run() { mHandler.sendEmptyMessage(MSG_PLAY_START); byte[] strByte = null; try { strByte = mText.getBytes("UTF-8"); } catch (UnsupportedEncodingException e) { } mAudioTrack.play(); for (int i = 0; i < strByte.length; i++) { valueToWave(strByte[i]); } valueToWave((byte) 0x20); // ダミー mAudioTrack.write(mPlayBuf3, 0, ELMS_1SEC); // 終端 mAudioTrack.stop(); mAudioTrack.flush(); mHandler.sendEmptyMessage(MSG_PLAY_END); } // 指定されたバイト値を音声信号に置き換えて再生する private void valueToWave(byte val) { for (int i = 0; i < BITS.length; i++) { // ビットごとに Hi, Low を出力 mAudioTrack.write(((val & BITS[i]) != 0) ? mPlayBuf2 : mPlayBuf1, 0, ELMS_100MSEC); } } }
受信側: sonic06
- MainActivity.java
/** * * sonic06 * * 端末のマイクから集音した信号をバイトデータに変換する * Lo -> 8000Hz, Hi -> 10000Hz * FFT 処理に JTransforms ライブラリを利用 * 対向の送信プログラムは sonic05 * */ package jp.klab.sonic06; import org.jtransforms.fft.DoubleFFT_1D; import android.media.AudioFormat; import android.media.AudioRecord; import android.media.MediaRecorder; import android.os.Bundle; import android.os.Handler; import android.os.Message; import android.support.v7.app.AppCompatActivity; import android.support.v7.widget.Toolbar; import android.util.Log; import android.view.View; import android.widget.Button; import android.widget.TextView; import java.io.UnsupportedEncodingException; public class MainActivity extends AppCompatActivity implements Runnable, View.OnClickListener, Handler.Callback { private static final String TAG = "SNC"; private static final int SAMPLE_RATE = 44100; private static final short THRESHOLD_SILENCE = 0x00ff; private static final int FREQ_LOW = 8000; private static final int FREQ_HI = 10000; private static final int MSG_RECORD_START = 100; private static final int MSG_RECORD_END = 110; private static final int MSG_DATA_RECV = 130; private static final byte [] BITS = new byte [] { (byte)0x80, (byte)0x40, (byte)0x20, (byte)0x10, (byte)0x08, (byte)0x04, (byte)0x02, (byte)0x01}; private Handler mHandler; private AudioRecord mAudioRecord = null; private Button mButton01; private TextView mTextView02; private boolean mInRecording = false; private boolean mStop = false; private int mBufferSizeInShort; private short mRecordBuf[]; private short mTestBuf[]; private DoubleFFT_1D mFFT; private double mFFTBuffer[]; private int mFFTSize; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.d(TAG, "onCreate"); mHandler = new Handler(this); setContentView(R.layout.activity_main); Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); setSupportActionBar(toolbar); mButton01 = (Button)findViewById(R.id.button01); mButton01.setOnClickListener(this); mTextView02 = (TextView)findViewById(R.id.textView02); mTextView02.setOnClickListener(this); int bufferSizeInBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT); mBufferSizeInShort = bufferSizeInBytes / 2; // 録音用バッファ mRecordBuf = new short[mBufferSizeInShort]; // FFT 処理用 mTestBuf = new short[SAMPLE_RATE/10]; // 100msec mFFTSize = mBufferSizeInShort; mFFT = new DoubleFFT_1D(mFFTSize); mFFTBuffer = new double[mFFTSize]; mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC, SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufferSizeInBytes); } @Override public void onStop() { super.onStop(); Log.d(TAG, "onStop"); } @Override public void onDestroy() { super.onDestroy(); Log.d(TAG, "onDestroy"); mStop = true; try { Thread.sleep(2000); } catch (InterruptedException e) { } if (mAudioRecord != null) { if (mAudioRecord.getRecordingState() != AudioRecord.RECORDSTATE_STOPPED) { Log.d(TAG, "cleanup mAudioRecord"); mAudioRecord.stop(); } mAudioRecord = null; } } @Override public void onClick(View v) { if (v == (View)mButton01) { // 集音開始 or 終了 if (!mInRecording) { mInRecording = true; new Thread(this).start(); } else { mInRecording = false; } } else if (v == (View)mTextView02) { // 表示データをクリア mTextView02.setText(""); } return; } @Override public boolean handleMessage(Message msg) { switch (msg.what) { case MSG_RECORD_START: Log.d(TAG, "MSG_RECORD_START"); mButton01.setText("STOP"); break; case MSG_RECORD_END: Log.d(TAG, "MSG_RECORD_END"); mButton01.setText("START"); break; case MSG_DATA_RECV: //Log.d(TAG, "MSG_DATA_RECV"); byte[] ch = new byte[] {(byte)msg.arg1}; try { // 受信データを表示 String s = new String(ch, "UTF-8"); s = mTextView02.getText() + s; mTextView02.setText(s); } catch (UnsupportedEncodingException e) { } break; } return true; } @Override public void run() { int dataCount = 0; int bitCount = 0; byte val = 0; boolean bSilence = false; mHandler.sendEmptyMessage(MSG_RECORD_START); // 集音開始 mAudioRecord.startRecording(); while (mInRecording && !mStop) { // 音声データ読み込み mAudioRecord.read(mRecordBuf, 0, mBufferSizeInShort); bSilence = true; for (int i = 0; i < mBufferSizeInShort; i++) { short s = mRecordBuf[i]; if (s > THRESHOLD_SILENCE) { bSilence = false; } } if (bSilence) { // 静寂 dataCount = bitCount = 0; continue; } int copyLength = 0; // データを mTestBuf へ順次アペンド if (dataCount < mTestBuf.length) { // mTestBuf の残領域に応じてコピーするサイズを決定 int remain = mTestBuf.length - dataCount; if (remain > mBufferSizeInShort) { copyLength = mBufferSizeInShort; } else { copyLength = remain; } System.arraycopy(mRecordBuf, 0, mTestBuf, dataCount, copyLength); dataCount += copyLength; } if (dataCount >= mTestBuf.length) { byte ret = doFFT(mTestBuf); //Log.d(TAG, "ret=" + ret); if (ret == -1) { // FREQ_LOW, FREQ_HI 以外の周波数の場合 dataCount = bitCount = 0; continue; } else { // バイトデータを順次構成 if (bitCount == 0) { val = 0; } val |= (ret == 1) ? BITS[bitCount] : 0; if (bitCount < 7) { bitCount++; } else { // 1バイト分完了 bitCount = 0; Message msg = new Message(); msg.what = MSG_DATA_RECV; msg.arg1 = (int) val; mHandler.sendMessage(msg); } } dataCount = 0; // mRecordBuf の途中までを mTestBuf へコピーして FFT した場合は // mRecordBuf の残データを mTestBuf 先頭へコピーした上で継続 if (copyLength < mBufferSizeInShort) { int startPos = copyLength; copyLength = mBufferSizeInShort - copyLength; System.arraycopy(mRecordBuf, startPos, mTestBuf, 0, copyLength); dataCount += copyLength; } } } // 録音終了 mAudioRecord.stop(); mHandler.sendEmptyMessage(MSG_RECORD_END); } private byte doFFT(short[] data) { for (int i = 0; i < mFFTSize; i++) { mFFTBuffer[i] = (double)data[i]; } mFFT.realForward(mFFTBuffer); // 処理結果の複素数配列からピーク周波数成分の要素番号を得る double maxAmp = 0; int index = 0; for (int i = 0; i < mFFTSize/2; i++) { double a = mFFTBuffer[i*2]; // 実部 double b = mFFTBuffer[i*2 + 1]; // 虚部 // a+ib の絶対値 √ a^2 + b^2 = r が振幅値 double r = Math.sqrt(a*a + b*b); if (r > maxAmp) { maxAmp = r; index = i; } } // ピーク周波数を求める int freq = index * SAMPLE_RATE / mFFTSize; return (byte)((freq == FREQ_LOW) ? 0 : (freq == FREQ_HI) ? 1 : -1); } }
周波数切り替え箇所の波形を見る
信号中の Lo(8000Hz)から Hi(10000Hz)、Hi から Lo への切り替え部分の波形の確認を行いました。
Lo, Hi ともに 10 で割り切れる周波数なので、先頭から 100ミリ秒後の位置での運動円上の Y 座標は 1秒後の位置と等しく 0 であり、そこに同じく Y=0 始まりの別の周期のサイン波を接合しても位相の不連続は発生しないはずです。 (ただし周期の違いが継ぎ目前後のカーブの形状の違いとして表れるため波形として完璧ではないでしょう)
以下は送信側アプリからの信号出力を PC に接続したマイクから取り込んだ Audacity のスクリーンショットです。
Lo -> Hi 部分のズーム
後続の Hi -> Lo 部分を同様にズーム
2. ビットデータを音声信号で送受信する 2
上の最初の試作ではまず音によるデータの送受信自体が可能であることを確認しましたが、動作が不安定で何より通信の遅さが目につきます。この点について考えてみます。
伝送効率改善の考え方
効率を改善するために伝達する情報の内容に手を加えることにしました。1バイト分のキャラクタデータをビットごとに 0,1 を表わすふたつの周波数の波の羅列で表現するのではなく、256種類の周波数を用いて直接 0x00 〜 0xFF を表現する形にすれば理論上伝送効率が 8倍向上するはずです。これを具体化してみることにします。
試作
処理のイメージ
以下の要領で実装を行いました。
- 送信側はバイト値 0x00 〜 0xFF を 400Hz 〜 5500Hz の 20Hz 刻みの 256 パターンの周波数から成る音声信号に変調する
- 受信側は「(検知したピーク周波数 - 400)/ 20」の平易な計算で元のバイト値へ復号する
- アプリの UI は最初の試作と同じ
動作の様子
動画: 1分 ※音量注意!
ソースコード
送信側: sonic07 GitHub
- MainActivity.java
/** * sonic07 * * 文字列をサイン波信号に置き換えて出力する * 伝送効率改善版 * 対向の受信プログラムは sonic08 * */ package jp.klab.sonic07; import android.media.AudioFormat; import android.media.AudioRecord; import android.media.AudioManager; import android.media.AudioTrack; import android.os.Bundle; import android.os.Handler; import android.os.Message; import android.support.v7.app.AppCompatActivity; import android.support.v7.widget.Toolbar; import android.util.Log; import android.view.View; import android.widget.EditText; import android.widget.ToggleButton; import java.io.UnsupportedEncodingException; import java.util.Arrays; public class MainActivity extends AppCompatActivity implements Runnable, View.OnClickListener, Handler.Callback { private static final String TAG = "SNC"; private static final int SAMPLE_RATE = 44100; private static final float SEC_PER_SAMPLEPOINT = 1.0f / SAMPLE_RATE; private static final int AMP = 4000; private static final int FREQ_BASE = 400; private static final int FREQ_STEP = 20; private static final int FREQ_KEY = 300; private static final int ELMS_1SEC = SAMPLE_RATE; private static final int ELMS_100MSEC = SAMPLE_RATE/10; private static final int ELMS_MAX = 256; private static final int MSG_PLAY_START = 120; private static final int MSG_PLAY_END = 130; private Handler mHandler; private AudioTrack mAudioTrack = null; private ToggleButton mButton01; private EditText mEditText01; private short mPlayBuf[] = new short[SAMPLE_RATE]; private short mSignals[][] = new short[ELMS_MAX][SAMPLE_RATE/10]; private String mText; // サイン波データを生成 private void createSineWave(short[] buf, int freq, int amplitude, boolean doClear) { if (doClear) { Arrays.fill(buf, (short) 0); } for (int i = 0; i < buf.length; i++) { float currentSec = i * SEC_PER_SAMPLEPOINT; // 現在位置の経過秒数 double val = amplitude * Math.sin(2.0 * Math.PI * freq * currentSec); buf[i] += (short)val; } } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.d(TAG, "onCreate"); mHandler = new Handler(this); setContentView(R.layout.activity_main); Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); setSupportActionBar(toolbar); mButton01 = (ToggleButton)findViewById(R.id.button01); mButton01.setOnClickListener(this); mEditText01 = (EditText)findViewById(R.id.editText01); int bufferSizeInBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT); // 先頭・終端の目印用信号データ createSineWave(mPlayBuf, FREQ_KEY, AMP, true); // 256種類の信号データを生成 for (int i = 0; i < ELMS_MAX; i++) { createSineWave(mSignals[i], (short) (FREQ_BASE + FREQ_STEP*i), AMP, true); } // 再生用 mAudioTrack = new AudioTrack(AudioManager.STREAM_MUSIC, SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT, bufferSizeInBytes, AudioTrack.MODE_STREAM); } @Override public void onStop() { super.onStop(); Log.d(TAG, "onStop"); } @Override public void onDestroy() { super.onDestroy(); Log.d(TAG, "onDestroy"); try { Thread.sleep(2000); } catch (InterruptedException e) { } if (mAudioTrack != null) { if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) { Log.d(TAG, "cleanup mAudioTrack"); mAudioTrack.stop(); mAudioTrack.flush(); } mAudioTrack = null; } } @Override public void onClick(View v) { if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) { mAudioTrack.stop(); mAudioTrack.flush(); } if (mButton01.isChecked()) { mText = mEditText01.getText().toString(); if (mText.length() > 0) { new Thread(this).start(); } else { mButton01.setChecked(false); } } } @Override public boolean handleMessage(Message msg) { switch (msg.what) { case MSG_PLAY_START: Log.d(TAG, "MSG_PLAY_START"); break; case MSG_PLAY_END: Log.d(TAG, "MSG_PLAY_END"); mButton01.setChecked(false); break; } return true; } @Override public void run() { mHandler.sendEmptyMessage(MSG_PLAY_START); byte[] strByte = null; try { strByte = mText.getBytes("UTF-8"); } catch (UnsupportedEncodingException e) { } mAudioTrack.play(); mAudioTrack.write(mPlayBuf, 0, ELMS_1SEC); // 開始 for (int i = 0; i < strByte.length; i++) { valueToWave(strByte[i]); } mAudioTrack.write(mPlayBuf, 0, ELMS_1SEC); // 終端 mAudioTrack.stop(); mAudioTrack.flush(); mHandler.sendEmptyMessage(MSG_PLAY_END); } // 指定されたバイト値を音声信号に置き換えて再生する private void valueToWave(byte val) { mAudioTrack.write(mSignals[val], 0, ELMS_100MSEC); } }
受信側: sonic08 GitHub
- MainActivity.java
/** * sonic08 * * 端末のマイクから集音した信号をバイトデータに変換する * 伝送効率改善版 * FFT 処理に JTransforms ライブラリを利用 * 対向の送信プログラムは sonic07 * */ package jp.klab.sonic08; import org.jtransforms.fft.DoubleFFT_1D; import android.media.AudioFormat; import android.media.AudioRecord; import android.media.MediaRecorder; import android.os.Bundle; import android.os.Handler; import android.os.Message; import android.support.v7.app.AppCompatActivity; import android.support.v7.widget.Toolbar; import android.util.Log; import android.view.View; import android.widget.Button; import android.widget.TextView; import java.io.UnsupportedEncodingException; public class MainActivity extends AppCompatActivity implements Runnable, View.OnClickListener, Handler.Callback { private static final String TAG = "SNC"; private static final int SAMPLE_RATE = 44100; private static final short THRESHOLD_SILENCE = 0x00ff; private static final int FREQ_BASE = 400; private static final int FREQ_STEP = 20; private static final int FREQ_MAX = 400 + 255 * 20; private static final int UNITSIZE = SAMPLE_RATE/10; // 100msec分 private static final int MSG_RECORD_START = 100; private static final int MSG_RECORD_END = 110; private static final int MSG_DATA_RECV = 120; private Handler mHandler; private AudioRecord mAudioRecord = null; private Button mButton01; private TextView mTextView02; private boolean mInRecording = false; private boolean mStop = false; private int mBufferSizeInShort; private short mRecordBuf[]; private short mTestBuf[]; private DoubleFFT_1D mFFT; private double mFFTBuffer[]; private int mFFTSize; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.d(TAG, "onCreate"); mHandler = new Handler(this); setContentView(R.layout.activity_main); Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); setSupportActionBar(toolbar); mButton01 = (Button)findViewById(R.id.button01); mButton01.setOnClickListener(this); mTextView02 = (TextView)findViewById(R.id.textView02); mTextView02.setOnClickListener(this); int bufferSizeInBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT); mBufferSizeInShort = bufferSizeInBytes / 2; // 集音用バッファ mRecordBuf = new short[mBufferSizeInShort]; // FFT 処理用 mTestBuf = new short[UNITSIZE]; mFFTSize = UNITSIZE; mFFT = new DoubleFFT_1D(mFFTSize); mFFTBuffer = new double[mFFTSize]; mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC, SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufferSizeInBytes); } @Override public void onStop() { super.onStop(); Log.d(TAG, "onStop"); } @Override public void onDestroy() { super.onDestroy(); Log.d(TAG, "onDestroy"); mStop = true; try { Thread.sleep(2000); } catch (InterruptedException e) { } if (mAudioRecord != null) { if (mAudioRecord.getRecordingState() != AudioRecord.RECORDSTATE_STOPPED) { Log.d(TAG, "cleanup mAudioRecord"); mAudioRecord.stop(); } mAudioRecord = null; } } @Override public void onClick(View v) { if (v == (View)mButton01) { // 集音開始 or 終了 if (!mInRecording) { mInRecording = true; new Thread(this).start(); } else { mInRecording = false; } } else if (v == (View)mTextView02) { // 表示データをクリア mTextView02.setText(""); } return; } @Override public boolean handleMessage(Message msg) { switch (msg.what) { case MSG_RECORD_START: Log.d(TAG, "MSG_RECORD_START"); mButton01.setText("STOP"); break; case MSG_RECORD_END: Log.d(TAG, "MSG_RECORD_END"); mButton01.setText("START"); break; case MSG_DATA_RECV: //Log.d(TAG, "MSG_DATA_RECV"); byte[] ch = new byte[] {(byte)msg.arg1}; try { // 受信データを表示 String s = new String(ch, "UTF-8"); s = mTextView02.getText() + s; mTextView02.setText(s); } catch (UnsupportedEncodingException e) { } break; } return true; } @Override public void run() { int dataCount = 0; boolean bSilence = false; mHandler.sendEmptyMessage(MSG_RECORD_START); // 集音開始 mAudioRecord.startRecording(); while (mInRecording && !mStop) { // 音声データ読み込み mAudioRecord.read(mRecordBuf, 0, mBufferSizeInShort); bSilence = true; for (int i = 0; i < mBufferSizeInShort; i++) { short s = mRecordBuf[i]; if (s > THRESHOLD_SILENCE) { bSilence = false; } } if (bSilence) { // 静寂 dataCount = 0; continue; } int copyLength = 0; // データを mTestBuf へ順次アペンド if (dataCount < mTestBuf.length) { // mTestBuf の残領域に応じてコピーするサイズを決定 int remain = mTestBuf.length - dataCount; if (remain > mBufferSizeInShort) { copyLength = mBufferSizeInShort; } else { copyLength = remain; } System.arraycopy(mRecordBuf, 0, mTestBuf, dataCount, copyLength); dataCount += copyLength; } if (dataCount >= mTestBuf.length) { // 100ms 分溜まったら FFT にかける int freq = doFFT(mTestBuf); // 待ってた範囲の周波数かチェック if (freq >= FREQ_BASE && freq <= FREQ_MAX) { int val = (int) ((freq - FREQ_BASE) / FREQ_STEP); if (val >= 0 && val <= 255) { Message msg = new Message(); msg.what = MSG_DATA_RECV; msg.arg1 = val; mHandler.sendMessage(msg); } else { freq = -1; } } else { freq = -1; } dataCount = 0; if (freq == -1) { continue; } // mRecordBuf の途中までを mTestBuf へコピーして FFT した場合は // mRecordBuf の残データを mTestBuf 先頭へコピーした上で継続 if (copyLength < mBufferSizeInShort) { int startPos = copyLength; copyLength = mBufferSizeInShort - copyLength; System.arraycopy(mRecordBuf, startPos, mTestBuf, 0, copyLength); dataCount += copyLength; } } } // 集音終了 mAudioRecord.stop(); mHandler.sendEmptyMessage(MSG_RECORD_END); } private int doFFT(short[] data) { for (int i = 0; i < mFFTSize; i++) { mFFTBuffer[i] = (double)data[i]; } // FFT 実行 mFFT.realForward(mFFTBuffer); // 処理結果の複素数配列からピーク周波数成分の要素番号を得る double maxAmp = 0; int index = 0; for (int i = 0; i < mFFTSize/2; i++) { double a = mFFTBuffer[i*2]; // 実部 double b = mFFTBuffer[i*2 + 1]; // 虚部 // a+ib の絶対値 √ a^2 + b^2 = r が振幅値 double r = Math.sqrt(a*a + b*b); if (r > maxAmp) { maxAmp = r; index = i; } } // ピーク周波数を求める int freq = index * SAMPLE_RATE / mFFTSize; //Log.d(TAG, "peak=" + freq + "Hz"); return freq; } }
3. ビットデータを音声信号で送受信する 3
伝送効率の改善に続き、次の要素に目を向けてみます。
- 誤り発生への対処
- 超音波の利用
誤り発生への対処について
他の通信方法と同様に、音波による通信においても送受信の際に何らかの理由でデータに誤りが発生する可能性があります。この問題への対処について考えてみます。
まず誤りを検出可能であること、さらに、自動的にデータを訂正することができれば理想的でしょう。しかしながら、誤り訂正を実現するためには何らかの形でデータの冗長化が必須となり必然的に伝送効率とのトレードオフとなります。冗長化されたより大きなデータを授受することで自動訂正実現の可能性向上を選択すべきか?あるいは、誤り有無判定のみを可能とするスリムなデータを短時間で授受することを旨とするか?
たとえば、プライベートな環境での音波通信の応用を想定すると、エラー発生に気づいた利用者が自発的に送受信を再試行するのはごく普通の自然な流れです。この場面では訂正機能よりもすみやかに通信が行われることのほうが求められるでしょう。また、パブリックな空間でのビーコン系サービスにおいては送り手が同一の情報を所定の期間何度も繰り返し発信する(それもまた単方向通信であることに起因)ことが一般的なので受け手には情報取得に成功するまで何度もチャンスが提供されます。
このように考えると、ここでは誤り訂正のための機構は現在の話題に不可欠の要素ではなく、むしろ誤りの検出までが重要であることが見えてきます。
超音波の利用について
ここまでの試作では開発上の便宜から可聴音域の信号で通信を行ってきました。
「音を利用する 1」冒頭の「はじめに」の項でも触れたように、音声信号を利用する際の音域の選定は環境や使途とのかねあいによって単に適材適所で判断されるべきはずのものですが、そのこととはあまり関係なく洋の東西を問わず「超音波」「Ultrasonic」は人気がありますね。そろそろこのあたりで従来の可聴音に加え超音波も扱ってみることにします。
試作
処理のイメージ
以下の要領で実装を行いました。
「超音波モード」を追加。
- 送信側は所定のバイト値 0x00 〜 0xFF を以下の周波数域で構成される音声信号に変調する
(前項のふたつめの試作では 20Hz刻みとしたが 10Hz刻みでも性能上の支障が見られないため帯域幅を節約)
発信する音声信号の先頭と終端に目印として上図の範囲に含まれない所定の周波数の信号を 1秒間付与する。
- 先端符丁:各モードで使用する音域の先頭周波数 − 80Hz(=420Hz,13920Hz)
- 終端符丁:各モードで使用する音域の先頭周波数 − 100Hz(=400Hz, 13900Hz)
次の内容で誤り検出機構を追加。
- 送信側は先端符丁とデータ本体の間にデータの 32ビット CRC 計算結果(java.util.zip.CRC32 使用)を 4 * 100ミリ秒間発信する
- 受信側は先端符丁を検知すると上記 CRC 値を取得・復号して保持。終端符丁検出時に復号ずみデータ本体の CRC を計算し両者を比較することで誤りの可能性の有無を判定する
- データ本体の受け渡しに失敗したケースのみならず CRC 値の受け渡しに失敗のケースもありえるが、現時点では CRC 値情報授受の冗長化は考えず一律に「エラー」とみなす
- 左:送信側 右:受信側
両者とも「Ultrasonic」スイッチで超音波モードへ移行
動作の様子
動画: 1分19秒 ※音量注意!
ソースコード
送信側: sonic09 GitHub
- MainActivity.java
/** * sonic09 * * 文字列をサイン波信号に置き換えて出力する * 32ビットCRC付与, 超音波モード追加 * 対向の受信プログラムは sonic10 * */ package jp.klab.sonic09; import android.media.AudioFormat; import android.media.AudioRecord; import android.media.AudioManager; import android.media.AudioTrack; import android.os.Bundle; import android.os.Handler; import android.os.Message; import android.support.v7.app.AppCompatActivity; import android.support.v7.widget.Toolbar; import android.util.Log; import android.view.View; import android.widget.CompoundButton; import android.widget.EditText; import android.widget.Switch; import android.widget.ToggleButton; import java.io.UnsupportedEncodingException; import java.util.Arrays; import java.util.zip.CRC32; public class MainActivity extends AppCompatActivity implements Runnable, View.OnClickListener, Switch.OnCheckedChangeListener, Handler.Callback { private static final String TAG = "SNC"; private static final int SAMPLE_RATE = 44100; private static final float SEC_PER_SAMPLEPOINT = 1.0f / SAMPLE_RATE; private static final int FREQ_BASE_LOW = 500; private static final int FREQ_BASE_HIGH = 14000; private static final int AMP_SMALL = 5000; private static final int AMP_LARGE = 28000; private static final int ELMS_1SEC = SAMPLE_RATE; private static final int ELMS_UNITSEC = SAMPLE_RATE/10; private static final int ELMS_MAX = 256; private static final int MSG_PLAY_START = 120; private static final int MSG_PLAY_END = 130; private int FREQ_STEP = 10; private int AMP; private int FREQ_BASE; private int FREQ_OUT; private int FREQ_IN; private Handler mHandler; private AudioTrack mAudioTrack = null; private ToggleButton mButton01; private EditText mEditText01; private Switch mSwitch01; private short mSigIn[] = new short[SAMPLE_RATE]; private short mSigOut[] = new short[SAMPLE_RATE]; private short mSignals[][] = new short[ELMS_MAX][ELMS_UNITSEC]; private String mText; // サイン波データを生成 private void createSineWave(short[] buf, int freq, int amplitude, boolean doClear) { if (doClear) { Arrays.fill(buf, (short) 0); } for (int i = 0; i < buf.length; i++) { float currentSec = i * SEC_PER_SAMPLEPOINT; // 現在位置の経過秒数 double val = amplitude * Math.sin(2.0 * Math.PI * freq * currentSec); buf[i] += (short)val; } } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.d(TAG, "onCreate"); mHandler = new Handler(this); setContentView(R.layout.activity_main); Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); setSupportActionBar(toolbar); mButton01 = (ToggleButton)findViewById(R.id.button01); mButton01.setOnClickListener(this); mEditText01 = (EditText)findViewById(R.id.editText01); mSwitch01 = (Switch)findViewById(R.id.switch01); mSwitch01.setOnCheckedChangeListener(this); int bufferSizeInBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT); setParams(false); mAudioTrack = new AudioTrack(AudioManager.STREAM_MUSIC, SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT, bufferSizeInBytes, AudioTrack.MODE_STREAM); } @Override public void onStop() { super.onStop(); Log.d(TAG, "onStop"); } @Override public void onDestroy() { super.onDestroy(); Log.d(TAG, "onDestroy"); try { Thread.sleep(2000); } catch (InterruptedException e) { } if (mAudioTrack != null) { if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) { Log.d(TAG, "cleanup mAudioTrack"); mAudioTrack.stop(); mAudioTrack.flush(); } mAudioTrack = null; } } @Override public void onClick(View v) { if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) { mAudioTrack.stop(); mAudioTrack.flush(); } if (mButton01.isChecked()) { mText = mEditText01.getText().toString(); if (mText.length() > 0) { new Thread(this).start(); } else { mButton01.setChecked(false); } } } @Override public void onCheckedChanged(CompoundButton b, boolean isChecked) { setParams(isChecked); } @Override public boolean handleMessage(Message msg) { switch (msg.what) { case MSG_PLAY_START: Log.d(TAG, "MSG_PLAY_START"); break; case MSG_PLAY_END: Log.d(TAG, "MSG_PLAY_END"); mButton01.setChecked(false); break; } return true; } @Override public void run() { mHandler.sendEmptyMessage(MSG_PLAY_START); byte[] strByte = null; try { strByte = mText.getBytes("UTF-8"); } catch (UnsupportedEncodingException e) { } mAudioTrack.play(); mAudioTrack.write(mSigIn, 0, ELMS_1SEC); // 先端符丁 // データ本体の CRC32 を計算して発信 CRC32 crc = new CRC32(); crc.reset(); crc.update(strByte, 0, strByte.length); long crcVal = crc.getValue(); //Log.d(TAG, "crc=" + Long.toHexString(crcVal)); byte crcData[] = new byte[4]; for (int i = 0; i < 4; i++) { crcData[i] = (byte)(crcVal >> (24-i*8)); valueToWave((short) (crcData[i] & 0xFF)); } // データ本体 for (int i = 0; i < strByte.length; i++) { valueToWave(strByte[i]); } mAudioTrack.write(mSigOut, 0, ELMS_1SEC); // 終端符丁 mAudioTrack.stop(); mAudioTrack.flush(); mHandler.sendEmptyMessage(MSG_PLAY_END); } // 指定されたバイト値を音声信号に置き換えて再生する private void valueToWave(short val) { //Log.d(TAG, "val=" + val); mAudioTrack.write(mSignals[val], 0, ELMS_UNITSEC); } private void setParams(boolean useUltrasonic) { AMP = (useUltrasonic) ? AMP_LARGE : AMP_SMALL; FREQ_BASE = (useUltrasonic) ? FREQ_BASE_HIGH : FREQ_BASE_LOW; FREQ_OUT = FREQ_BASE - 100; FREQ_IN = FREQ_OUT + 20; // 先端・終端符丁の信号データを生成 createSineWave(mSigIn, FREQ_IN, AMP, true); createSineWave(mSigOut, FREQ_OUT, AMP, true); // 256種類の信号データを生成 for (int i = 0; i < ELMS_MAX; i++) { createSineWave(mSignals[i], (short) (FREQ_BASE + FREQ_STEP * i), AMP, true); } } }
受信側: sonic10 GitHub
- MainActivity.java
/** * sonic10 * * 端末のマイクから集音した信号をバイトデータに変換する * CRC32による誤り検出, 超音波モードへ対応 * FFT 処理に JTransforms ライブラリを利用 * 対向の送信プログラムは sonic09 * */ package jp.klab.sonic10; import org.jtransforms.fft.DoubleFFT_1D; import android.graphics.Color; import android.media.AudioFormat; import android.media.AudioRecord; import android.media.MediaRecorder; import android.os.Bundle; import android.os.Handler; import android.os.Message; import android.support.v7.app.AppCompatActivity; import android.support.v7.widget.Toolbar; import android.util.Log; import android.view.View; import android.widget.Button; import android.widget.CompoundButton; import android.widget.Switch; import android.widget.TextView; import java.io.UnsupportedEncodingException; import java.util.ArrayList; import java.util.zip.CRC32; public class MainActivity extends AppCompatActivity implements Runnable, View.OnClickListener, Switch.OnCheckedChangeListener, Handler.Callback { private static final String TAG = "SNC"; private static final int SAMPLE_RATE = 44100; private static final short THRESHOLD_SILENCE = 0x00ff; private static final int FREQ_BASE_LOW = 500; private static final int FREQ_BASE_HIGH = 14000; private static final int FREQ_STEP = 10; private static final int UNITSIZE = SAMPLE_RATE/10; // 100msec分 private static final int MSG_RECORD_START = 100; private static final int MSG_RECORD_END = 110; private static final int MSG_DATA_RECV = 120; private static final int MSG_RECV_OK = 200; private static final int MSG_RECV_NG = 210; private int FREQ_BASE = FREQ_BASE_LOW; private int FREQ_OUT = FREQ_BASE - 100; private int FREQ_IN = FREQ_OUT + 20; private int FREQ_MAX = FREQ_BASE + FREQ_STEP * 255; private Handler mHandler; private AudioRecord mAudioRecord = null; private Button mButton01; private TextView mTextView02; private TextView mTextView03; private Switch mSwitch01; private boolean mInRecording = false; private boolean mStop = false; private int mBufferSizeInShort; private short mRecordBuf[]; private short mTestBuf[]; private DoubleFFT_1D mFFT; private double mFFTBuffer[]; private int mFFTSize; private byte mValueCount = -1; private long mCrc32Val = 0; private String mRecvStr; private ArrayList<Byte> mDataArrayList = new ArrayList<Byte>(); @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.d(TAG, "onCreate"); mHandler = new Handler(this); setContentView(R.layout.activity_main); Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); setSupportActionBar(toolbar); mButton01 = (Button)findViewById(R.id.button01); mButton01.setOnClickListener(this); mTextView02 = (TextView)findViewById(R.id.textView02); mTextView02.setOnClickListener(this); mTextView03 = (TextView)findViewById(R.id.textView03); mTextView03.setTextColor(Color.RED); mSwitch01 = (Switch)findViewById(R.id.switch01); mSwitch01.setOnCheckedChangeListener(this); int bufferSizeInBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT); mBufferSizeInShort = bufferSizeInBytes / 2; // 集音用バッファ mRecordBuf = new short[mBufferSizeInShort]; // FFT 処理用 mTestBuf = new short[UNITSIZE]; mFFTSize = UNITSIZE; mFFT = new DoubleFFT_1D(mFFTSize); mFFTBuffer = new double[mFFTSize]; mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC, SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufferSizeInBytes); } @Override public void onStop() { super.onStop(); Log.d(TAG, "onStop"); } @Override public void onDestroy() { super.onDestroy(); Log.d(TAG, "onDestroy"); mStop = true; try { Thread.sleep(2000); } catch (InterruptedException e) { } if (mAudioRecord != null) { if (mAudioRecord.getRecordingState() != AudioRecord.RECORDSTATE_STOPPED) { Log.d(TAG, "cleanup mAudioRecord"); mAudioRecord.stop(); } mAudioRecord = null; } } @Override public void onClick(View v) { if (v == (View)mButton01) { // 集音開始 or 終了 if (!mInRecording) { mInRecording = true; new Thread(this).start(); } else { mInRecording = false; } } else if (v == (View)mTextView02) { // 表示データをクリア mTextView02.setText(""); mTextView03.setText(""); } return; } @Override public void onCheckedChanged(CompoundButton b, boolean isChecked) { if (b == (CompoundButton)mSwitch01) { FREQ_BASE = (isChecked) ? FREQ_BASE_HIGH : FREQ_BASE_LOW; FREQ_OUT = FREQ_BASE - 100; FREQ_IN = FREQ_OUT + 20; FREQ_MAX = FREQ_BASE + FREQ_STEP * 255; } } @Override public boolean handleMessage(Message msg) { switch (msg.what) { case MSG_RECORD_START: Log.d(TAG, "MSG_RECORD_START"); mButton01.setText("STOP"); mSwitch01.setEnabled(false); break; case MSG_RECORD_END: Log.d(TAG, "MSG_RECORD_END"); mButton01.setText("START"); mSwitch01.setEnabled(true); break; case MSG_DATA_RECV: //Log.d(TAG, "MSG_DATA_RECV"); byte[] ch = new byte[] {(byte)msg.arg1}; try { // 受信データを表示 String s = new String(ch, "UTF-8"); s = mTextView02.getText() + s; mTextView02.setText(s); } catch (UnsupportedEncodingException e) { } mTextView03.setText(""); break; case MSG_RECV_OK: mTextView03.setText("OK!"); break; case MSG_RECV_NG: mTextView03.setText("NG!"); break; } return true; } @Override public void run() { int dataCount = 0; boolean bSilence = false; mHandler.sendEmptyMessage(MSG_RECORD_START); // 集音開始 mAudioRecord.startRecording(); while (mInRecording && !mStop) { // 音声データ読み込み mAudioRecord.read(mRecordBuf, 0, mBufferSizeInShort); bSilence = true; for (int i = 0; i < mBufferSizeInShort; i++) { short s = mRecordBuf[i]; if (s > THRESHOLD_SILENCE) { bSilence = false; } } if (bSilence) { // 静寂 dataCount = 0; continue; } int copyLength = 0; // データを mTestBuf へ順次アペンド if (dataCount < UNITSIZE) {//mTestBuf.length) { // mTestBuf の残領域に応じてコピーするサイズを決定 int remain = UNITSIZE - dataCount; if (remain > mBufferSizeInShort) { copyLength = mBufferSizeInShort; } else { copyLength = remain; } System.arraycopy(mRecordBuf, 0, mTestBuf, dataCount, copyLength); dataCount += copyLength; } if (dataCount >= UNITSIZE) {//mTestBuf.length) { // 100ms 分溜まったら FFT にかける int freq = doFFT(mTestBuf); if (mValueCount < 0) { // データ終了 if (freq == FREQ_OUT && mCrc32Val != 0) { byte check [] = new byte[mDataArrayList.size()]; for (int i = 0; i < check.length; i++) { check[i] = mDataArrayList.get(i); } CRC32 crc = new CRC32(); crc.reset(); crc.update(check, 0, check.length); long crcVal = crc.getValue(); Log.d(TAG, "crc check=" + Long.toHexString(crcVal)); if (crcVal == mCrc32Val) { mHandler.sendEmptyMessage(MSG_RECV_OK); } else { mHandler.sendEmptyMessage(MSG_RECV_NG); } mCrc32Val = 0; } } // 待ってた範囲の周波数かチェック if (freq >= FREQ_BASE && freq <= FREQ_MAX) { int val = (int) ((freq - FREQ_BASE) / FREQ_STEP); if (val >= 0 && val <= 255) { if (mValueCount > 4) { mDataArrayList.add((byte)val); Message msg = new Message(); msg.what = MSG_DATA_RECV; msg.arg1 = val; mHandler.sendMessage(msg); } } else { freq = -1; } } else { freq = -1; } dataCount = 0; if (freq == -1) { continue; } // mRecordBuf の途中までを mTestBuf へコピーして FFT した場合は // mRecordBuf の残データを mTestBuf 先頭へコピーした上で継続 if (copyLength < mBufferSizeInShort) { int startPos = copyLength; copyLength = mBufferSizeInShort - copyLength; System.arraycopy(mRecordBuf, startPos, mTestBuf, 0, copyLength); dataCount += copyLength; } } } // 集音終了 mAudioRecord.stop(); mHandler.sendEmptyMessage(MSG_RECORD_END); } private int doFFT(short[] data) { for (int i = 0; i < mFFTSize; i++) { mFFTBuffer[i] = (double)data[i]; } // FFT 実行 mFFT.realForward(mFFTBuffer); // 処理結果の複素数配列からピーク周波数成分の要素番号を得る double maxAmp = 0; int index = 0; for (int i = 0; i < mFFTSize/2; i++) { double a = mFFTBuffer[i*2]; // 実部 double b = mFFTBuffer[i*2 + 1]; // 虚部 // a+ib の絶対値 √ a^2 + b^2 = r が振幅値 double r = Math.sqrt(a*a + b*b); if (r > maxAmp) { maxAmp = r; index = i; } } // ピーク周波数を求める int freq = index * SAMPLE_RATE / mFFTSize; byte val = (byte)((freq-FREQ_BASE)/FREQ_STEP); if (freq == FREQ_IN) { // 先端符丁 mValueCount = 0; mCrc32Val = 0; if (!mDataArrayList.isEmpty()) { mDataArrayList.clear(); } return freq; } else if (freq == FREQ_OUT) { // 終端符丁 mValueCount = -1; return freq; } // 先端符丁直後の 4バイトは32ビットCRC if (mValueCount >= 0 && mValueCount < 4) { mCrc32Val |= (val & 0xFF); if (mValueCount != 3) { mCrc32Val <<= 8; } else { Log.d(TAG, "mCrc32Val=" + Long.toHexString(mCrc32Val)); } } mValueCount++; return freq; } }
4. ビットデータを音声信号で送受信する 4
ここまでの試作では単方向の通信のみを扱ってきました。これは以下の理由によるものです。
- 集音・発音の両機能を混在させると自機の発した信号音による混乱が起こり得る
- 伝送が基本的に低速であるため機器間で相互に通信を行うなら双方の拘束時間が長くなることになり実用性を想定しにくい
- 双方向通信に伴いプロトコルが煩雑化することよりも単方向通信であることを活かせる用途を選ぶべき
これらはおそらく考え方として間違っていないように思いますが、一方で、二台の機器が相互に音で情報をやりとりする光景を想像すると理由のない好奇心をくすぐられます。そこで、実験の一環として最後にデバイス間の双方向通信を試すことにしました。
双方向通信を考える
双方向通信の方法として以下の内容を想定しました。
- 二台の機器間での相互通信を前提とする
- 双方とも同一のプログラムを使用する
- 上記 1. に挙げた混乱への懸念は半二重方式での実装により回避可能と考えられる
- 相互に先端符丁と終端符丁の検知によりデータを識別。それに呼応する内容で返信を行う
試作
処理のイメージ
以下の要領で実装を行いました。
- 機器間で自律的にある程度意味のある応酬を行わせるために英単語のしりとりを題材とした
ability -> yellow -> warn -> national .... の要領 - 機器間の応酬は自動化し最初の単語を発信するために初回のみ UI を操作する形とした
- 集音状態での待機中に先端符丁の信号音を検知したらデータ到着とみなし後続処理を開始。その後終端符丁を検知したら所定の時間間隔をおいて発信を行う。発信が完了したら集音状態での待機に戻る
- 受信・復号したデータの内容に誤りを検出した場合、「?」文字を発信することで相手に再送を要求する
動作の様子
動画: 1分29秒 ※音量注意!
ソースコード
sonic11 GitHub
- MainActivity.java
/** * * sonic11 * * サイン波音声信号による半二重式での双方向通信のしくみの試作 * 二台の機器間で「しりとり」を行う * FFT 処理に JTransforms ライブラリを利用 * */ package jp.klab.sonic11; import org.jtransforms.fft.DoubleFFT_1D; import android.graphics.Color; import android.media.AudioFormat; import android.media.AudioRecord; import android.media.AudioManager; import android.media.AudioTrack; import android.media.MediaRecorder; import android.os.Bundle; import android.os.Handler; import android.os.Message; import android.support.v7.app.AppCompatActivity; import android.support.v7.widget.Toolbar; import android.util.Log; import android.view.View; import android.widget.Button; import android.widget.CompoundButton; import android.widget.Switch; import android.widget.TextView; import java.io.UnsupportedEncodingException; import java.util.ArrayList; import java.util.Arrays; import java.util.zip.CRC32; public class MainActivity extends AppCompatActivity implements Runnable, View.OnClickListener, Switch.OnCheckedChangeListener, Handler.Callback { private static final String TAG = "SNC"; private static final int SAMPLE_RATE = 44100; private static final float SEC_PER_SAMPLEPOINT = 1.0f / SAMPLE_RATE; private static final short THRESHOLD_SILENCE = 0x00ff; private static final int THRESHOLD_COUNT_SILENCE = 25; // 無音区間判定用 private static final int FREQ_BASE_LOW = 500; private static final int FREQ_BASE_HIGH = 14000; private static final int AMP_SMALL = 28000; private static final int AMP_LARGE = 28000; private static final int ELMS_1SEC = SAMPLE_RATE; private static final int ELMS_UNITSEC = SAMPLE_RATE/10; private static final int ELMS_MAX = 256; private static final int FREQ_STEP = 10; private static final int UNITSIZE = SAMPLE_RATE/10; // 100msec分 private static final int MSG_RECORD_START = 100; private static final int MSG_RECORD_END = 110; private static final int MSG_DATA_RECV = 130; private static final int MSG_DATA_SEND_START = 140; private static final int MSG_RECV_OK = 200; private static final int MSG_RECV_NG = 210; private int AMP; private int FREQ_BASE; private int FREQ_OUT; private int FREQ_IN; private int FREQ_MAX = FREQ_BASE + FREQ_STEP * 255; private Handler mHandler; private AudioRecord mAudioRecord = null; private AudioTrack mAudioTrack = null; private Button mButton01; private TextView mTextView01; private TextView mTextView02; private TextView mTextView03; private TextView mTextView04; private Switch mSwitch01; private short mSigIn[] = new short[SAMPLE_RATE]; private short mSigOut[] = new short[SAMPLE_RATE]; private short mSignals[][] = new short[ELMS_MAX][SAMPLE_RATE/10]; private boolean mInRecording = false; private boolean mStop = false; private int mBufferSizeInShort; private short mRecordBuf[]; private short mTestBuf[]; private DoubleFFT_1D mFFT; private double mFFTBuffer[]; private int mFFTSize; private byte mValueCount = -1; private long mCrc32Val = 0; private ArrayList<Byte> mDataArrayList = new ArrayList<Byte>(); private int mLastFreq; private String mRecvWord = ""; private String mSendWord = ""; private boolean mRecvOK; // サイン波データを生成 private void createSineWave(short[] buf, int freq, int amplitude, boolean doClear) { if (doClear) { Arrays.fill(buf, (short) 0); } for (int i = 0; i < buf.length; i++) { float currentSec = i * SEC_PER_SAMPLEPOINT; // 現在位置の経過秒数 double val = amplitude * Math.sin(2.0 * Math.PI * freq * currentSec); buf[i] += (short)val; } } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.d(TAG, "onCreate"); mHandler = new Handler(this); setContentView(R.layout.activity_main); Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); setSupportActionBar(toolbar); mButton01 = (Button)findViewById(R.id.button01); mButton01.setOnClickListener(this); mTextView01 = (TextView)findViewById(R.id.textView01); mTextView01.setOnClickListener(this); mTextView02 = (TextView)findViewById(R.id.textView02); mTextView02.setOnClickListener(this); mTextView03 = (TextView)findViewById(R.id.textView03); mTextView03.setTextColor(Color.RED); mTextView04 = (TextView)findViewById(R.id.textView04); mSwitch01 = (Switch)findViewById(R.id.switch01); mSwitch01.setOnCheckedChangeListener(this); int bufferSizeInBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT); mBufferSizeInShort = bufferSizeInBytes / 2; // 集音用バッファ mRecordBuf = new short[mBufferSizeInShort]; // FFT 処理用 mTestBuf = new short[UNITSIZE]; mFFTSize = UNITSIZE; mFFT = new DoubleFFT_1D(mFFTSize); mFFTBuffer = new double[mFFTSize]; mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC, SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufferSizeInBytes); mAudioTrack = new AudioTrack(AudioManager.STREAM_MUSIC, SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT, bufferSizeInBytes, AudioTrack.MODE_STREAM); setParams(false); } @Override public void onStop() { super.onStop(); Log.d(TAG, "onStop"); } @Override public void onDestroy() { super.onDestroy(); Log.d(TAG, "onDestroy"); mStop = true; try { Thread.sleep(2000); } catch (InterruptedException e) { } if (mAudioRecord != null) { if (mAudioRecord.getRecordingState() != AudioRecord.RECORDSTATE_STOPPED) { Log.d(TAG, "cleanup mAudioRecord"); mAudioRecord.stop(); } mAudioRecord = null; } if (mAudioTrack != null) { if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) { Log.d(TAG, "cleanup mAudioTrack"); mAudioTrack.stop(); mAudioTrack.flush(); } mAudioTrack = null; } } @Override public void onClick(View v) { if (v == (View)mButton01) { // 集音開始 or 終了 if (!mInRecording) { mInRecording = true; new Thread(this).start(); } else { mInRecording = false; } } else if (v == (View)mTextView01) { // しりとりの開始・再開 // すでに集音中なら自分で自分の音を拾わないように // スレッドを一旦終了 boolean running = mInRecording; if (mInRecording) { mStop = true; try { while (mInRecording) { Thread.sleep(100); } } catch (InterruptedException e) { } } // ランダムに選んだ英単語を音で発信 mSendWord = words.getWord(); doSendWord(mSendWord); if (running) { // 集音スレッド再稼働 mInRecording = true; mStop = false; new Thread(this).start(); } } else if (v == (View)mTextView02) { mTextView02.setText(""); mTextView03.setText(""); } return; } @Override public void onCheckedChanged(CompoundButton b, boolean isChecked) { if (b == (CompoundButton)mSwitch01) { setParams(isChecked); } } @Override public boolean handleMessage(Message msg) { switch (msg.what) { case MSG_RECORD_START: Log.d(TAG, "MSG_RECORD_START"); mButton01.setText("STOP"); mSwitch01.setEnabled(false); break; case MSG_RECORD_END: Log.d(TAG, "MSG_RECORD_END"); mButton01.setText("START"); mSwitch01.setEnabled(true); break; case MSG_DATA_RECV: //Log.d(TAG, "MSG_DATA_RECV"); if (mRecvWord.length() <= 0) { mTextView02.setText("RECV: "); } byte[] ch = new byte[] {(byte)msg.arg1}; try { // 受信データを表示 String s = new String(ch, "UTF-8"); mRecvWord += s; s = mTextView02.getText() + s; mTextView02.setText(s); } catch (UnsupportedEncodingException e) { } mTextView03.setText(""); break; case MSG_DATA_SEND_START: mTextView04.setText("SEND: " + mSendWord); break; case MSG_RECV_OK: mTextView03.setText("OK!"); break; case MSG_RECV_NG: mTextView03.setText("NG!"); break; } return true; } @Override public void run() { int dataCount = 0; boolean bSilence = false; long countSilence = 0; mHandler.sendEmptyMessage(MSG_RECORD_START); // 集音開始 mAudioRecord.startRecording(); while (mInRecording && !mStop) { // 音声信号読み込み mAudioRecord.read(mRecordBuf, 0, mBufferSizeInShort); bSilence = true; for (int i = 0; i < mBufferSizeInShort; i++) { short s = mRecordBuf[i]; if (s > THRESHOLD_SILENCE) { bSilence = false; break; } } if (bSilence) { // 静寂 dataCount = 0; countSilence++; } else { countSilence = 0; } // 単語を受信ずみかつ一定期間以上静寂が続いたら送信にまわる if (countSilence > THRESHOLD_COUNT_SILENCE && mLastFreq == FREQ_OUT && mRecvWord.length() > 0) { if (mRecvOK) { // 直前の受信・復号結果が正常だった if (!mRecvWord.equals("?")) { // 受信した単語のしりとり語を取得 mSendWord = words.getWord(mRecvWord); } } else { // 再送要求 mSendWord = "?"; } Log.d(TAG, "mSendWord=" + mSendWord); // 単語を発信 doSendWord(mSendWord); continue; } int copyLength = 0; // データを mTestBuf へ順次アペンド if (dataCount < UNITSIZE) { // mTestBuf の残領域に応じてコピーするサイズを決定 int remain = UNITSIZE - dataCount; if (remain > mBufferSizeInShort) { copyLength = mBufferSizeInShort; } else { copyLength = remain; } System.arraycopy(mRecordBuf, 0, mTestBuf, dataCount, copyLength); dataCount += copyLength; } if (dataCount >= UNITSIZE) { // 100ms 分溜まったら FFT にかける int freq = doFFT(mTestBuf); mLastFreq = freq; if (mValueCount < 0) { // データ終了 if (freq == FREQ_OUT && mCrc32Val != 0) { byte check [] = new byte[mDataArrayList.size()]; for (int i = 0; i < check.length; i++) { check[i] = mDataArrayList.get(i); } //Log.d(TAG, "mRecvWord=" + mRecvWord); CRC32 crc = new CRC32(); crc.reset(); crc.update(check, 0, check.length); long crcVal = crc.getValue(); //Log.d(TAG, "crc check=" + Long.toHexString(crcVal)); mRecvOK = false; if (crcVal == mCrc32Val) { mRecvOK = true; mHandler.sendEmptyMessage(MSG_RECV_OK); } else { mHandler.sendEmptyMessage(MSG_RECV_NG); } mCrc32Val = 0; } } // 待ってた範囲の周波数かチェック if (freq >= FREQ_BASE && freq <= FREQ_MAX) { int val = (int) ((freq - FREQ_BASE) / FREQ_STEP); if (val >= 0 && val <= 255) { if (mValueCount > 4) { mDataArrayList.add((byte)val); Message msg = new Message(); msg.what = MSG_DATA_RECV; msg.arg1 = val; mHandler.sendMessage(msg); } } else { freq = -1; } } else { freq = -1; } dataCount = 0; if (freq == -1) { continue; } // mRecordBuf の途中までを mTestBuf へコピーして FFT した場合は // mRecordBuf の残データを mTestBuf 先頭へコピーした上で継続 if (copyLength < mBufferSizeInShort) { int startPos = copyLength; copyLength = mBufferSizeInShort - copyLength; System.arraycopy(mRecordBuf, startPos, mTestBuf, 0, copyLength); dataCount += copyLength; } } } // 集音終了 mAudioRecord.stop(); mInRecording = false; mHandler.sendEmptyMessage(MSG_RECORD_END); } private int doFFT(short[] data) { for (int i = 0; i < mFFTSize; i++) { mFFTBuffer[i] = (double)data[i]; } // FFT 実行 mFFT.realForward(mFFTBuffer); // 処理結果の複素数配列からピーク周波数成分の要素番号を得る double maxAmp = 0; int index = 0; for (int i = 0; i < mFFTSize/2; i++) { double a = mFFTBuffer[i*2]; // 実部 double b = mFFTBuffer[i*2 + 1]; // 虚部 // a+ib の絶対値 √ a^2 + b^2 = r が振幅値 double r = Math.sqrt(a*a + b*b); if (r > maxAmp) { maxAmp = r; index = i; } } // ピーク周波数を求める int freq = index * SAMPLE_RATE / mFFTSize; byte val = (byte)((freq-FREQ_BASE)/FREQ_STEP); if (freq == FREQ_IN) { // 先端符丁 mValueCount = 0; mCrc32Val = 0; if (!mDataArrayList.isEmpty()) { mDataArrayList.clear(); } mRecvWord = ""; return freq; } else if (freq == FREQ_OUT) { // 終端符丁 mValueCount = -1; return freq; } // 先端符丁直後の 4バイトは32ビットCRC if (mValueCount >= 0 && mValueCount < 4) { mCrc32Val |= (val & 0xFF); if (mValueCount != 3) { mCrc32Val <<= 8; } else { Log.d(TAG, "mCrc32Val=" + Long.toHexString(mCrc32Val)); } } mValueCount++; return freq; } // 指定されたバイト値を音声信号に置き換えて再生する private void valueToWave(short val) { //Log.d(TAG, "val=" + val); mAudioTrack.write(mSignals[val], 0, ELMS_UNITSEC); } private void doSendWord(String str) { mSendWord = str; mHandler.sendEmptyMessage(MSG_DATA_SEND_START); Log.d(TAG, "mSendWord=" + mSendWord); byte[] strByte = null; try { strByte = mSendWord.getBytes("UTF-8"); } catch (UnsupportedEncodingException e) { } mAudioTrack.play(); mAudioTrack.write(mSigIn, 0, ELMS_1SEC); // 先端符丁 // データ本体の CRC32 を計算して発信 CRC32 crc = new CRC32(); crc.reset(); crc.update(strByte, 0, strByte.length); long crcVal = crc.getValue(); //Log.d(TAG, "crc=" + Long.toHexString(crcVal)); byte crcData[] = new byte[4]; for (int i = 0; i < 4; i++) { crcData[i] = (byte)(crcVal >> (24-i*8)); valueToWave((short) (crcData[i] & 0xFF)); } // データ本体 for (int i = 0; i < strByte.length; i++) { valueToWave(strByte[i]); } mAudioTrack.write(mSigOut, 0, ELMS_1SEC); // 終端符丁 mAudioTrack.stop(); mAudioTrack.flush(); mLastFreq = -1; mRecvWord = ""; } private void setParams(boolean useUltrasonic) { AMP = (useUltrasonic) ? AMP_LARGE : AMP_SMALL; FREQ_BASE = (useUltrasonic) ? FREQ_BASE_HIGH : FREQ_BASE_LOW; FREQ_OUT = FREQ_BASE - 100; FREQ_IN = FREQ_OUT + 20; // 先端・終端符丁の信号データを生成 createSineWave(mSigIn, FREQ_IN, AMP, true); createSineWave(mSigOut, FREQ_OUT, AMP, true); // 256種類の信号データを生成 for (int i = 0; i < ELMS_MAX; i++) { createSineWave(mSignals[i], (short) (FREQ_BASE + FREQ_STEP * i), AMP, true); } FREQ_MAX = FREQ_BASE + FREQ_STEP * 255; } }
(tanabe)