aoe1231

知之为知之,不知为不知

JUCE - 音频

官方文档:https://juce.com/learn/tutorials/

1、构建音频播放器

本教程介绍如何打开和播放声音文件。其中包括一些在 JUCE 中处理声音文件的重要类。

级别:中级

平台: Windows、macOS、Linux

类: AudioFormatManagerAudioFormatReaderAudioFormatReaderSourceAudioTransportSourceFileChooserChangeListenerFileFileChooser

1.1、入门

在此处下载本教程的演示项目:PIP | ZIP。解压缩项目并在 Projucer 中打开第一个头文件。

1.2、演示项目

演示项目提供了一个三按钮界面,用于控制声音文件的播放。这三个按钮分别是:

  • 一个按钮向用户显示文件选择器,供他们选择声音文件。
  • 播放声音的按钮。
  • 一个按钮来停止声音。

界面如下图所示:

1.3、有用的课程

1.3.1、AudioSource 类

虽然我们可以在音频应用程序模板的getNextAudioBlock()函数中逐个生成音频样本,但有一些内置工具可用于生成和处理音频。这些工具允许我们将高级构建块链接在一起以形成强大的音频应用程序,而无需在我们的应用程序代码中处理每个音频样本(JUCE 代表我们执行此操作)。这些构建块基于AudioSource。事实上,如果您已经遵循了基于AudioAppComponent类的任何教程(例如,教程:构建白噪声生成器),那么您已经在使用AudioSource类了。AudioAppComponent类本身继承自AudioSource类,重要的是,它包含一个AudioSourcePlayer对象,该对象在AudioAppComponent和音频硬件设备之间传输音频。我们可以直接在getNextAudioBlock()函数中生成音频样本,但我们可以将多个AudioSource对象链接在一起以形成一系列流程。我们在本教程中使用此功能。

1.3.2、音频格式

JUCE 提供了许多用于读取和写入多种格式的声音文件的工具。在本教程中,我们将使用其中的几个,特别是使用以下类:

1.4、整合

现在,我们将把这些类与合适的用户界面类组合在一起,以制作我们的声音文件播放应用程序。此时,考虑播放音频文件的各个阶段(或传输状态)很有用。加载音频文件后,我们可以考虑以下四种可能的状态:

  • Stopped:音频播放已停止并准备开始。
  • Starting:音频播放尚未开始,但已被告知开始。
  • Playing:音频正在播放。
  • Stopping:音频正在播放,但播放已被告知停止,此后它将返回停止状态。

为了表示这些状态,我们在MainContentComponent类中创建一个enum

    enum {
        Stopped, // 已停止
        Starting, // 正在开始
        Playing, // 播放中
        Stopping // 停止中
    };

1.4.1、初始化接口

在我们MainContentComponent类的构造函数中,我们配置了三个按钮:

MainComponent::MainComponent():
    state(TransportState::Stopped)
{
    setSize (800, 600);

    // Some platforms require permissions to open input channels so request that here
    if (juce::RuntimePermissions::isRequired (juce::RuntimePermissions::recordAudio)
        && ! juce::RuntimePermissions::isGranted (juce::RuntimePermissions::recordAudio)) {
        juce::RuntimePermissions::request (juce::RuntimePermissions::recordAudio,
                                           [&] (bool granted) { setAudioChannels (granted ? 2 : 0, 2); });
    }
    else {
        // Specify the number of input and output channels that we want to open
        setAudioChannels (2, 2);
    }

    addAndMakeVisible(&openButton);
    openButton.setButtonText("Open...");
    openButton.onClick = [this]() {openButtonClicked(); };
    addAndMakeVisible(&playButton);
    playButton.setButtonText("Play");
    playButton.onClick = [this]() {playButtonClicked(); };
    playButton.setColour(juce::TextButton::buttonColourId, juce::Colours::green);
    playButton.setEnabled(false);
    addAndMakeVisible(&stopButton);
    stopButton.setButtonText("Stop");
    stopButton.onClick = [this]() {stopButtonClicked(); };
    stopButton.setColour(juce::TextButton::buttonColourId, juce::Colours::red);
    stopButton.setEnabled(false);
}

特别注意,我们最初禁用了播放停止按钮。加载有效文件后,播放按钮就会启用。我们可以在这里看到,我们为这三个按钮的Button::onClick辅​​助对象分配了一个 lambda 函数(请参阅教程:监听器和广播器)。我们还在构造函数的初始化列表中初始化了传输状态。

1.4.2、其他初始化

除了三个TextButton对象之外,我们的MainContentComponent类还有另外四个成员:

    juce::AudioFormatManager formatManager; // 音频格式管理
    std::unique_ptr<juce::AudioFormatReaderSource> readerSource;
    juce::AudioTransportSource transportSource;
    TransportState state;

这里我们看到前面提到的AudioFormatManagerAudioFormatReaderSourceAudioTransportSource类。

MainContentComponent构造函数中,我们需要初始化AudioFormatManager对象来注册标准格式列表:

    // 注册标准格式列表
    formatManager.registerBasicFormats();

至少这将使AudioFormatManager对象能够为 WAV 和 AIFF 格式创建读取器。其他格式可能可用,具体取决于平台和 Projucer 项目中模块中启用的选项,juce_audio_formats如以下屏幕截图所示:

MainContentComponent构造函数中,我们还将我们的MainContentComponent对象作为侦听器添加到AudioTransportSource对象中,以便我们可以响应其状态的变化(例如,当它停止时):

// 继承 ChangeListener 类
class MainComponent  : public juce::AudioAppComponent, public juce::ChangeListener    

// 在构造函数中添加如下代码
transportSource.addChangeListener(this);

注意:在这种情况下,函数addChangeListener()名称是这样的,而不是像JUCE 中的许多其他监听器类那样简单地使用addListener()

1.4.3、响应 AudioTransportSource 更改

当传输中的变化被报告时,changeListenerCallback()将被调用。这将在消息线程上异步调用:

void MainComponent::changeListenerCallback(juce::ChangeBroadcaster* source) {
    if (source == &transportSource) {
        if (transportSource.isPlaying()) {
            changeState(TransportState::Playing);
        } else {
            changeState(TransportState::Stopped);
        }
    }
}

您可以看到这只是调用一个changeState()成员函数。

1.4.4、改变状态

传输状态的改变被局限在这个单一的changeState()函数中。这有助于将此功能的所有逻辑集中在一个地方。此函数更新state成员并触发在新状态下需要对其他对象进行的任何更改。

void MainComponent::changeState(TransportState newState) {
    if (state != newState) {
        state = newState;
        switch (state) {
        case Stopped:
            stopButton.setEnabled(false);
            playButton.setEnabled(true);
            transportSource.setPosition(0.0);
            break;
        case Starting:
            playButton.setEnabled(false);
            transportSource.start();
        case Playing:
            stopButton.setEnabled(true);
            break;
        case Stopping:
            transportSource.stop();
            break;
        }
    }
}
  • 当传输返回到停止状态时,它会禁用“停止”按钮,启用“播放”按钮,并将传输位置重置回文件的开头。
  • 用户点击播放按钮会触发“开始”状态,这会告诉AudioTransportSource对象开始播放。此时我们也禁用了播放按钮。
  • AudioTransportSource对象通过函数报告变化后,会触发播放状态。这里我们启用了停止按钮。changeListenerCallback()
  • 停止状态是由用户点击停止按钮触发的,因此我们告诉AudioTransportSource对象停止。

1.4.5、处理音频

此演示项目中的音频处理非常简单:我们只需将通过AudioAppComponent类传递的AudioSourceChannelInfo结构传递给AudioTransportSource对象即可:

void MainComponent::getNextAudioBlock (const juce::AudioSourceChannelInfo& bufferToFill)
{
    if (readerSource.get() == nullptr) {
        bufferToFill.clearActiveBufferRegion();
        return;
    }
    transportSource.getNextAudioBlock(bufferToFill);
}

请注意,我们首先检查是否存在有效的AudioFormatReaderSource对象,如果没有,则简单地将输出归零(使用方便的AudioSourceChannelInfo ::clearActiveBufferRegion()函数)。AudioFormatReaderSource成员存储在 std::unique_ptr 对象中,因为我们需要根据用户的操作动态创建这些对象。它还允许我们检查是否存在nullptr无效对象。

我们还需要记住将prepareToPlay()回调传递给我们正在使用的任何其他AudioSource对象:

void MainComponent::prepareToPlay (int samplesPerBlockExpected, double sampleRate)
{
    // This function will be called when the audio device is started, or when
    // its settings (i.e. sample rate, block size, etc) are changed.

    // You can use this function to initialise any resources you might need,
    // but be careful - it will be called on the audio thread, not the GUI thread.

    // For more details, see the help for AudioProcessor::prepareToPlay()
    transportSource.prepareToPlay(samplesPerBlockExpected, sampleRate);
}

还有releaseResources()回调:

void MainComponent::releaseResources()
{
    // This will be called when the audio device stops, or when it is being
    // restarted due to a setting change.

    // For more details, see the help for AudioProcessor::releaseResources()
    transportSource.releaseResources();
}

1.4.6、打开文件

要打开文件,我们会弹出一个FileChooser对象来响应单击“打开...”按钮:

void MainComponent::openButtonClicked() {
    // 创建带有简短消息的FileChooser对象并允许用户选择.wav文件
    chooser = std::make_unique<juce::FileChooser>("Select a Wave file to play...",
        juce::File{},
        "*.wav");
    auto chooserFlags = juce::FileBrowserComponent::openMode
        | juce::FileBrowserComponent::canSelectFiles;

    // 弹出FileChooser对象
    chooser->launchAsync(chooserFlags, [this](const juce::FileChooser& fc) {
        auto file = fc.getResult();

        // 如果文件不为空(用户实际选择了一个文件)
        if (file != juce::File{}) {
            // 尝试为特定文件创建读取器,如果失败返回nullptr(比如:该文件不是AudioFormatManager对象可以处理的音频格式)
            auto* reader = formatManager.createReaderFor(file);

            if (reader != nullptr) {
                // 使用reader创建一个新的AudioFormatReaderSource对象,第二个参数为true,表示我们希望AudioFormatReaderSource对象管理AudioFormatReader对象并在不再需要时将其删除。我们将AudioFormatReaderSource对象存储在临时的std::unique_ptr对象中,以避免在后续打开文件的命令中过早删除之前分配的AudioFormatReaderSource。
                auto newSource = std::make_unique<juce::AudioFormatReaderSource>(reader, true);
                // 将AudioFormatReaderSource对象连接到我们getNextAudioBlock()函数中使用的AudioTransportSource对象。如果文件的采样率与硬件采样率不匹配,我们会将其作为第四个参数传入,该参数是从AudioFormatReader获得的。AudioTransportSource将处理任何必要的采样率转换。
                transportSource.setSource(newSource.get(), 0, nullptr, reader->sampleRate);
                // 启用播放按钮,以便用户可以点击它
                playButton.setEnabled(true);
                // 由于AudioTransportSource现在应该使用我们新分配的AudioFormatReaderSource对象,因此我们可以安全地将AudioFormatReaderSource对象存储在我们的成员中。为此,我们必须使用std::unique_ptr::release()从局部变量newSource转移所有权
                readerSource.reset(newSource.release());
            }
        }
    });
}

注意:将新分配的AudioFormatReaderSource对象存储在临时 std::unique_ptr 对象中还有一个额外的好处,那就是可以避免异常。在函数调用AudioTransportSource::setSource()期间可能会抛出异常,在这种情况下,std::unique_ptr 对象将删除不再需要的AudioFormatReaderSource对象。如果此时使用原始指针来存储AudioFormatReaderSource对象,那么可能会发生内存泄漏,因为如果抛出异常,指针将处于悬空状态。

1.4.7、播放和停止文件

由于我们已经设置了实际播放文件的代码,因此我们只需使用适当的参数调用我们的changeState()函数即可播放文件。单击“播放”按钮时,我们将执行以下操作:

void MainComponent::playButtonClicked() {
    changeState(TransportState::Starting);
}

当单击“停止”按钮时,停止文件同样简单:

练习:创建FileChooser对象时更改第三个 ( filePatternsAllowed) 参数,以允许应用程序也加载 AIFF 文件。文件模式可以用分号分隔,因此这应该允许此格式的两个常见文件扩展名"*.wav;*.aif;*.aiff"

1.4.8、添加暂停功能

现在,我们将逐步介绍如何为应用程序添加暂停功能。在这里,我们将使播放按钮在文件播放时变为暂停按钮(而不是仅仅禁用它)。我们还将使停止按钮在声音文件暂停时变为返回零按钮。

首先,我们需要向TransportState枚举中添加两个状态PausingPaused:

    enum TransportState {
        Stopped,
        Starting,
        Playing,
        Pausing,
        Paused,
        Stopping
    };  

我们的changeState()函数需要处理这两个新状态,并且其他状态的代码也需要更新:

void MainComponent::changeState(TransportState newState) {
    if (state != newState) {
        state = newState;
        switch (state) {
        case Stopped:
            playButton.setButtonText("Play");
            stopButton.setButtonText("Stop");
            stopButton.setEnabled(false);
            // playButton.setEnabled(true);
            transportSource.setPosition(0.0);
            break;
        case Starting:
            // playButton.setEnabled(false);
            transportSource.start();
        case Playing:
            playButton.setButtonText("Pause");
            stopButton.setButtonText("Stop");
            stopButton.setEnabled(true);
            break;
        case Pausing:
            transportSource.stop();
            break;
        case Paused:
            playButton.setButtonText("Resume");
            stopButton.setButtonText("Return to Zero");
            break;
        case Stopping:
            transportSource.stop();
            break;
        }
    }
}

我们适当地启用和禁用按钮,并在每个状态下正确更新按钮文本。

请注意,当要求在暂停状态下暂停时,我们实际上会停止传输。在changeListenerCallback()函数中,我们需要根据是否发出暂停或停止请求来更改逻辑以移动到正确的状态:

void MainComponent::changeListenerCallback(juce::ChangeBroadcaster* source) {
    if (source == &transportSource) {
        if (transportSource.isPlaying()) {
            changeState(TransportState::Playing);
        } else if ((state == Stopping) || (state == Playing)) {
            changeState(TransportState::Stopped);
        } else if (state == Pausing) {
            changeState(Paused);
        }
    }
}

我们需要更改单击播放按钮时的代码:

void MainComponent::playButtonClicked() {
    if ((state == Stopped) || (state == Paused))
        changeState(Starting);
    else if (state == Playing)
        changeState(Pausing);
}

当单击“停止”按钮时:

void MainComponent::stopButtonClicked() {
    if (state == Paused)
        changeState(Stopped);
    else
        changeState(Stopping);
}

就是这样:您现在应该能够构建并运行该应用程序。

练习:将Label对象添加到显示AudioTransportSource对象当前时间位置的界面。您可以使用AudioTransportSource::getCurrentPosition()函数获取此位置。您还需要让该MainContentComponent类继承自Timer类,并在timerCallback()函数中执行定期更新以更新标签。您甚至可以使用RelativeTime类将原始时间(以秒为单位)转换为更有用的格式(以分钟、秒和毫秒为单位)。

2、处理音频输入

本教程展示如何处理音频输入并将其传递到音频输出。

级别:初学者

平台: Windows、macOS、Linux

类: RandomBigIntegerAudioBuffer

2.1、入门

在此处下载本教程的演示项目:PIP | ZIP。解压缩项目并在 Projucer 中打开第一个头文件。

注意:如果您的操作系统要求您请求访问麦克风的权限(目前为 iOS、Android 和 macOS Mojave),那么您需要在 Projucer 中的相关导出器下设置相应的选项并重新保存项目。

2.2、演示项目

演示项目使用白噪声调制输入信号。白噪声的级别可以改变,从而影响整体输出的级别(请参阅教程:控制音频级别以了解用于生成白噪声的技术)。结果是输入信号非常“模糊”的版本。

警告:运行应用程序时请小心避免反馈(尽管效果可能非常有趣!)。

最好使用单独的麦克风和耳机。当然,您需要某种音频输入设备才能使项目正常工作。

2.3、音频输入

本教程使用AudioAppComponent类作为演示项目应用程序的基础。在其他教程中,我们在getNextAudioBlock()函数内生成音频 - 请参阅教程:构建白噪声生成器教程:控制音频级别教程:构建正弦波合成器。在本教程中,我们读取音频输入并输出一些音频。在MainContentComponent构造函数中,我们请求两个音频输入和两个音频输出:

setAudioChannels (2, 2);

注意:实际可用的输入或输出数量可能少于我们请求的数量。

2.4、重复使用缓冲区

重要的是要知道输入和输出缓冲区并非完全分开。输入和输出使用相同的缓冲区。您可以通过暂时注释掉getNextAudioBlock()函数中的所有代码来测试这一点。如果您随后运行应用程序,音频输入将直接传递到输出。在getNextAudioBlock()函数中,bufferToFill结构内的AudioSampleBuffer对象中的通道数可能大于输入通道数、输出通道数或两者。重要的是仅访问引用您请求的可用输入和输出通道数的数据。特别是,如果您的输入通道多于输出通道,则不得修改包含只读数据的通道。

2.5、获取活跃频道

getNextAudioBlock()函数中,我们获取BigInteger对象,该对象将活动输入和输出通道列表表示为位掩码(这类似于std::bitset类或使用std::vector<bool>对象)。在这些BigInteger对象中,通道由组成BigInteger值的位中的 0(非活动)或 1(活动)表示。

注意:请参阅教程:BigInteger 类,了解可对BigInteger对象执行的其他操作。

    void getNextAudioBlock (const juce::AudioSourceChannelInfo& bufferToFill) override
    {
        auto* device = deviceManager.getCurrentAudioDevice();
        juce::BigInteger activeInputChannels  = device->getActiveInputChannels();
        juce::BigInteger activeOutputChannels = device->getActiveOutputChannels();
        ...
    }

要计算出我们需要迭代的最大通道数,我们可以检查BigInteger对象中的位以找到最高数字位。最大通道数将比此多一个。

        auto maxInputChannels  = activeInputChannels .getHighestBit() + 1;
        auto maxOutputChannels = activeOutputChannels.getHighestBit() + 1;

然后从我们的levelSlider获取所需的级别,然后继续处理音频,一次处理一个输出通道。如果最大输入通道数为零(如果我们的硬件没有音频输入,即使我们请求两个通道,也可能发生这种情况),那么我们不能尝试处理音频。在这种情况下,我们只需将输出通道缓冲区清零(以输出静音)。单个输出通道也可能处于非活动状态,因此我们检查通道的状态,如果通道处于非活动状态,则输出该通道的静音,否则继续处理输入数据直至输出:

        auto level = (float) levelSlider.getValue();

        for (auto channel = 0; channel < maxOutputChannels; ++channel)
        {
            // 通道不是活跃状态或者没有输入状态,则静音
            if ((! activeOutputChannels[channel]) || maxInputChannels == 0)
            {
                bufferToFill.buffer->clear (channel, bufferToFill.startSample, bufferToFill.numSamples);
            }
            else
            {
                // 我们请求的输出通道可能多余输入通道,因此我们的应用需要决定如何处理这些额外的输出。这里是重复输入通道即可。在其他应用中,当输出通道多余输入通道时,为编号较高的通道输出静音可能更合适
                auto actualInputChannel = channel % maxInputChannels; // [1]

                // 单个输入通道可能处于非活动状态,因此在这种情况下我们也输出静音
                if (! activeInputChannels[channel]) // [2]
                {
                    bufferToFill.buffer->clear (channel, bufferToFill.startSample, bufferToFill.numSamples);
                }
                else // [3]
                {
                    // 这个最后的块实际上负责处理,在这里,我们获取指向输入和输出缓冲区样本的指针,并向输入样本添加一些噪声
                    // 实际上inBuffer和outBuffer指向的是同一个对象,而inBuffer使用const修饰,为只读
                    auto* inBuffer = bufferToFill.buffer->getReadPointer (actualInputChannel, bufferToFill.startSample);
                    auto* outBuffer = bufferToFill.buffer->getWritePointer (channel, bufferToFill.startSample);

                    for (auto sample = 0; sample < bufferToFill.numSamples; ++sample)
                    {
                        auto noise = (random.nextFloat() * 20.0f) - 1.0f;
                        outBuffer[sample] = inBuffer[sample] + (inBuffer[sample] * noise * level);
                    }
                }
            }
        }

练习:在此示例中,我们不会像教程:构建正弦波合成器中那样平滑振幅变化。这样做部分是为了使示例保持简单,但您可能不会听到由于噼啪声效果而产生的任何其他故障。修改代码以仅输出输入通道(按级别滑块值缩放),但也要平滑级别变化,以免出现故障。

3、使用AudioSampleBuffer 类循环音频

本教程介绍如何播放和循环播放 AudioSampleBuffer 对象中存储的音频。这是处理录制的音频数据的采样应用程序的有用基础。

级别:中级

平台: Windows、macOS、Linux

类: AudioBufferAudioFormatReaderAudioAppComponent

3.1、入门

在此处下载本教程的演示项目:PIP | ZIP。解压缩项目并在 Projucer 中打开第一个头文件。

3.2、演示项目

本教程的演示项目允许用户打开声音文件,将整个文件读入AudioSampleBuffer对象,然后循环播放。在教程:构建音频播放器中,我们使用连接到AudioTransportSource对象的AudioFormatReaderSource对象播放声音文件。使用此方法可以进行循环,方法是启用AudioFormatReaderSource对象的循环标志 — 使用AudioFormatReaderSource::setLooping()函数。

本教程中与讨论相关的所有代码都在演示项目的MainContentComponent类中。

3.3、将示例数据加载到内存中

在许多情况下,使用内置类来播放声音文件可能更好。有时您可能需要自己执行此操作,本教程将介绍一些技术。采样器应用程序通常像这样将声音文件数据加载到内存中,尤其是当声音相对较短时(有关示例,请参阅SamplerSound类)。合成声音也可以通过将波表存储在AudioSampleBuffer对象中并以适当的速率循环以产生所需的音乐音高来实现。教程:波表合成中对此进行了探讨。

本教程还重点介绍了在将文件访问和音频线程上的音频处理相结合时可能遇到的一些潜在多线程问题。其中一些问题表面上看起来很简单,但通常需要谨慎应用技术才能避免崩溃和音频故障。这些技术将在教程:使用 AudioSampleBuffer 类循环音频(高级)中进一步探讨。

3.3.1、为什么要限制长度?

演示项目将您可以加载的声音文件的长度限制为少于 2 秒。这个限制相当随意,但大致有两个原因:

  1. 如果整个文件非常大,那么您的计算机可能会耗尽物理内存。当然,在实际应用中,您可以使用更高的限制。加载到AudioSampleBuffer对象中的 2 秒立体声音频文件(采样率为 44.1kHz)仅占用 705,600 字节内存。(参见注释)
  2. 即使加载很短的文件也不会花费太多时间。

关于第 1 点:如果我们超出了计算机的物理内存量,它可能会开始使用虚拟内存(即硬盘等辅助存储)。这违背了将数据加载到内存中的初衷!当然,如果内存不足,该操作在某些设备上可能会失败。

关于第 2 点:在FileChooser::browseForFileToOpen()函数返回用户选择的文件后,我们直接加载音频数据,使示例保持简单。这意味着消息线程将被阻止,直到所有音频都从磁盘读入AudioSampleBuffer对象。即使是短声音,我们也应该在后台线程上执行此操作,以保持用户界面对用户的响应尽可能快。对于长声音,延迟和无响应将非常明显。添加另一个(后台)线程会增加此示例的复杂性。有关如何以这种方式在后台线程上加载文件的示例,请参阅教程:使用 AudioSampleBuffer 类循环音频(高级)。

练习:为简单起见,如果您尝试加载较长的文件,演示项目不会报告错误 — 它只是失败。添加这样的错误报告留给您作为额外的练习。

3.3.2、读取声音文件

当用户点击“打开...”按钮时,系统会显示一个文件选择器。然后整个文件会被读入我们MainContentComponent类中的AudioSampleBuffer成员fileBuffer中。

    void openButtonClicked()
    {
        // 请注意,每次打开新文件时,我们都会关闭AudioAppComponent对象的音频系统。这是为了避免已经暗示的一些多线程问题。一旦音频系统关闭,当我们仍在调用Button::onClick lambda函数(该函数将从消息线程调用openButtonClicked()函数)时,我们的getNextAudioBlock()函数就不会在音频线程上被调用。
        shutdownAudio(); // [1]

        chooser = std::make_unique<juce::FileChooser> ("Select a Wave file shorter than 2 seconds to play...", juce::File{}, "*.wav");
        auto chooserFlags = juce::FileBrowserComponent::openMode
                          | juce::FileBrowserComponent::canSelectFiles;

        chooser->launchAsync (chooserFlags, [this] (const juce::FileChooser& fc)
        {
            auto file = fc.getResult();

            if (file == juce::File{})
                return;

            // 使用std::unique_ptr来自己管理此对象。在构建音频播放器小节中,我们将AudioFormatReader对象传递给AudioFormatReaderSource对象来为我们管理。此操作可能无法创建读取器对象,因此我们必须检查下一行中的指针是否为nullptr
            std::unique_ptr<juce::AudioFormatReader> reader (formatManager.createReaderFor (file)); // [2]

            if (reader.get() != nullptr)
            {
                // 将文件的长度(以sample为单位)除以其采样率来计算声音文件的持续时间
                auto duration = (float) reader->lengthInSamples / reader->sampleRate; // [3]

                // 如果持续时间大于2秒
                if (duration < 2)
                {
                    // 使用来自AudioFormatReader对象的通道数和长度来调整AudioSampleBuffer对象的大小
                    fileBuffer.setSize ((int) reader->numChannels, (int) reader->lengthInSamples);  // [4]

                    // 将AudioFormatReader对象中的音频数据读入AudioSampleBuffer成员
                    reader->read (&fileBuffer, // [5]
                                  0, //  [5.1] AudioSampleBuffer对象中目标起始sample,数据将开始从此处写入
                                  (int) reader->lengthInSamples, //  [5.2] 要读取的样本数量
                                  0,  //  [5.3] AudioSampleBuffer对象中将开始读取的起始sample
                                  true, //  [5.4] 对于立体声(或其他双声道)文件,此标志指示是否读取左声道
                                  true); //  [5.5] 对于立体声文件,此标志指示是否读取右声道

                    // 我们需要在播放时将最新的读取位置存储在缓冲区中,这会将我们的position成员重置为0
                    position = 0; // [6]

                    // 这将重新启动音频系统,在这里,我们有机会使用声音文件中的通道数来尝试将音频设备配置为相同的通道数
                    setAudioChannels (0, (int) reader->numChannels); // [7]
                }
                else
                {
                    // handle the error that the file is 2 seconds or longer..
                }
            }
        });
    }

3.4、处理音频

getNextAudioBlock()函数中,从我们的AudioSampleBuffer fileBuffer成员中读取适当数量的样本,并将其写出到AudioSourceChannelInfo结构中的AudioSampleBuffer对象。

从文件读取音频数据时,我们使用position成员跟踪当前读取位置(注意在处理完指定样本块的所有音频通道后更新它):

    void getNextAudioBlock (const juce::AudioSourceChannelInfo& bufferToFill) override
    {
        auto numInputChannels = fileBuffer.getNumChannels();
        auto numOutputChannels = bufferToFill.buffer->getNumChannels();

        // 需要输出的样本总数,用来检查是否需要退出下面的while循环
        auto outputSamplesRemaining = bufferToFill.numSamples; // [8]
        // 用作目标缓冲区内的偏移量
        auto outputSamplesOffset = bufferToFill.startSample; // [9]

        while (outputSamplesRemaining > 0)
        {
            // 计算读取的缓冲区中还剩下多少个样本
            auto bufferSamplesRemaining = fileBuffer.getNumSamples() - position; // [10]
            // 表示本次的采样数
            auto samplesThisTime = juce::jmin (outputSamplesRemaining, bufferSamplesRemaining); // [11]

            for (auto channel = 0; channel < numOutputChannels; ++channel)
            {
                // 将数据从一个缓冲区的一个通道复制到另一个通道
                bufferToFill.buffer->copyFrom (channel, // [12] 指定目标通道索引
                                               outputSamplesOffset, // [12.1] 目标缓冲区内的样本偏移量
                                               fileBuffer, //  [12.2] 要复制的源buffer对象
                                               channel % numInputChannels, //  [12.3] 源缓冲区的通道索引,如果源缓冲区的通道数小于目标缓冲区,将使用此模数计算。例如,单声道源缓冲区将意味着结果始终为0,将相同的数据复制到每个输出通道
                                               position, //  [12.4] 源缓冲区中开始读取的位置
                                               samplesThisTime); //  [12.5] 要读取的样本数量
            }

            // 扣除刚刚处理的样本数
            outputSamplesRemaining -= samplesThisTime; // [13]
            // 如果再进行一次循环,则增加相同的量
            outputSamplesOffset += samplesThisTime; // [14]
            // 给予相同的补偿
            position += samplesThisTime; // [15]

            // 检查是否到达末尾,并在必要时将其重置为0以形成循环
            if (position == fileBuffer.getNumSamples())
                position = 0; // [16]
        }
    }

3.5、多线程问题

如前所述,本教程通过在用户每次单击“打开...”getNextAudioBlock()按钮时关闭并重新启动音频来避免多线程问题。但如果我们不这样做,会发生什么?有很多事情可能会出错,所有这些都与和openButtonClicked()函数可能同时在不同的线程中运行这一事实有关。以下是一些示例:

  • 假设应用程序已经在播放音频文件,用户单击“打开...”按钮并选择了一个新文件。假设音频线程在[4][5]之间中断此功能。缓冲区已调整大小,但数据尚未写入缓冲区。缓冲区可能仍包含来自前一个文件的音频数据,但这取决于缓冲区调整大小时是否需要移动缓冲区的内存。无论如何,我们可能会遇到故障。
  • getNextAudioBlock()函数可能会被openButtonClicked()函数中的代码中断。假设这种情况发生在[11]之后,并且openButtonClicked()函数刚刚到达[4]。缓冲区的大小可能会调整为比原来短,但我们在几行之前已经计算了起点。这可能会导致内存访问错误,应用程序可能会崩溃。
  • 调用AudioSampleBuffer::copyFrom()函数时,该getNextAudioBlock()函数可能会被中断。同样,根据此函数的实现,我们最终可能会访问不应该访问的内存。

警告:还有许多其他事情可能会出错。您可能熟悉使用关键部分来同步线程之间的内存访问。这只是一种可能的解决方案,但在音频代码中使用关键部分时应小心谨慎,因为它可能导致优先级反转,从而导致音频丢失。我们在教程:使用 AudioSampleBuffer 类循环音频(高级)中查看了一种避免关键部分的解决方案。

4、使用AudioSampleBuffer 类循环音频(高级)

本教程将介绍如何使用线程安全技术播放和循环播放AudioSampleBuffer对象中存储的音频。此外,还介绍了一种在后台线程中加载音频数据的技术。

级别:高级

平台: Windows、macOS、Linux

类: ReferenceCountedObjectReferenceCountedArrayThreadAudioBuffer

4.1、入门

在此处下载本教程的演示项目:PIP | ZIP。解压缩项目并在 Projucer 中打开第一个头文件。

4.2、演示项目

该演示项目实现了与教程:使用 AudioSampleBuffer 类循环播放音频中的演示项目类似的行为。它允许用户打开加载到缓冲区并循环播放的音频文件。本教程的一个主要区别是音频系统保持运行,而不是每次我们浏览文件时都关闭它。这是通过使用一些有用的类以线程安全的方式在线程之间进行通信来实现的。

4.3、线程安全技术

您应该还记得在教程:使用 AudioSampleBuffer 类循环音频中,我们如何解决音频线程和消息线程访问可能不完整或损坏的数据的潜在问题。就在浏览文件之前,我们关闭了音频系统。然后,一旦选择了文件,我们就打开文件并重新启动音频系统。这在实际应用中显然是一种不切实际且繁琐的方法!

4.3.1、引用计数对象

ReferenceCountedObject类是用于在线程之间传递消息和数据的有用工具。在这里,我们将AudioSampleBuffer对象和播放位置存储在ReferenceCountedObject类中。为了帮助调试并帮助说明该类的工作原理,我们还包含name成员(尽管这对于该类的运行并非绝对必要):

public:
    class ReferenceCountedBuffer  : public juce::ReferenceCountedObject
    {
    public:
        typedef juce::ReferenceCountedObjectPtr<ReferenceCountedBuffer> Ptr;

        ReferenceCountedBuffer (const juce::String& nameToUse,
                                int numChannels,
                                int numSamples)
            : name (nameToUse),
              buffer (numChannels, numSamples)
        {
            DBG (juce::String ("Buffer named '") + name + "' constructed. numChannels = " + juce::String (numChannels) + ", numSamples = " + juce::String (numSamples));
        }

        ~ReferenceCountedBuffer()
        {
            DBG (juce::String ("Buffer named '") + name + "' destroyed");
        }

        juce::AudioSampleBuffer* getAudioSampleBuffer()
        {
            return &buffer;
        }

        int position = 0;

    private:
        juce::String name;
        juce::AudioSampleBuffer buffer;

        JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (ReferenceCountedBuffer)
    };

类顶部的typedef是实现ReferenceCountedObject子类的重要部分。我们不将ReferenceCountedBuffer对象存储在原始指针中,而是将其存储在ReferenceCountedBuffer::Ptr类型中。正是它管理对象的引用计数(根据需要递增和递减)及其生存期(当引用计数达到零时删除对象)。我们还可以使用ReferenceCountedArray类存储ReferenceCountedBuffer对象的数组。

在我们的MainContentComponent类中,我们存储一个数组和一个实例:

    juce::SpinLock mutex;
    juce::ReferenceCountedArray<ReferenceCountedBuffer> buffers;
    ReferenceCountedBuffer::Ptr currentBuffer;

buffers成员会保留数组中的缓冲区,直到我们完全确定音频线程不再需要它们为止。该currentBuffer成员会保留当前选定的缓冲区。

4.3.2、实现后台线程

我们的MainContentComponent类继承自Thread类:

class MainContentComponent   : public juce::AudioAppComponent,
                               private juce::Thread
{
}

这用于实现我们的后台线程。我们重写的Thread::run()函数如下:

    void run() override
    {
        while (! threadShouldExit())
        {
            checkForBuffersToFree();
            wait (500);
        }
    }

在这里,我们检查是否有任何缓冲区需要释放,然后我们的线程等待 500 毫秒或被唤醒(使用Thread::notify()函数)。本质上,这意味着检查将至少每 500 毫秒发生一次。该checkForBuffersToFree()函数搜索我们的buffers数组以查看是否可以释放任何缓冲区:

    void checkForBuffersToFree()
    {
        // 在这些情况下,记住反向迭代数组很有用,如果我们在迭代数组时删除item,则更容易避免破坏数组索引访问
        for (auto i = buffers.size(); --i >= 0;)                           // [1]
        {
            // 保留指定索引处的缓冲区的副本
            ReferenceCountedBuffer::Ptr buffer (buffers.getUnchecked (i)); // [2]

            // 如果此时引用计数等于2,那么我们就知道音频线程不能使用缓冲区,我们可以将其从数组中删除。这两个引用中的一个将位于buffers中,另一个将位于局部buffer变量中。被删除的缓冲区将随着buffer变量超出范围而自行删除(因为这将是最后一个剩余的引用)
            if (buffer->getReferenceCount() == 2)                          // [3]
                buffers.remove (i);
        }
    }

当然,我们需要在应用程序启动时启动线程,我们在MainContentComponent构造函数中执行此操作:

        startThread();

4.3.3、打开文件

我们的openButtonClicked()函数与教程中的openButtonClicked()函数类似:使用 AudioSampleBuffer 类循环音频,但有一些细微的差别:

    void openButtonClicked()
    {
        chooser = std::make_unique<juce::FileChooser> ("Select a Wave file shorter than 2 seconds to play...",
                                                       juce::File{},
                                                       "*.wav");
        auto chooserFlags = juce::FileBrowserComponent::openMode
                          | juce::FileBrowserComponent::canSelectFiles;

        chooser->launchAsync (chooserFlags, [this] (const juce::FileChooser& fc)
        {
            auto file = fc.getResult();

            if (file == juce::File{})
                return;

            std::unique_ptr<juce::AudioFormatReader> reader (formatManager.createReaderFor (file));

            if (reader != nullptr)
            {
                auto duration = (float) reader->lengthInSamples / reader->sampleRate;

                if (duration < 2)
                {
                    // 分配一个新实例
                    ReferenceCountedBuffer::Ptr newBuffer = new ReferenceCountedBuffer (file.getFileName(),
                                                                                        (int) reader->numChannels,
                                                                                        (int) reader->lengthInSamples);

                    // 将音频数据读入其包含的AudioSampleBuffer对象
                    reader->read (newBuffer->getAudioSampleBuffer(), 0, (int) reader->lengthInSamples, 0, true, true);

                    {
                        // 使其成为当前缓冲区
                        const juce::SpinLock::ScopedLockType lock (mutex);
                        currentBuffer = newBuffer;
                    }

                    // 将其添加到我们的缓冲区数组中
                    buffers.add (newBuffer);
                }
                else
                {
                    // handle the error that the file is 2 seconds or longer..
                }
            }
        });
    }

要清除当前缓冲区,我们可以将其值设置为nullptr:

    void clearButtonClicked()
    {
        const juce::SpinLock::ScopedLockType lock (mutex);
        currentBuffer = nullptr;
    }

4.3.4、播放缓冲区

我们的getNextAudioBlock()函数类似于教程getNextAudioBlock()中的函数:使用 AudioSampleBuffer 类循环音频,只是我们需要访问当前ReferenceCountedBuffer对象及其包含的AudioSampleBuffer对象。

    void getNextAudioBlock (const juce::AudioSourceChannelInfo& bufferToFill) override
    {
        // 保留currentBuffer成员的副本。在函数的这一点之后,如果成员在另一个线程上被更改,则无关紧要,因为我们已经获取了本地副本。请注意,我们在这里使用尝试锁定currentBuffer,这样音频线程就不会因为另一个线程正在修改currentBuffer而卡住等待访问。
        auto retainedCurrentBuffer = [&]() -> ReferenceCountedBuffer::Ptr // [4]
        {
            const juce::SpinLock::ScopedTryLockType lock (mutex);

            if (lock.isLocked())
                return currentBuffer;

            return nullptr;
        }();

        // 如果复制时成员为空,则输出静音
        if (retainedCurrentBuffer == nullptr)                                           // [5]
        {
            bufferToFill.clearActiveBufferRegion();
            return;
        }

        // 访问ReferenceCountedBuffer对象内部的AudioSampleBuffer对象
        auto* currentAudioSampleBuffer = retainedCurrentBuffer->getAudioSampleBuffer(); // [6]
        // 获取缓冲区的当前播放位置
        auto position = retainedCurrentBuffer->position;                                // [7]

        auto numInputChannels = currentAudioSampleBuffer->getNumChannels();
        auto numOutputChannels = bufferToFill.buffer->getNumChannels();

        auto outputSamplesRemaining = bufferToFill.numSamples;
        auto outputSamplesOffset = 0;

        while (outputSamplesRemaining > 0)
        {
            auto bufferSamplesRemaining = currentAudioSampleBuffer->getNumSamples() - position;
            auto samplesThisTime = juce::jmin (outputSamplesRemaining, bufferSamplesRemaining);

            for (auto channel = 0; channel < numOutputChannels; ++channel)
            {
                bufferToFill.buffer->copyFrom (channel,
                                               bufferToFill.startSample + outputSamplesOffset,
                                               *currentAudioSampleBuffer,
                                               channel % numInputChannels,
                                               position,
                                               samplesThisTime);
            }

            outputSamplesRemaining -= samplesThisTime;
            outputSamplesOffset += samplesThisTime;
            position += samplesThisTime;

            if (position == currentAudioSampleBuffer->getNumSamples())
                position = 0;
        }

        // 修改当前播放位置后,我们将其存储回ReferenceCounterBuffer对象中
        retainedCurrentBuffer->position = position;                                     // [8]
    }

此算法可确保ReferenceCountedBuffer不会在音频线程中删除对象。在音频线程上分配或释放内存并不是一个好主意。对象ReferenceCountedBuffer只会在我们的后台线程上被删除。

4.4、在后台线程中读取音频

我们的应用程序仍然在消息线程上读取音频数据。这并不理想,因为这会阻塞消息线程,并且大文件可能需要一些时间才能加载。事实上,我们也可以使用后台线程来执行此任务。

4.4.1、将文件路径传递给后台线程

首先,向MainContentComponent类中添加以下成员:

    juce::CriticalSection pathMutex;
    juce::String chosenPath;

现在将openButtonClicked()函数更改为将文件的完整路径交换到此成员中。交换字符串在技术上不是线程安全的,因此我们还需要锁定以确保chosenPath在此线程使用它时没有其他线程尝试修改它。

    void openButtonClicked()
    {
        chooser = std::make_unique<juce::FileChooser> ("Select a Wave file shorter than 2 seconds to play...",
                                                       juce::File{},
                                                       "*.wav");
        auto chooserFlags = juce::FileBrowserComponent::openMode
                          | juce::FileBrowserComponent::canSelectFiles;

        chooser->launchAsync(chooserFlags, [this](const juce::FileChooser& fc) {
            auto file = fc.getResult();
            if (file == juce::File{}) {
                return;
            }
            auto path = file.getFullPathName();
            {
                const juce::ScopedLock lock(pathMutex);
                chosenPath.swapWith(path);
            }

            notify();
        });
    }

这里我们还唤醒了后台线程,因为我们将调用后台线程上的函数来打开文件。

4.4.2、从后台线程访问路径

我们的run()函数应更新如下:

    void run() override
    {
        while (! threadShouldExit())
        {
            checkForPathToOpen();
            checkForBuffersToFree();
            wait (500);
        }
    }

checkForPathToOpen()函数通过将chosenPath成员交换到局部变量来检查成员。同样,交换不是线程安全的,因此我们必须在访问之前锁定chosenPath

    void checkForPathToOpen() {
        juce::String pathToOpen;
        {
            const juce::ScopedLock lock(pathMutex);
            pathToOpen.swapWith(chosenPath);
        }

        if (pathToOpen.isNotEmpty()) {
            juce::File file(pathToOpen);
            std::unique_ptr<juce::AudioFormatReader> reader(formatManager.createReaderFor(file));
            if (reader.get() != nullptr) {
                auto duration = (float)reader->lengthInSamples / reader->sampleRate;
                if (duration < 2) {
                    ReferenceCountedBuffer::Ptr newBuffer = new ReferenceCountedBuffer(file.getFileName(), (int)reader->numChannels, (int)reader->lengthInSamples);
                    reader->read(newBuffer->getAudioSampleBuffer(), 0, (int)reader->lengthInSamples, 0, true, true);
                    {
                        const juce::SpinLock::ScopedLockType lock(mutex);
                        currentBuffer = newBuffer;
                    }
                    buffers.add(newBuffer);
                }
            } else {
                // 处理文件长达2秒或更长时间的错误
            }
        }
    }

如果pathToOpen变量是空字符串,则我们就知道没有新文件要打开。此函数中的其余代码您应该很熟悉。

再次运行该应用程序,它仍应正常运行。

5、绘制音频波形

本教程介绍如何使用AudioThumbnail类显示音频波形。这提供了一种在音频应用程序中绘制任意数量波形的简单方法。

级别:中级

平台: Windows、macOS、Linux

类: AudioThumbnailAudioThumbnailCacheAudioFormatReaderChangeListener

5.1、入门

在此处下载本教程的演示项目:PIP | ZIP。解压缩项目并在 Projucer 中打开第一个头文件。

5.2、演示项目

演示项目以与教程相同的方式呈现三个按钮:构建音频播放器(用于打开、播放和停止声音文件)。

还有一个矩形区域,可以在此绘制声音文件的波形。在默认状态下(未加载声音文件),应用程序如下所示:

一旦加载了声音文件,应用程序看起来如下所示:

绘制音频波形(尤其是长文件)通常需要以某种格式存储音频数据的低分辨率版本,以便高效绘制波形,同时让用户看得更清楚。AudioThumbnail类会为您处理此低分辨率版本,并在需要时创建和更新。

5.3、设置音频缩略图

第一个重点是AudioThumbnail不是Component类的子类。AudioThumbnail类用于在另一个Component对象的paint()函数内执行音频波形的绘制。下面的代码展示了如何基于教程:构建音频播放器中的演示项目添加此功能。

5.3.1、附加对象

在我们的MainContentComponent类中,我们需要添加两个成员:一个AudioThumbnailCache对象和一个AudioThumbnail对象。AudioThumbnailCache类用于缓存一个或多个音频文件的必要低分辨率版本。这意味着,例如,如果我们关闭一个文件,打开一个新文件,然后返回打开第一个文件,AudioThumbnailCache仍将包含第一个文件的低分辨率版本,无需重新扫描和重新计算数据。另一个有用的功能是AudioThumbnailCache对象可以在AudioThumbnail类的不同实例之间共享。

    juce::TextButton openButton;
    juce::TextButton playButton;
    juce::TextButton stopButton;

    std::unique_ptr<juce::FileChooser> chooser;

    juce::AudioFormatManager formatManager;                    // [3]
    std::unique_ptr<juce::AudioFormatReaderSource> readerSource;
    juce::AudioTransportSource transportSource;
    TransportState state;
    juce::AudioThumbnailCache thumbnailCache;                  // [1]
    juce::AudioThumbnail thumbnail;                            // [2]

如果使用这样的静态分配对象,则务必将AudioThumbnailCache对象[1]列在AudioThumbnail对象[2]之前,因为它是作为参数传递给AudioThumbnail 构造函数的。出于同样的原因,务必将AudioFormatManager对象[3]列在AudioThumbnail对象之前。

5.3.2、初始化对象

在构造函数的初始化列表中,MainContentComponent我们设置了这些对象:

    MainContentComponent()
       : state (Stopped),
         // 必须构造AudioThumbnailCache对象来指定要存储的缩略图数量
         thumbnailCache (5),                            // [4]
         // AudioThumbnail对象本身通过告知其将使用多少个源样本来创建单个缩略图样本来构造。
         // 这决定了低分辨率版本的分辨率。另外两个参数是 AudioFormatManager和AudioThumbnailCache对象,如上所述。
         thumbnail (512, formatManager, thumbnailCache) // [5]
    {

AudioThumbnail类也是ChangeBroadcaster类的一种。我们可以注册为更改监听器[6](在我们的MainContentComponent构造函数中)。这些更改将发生在AudioThumbnail对象发生变化时,因此我们需要更新波形的绘制。

        thumbnail.addChangeListener (this);            // [6]

5.3.3、响应变化

在我们的changeListenerCallback()函数中,我们需要确定更改是从AudioTransportSource对象还是AudioThumbnail对象广播的:

    void changeListenerCallback (juce::ChangeBroadcaster* source) override
    {
        if (source == &transportSource) transportSourceChanged();
        if (source == &thumbnail)       thumbnailChanged();
    }

transportSourceChanged()函数仅包含我们用于响应AudioTransportSource对象中的变化的原始代码:

    void transportSourceChanged()
    {
        changeState (transportSource.isPlaying() ? Playing : Stopped);
    }

如果AudioThumbnail对象发生了变化,我们将调用 Component::repaint() 函数。这将导致我们的paint()函数在下一次屏幕绘制操作期间被调用:

    void thumbnailChanged()
    {
        repaint();
    }

5.3.4、打开文件

当我们成功打开声音文件时,我们还需要将该文件传递给FileInputSource对象内的AudioThumbnail对象[7]

    void openButtonClicked()
    {
        chooser = std::make_unique<juce::FileChooser> ("Select a Wave file to play...",
                                                       juce::File{},
                                                       "*.wav");
        auto chooserFlags = juce::FileBrowserComponent::openMode
                          | juce::FileBrowserComponent::canSelectFiles;

        chooser->launchAsync (chooserFlags, [this] (const juce::FileChooser& fc)
        {
            auto file = fc.getResult();

            if (file != juce::File{})
            {
                auto* reader = formatManager.createReaderFor (file);

                if (reader != nullptr)
                {
                    auto newSource = std::make_unique<juce::AudioFormatReaderSource> (reader, true);
                    transportSource.setSource (newSource.get(), 0, nullptr, reader->sampleRate);
                    playButton.setEnabled (true);
                    thumbnail.setSource (new juce::FileInputSource (file));                            // [7]
                    readerSource.reset (newSource.release());
                }
            }
        });
    }

5.3.5、进行绘图

在我们的paint()函数中,我们首先计算要绘制的矩形。然后我们检查AudioThumbnail对象包含多少个通道,这告诉我们是否已加载文件:

    void paint (juce::Graphics& g) override
    {
        juce::Rectangle<int> thumbnailBounds (10, 100, getWidth() - 20, getHeight() - 120);

        if (thumbnail.getNumChannels() == 0)
            paintIfNoFileLoaded (g, thumbnailBounds);
        else
            paintIfFileLoaded (g, thumbnailBounds);
    }

如果我们没有加载文件,那么我们通过将图形对象和边界矩形传递给paintIfNoFileLoaded()函数来显示消息“未加载文件”:

    void paintIfNoFileLoaded (juce::Graphics& g, const juce::Rectangle<int>& thumbnailBounds)
    {
        g.setColour (juce::Colours::darkgrey);
        g.fillRect (thumbnailBounds);
        g.setColour (juce::Colours::white);
        g.drawFittedText ("No File Loaded", thumbnailBounds, juce::Justification::centred, 1);
    }

接下来是重要的部分。如果我们确实加载了文件,我们可以绘制波形:

    void paintIfFileLoaded (juce::Graphics& g, const juce::Rectangle<int>& thumbnailBounds)
    {
        g.setColour (juce::Colours::white);
        g.fillRect (thumbnailBounds);

        // 设置画笔的当前颜色,这将控制AudioThumbnail对象绘制波形时使用的颜色
        g.setColour (juce::Colours::red);                               // [8]

        // 我们调用AudioThumbnail::drawChannels()函数,并向其传递要绘制的Graphics对象、应绘制的矩
        // 形、开始和结束时间(以秒为单位)以及垂直缩放系数。这里我们使用AudioThumbnail::getTotalLength()函数来获取文件时长,以便绘制整个文件。
        // (我们可以使用AudioTransportSource::getLengthInSeconds()函数从AudioTransportSource对象获取长度,以获得相同的结果。)
        thumbnail.drawChannels (g,                                      // [9]
                                thumbnailBounds,
                                0.0,                                    // start time
                                thumbnail.getTotalLength(),             // end time
                                1.0f);                                  // vertical zoom
    }

这涵盖了使用AudioThumbnail对象的所有基本点。

练习:实际上,您通常希望仅显示声音文件的某些区域。从AudioThumbnail::drawChannels()函数中应该可以清楚地看出,使用 JUCE 实现这一点是多么简单。尝试修改代码以仅显示文件的特定区域。

5.4、添加时间位置标记

在本节中,我们将引导您在显示屏上添加一条垂直线,显示文件播放的当前时间位置。

5.4.1、添加计时器

首先我们需要将Timer类添加到基类列表中[10]

class MainContentComponent   : public juce::AudioAppComponent,
                               private juce::ChangeListener,
                               private juce::Timer

然后我们需要让定时器回调重新绘制我们的组件。确保将此代码添加到private部分,因为您会注意到我们从Timer类私有继承:

    void timerCallback() override {
        repaint();
    }

MainContentComponent构造函数中,我们需要启动计时器[11] — 每 40 毫秒就足够了:

        startTimer(40);

练习:事实上,您可以延迟启动计时器,方法是等文件成功打开后再启动它。

5.4.2、绘制位置线

最后,为了绘制线条我们需要计算线条的位置并绘制缩略图后绘制它:

    void paintIfFileLoaded (juce::Graphics& g, const juce::Rectangle<int>& thumbnailBounds)
    {
        g.setColour (juce::Colours::white);
        g.fillRect (thumbnailBounds);

        g.setColour (juce::Colours::red);       
        
        // 将文件的长度存储在变量中
        auto audioLength = (float)thumbnail.getTotalLength();
        thumbnail.drawChannels (g,                                      // [9]
                                thumbnailBounds,
                                0.0,                                    // start time
                                thumbnail.getTotalLength(),             // end time
                                1.0f);                                  // vertical zoom

        g.setColour(juce::Colours::green);

        auto audioPosition = (float)transportSource.getCurrentPosition();
        // 位置是按照音频文件总长度的比例计算的。绘制线的位置需要基于缩略图所处矩形的宽度的相同比例。
        // 我们需要根据矩形的 x 坐标偏移绘制位置。
        auto drawPosition = (audioPosition / audioLength) * (float)thumbnailBounds.getWidth() + (float)thumbnailBounds.getX();
        // 在这里,我们在矩形的顶部(y)和底部之间画一条 2 像素宽的线。
        g.drawLine(drawPosition, (float)thumbnailBounds.getY(), drawPosition, (float)thumbnailBounds.getBottom(), 2.0f);
    }

就是这样:您现在应该能够构建并运行该应用程序。

警告:此示例的问题在于我们强制组件每 40 毫秒重新绘制一次。虽然这对于简单示例来说可能可以接受,但在更复杂的情况下,您可能会遇到性能问题。请查看下面的练习以了解更多信息。

练习:将绘图分离为单独的子组件(请参阅教程:父组件和子组件)。您应该有三个组件:

  • 绘制音频波形的组件。
  • 将播放位置绘制为垂直线的组件。
  • 包含这两个子组件(彼此叠在一起)的主要父组件。

这不仅会使代码更易于理解,而且如果操作正确,还会提高效率,因为我们可以避免每帧重新绘制波形。您还可以添加功能,以便在用户点击波形时更改播放位置。

6、AudioDeviceManager 类

本教程介绍了AudioDeviceManager类,该类用于管理所有平台上的音频设备。它允许您配置设备采样率以及输入和输出数量等。

级别:中级

平台: Windows、macOS、Linux、iOS、Android

类: AudioDeviceManagerAudioDeviceSelectorComponentChangeListenerBigInteger

6.1、入门

在此处下载本教程的演示项目:PIP | ZIP。解压缩项目并在 Projucer 中打开第一个头文件。

6.2、演示项目

演示项目基于 Projucer 的音频应用程序模板。它提供了一个AudioDeviceSelectorComponent对象,允许您配置音频设备设置。演示项目还提供了一个简单的文本控制台,用于报告当前的音频设备设置。该应用程序还显示了应用程序音频处理元素的当前 CPU 使用率。

注意:这里介绍的代码与JUCE Demo 中的AudioSettingsDemo大致相似。主要区别在于生成的音频与教程:处理音频输入中的相同(这会使用白噪声对音频输入进行环形调制)。

6.3、音频设备

JUCE 提供了一种在其支持的所有平台上访问音频设备的一致方法。虽然此处提供的演示应用程序可能仅部署到桌面平台,但这仅仅是由于 GUI 布局限制。音频在移动平台上也能无缝运行。

在音频应用程序模板中,AudioAppComponent 类实例化 AudioDeviceManager 对象 deviceManager— 它是公共成员,因此可以从子类中访问。AudioAppComponent 类还会执行此 AudioDeviceManager对象一些基本初始化—发生在您调用AudioAppComponent::setAudioChannels()时。

AudioDeviceManager类将尝试使用默认音频设备,除非该设备被覆盖。这可以通过代码或AudioDeviceSelectorComponent进行配置,如下所示。设备设置和首选项可以存储并在后续应用程序启动时调用。如果首选设备不再可用(例如,如果自上次启动以来已拔下外部音频设备), AudioDeviceManager类也可以在这种情况下回退到默认设备。

AudioDeviceManager类也是传入 MIDI 消息的枢纽。其他教程对此进行了探讨(请参阅教程:处理 MIDI 事件)。

AudioDeviceManager类可以广播其设置的更改,因为它继承自ChangeBroadcaster类。每当触发AudioDeviceManager对象的更改时,我们组件的右侧就会发布一些重要的音频设备设置。

6.3.1、AudioDeviceSelectorComponent 类

AudioDeviceSelectorComponent类提供了在所有平台上配置音频设备的便捷方法。如上所述,它显示在演示项目的用户界面右侧。构造AudioDeviceSelectorComponent对象时,我们将希望它控制的AudioDeviceManager对象以及许多其他选项(包括要支持的通道数)传递给它(有关更多信息,请参阅AudioDeviceSelectorComponent构造函数)。在这里,我们通过将AudioDeviceManager对象传递给AudioAppComponent的成员来创建AudioDeviceSelectorComponent对象,允许最多 256 个输入和输出通道,隐藏 MIDI 配置,并将我们的通道显示为单通道而不是立体声对:

    MainContentComponent()
        : audioSetupComp (deviceManager,
                          0,     // minimum input channels
                          256,   // maximum input channels
                          0,     // minimum output channels
                          256,   // maximum output channels
                          false, // ability to select midi inputs
                          false, // ability to select midi output device
                          false, // treat channels as stereo pairs
                          false) // hide advanced options
    {

我们的界面配置允许控制:

  • 选择输出设备。
  • 选择输入设备。
  • 启用和禁用输入和输出通道。
  • 选择采样率(设备支持)。
  • 选择音频缓冲区大小(块大小)。

您应该注意到,当我们更改任何这些设置时,界面右侧的小控制台窗口中会发布一个新的数据列表。这是因为AudioDeviceManager类是ChangeBroadcaster类的一种类型。

在我们的changeListenerCallback()函数中,我们调用访问AudioDeviceManager对象的dumpDeviceInfo()函数来检索当前音频设备。然后,我们获取有关该设备的各种信息:

    void dumpDeviceInfo()
    {
        logMessage ("--------------------------------------");
        logMessage ("Current audio device type: " + (deviceManager.getCurrentDeviceTypeObject() != nullptr
                                                     ? deviceManager.getCurrentDeviceTypeObject()->getTypeName()
                                                     : "<none>"));

        if (auto* device = deviceManager.getCurrentAudioDevice())
        {
            logMessage ("Current audio device: "   + device->getName().quoted());
            logMessage ("Sample rate: "    + juce::String (device->getCurrentSampleRate()) + " Hz");
            logMessage ("Block size: "     + juce::String (device->getCurrentBufferSizeSamples()) + " samples");
            logMessage ("Bit depth: "      + juce::String (device->getCurrentBitDepth()));
            logMessage ("Input channel names: "    + device->getInputChannelNames().joinIntoString (", "));
            logMessage ("Active input channels: "  + getListOfActiveBits (device->getActiveInputChannels()));
            logMessage ("Output channel names: "   + device->getOutputChannelNames().joinIntoString (", "));
            logMessage ("Active output channels: " + getListOfActiveBits (device->getActiveOutputChannels()));
        }
        else
        {
            logMessage ("No audio device open");
        }
    }

注意:我们使用getListOfActiveBits()函数将表示活动通道列表的BigInteger对象转换为String对象(位掩码)。BigInteger对象用作位掩码,类似于std::bitsetstd::vector<bool> 。此处,通道由BigInteger值组成位中的 0(非活动)或 1(活动)表示。有关可对 BigInteger 对象执行的其他操作,请参阅教程 BigInteger

在实际应用中,响应此类变化确实非常有用,因为我们经常需要知道应用程序可用的通道数何时发生变化。通常,对采样率和其他音频参数的变化做出适当的响应非常重要。

锻炼:尝试对AudioDeviceSelectorComponent类构造函数使用不同的设置 — 如果您拥有多通道声卡,并且您可以限制应用可以处理的通道数量,那么这可能特别有用。您还可以了解如何将通道对视为立体声对。

AudioDeviceSelectorComponent类还包含一个测试按钮。该按钮会从设备输出端播放正弦音,这对用户测试目标设备上的音频输出是否正常工作非常有用。

6.3.2、CPU 使用率

我们通过调用AudioDeviceManager::getCpuUsage()函数来获取应用程序音频处理元素的 CPU 使用率。在我们的MainContentComponent类中,我们从Timer类继承,启动计时器以每 50 毫秒触发一次。在我们的timerCallback()函数中,我们从AudioDeviceManager对象获取 CPU 使用率。此值以百分比形式显示在Label对象中(精确到小数点后六位):

    void timerCallback() override
    {
        auto cpu = deviceManager.getCpuUsage() * 100;
        cpuUsageText.setText (juce::String (cpu, 6) + " %", juce::dontSendNotification);
    }

由于我们在此特定应用程序中进行的音频处理很少,因此 CPU 使用率可能非常低 — 在许多目标设备上可能不到 1%。但是,您可以通过尝试设置组合来查看不同采样率和缓冲区大小对 CPU 负载的影响。一般来说,较高的采样率和较小的缓冲区大小将使用更多的 CPU。

posted on 2024-10-07 17:37  啊噢1231  阅读(66)  评论(0编辑  收藏  举报

导航

回到顶部