よもやもダンプ

音楽、サッカー、プログラムなどをだらだらと。

Qtで音声のリアルタイム再生をする

キーボードをピアノの鍵盤に見立ててキーを押すと音が鳴る簡単なシンセサイザーSDLで作った後、音色もいじれるようにしようとQtでGUI化した。
オーディオストリーミング部分を移植中にQt(5.10)で説明してるものがあまり見つからなかったのでここで自分のやり方をまとめておく。

基本的な方法

今回はシンセを作っていたので、外部音声の録音は考えなかった。波形処理をするChipクラスを用意し、そこに音色パラメータを設定するメンバ関数と用意したバッファ配列にpcmデータ(16bit/任意のサンプリングレート/ステレオ)を指定したサンプル数だけ出力するメンバ関数mixを実装しておく。

キー入力・GUI処理とストリーミング処理は別スレッドで行う。
前者ではパラメータの変更が起こった時にChipへデータをセットする。ここはタイミングを気にせず素直に書き換えをする。
後者ではタイマやオーディオバッファの残量により一定のサンプリング数でChipのミキシングを行い、得られたpcmデータをオーディオバッファに詰め込む。つまりパラメータの変更が実際の出力に影響を与えるのは次のミキシングを行うタイミングからになる。ここでChipはスレッドセーフである必要が出てくるので、Chip内部でstd::mutexなどの排他制御を行うようにする。

Qtでの音声再生

QtのMultimediaモジュールにいくつか音声を扱うクラスが用意されている。このクラスたちは音声データを渡すと勝手に再生してくれるのであまりスレッドのことは考えなくてもいい、やったね。今回は生のpcmデータを出力するだけなので、QAudioOutputQIODeviceを使う。
QAudioOutputはサウンドデバイスを制御し、QIODeviceにデータを渡すと音声を再生する。QAudioOutputのstartを行うとストリーミングが開始するが、その方法によってバッファリングの方法がpushモードかpullモードのどちらかに決まる。

  • pushモード - start()を実行した時の戻り値QIODevice*にwriteを使ってpcmデータを適宜書き込むモード。SDLSDL_QueueAudioと同じ。書き込むタイミングはタイマーやバッファ残量を監視して決める。色々なサイトを見てみるとこの方法でリアルタイム再生を行っているコードが多かった。
  • pullモード - 予め用意したQIODeviceをstart(QIODevice*)でQAudioOutputに渡したときのモード。オーディオバッファの残量が減ってくるとQIODeviceのreadDataを呼び出し、バッファを補充する。SDLのサウンドコールバックと同じ。

今回は移植元のSDLでコールバックを使っていたのでpullモードで実装した。

サンプルコード

オーディオストリーミングはQIODeviceを継承した自動でミキシングするクラスAudioStreamMixerとそれらを扱うクラスAudioStreamで制御した。
キー入力・GUI処理の部分は最初にAudioStreamクラスを生成して、シンセのパラメータ変更があったらChipを書き換えるだけでいい。

  • audio_stream.hpp
#pragma once

#include <QAudioOutput>
#include <memory>
#include "chip.hpp"
#include "audio_stream_mixier.hpp"

class AudioStream
{
public:
    AudioStream(Chip& chip, int rate);
    ~AudioStream();

private:
    std::unique_ptr<QAudioOutput> audio_;
    std::unique_ptr<AudioStreamMixier> mixer_;
};
  • audio_stream.cpp
#include "audio_stream.hpp"
#include <QAudioFormat>
#include <QSysInfo>

AudioStream::AudioStream(Chip& chip, int rate)
{
    QAudioFormat format;
    format.setByteOrder(QAudioFormat::Endian(QSysInfo::ByteOrder));
    format.setChannelCount(2); // ステレオ2チャンネル
    format.setCodec("audio/pcm");
    format.setSampleRate(rate);
    format.setSampleSize(16);   // 16bit
    format.setSampleType(QAudioFormat::SignedInt);

    audio_ = std::make_unique<QAudioOutput>(&format);
    mixer_ = std::make_unique<AudioStreamMixier>(chip);

    mixer_->start();    // ミキシング開始
    audio_->start(mixer_.get());    // ストリーミング開始
}

AudioStream::~AudioStream()
{
    mixer_->stop();
    if (audio_->state() != QAudio::StoppedState) audio_->stop();
}
  • audio_stream_mixer.hpp
#pragma once

#include <QObject>
#include <QIODevice>
#include "chip.hpp"

class AudioStreamMixier : public QIODevice
{
public:
    AudioStreamMixier(Chip& chip, QObject* parent = nullptr);
    ~AudioStreamMixier();

    void start();
    void stop();

    qint64 readData(char *data, qint64 maxlen) override;
    qint64 writeData(const char *data, qint64 len) override;

private:
    Chip& chip_;
};
  • audio_stream_mixer.cpp
#include "audio_stream_mixier.hpp"

AudioStreamMixier::AudioStreamMixier(Chip& chip, QObject* parent) :
    QIODevice(parent),
    chip_(chip)
{
}

AudioStreamMixier::~AudioStreamMixier()
{
    if (isOpen()) stop();
}

void AudioStreamMixier::start()
{
    open(QIODevice::ReadOnly);    // Read onlyにしてデバイスオープン
}

void AudioStreamMixier::stop()
{
    close();    // デバイスを閉じる
}

qint64 AudioStreamMixier::readData(char* data, qint64 maxlen)    // オーディオバッファが少なくなると自動的に呼び出される
{
    qint64 generatedCount = maxlen >> 2;    // 必要なバイト数 / 2 (1サンプルのバイト数) / 2 (ステレオ) = 必要なサンプル数
    qint16* destPtr = reinterpret_cast<qint16*>(data);
    chip_.mix(destPtr, generatedCount);    // バッファに必要数だけサンプルをL,R,L,R...の順にをセット
    return maxlen;    // バッファにセットしたバイト数を返す
}

qint64 AudioStreamMixier::writeData(const char *data, qint64 len)   // 使わない
{
    Q_UNUSED(data);
    Q_UNUSED(len);
    return 0;
}

参考