ホーム < 電子工作やってみた


Arduino編

その7 光センサーの値を音にしてみよう


 前章で光センサーであるCdsセルが反応して返してくれる値をPCの画面に出す事に成功しました。0〜255の間でアナログ的にぐねぐねと変わる値…「これは音に変換すると面白そう!」とふと思い付きました。そこで本章では、このリアルタイムに変わる値を音に変換してPCのスピーカーから出力してみる事にしました。電子工作から少し脱線ですが、実は「通信」という面で大切な事がわかった章でもあります。



@ Arduinoからデータを貰う時の問題

 Arduinoからシリアルポートにデータを送信する一つの方法がSerial.print関数です。では、次のようなプログラムを走らせるとどういう事が起こるでしょうか?

void setup() {
    Serial.begin( 9600 );
}

void loop() {
    int analogInVal = analogRead( inPin );
    Serial.print( analogInVal / 4, DEC );
    Serial.print( "," );
}

loop関数はガンガン回るので、シリアルポートを通して例えば「158,」という文字列も物凄い量が送信されます。この時「158,」という塊では送信されません。シリアルポートはバイト単位での送信となりますので「1」「5」「8」「,」と1文字ずつ送信されます。その文字列は受け取り側の「バッファ」に一時的に蓄えられます。

 一方センサーから届く値を受ける受信側は、例えば0.1秒に1回など受信側の都合で受け取るタイミングを調節するのが普通です。この時に不都合が起こります。受信側がシリアルポートから貰い受ける値は常にバッファに溜まっている「最古値」になります。送信側はバッファにガンガン新しい値を書き込むのに対し、受信側はのんびりと古いデータを取って処理する。受信側で1回につき1データ(158)だけ処理するとした場合、受信側はいつまでたっても「今」の値に辿りつけません。また、バッファは有限なのでガンガン溜めこむとあっという間に満杯になります。その場合、新しい情報をバッファに記録できなくなります。

 そのため、上の方針でいく場合、受信側は受け取る時に「バッファに溜まっている情報を全部処理する」という作業をする必要があります。しかしそれでも不都合が出ます。シリアルポートを通し文字列が1文字ずつバッファに蓄えられるため、タイミングによっては、「154,157,15」と終端が尻切れトンボになってしまう事がありえます。これをどうするか考えなければなりません。

 要は「センサー側からガンガン値が返って来るとめんどくせー」という訳です(^-^;



A 「くれ」と言った時だけ貰う

 場合によりますが、今回のような「今の値が欲しい!」という場合はArduino基板に「くれ!」と言った時にだけセンサーに測定してもらうのが良いかもしれません。それを実現するにはArduino基板側へシリアルポートを通して送信する必要があります。

 PC側からArduino基板側へ送信するにはWindows APIのWriteFile関数を使います:

WriteFile関数
BOOL WriteFile(
    HANDLE   hFile,
    LPCVOID  lpBuffer,
    DWORD    nNumberOfBytesToWrite,
    LPDWORD  lpNumberOfBytesWritten,
    LPOVERLAPPED lpOverlapped
);

hFileはCreateFile関数でオープンしたシリアルポートのハンドルを渡します。
lpBufferにはArduino基板側へ渡したい文字列を指定します。基板側に64バイトの制限があるので注意して下さい。
nNumberOfBytesToWriteは送信する文字数を渡します。
lpNumberOfBytesWrittenは実際に送信できた文字数が返ります。
lpOverlappedにはファイル等を特殊な方法でオープンした場合にOVERLAPPED構造体を渡すのですが、通常はNULLで大丈夫です。

典型的なコード例はこういう感じです:

const char* buf = "hoge";
DWORD writtenNum = 0;

WriteFile( hCom, buf, strlen(buf), &writtenNum, 0 );

これでArduino側へ「くれ!」という信号を送る事ができます。

 一方Arduino側はSerial.available関数でその「くれ!」の信号を捉える事ができます:

WriteFile関数
void loop() {
    if ( Serial.available() > 0 ) {
        Serial.read();

        Serial.print( analogInVal / 4, DEC );
        Serial.print( "," );
    }
}

loop関数内でポーリングしていると、PC側から「くれ!」の信号(バッファ)がやってきます。するとSerial.available関数はその受信バイト数を返しますので、すぐ下でそれを読み込みます(Serial.read関数)。信号を捉えるだけで良いなら、上のようにSerial.read関数で取得できるバッファの内容はそのまま破棄してしまって構いません。信号が来たタイミングで出力したいセンサーの値を出力すれば、バッファには最小限の値しか積まれないので経済的ですし通信不可も最小に抑えられます。



B 「くれ!」と言った後は少し待ってあげる

 Arduino側へ「くれ!」と言うと、直ちにその値を出力してくれるのですが、実際は、

・ ループ関数が回って来る
・ Serial.available関数がキャッチする
・ 値を出力する
タイミングを待ってシリアルポートを通して送信する

という処理時間がかかります。特に最後の送信時は特定のタイミングが来るまで待ちが入ります。つまり、「くれ!」と言った直ぐ後にReadFile関数で受信しようとしても、値がまだ用意されていないかもしれないんです。そのため、「くれ!」と言った後はほんの少しだけ待ってあげる必要があります。loop関数が十分に速く回ってくれるならSleep関数などで数ミリ待ってあげるだけで十分ですが、厳密にやりたいなら確実にバッファに何か届いたかをチェックするとより頑健です。そのためにはWindows APIのClearCommError関数を用います:

ClearCommError関数
BOOL ClearCommError(
    HANDLE hFile,
    LPDWORD lpErrors,
    LPCOMSTAT lpStat
);

hfileはシリアルポートのハンドルです。
lpErrorsはエラーコードがここに帰ります。今回は使いません。
lpStatにはCOMSTAT構造体を渡します。構造体のcbInQueメンバに現在の入力バッファに溜まっているバイト数が格納されています。

 COMSTAT構造体のcbInQueメンバが0より大きければバッファにデータが入ったと判断できます。くれと言ってから貰い受けるまでのコード例はこんな感じです:

Arduino側へセンサーの値を出力するよう要請する
int getCensorValue() {

    // シリアルポートに対してデータ出力要請
    DWORD writtenBytes = 0;
    WriteFile( hCom, "1", 1, &writtenBytes, 0 );

    // ちょっと待つ
    Sleep(10);

    // バッファの存在をチェック
    DWORD err = 0;
    COMSTAT state;
    ClearCommError( hCom, &err, &state );

    if ( state.cbInQue == 0 )
        return -1;

    // シリアルポートから読み込み
    char str[65] = {};
    char s = 0;
    for ( int i = 0; i < 64; i++ ) {
        ReadFile( hCom, &s, 1, &writtenBytes, 0 );
        if ( writtenBytes == 0 ) {
            i--;
            continue;
        }

        // 終端コンマを検出したら読み込み終了
        if ( s == ',' )
            break;

        str[i] = s;
    }

    return atoi( str );
}

さて、これで送受信の面倒臭い所は乗り越えましたので、PC側で光センサーの値を音に変換する部分を作成しましょう。



C DirectSoundで周波数を「ぴゅい〜〜」

 光センサーから返る値は整数値(0〜255)なのに対し音は周波数です。なので、例えば光センサーの値をCとして、Hz = C * 2 + 20 などとすると20Hz〜530Hzまでの周波数に変換できます。しかし、特定の周波数の音を鳴らすというのは案外面倒臭いのです。周波数は1秒間に繰り返される波の回数です。デジタルで音を鳴らす場合、そういう周期の値をメモリに書き込んで、それを追従するように再生してもらう必要があります。しかし、その仕組みを整えるのはかなり骨が折れます。そこで、DirectSoundのIDirectSoundBuffer8の機能であるSetFrequencyメソッドに注目しました。

 IDirectSoundBuffer8::SetFrequencyメソッドは、そのバッファを再生する速度を変更してくれます。例えば元のサンプル数が44100の場合、ここに22050と半分の値を渡すと、再生スピードが半分になります。つまり周波数が半分になった状態で再生されるという事になります。音程で言えば1オクターブ下がった音に聞こえます。逆に倍の88200にすると2倍速になるというわけです。ただし、100000以上は受け付けてくれない事が多いので注意です(サウンドドライバによります)。

 DirectSoundの細かな実装方法はゲームつくろ〜のDirectX9技術編DirectSoundその1「音を鳴らすのは簡単」を見て頂くとして、ここでは「1秒分の440Hz(『ラ』の音)をセカンダリバッファに書き込む」所をまずは見て行く事にしましょう。


 モノラル(1チャンネル)でサンプリングレートが16bit、サンプル周波数を44100とすると、1秒間分に必要なバッファサイズは、1×(16/8)×44100 = 88200バイト と計算できます。ここに440Hzの波の値を書き込むコードは以下のようになります:

440Hzのメモリブロックを出力する
// samplePerSec -> 44100
// time = 1.0f
// hz = 440Hz

WORD* createSourceWave( DWORD samplePerSec, float time, float hz ) {

    unsigned int valueNum = (unsigned int)( samplePerSec / time );
    WORD *mem = new WORD[ valueNum ];

    float dt = 1.0f / samplePerSec;
    float _2Pi = 3.14159265358979f * 2.0f;
    WORD maxVal = (WORD)-1;

    for ( unsigned int i = 0; i < valueNum; i++ ) {
        float t = dt * i * hz;
        float v = ( sinf( _2Pi * t ) + 1.0f ) * 0.5f * maxVal;
        mem[ i ] = (WORD)v;
    }

    return mem;
}

 引数sanplePerSecはサンプル周波数、timeは鳴らす時間、hzは鳴らしたい音の周波数です。最初に必要なメモリサイズ分ヒープから確保し、forループ内でそこにサイン波を書き込んでいます。1秒間に440回サイン波を発生させるには、1/440秒で1周回るようにsinf関数の中のtを増やしていけば良い訳です。そのためにdtという微小時間(1/サンプル周波数)を計算しています。sinf関数が返す値は-1〜1までの波なのに対し、音のデータは-32767〜32767までの整数値なので、変数vの右辺でその変換を行っています。この関数が返すメモリブロック内の音をそっくりそのままDirectSoundのセカンダリバッファにコピーすれば、440Hzでぴゅい〜っとなるサウンドバッファの出来上がりです。

 IDirectSoundBuffer8::SetFrequencyメソッドを有効にするには、セカンダリバッファを作成する時に用いるDSBUFFERDESC構造体内で周波数コントロールを有効にする必要があります:

ループ再生を許可
DSBUFFERDESC DSBufferDesc;
DSBufferDesc.dwSize = sizeof( DSBUFFERDESC );
DSBufferDesc.dwFlags = DSBCAPS_CTRLFREQUENCY;
DSBufferDesc.dwBufferBytes = 44100 * 16 / 8; // 1 sec.
DSBufferDesc.dwReserved = 0;
DSBufferDesc.lpwfxFormat = &wFmt;
DSBufferDesc.guid3DAlgorithm = GUID_NULL;

赤文字のDSBCAPS_CTRLFREQUENCYフラグをdwFlagsに追加すると周波数のコントロール(SetFrequencyメソッド)が有効になります。後は再生時にループ再生して、SetFrequencyメソッドを呼ぶと、指定した再生スピードで再生されます:

再生スピードを半分にして再生
pDSBuffer->Play( 0, 0, DSBPLAY_LOOPING );
pDSBuffer->SetFrequency( 22050 );   // 元のサンプル周波数44100の半分



D センサーの値と音程の対応

 センサーからの値は0〜255の整数値です。例えばその幅を3オクターブの音程に置きかえるとします。低い方を110Hz(A2)、高い方を880Hz(A5)とすると、その間は線形ではなくて指数関数で結ばれます:

こうすると、0の時は結構低い「ラ」の音、255の時は高音な「ラ」の音が鳴り、その間がピアノの鍵盤のように等間隔な音程となります。

 一方IDirectSoundBuffer8::SetFrequencyメソッドにはサンプリング周波数を指定します。元が44100とすると、22050にすると周波数が半分になり、1オクターブ下がります。3オクターブの幅を持たせようとすると、44100 / ( 2 ^ 3 ) = 5512を一番下、44100を一番上として、上のグラフと全く同じような形状になるようにセンサーからの整数値を対応させます。

 下の周波数をsampleLowHz、上をsampleHighHzとして上のような整数値に対する周波数を算出する関数はこういう感じになります:

センサーの値を音程に変換する
float sampleHighHz = 44100;
float oct = 3.0f;
float sampleLowHz = highHz / powf( 2.0f, oct ); // 3オクターブ

DWORD calcHz( unsigned x ) {
    float unit = oct / 255.0f;
    float mult = powf( 2.0f, unit * x );
    return (DWORD)( sampleLowHz * mult );
}



E 回路とプログラムまとめ

 今回の回路とプログラムを以下にまとめます。プログラムと実行ファイルはこちらからDLできます(EL_Ard_No7_v100)。binフォルダ下の実行ファイルは、単独でも動きますが、ピーっと音が鳴るだけです(^-^;。下のArduinoプログラムを適用したArduino基板がCOM3で繋がるとCdsセルに入った光量に合わせた音になります。

○ Arduino基板回路



○ Arduinoプログラム

int inPin = 0;

void setup() {
    Serial.begin( 9600 );
}

void loop() {
    int analogInVal = analogRead( inPin );

    if ( Serial.available() > 0 ) {
        Serial.read();
        Serial.print( analogInVal / 4, DEC );
        Serial.print( "," );
    }
}



○ クライアント側プログラム(C++)

 DirectSoundのライブラリ設定が必要です:

main.cpp
#include <Windows.h>
#include <tchar.h>
#include <dsound.h>
#include <math.h>
#include <stdio.h>

#include "CensorIO.h"


IDirectSound8 *pDS8 = 0;
IDirectSoundBuffer8 *pDSBuffer = 0;
DWORD playHz = 44100;
float baseHz = 880.0f;
float highHz = 44100;
float oct = 5.0f;
float lowHz = highHz / powf( 2.0f, oct ); // 3オクターブ

// センサーの値に対応した音程の周波数を計算
DWORD calcHz( unsigned x ) {
    float unit = oct / 255.0f;
    float mult = powf( 2.0f, unit * x );
    return (DWORD)( lowHz * mult );
}

// ソースとなる周波数を作成(bitrate 16bit前提)
WORD* createSourceWave( DWORD samplePerSec, float time, float hz ) {

    unsigned int valueNum = (unsigned int)( samplePerSec / time );
    WORD *mem = new WORD[ valueNum ];

    float dt = 1.0f / samplePerSec;
    float _2Pi = 3.14159265358979f * 2.0f;
    WORD maxVal = (WORD)-1;
    for ( unsigned int i = 0; i < valueNum; i++ ) {
        float t = dt * i * hz;
        float v = ( sinf( _2Pi * t ) + 1.0f ) * 0.5f * maxVal;
        mem[ i ] = (WORD)v;
    }

    return mem;
}

bool initializeSound( HWND hWnd ) {

    // サウンドデバイス作成
    DirectSoundCreate8( NULL, &pDS8, NULL );
    pDS8->SetCooperativeLevel( hWnd, DSSCL_PRIORITY );

    // セカンダリバッファ作成
    WAVEFORMATEX wFmt;
    wFmt.wFormatTag = WAVE_FORMAT_PCM;
    wFmt.nChannels = 1;
    wFmt.nSamplesPerSec = playHz;
    wFmt.wBitsPerSample = 16;
    wFmt.nBlockAlign = wFmt.nChannels * wFmt.wBitsPerSample / 8;
    wFmt.nAvgBytesPerSec = wFmt.nSamplesPerSec * wFmt.nBlockAlign;
    wFmt.cbSize = 0;

    DSBUFFERDESC DSBufferDesc;
    DSBufferDesc.dwSize = sizeof( DSBUFFERDESC );
    DSBufferDesc.dwFlags = DSBCAPS_CTRLFREQUENCY;
    DSBufferDesc.dwBufferBytes = wFmt.nSamplesPerSec * wFmt.wBitsPerSample * wFmt.nChannels / 8 * 1; // 1 sec.
    DSBufferDesc.dwReserved = 0;
    DSBufferDesc.lpwfxFormat = &wFmt;
    DSBufferDesc.guid3DAlgorithm = GUID_NULL;

    IDirectSoundBuffer *ptmpBuf = 0;
    pDS8->CreateSoundBuffer( &DSBufferDesc, &ptmpBuf, NULL );
    ptmpBuf->QueryInterface( IID_IDirectSoundBuffer8 ,(void**)&pDSBuffer );
    ptmpBuf->Release();
    if ( pDSBuffer == 0 ) {
        pDS8->Release();
        return false;
    }

    WORD *pWaveData = createSourceWave( wFmt.nSamplesPerSec, 1.0f, baseHz );

    LPVOID lpvWrite = 0;
    DWORD dwLength = 0;
    if ( DS_OK == pDSBuffer->Lock( 0, 0, &lpvWrite, &dwLength, NULL, NULL, DSBLOCK_ENTIREBUFFER ) ) {
        memcpy( lpvWrite, pWaveData, dwLength );
        pDSBuffer->Unlock( lpvWrite, dwLength, NULL, 0);
    }

    delete pWaveData; // 元音はもういらない

    pDSBuffer->Play( 0, 0, DSBPLAY_LOOPING );

    return true;
}

LRESULT CALLBACK WndProc(HWND hWnd, UINT mes, WPARAM wParam, LPARAM lParam){
    switch( mes ) {
    case WM_DESTROY:
        PostQuitMessage( 0 );
        return 0;
    }
    return DefWindowProc(hWnd, mes, wParam, lParam);
}

int APIENTRY _tWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPTSTR lpCmdLine, int nCmdShow)
{
    const char* windowName = "Sound Test";
    MSG msg; HWND hWnd;
    WNDCLASSEX wcex ={sizeof(WNDCLASSEX), CS_HREDRAW | CS_VREDRAW, WndProc, 0, 0, hInstance, NULL, NULL,
    (HBRUSH)(COLOR_WINDOW+1), NULL, (TCHAR*)windowName, NULL};
    if(!RegisterClassEx(&wcex)) return 0;

    if(!(hWnd = CreateWindow(windowName, windowName, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, 0, CW_USEDEFAULT, 0, NULL, NULL, hInstance, NULL)))
        return 0;


    initializeSound( hWnd );

    ::ShowWindow( hWnd, SW_SHOW );

    // Arduinoとのシリアル通信開始
    CensorIO censor;
    censor.open();

    // メッセージ ループ
    HDC dc = ::GetDC( hWnd );
    do {
        if( PeekMessage( &msg, NULL, 0, 0, PM_REMOVE ) ) {
            TranslateMessage( &msg );
            DispatchMessage( &msg );
        } else {
            int v = censor.getValue();
            DWORD hz = calcHz( v );
            if ( v >= 0 )
                pDSBuffer->SetFrequency( calcHz( v ) );

            char c[256];
            sprintf_s( c, 256, "Censor output: %03d, Sample Hz: %05d, Hz: %05d ", v, hz, (DWORD)(baseHz * hz / 44100.0f) );
            ::TextOutA( dc, 0, 0, c, strlen( c ) );
        }
    } while( msg.message != WM_QUIT );

    censor.close();

    ReleaseDC( hWnd, dc );

    return 0;
}
CensorIO.h
#ifndef MARUPEKE_CENSORIO_H
#define MARUPEKE_CENSORIO_H

#include <Windows.h>

class CensorIO {

    HANDLE hCom;

public:
    CensorIO();
    virtual ~CensorIO();

    bool open();
    void close();
    unsigned int getValue();
};

#endif

CensorIO.cpp
#include "CensorIO.h"
#include <Windows.h>

CensorIO::CensorIO() : hCom() {
}

CensorIO::~CensorIO() {
}

bool CensorIO::open() {
    // シリアルポートをオープン
    hCom = CreateFileA( "COM3", GENERIC_READ | GENERIC_WRITE, 0, 0, OPEN_EXISTING, 0, 0 );
    if ( hCom == INVALID_HANDLE_VALUE ) {
        hCom = 0;
        return false;
    }

    DCB dcb;
    GetCommState( hCom, &dcb );
    dcb.BaudRate = 9600;
    dcb.fBinary = 1;
    dcb.fDtrControl = DTR_CONTROL_ENABLE;
    dcb.ByteSize = 8;
    SetCommState( hCom, &dcb );

    return true;
}

void CensorIO::close() {
    if ( hCom != 0 )
    CloseHandle( hCom );
}

unsigned int CensorIO::getValue() {

    if ( hCom == 0 )
        return -1;

    // シリアルポートに対してデータ出力要請
    DWORD wroteBytes = 0;
    WriteFile( hCom, "1", 1, &wroteBytes, 0 );

    Sleep(10);

    // バッファの存在をチェック
    // 書き込み可能かチェック
    DWORD err = 0;
    COMSTAT state;
    ClearCommError( hCom, &err, &state );
    if ( state.cbInQue == 0 )
        return -1;

    // シリアルポートから読み込み要求
    char str[65] = {};
    char s = 0;
    for ( int i = 0; i < 64; i++ ) {
        ReadFile( hCom, &s, 1, &wroteBytes, 0 );
        if ( wroteBytes == 0 ) {
            i--;
            continue;
        }

        if ( s == ',' )
            break;

        str[i] = s;
    }

    return atoi( str );
}