Taro3

View on GitHub

ドラムトラックの作成

それでは、このプロジェクトに参加してみましょう。ch11-drum-machineという名前の新しいQtウィジェットアプリケーションプロジェクトを作成します。いつものように、ch11-drummachine.proにCONFIG += c++14を追加します。

では、SoundEventという名前の新しいC++クラスを作成します。ここでは、SoundEvent.hを関数から剥ぎ取っています。

#include <QtGlobal>

class SoundEvent
{
public:
    SoundEvent(qint64 timestamp = 0, int soundId = 0);
    ~SoundEvent();
    qint64 timestamp;
    int soundId;
};

このクラスには2つのパブリック・メンバしか含まれていません。

録音モードでは、ユーザーが音を再生するたびに、適切なデータでSoundEventが作成されます。SoundEvent.cppファイルがつまらないので、ここでは煽りません。

次に作るクラスはTrackです。ここでも新しいC++クラスを作成します。Track.hをメンバーだけで復習してみましょう。

#include <QObject>
#include <QVector>
#include <QElapsedTimer>

#include "SoundEvent.h"

class Track : public QObject
{
    Q_OBJECT

public:
    enum class State {
        STOPPED,
        PLAYING,
        RECORDING,
    };

    explicit Track(QObject *parent = 0);
    ~Track();

private:
    qint64 mDuration;
    std::vector<std::unique_ptr<SoundEvent>> mSoundEvents;
    QElapsedTimer mTimer;
    State mState;
    State mPreviousState;
};

これで、彼らについて詳しく知ることができるようになりました。

Track クラスは、プロジェクトのビジネスロジックの支点となるクラスです。これはアプリケーション全体の状態を表す mState を保持しています。その内容は、あなたの素晴らしい演奏の再生中に読み込まれ、ファイルにもシリアライズされます。

Track.hに関数を追加してみましょう。

class Track : public QObject
{
    Q_OBJECT

public:
    ...
    qint64 duration() const;
    State state() const;
    State previousState() const;
    quint64 elapsedTime() const;
    const std::vector<std::unique_ptr<SoundEvent>>& soundEvents() const;

signals:
    void stateChanged(State state);

public slots:
    void play();
    void record();
    void stop();
    void addSoundEvent(int soundEventId);

private:
    void clear();
    void setState(State state);

private:
    ...
};

簡単なゲッターは飛ばして、重要な機能に集中します。

ヘッダーがカバーされたので、Track.cppの面白いところを勉強していきましょう。

void Track::play()
{
    setState(State::PLAYING);
    mTimer.start();
}

Track.play()を呼び出すと、状態がPLAYINGに更新され、mTimerが起動します。TrackクラスはQt Multimedia APIに関連する何かを保持しているわけではなく、進化したデータホルダーに限定されています(ステートも管理しているので)。

今度はrecord()について、多くの驚きをもたらします。

void Track::record()
{
    clearSoundEvents();
    setState(State::RECORDING);
    mTimer.start();
}

データをクリアすることから始まり、状態をRECORDINGに設定し、mTimerを起動します。ここで、少し変わったstop()を考えてみましょう。

void Track::stop()
{
    if (mState == State::RECORDING) {
        mDuration = mTimer.elapsed();
    }
    setState(State::STOPPED);
}

RECORDING状態で停止している場合は、mDurationが更新されます。ここでは、何も特別なことはしていません。setState()の呼び出しを、本体を見ずに3回見ました。

void Track::setState(Track::State state)
{
    mPreviousState = mState;
    mState = state;
    emit stateChanged(mState);
}

mStateの現在値は、更新される前にmPreviousStateに格納されます。最後に、新しい値で stateChanged() が発行されます。

Trackのステートシステムは完全にカバーしています。最後に足りないのはSoundEventsのインタラクションです。まずはaddSoundEvent()スニペットから。

void Track::addSoundEvent(int soundEventId)
{
    if (mState != State::RECORDING) {
        return;
    }
    mSoundEvents.push_back(make_unique<SoundEvent>(
                           mTimer.elapsed(),
                           soundEventId));
}

SoundEventは、RECORDING状態になっている場合にのみ作成されます。その後、mTimerの現在の経過時間と渡されたsoundEventIdでmSoundEventsにSoundEventを追加します。

さて、clear()関数です。

void Track::clear()
{
    mSoundEvents.clear();
    mDuration = 0;
}

mSoundEventsではunique_ptr<SoundEvent>を使用しているので、mSoundEvents.clear()関数でベクトルを空にして、各SoundEventも削除するだけで十分です。これでスマートポインタで悩むことが一つ減りました。

SoundEventとTrackは、未来のビートに関する情報を保持する基底クラスです。このデータを読み込んで再生する役割を担うクラスを見ていきます。PlaybackWorkerです。

新しいC++クラスを作成し、PlaybackWorker.hをこのように更新します。

#include <QObject>
#include <QAtomicInteger>

class Track;

class PlaybackWorker : public QObject
{
    Q_OBJECT

public:
    explicit PlaybackWorker(const Track& track, QObject *parent = 0);

signals:
    void playSound(int soundId);
    void trackFinished();

public slots:
    void play();
    void stop();

private:
    const Track& mTrack;
    QAtomicInteger<bool> mIsPlaying;
};

PlaybackWorker クラスは別のスレッドで実行されます。メモリをリフレッシュする必要がある場合は、第9章の「マルチスレッドで正気を保つ」を参照してください。PlaybackWorker クラスの役割は、Track クラスのコンテンツを反復処理してサウンドをトリガーすることです。このヘッダを分解してみましょう。

このクラスの本質は play() 関数にあります。

void PlaybackWorker::play()
{
    mIsPlaying.store(true);
    QElapsedTimer timer;
    size_t soundEventIndex = 0;
    const auto& soundEvents = mTrack.soundEvents();

    timer.start();
    while(timer.elapsed() <= mTrack.duration()
            && mIsPlaying.load()) {
        if (soundEventIndex < soundEvents.size()) {
            const auto& soundEvent =
                                soundEvents.at(soundEventIndex);

            if (timer.elapsed() >= soundEvent->timestamp) {
                emit playSound(soundEvent->soundId);
                soundEventIndex++;
            }
        }
        QThread::msleep(1);
    }

    if (soundEventIndex >= soundEvents.size()) {
        emit trackFinished();
    }
}

play()関数が最初に行うことは、読み込みの準備です。mIsPlayingがtrueに設定され、QElapsedTimerクラスが宣言され、soundEventIndexが初期化されます。timer.elapsed()が呼ばれるたびに、音が再生されるべきかどうかを知ることができます。

どの音を再生するかを知るために、 soundEventIndex を使用して soundEvents ベクトルのどこにいるかを知ることができます。

その直後に、timerオブジェクトが開始され、whileループに入ります。このwhileループには、継続するための条件が2つあります。

直感的には、while条件の中にsoundEventIndex < soundEvents.size()条件を追加したかもしれません。そうすることで、最後の音が再生されたらすぐにPlaybackWorkerを終了させることができます。技術的にはこれで動作しますが、これではユーザが録音した音が尊重されません。

複雑なビートを作成し(4つの音で何ができるか侮ってはいけません!)、曲の最後に5秒間の長い一時停止を決めたユーザーを考えてみましょう。彼が停止ボタンをクリックすると、時間表示は00:55(55秒間)と表示されます。しかし、彼の演奏を再生すると、最後の音は00:50で終了。再生は00:50で停止し、番組は彼が録音したものを尊重しない。

このため、soundEventIndex < size() テストは while ループの中に移動され、読み込んだ soundEvents のヒューズとしてのみ使用されます。

この条件の中で、現在の soundEvent への参照を取得します。次に、経過時間を soundEvent のタイムスタンプと比較します。timer.elapsed() が soundEvent->timestamp 以上の場合、シグナル playSound() が soundId と一緒に発せられます。

これはあくまでも音を再生するためのリクエストです。PlaybackWorker クラスは soundEvents を読み込んで、適切なタイミングで playSound() をトリガーすることに限定しています。実際の音は後からSoundEffectWidgetクラスで処理します。

whileループの各イテレーションでは、ビジーループを避けるためにQThread::msleep(1)が行われます。スリープを最小限にしているのは、元のスコアにできるだけ忠実に再生したいからです。スリープが長ければ長いほど、再生タイミングに不一致が生じる可能性が高くなります。

最後に、全てのサウンドイベントが処理されていれば、trackFinished信号が出力されます。


戻る