Разработка кроссплатформенных приложений с использованием Juce

Мультимедиа. Работа со звуком

Разбить на страницы
Показывать лекцию целиком

Цель лекции: Научиться создавать приложения с поддержкой звука

Изначально в IBM-совместимых персональных компьютерах вся работа со звуком заключалась в подаче сигналов с помощью встроенного динамика, а каких-либо средств для воспроизведения музыки вовсе не было, поскольку предполагалось, что персональные компьютеры будут использоваться исключительно в деловой сфере. Однако такое положение не устраивало конечных пользователей, и вскоре появилось довольно большое число звуковых карт, позволявших обеспечить качественное воспроизведение звука.

В наши дни к мультимедийным возможностям программ предъявляются довольно жёсткие требования. К счастью, библиотека Juce, изначально ориентированная именно на создание подобных приложений, предоставляет разработчику множество классов для воспроизведения и записи звука. Детальный их разбор потребовал бы написания отдельной книги, поэтому в данной главе мы коснёмся лишь базовых принципов взаимодействия некоторых из них на простейших примерах, чтобы дать читателю основу для будущей самостоятельной работы с документацией к библиотеке.

Воспроизведение аудио-файлов базовыми методами

За воспроизведение аудио-файлов в библиотеке Juce отвечает класс AudioTransportSource, который создаёт собственную нить (поток) и позволяет начинать проигрывание с произвольной позиции, а также в любой момент останавливать его.

Для того, чтобы объект класса AudioTransportSource мог "понять" данные аудио-файла, последний должен быть прочитан с помощью объекта класса, унаследованного от AudioFormat. Библиотека Juce предоставляет возможность воспроизведения ряда базовых форматов аудио:

  • AiffAudioFormat — читает и записывает файлы несжатого аудио, Audio Interchane File Format (*.aif, *.aiff);
  • FlacAudioFormat — читает и записывает файлы аудио, сжатого без потерь качества, Free Lossless Audio Codec (*.flac);
  • OggVorbisAudioFormat — читает и записывает файлы аудио, сжатого с потерей качества, Ogg Vorbis (*.ogg);
  • WavAudioFormat — читает и записывает файлы несжатого аудио, Waveform Audio File Format (*.wav).
  • Для хранения информации о доступных форматах аудио и принятия решения о том, какой из них использовать для открытия текущего файла, в библиотеке имеется класс AudioFormatManager, который в том числе создаёт чтеца (класс AudioFormatReader), читающего сэмплы из аудио-потока.

    Непосредственно управляет устройством воспроизведения звука (звуковой картой) объект класса AudioSourcePlayer, который служит промежуточным звеном между ним и объектом класса AudioTransportSource.

    Рассмотрим взаимодействие всех этих классов на примере простого проигрывателя, воспроизводящего вышеперечисленные форматы аудио.

    За основу будущего проекта возьмём пример из лекции 17 , в котором для открытия файла использовался ).

    (рис 19.1) Работа программы, демонстрирующей воспроизведение базовых типов аудио-файлов

    Все объекты, отвечающие за воспроизведение аудио-файлов, объявим в качестве закрытых членов компонента содержимого ().

    #ifndef _TCentralComponent_h_
    #define _TCentralComponent_h_
    //--------------------------------------------------
    #include "../JuceLibraryCode/JuceHeader.h"
    //--------------------------------------------------
    class TCentralComponent  : public Component,
                public ButtonListener,
                public ChangeListener
    {
    public:
      TCentralComponent();
      ~TCentralComponent();
    
      void paint(Graphics);
      void resized();
      // Функция, отслеживающая щелчки по кнопке
      void buttonClicked(Button*);
      void changeListenerCallback(ChangeBroadcaster*);
    
    private:
      // Текущий файл для воспроизведения
      File CurrentFile;
    
      // Менеджер аудио- и MIDI-устройств
      AudioDeviceManager* pAudioDeviceManager;
      // Аудио-устройство
      AudioIODevice* pAudioDevice;
      // Промежуточное звено между источником звука
      // и аудио-устройством
      AudioSourcePlayer AudioPlayer;
      // Источник воспроизведения звука
      AudioTransportSource TransportSource;
      // Класс для принятия данных от чтеца аудио-потока
      AudioFormatReaderSource* pCurrentAudioFileSource;
    
      // Ярлык для вывода названия аудио-устройства
      // при запуске программы, а в последующем -
      // названия открытого аудио-файла
      Label* pNameLabel;
      // Кнопка вызова диалога выбора файла
      TextButton* pChooseButton;
      // Кнопка начала / остановки воспроизведения
      TextButton* pPlayButton;
    
      // Компонент выбора файла
      FilenameComponent* pFileNameDialog;
    
      // Предотвращает создание копии конструктора и оператора =
      TCentralComponent(const TCentralComponent);
      const TCentralComponent operator= (const TCentralComponent);
    };
    //--------------------------------------------------
    #endif
    

    Их инициализация и установление связей между ними, необходимых для совместной работы, представлены в листинге 19.2 .

    Листинг

    #include "TCentralComponent.h"
    //----------------------------------------------
    #define tr(s) String::fromUTF8(s)
    //----------------------------------------------
    TCentralComponent::TCentralComponent() : Component("Central Component"),
                pNameLabel(0),
                pChooseButton(0)
    {
      pNameLabel = new Label(L"Name Label", tr("Выберите файл..."));
      pNameLabel->setFont(Font(15.0000f, Font::bold));
      pNameLabel->setJustificationType(Justification::centred);
      pNameLabel->setEditable(false, false, false);
      pNameLabel->setColour(Label::textColourId, Colours::black);
      pNameLabel->setColour(Label::backgroundColourId, Colours::azure);
      pNameLabel->setColour(Label::outlineColourId, Colours::black);
      addAndMakeVisible(pNameLabel);
    
      pChooseButton = new TextButton(L"Choose Button");
      pChooseButton->setButtonText(tr("Выбрать..."));
      pChooseButton->addListener(this);
      addAndMakeVisible(pChooseButton);
    
      pPlayButton = new TextButton(L"Play Button");
      pPlayButton->setButtonText(tr("Играть"));
      pPlayButton->setColour(TextButton::buttonColourId, Colours::lightgreen);
      pPlayButton->addListener(this);
      addAndMakeVisible(pPlayButton);
    
      // Файл для воспроизведения ещё не открыт, аудио-поток пока отсутствует
      pCurrentAudioFileSource = 0;
      
      LastOpenedFile = File::getCurrentWorkingDirectory();
      
      // Создаём пустой менеджер аудио-устройств
      pAudioDeviceManager = new AudioDeviceManager();
      // Инициируем менеджер аудио-устройств
      String sError = pAudioDeviceManager->initialise(1, 2, 0, true);
      // Сохраняем текущее аудио-устройство в переменной
      pAudioDevice = pAudioDeviceManager->getCurrentAudioDevice();
      // Если устройство отсутствует, выводим сообщение об ошибке
      if(!sError.isEmpty()) 
      {
        AlertWindow::showMessageBox(
                AlertWindow::WarningIcon, 
                tr("Ошибка воспроизведения"), 
               (tr("Не удалось открыть аудио-устройство:\n\n") += sError), 
                tr("Принять"), 0
                );
      }
      // Устанавливаем связи между менеджером устройств, 
      // AudioSourcePlayer и AudioTransportSource
      pAudioDeviceManager->addAudioCallback( AudioPlayer);
      TransportSource.addChangeListener(this);
      AudioPlayer.setSource( TransportSource);
    
      // Если есть доступное аудио-устройство...
      if(pAudioDevice != 0) 
      {
        // ...выводим его название на ярлык программы
        String sName = pAudioDevice->getName();
        pNameLabel->setText(sName, false);
      }
      else 
      {
        pNameLabel->setText(tr("Нет устройства!"), false);
      }
    
      setSize(400, 150);
    
      // Создаём компонент для выбора файла
      pFileNameDialog = new FilenameComponent(
               String::empty,
               CurrentFile,
               true,
               false,
               false,
               "*.wav;*.aif;*.aiff;*.ogg;*.flac",
               String::empty,
               tr("Выберите файл...")
               );
      File SearchDir = SearchDir.getSpecialLocation(File::userMusicDirectory);
      pFileNameDialog->setDefaultBrowseTarget(SearchDir);
      // Задаём его размер (иначе его не будет видно)
      pFileNameDialog->setSize(250, 25);
    }
    

    В конструкторе класса компонента содержимого мы создаём и инициализируем менеджер аудио-устройств.

    Метод const String AudioDeviceManager::initialise(int numInputChannelsNeeded, int numOutputChannelsNeeded, const XmlElement* savedState, bool selectDefaultDeviceOnFailure, const String preferredDefaultDeviceName = String::empty, const AudioDeviceSetup* preferredSetupOptions = 0) пытается открыть все доступные для использования аудио-устройства, начиная с устройства по умолчанию. Возвращаемая строка содержит сообщение об ошибке, если что-то пошло не так. В том случае, если открытие аудио-устройств прошло успешно, возвращается пустая строка.

    Метод AudioIODevice* AudioDeviceManager::getCurrentAudioDevice() const throw() возвращает указатель на текущее аудио-устройство. Мы сохраняем его в члене класса компонента содержимого pAudioDevice для последующих обращений.

    Затем мы устанавливаем связь между менеджером аудио-устройств и AudioSourcePlayer посредством вызова метода void AudioDeviceManager::addAudioCallback(AudioIODeviceCallback* newCallback). Удалить связь позволяет метод void AudioDeviceManager::removeAudioCallback(AudioIODeviceCallback* callback).

    Завершаем установку всех необходимых соединений установкой TransportSource в качестве источника воспроизведения для AudioSourcePlayer. Достигается это вызовом метода void AudioSourcePlayer::setSource(AudioSource* newSource).

    Заметим, что в нашей программе используются настройки текущего аудио-устройства по умолчанию. Однако библиотека Juce включает структуру, принадлежащую классу AudioDeviceManager— AudioDeviceSetup — хранящую набор свойств текущего аудио-устройства и дающую возможность пользователю их изменять на этапе выполнения программы. Пример использования AudioDeviceSetup вы можете найти в демонстрационном приложении Juce (выберите в меню Demo > Audio). Посмотреть реализацию примера можно, перейдя в папку <каталог Juce>/juce/extras/JuceDemo/Source/demos и открыв файл AudioDemoSetupPage.cpp.

    При первом запуске нашей программы на её ярлыке мы отображаем имя используемого аудио-устройства. Для его получения воспользуемся методом const String AudioIODevice::getName() const throw().

    Обратите внимание, что при завершении работы нашего приложения необходимо закрыть аудио-устройство и удалить вызов аудио с помощью методов ).

    TCentralComponent::~TCentralComponent()
    {
      pAudioDeviceManager->closeAudioDevice();
      TransportSource.setSource(0);
      AudioPlayer.setSource(0);
      pAudioDeviceManager->removeAudioCallback( AudioPlayer);
      if(pAudioDeviceManager) delete pAudioDeviceManager;
      if(pCurrentAudioFileSource) delete pCurrentAudioFileSource;
      if(pFileNameDialog) delete pFileNameDialog;
      deleteAllChildren();
    } 
    

    Наша программа будет менять цвет и текст кнопки ). Узнать, осуществляется ли воспроизведение в текущий момент, позводяет метод bool AudioTransportSource::isPlaying() const throw().

    void TCentralComponent::changeListenerCallback(ChangeBroadcaster*)
    {
      // Если идёт воспроизведение...
      if(TransportSource.isPlaying())
      {
        pPlayButton->setButtonText(tr("Остановить"));
        // изменяем цвет кнопки на розовый
        pPlayButton->setColour(TextButton::buttonColourId, Colours::pink);
      }
      else
      {
        pPlayButton->setButtonText(tr("Играть"));
        // иначе - меняем на зелёный.
        pPlayButton->setColour(TextButton::buttonColourId, Colours::lightgreen);
      }
    }
    

    Управление воспроизведением осуществляется в обработчике нажатия на кнопку ). В нём мы используем два метода класса AudioTransportSource:

  • void AudioTransportSource::start() — начинает воспроизведение, если его источник был выбран.
  • void AudioTransportSource::stop() — останавливает воспроизведение, посылая при этом сигнал слушателю изменений (ChangeListener), зарегистрированному для текщего объекта класса AudioTransportSource.
  • Перед началом воспроизведения необходимо установить его позицию в текущем аудио-потоке методом ), однако, если сохранять текущую позицию воспроизведения, получаемую методом double AudioTransportSource::getCurrentPosition() const, в какой-либо переменной и начинать в последующем проигрывание именно с этой позиции, то можно реализовать функцию паузы. Возвращаемое методом setPosition значение представляет собой время в секундах.

    else if(pButton == pPlayButton)
    {
      // Если идёт воспроизведение...
      if(TransportSource.isPlaying())
      {
        // останавливаем его
        TransportSource.stop();
      }
      else
      {
        // Иначе - устанавливаем позицию в начало потока воспроизведения...
        TransportSource.setPosition(0);
        // и начинаем проигрывание файла
        TransportSource.start();
      }
    }
        
    

    Загрузка аудио-файла в поток, и подготовка его к воспроизведению реализована в обработчике нажатия на кнопку — pChooseButton (). Выше мы уже описали взаимодействие используемых в нём классов.

    void TCentralComponent::buttonClicked(Button* pButton)
    {
      if(pButton == pChooseButton)
      {
        // Создаём "обёртку" для показа диалога...
        DialogWindow::showModalDialog(tr("Выбор файла"),
                    pFileNameDialog,
                    NULL,
                    Colours::azure,
                    true,
                    false,
                    false);
     
        // Когда файл открыт...
        CurrentFile = pFileNameDialog->getCurrentFile();
        // проверяем, существует ли он
        if(!(CurrentFile == File::nonexistent)) 
        {
          // Выводим на ярлык название файла
          pNameLabel->setText(CurrentFile.getFileName(), false);
    
          // Если ранее осуществлялось воспроизведение - останавливаем его.
          TransportSource.stop();
          TransportSource.setSource(0);
          pCurrentAudioFileSource = 0;
          AudioFormatManager FormatManager;
          // Загрузка файла в поток
          AudioFormatReader* pAudioFormatReader;
          // Регистрируем базовые форматы аудио
          FormatManager.registerBasicFormats();
          / Создаём чтеца для текущего аудио-файла
          pAudioFormatReader = FormatManager.createReaderFor(CurrentFile);
    
          if(pAudioFormatReader != 0)
          {
            // Если чтец существует, 
            // создаём с его помощью новый источник аудио-данных
            pCurrentAudioFileSource = 
            new AudioFormatReaderSource(pAudioFormatReader, true);
            // и передаём их в AudioTransportSource
            TransportSource.setSource(
                pCurrentAudioFileSource, 
                32768, 
                pAudioFormatReader->sampleRate);
            if(pCurrentAudioFileSource == 0)
            {
                AlertWindow::showMessageBox(
                AlertWindow::WarningIcon, 
                tr("Ошибка воспроизведения"), 
                tr("Отсутствует источник воспроизведения аудио-файла."), 
                  tr("Принять"), 0);
              return;
            }
          }
          else AlertWindow::showMessageBox(
                  AlertWindow::WarningIcon, 
                  tr("Ошибка воспроизведения"), 
                  tr("Не инициирован источник чтения аудио-файла.\n
                  Возможно, файл повреждён."), 
                  tr("Принять"), 0
                  );
        }
        else
        {
          pNameLabel->setText(tr("Файл не выбран!"), false);
        }
      }
    // ...
        
    

    Понятно, что для того, чтобы программа, написанная с помощью Juce, могла воспроизводить тот или иной формат аудио, необходимо написать класс, унаследованный от AudioFormat, в котором будет осуществляться чтение и декодирование аудио-данных. В последующем новый аудио-формат должен быть зарегистрирован в AudioFormatManager с помощью метода void AudioFormatManager::registerFormat(AudioFormat* newFormat, bool makeThisTheDefaultFormat). В случае если мы хотим использовать новый формат по умолчанию, параметр makeThisTheDefaultFormat принимает значение true. Как упоминалось, в нашем примере мы зарегистрировали базовые аудио-форматы с помощью метода void AudioFormatManager::registerBasicFormats().

    Членами сообщества уже созданы классы аудио-форматов, связывающие Juce с рядом свободных кодеков (например, avformat), что превращает её практически в универсальную мультимедийную библиотеку.

    Отправка и обработка сообщения MIDI

    MIDI (Musical Instrument Digital Interface) — цифровой интерфейс музыкальных инструментов, созданный в 1982 году и включающий аппаратные и программные средства, предназначенные для управления звуковой платой или звуковым синтезатором. Проще говоря, это способ обмена данными между устройствами либо программами, поддерживающими MIDI. Данными являются ноты, а также дополнительные сведения о них: громкость, длительность и тембр звука, положение в пространстве стереофонического звучания (величина смещения в сторону левой или правой колонки) и другие. Такие данные называются MIDI-сообщениями или MIDI-командами.

    Каждый байт данных начинается стартовым и заканчивается стоповым битом. Важно помнить, что сами данные, передаваемые через MIDI – интерфейс, не являются закодированными звуками ни в аналоговой, ни в цифровой форме.

    К программно-аппаратным средствам, или проще, устройствам MIDI относятся:

    MIDI-клавиатура (MIDI-контроллер) — MIDI-устройство, предназначенное для выдачи MIDI-данных на другие MIDI-устройства. Как правило, представляет собой инструмент с клавиатурой, похожей на фортепьянную; существуют также "ударные", "щипковые" и др. MIDI-контроллеры.

    MIDI-секвенсер (sequencer) — программа для персонального компьютера, обеспечивающая запись, редактирование и размещение нескольких дорожек с MIDI-данными; используется для записи партитуры и воспроизведения музыкальных произведений электронными музыкальными инструментами.

    MIDI-синтезатор (tone module) — автономное внешнее (периферийное) устройство, подсоединяемое к цифровому музыкальному инструменту или компьютеру, обеспечивающее музыкальный синтез.

    Устройства MIDI связываются между собой посредством кабелей, имеющих 5-штырьковые разъёмы стандарта DIN, в которых используются только 3 контакта из пяти (земля, токовая петля и линия данных). Протокол MIDI определяет пересылку цифровых команд по кабелю. В случае работы с MIDI на персональном компьютере устройства (клавиатура, секвенсер, синтезатор) могут быть реализованными программно (виртуальными).

    И в том, и в другом случае MIDI-клавиатура посылает сообщения на секвенсер, который передаёт их музыкальному синтезатору, который, собственно, и обеспечивает воспроизведение звука. Следует понимать, что стандарт MIDI никак не определяет способ создания звука, который может быть применён в той или иной конструкции синтезатора. Поэтому воспроизведение одной и той же последовательности команд MIDI на разных синтезаторах может отличаться.

    В настоящее время существует несколько стандартов MIDI, и самый распространённый — это General MIDI (GM). Стандарт предусматривает 16 независимых и равноправных логических каналов, имеющих собственные режимы работы. Например, канал номер 10 канал отведён для ударных. Все ноты, посылаемые на этот канал, будут воспроизведены виртуальной ударной установкой MIDI-синтезатора. Каждому каналу обычно назначается свой тембр, называемый по традиции "инструментом". Стандарт определяет так называемую таблицу инструментов, которая не что иное, как пронумерованный список из 128 названий инструментов. Например, под номером 1 идёт акустическое пианино.

    Напишем демонстрационную программу, использующую классы ).

    (рис 19.2) Работа программы, демонстрирующей генерацию и обработку сообщения MIDI

    В классе компонента содержимого объявим два выпадающих списка: для выбора пользователем устройств MIDI и инструментов (). Во второй список мы добавим некоторые из 128 стандартных инструментов; в первый же будут добавляться при запуске программы устройства MIDI, имеющиеся на компьютере пользователя. Для удобства включения их в список предусмотрим в нашем классе метод inline void TCentralComponent::AddInstruments().

    #ifndef _TCentralComponent_h_
    #define _TCentralComponent_h_
    //--------------------------------------------------
    #include "../JuceLibraryCode/JuceHeader.h"
    //-------------------------------------------------
    class TCentralComponent : public Component,
                public ComboBoxListener,
                public ButtonListener
    {
    public:
      TCentralComponent();
      ~TCentralComponent();
    
      void paint(Graphics);
      void resized();
      void comboBoxChanged(ComboBox*);
      void buttonClicked(Button*);
    
    private:
      // Флаг включения / выключения ноты
      bool bIsPlaying;
      // Номер текущего инструмента
      int iInstrument;
      // Выпадающий список устройств MIDI
      ComboBox* pMIDIDevicesBox;
      // Выпадающий список инструментов
      ComboBox* pInstrumentsBox;
      TextButton* pPlayButton;
    
      // Устройство вывода
      MidiOutput* pMidiOutput;
    
      // Добавляем новый инструмент в выпадающий список
      inline void AddInstruments();
      // Играем ноту
      void Play();
    };
    //-------------------------------------------------------
    #endif
    

    Когда на входной разъём синтезатора подаётся сообщение Note On (включить ноту), он реагирует на это воспроизведением соответствующего звука, который будет воспроизводиться до тех пор, пока не получит команду Note Off (выключить ноту).

    Команда Note On представляет собой трёхбайтовое сообщение формата 0x90 KK VV, где первый байт (0x90) — это собственно команда включения ноты, второй — номер ноты (точнее, клавиши MIDI-клавиатуры), а третий означает скорость нажатия на клавишу. Номера клавиш варьируют от 0 до 127. В случае, если номер байт KK равен нулю, команда превращается в Note Off.

    Например, если синтезатор получает сообщение 0x90 60 100, он воспроизводит ноту до первой октавы (соответствует клавише C 88-клавишной клавиатуры) со скоростью 100.

    Для управления командами Note On и Note Off предусмотрим в нашем классе флаг состояния ноты (включена / выключена) - bool bIsPlaying.

    Классом-обёрткой для хранения и управления сообщением MIDI в Juce является MidiMessage. В качестве параметров конструктор класса принимает три целых числа — байты сообщения и одну переменную типа double. Последний параметр — время, отводимое сообщению MIDI (по умолчанию принимает значение 0).

    Немедленную отправку сообщения (объект класса ).

    Класс ).

    #include "TCentralComponent.h"
    //---------------------------------------------------
    #define tr(s) String::fromUTF8(s)
    //---------------------------------------------------
    TCentralComponent::TCentralComponent() : Component("CentralComponent"),
                pMIDIDevicesBox(0),
                pPlayButton(0)
    {
      pMIDIDevicesBox = new ComboBox(L"MIDIDevicesBox");
      pMIDIDevicesBox->setEditableText(false);
      pMIDIDevicesBox->setJustificationType(Justification::centredLeft);
      pMIDIDevicesBox->setTextWhenNothingSelected(
                tr("MIDI устройство не выбрано"));
        pMIDIDevicesBox->setTextWhenNoChoicesAvailable(
                tr("Нет доступных устройств"));
      pMIDIDevicesBox->addListener(this);
      addAndMakeVisible(pMIDIDevicesBox);
    
      pInstrumentsBox = new ComboBox(L"InstrumentsBox");
      pInstrumentsBox->setEditableText(false);
      pInstrumentsBox->setJustificationType(Justification::centredLeft);
      pInstrumentsBox->setTextWhenNothingSelected(
                tr("Нет выбранных инструментов"));
      pInstrumentsBox->addListener(this);
      pInstrumentsBox->setEnabled(false);
      addAndMakeVisible(pInstrumentsBox);
    
      // Добавляем инструменты в выпадающий список
      AddInstruments();
    
      pPlayButton = new TextButton(L"PlayButton");
      pPlayButton->setButtonText(tr("Играть"));
      pPlayButton->setColour(TextButton::buttonColourId, Colours::lightgreen);
      pPlayButton->setEnabled(false);
      pPlayButton->addListener(this);
      addAndMakeVisible(pPlayButton);
    
      // Получаем названия имеющихся устройств MIDI...
      StringArray sMIDIDevices = MidiOutput::getDevices();
      for(int i = 0; i < sMIDIDevices.size(); i++)
      {
      // и добавляем их в список
        pMIDIDevicesBox->addItem(tr("MIDI устройство: ") + sMIDIDevices[i], 
                i + 1);
      }
      pMIDIDevicesBox->setSelectedId(1, false);
    
      setSize(600, 200);
    
      // Нота не воспроизводится
      bIsPlaying = false;
      // Инструмент по умолчанию - Acoustic Grand Piano
      iInstrument = 0;
    }
    

    Открыть устройство MIDI с индексом deviceIndex позволяет метод этого же класса static MidiOutput* MidiOutput::openDevice(int deviceIndex). После этого устройству можно посылать сообщения и удалить его, когда оно больше не будет нужно. Индекс устройства представляет собой его позицию в списке, возвращаемом функцией getDevices.

    В том случае, если устройство невозможно открыть, метод openDevice возвращает нулевой указатель.

    В нашем примере открытие устройства MIDI происходит после его выбора пользователем в выпадающем списке ().

    void TCentralComponent::comboBoxChanged(ComboBox* pComboBox)
    {
      if(pComboBox == pMIDIDevicesBox)
      {
        // Открываем выбранное пользователем устройство MIDI
        pMidiOutput = MidiOutput::openDevice(
                pMIDIDevicesBox->getSelectedId() - 1);
        // Если нет доступных устройств, выводим сообщение об этом
        if(pMidiOutput == NULL)
        {
          AlertWindow::showMessageBox(
                AlertWindow::WarningIcon, tr("Ошибка"),
                tr("Невозможно открыть MIDI устройство!"), 
                tr("Принять"), 0);
                pPlayButton->setEnabled(false);
          pInstrumentsBox->setEnabled(false);
            }
        else 
        {
          pPlayButton->setEnabled(true);
          // Иначе даём пользователю выбрать инструмент
          pInstrumentsBox->setEnabled(true);
        }
      }
      else if(pComboBox == pInstrumentsBox)
      {
        // Пользователь выбрал инструмент
        iInstrument = pInstrumentsBox->getSelectedId();
      }
    }
    

    Наша программа при нажатии на кнопку ). Цвет кнопки будет меняться при воспроизведении ноты и его остановке.

    В нашем примере отправка сообщения Note on реализована в методе ).

    void TCentralComponent::Play()
    {
      // Задаём тембр воспроизведения
      pMidiOutput->sendMessageNow(MidiMessage::programChange(1, iInstrument));
      // Команда Note on
      MidiMessage PlayMessage(0x90, 60, 100, 0);
      // Выбираем канал (в нашей программе вызываемый метод ничего не делает)
      PlayMessage.setChannel(1);
      // Отправляем сообщение
      pMidiOutput->sendMessageNow(PlayMessage);
    }
    

    Как уже упоминалось выше, команды MIDI посылаются одному из 16 логических каналов, отвечающих за режим воспроизведения нот тем или иным инструментом. Так, для смены инструмента на первом канале протокол MIDI предусматривает команду ).

    Задать канал для отправки сообщений MIDI позволяет метод void MidiMessage::setChannel(int newChannelNumber) throw(). Принимаемый параметр должен, разумеется, находиться в пределах от 1 до 16. В нашем примере вызов этого метода не нужен и приведён исключительно в иллюстративных целях. Если мы закомментируем строку PlayMessage.setChannel(1), то никаких изменений в работе нашей программы не произойдёт, поскольку сообщение MidiMessage PlayMessage(0x90, 60, 100, 0) и означает воспроизведение ноты до на первом канале. Для проигрывания её на втором канале конструктор сообщения должен в качестве первого параметра принимать значение 0x91, на третьем — 0x92 и т.п. Но попробуйте изменить параметр метода setChannel с 1 на 10 и пересоберите программу. Нота будет воспроизводиться ударной установкой синтезатора, а выбор инструмента пользователем будет проигнорирован.

    Метод ). Цвет кнопки при этом меняется со светло-зелёного на розовый.

    void TCentralComponent::buttonClicked(Button* pButton)
    {
      if(pButton == pPlayButton)
      {
        if(bIsPlaying == false)
        {
          Play();
          // Меняем текст...
          pPlayButton->setButtonText(tr("Остановить"));
          // ...и цвет кнопки (новый цвет - розовый)
          pPlayButton->setColour(TextButton::buttonColourId, Colours::pink);
          bIsPlaying = true;
        }
        else
        {
          // Команда Note off
          MidiMessage StopMessage(0x80, 60, 0);
          // Выбираем канал 
          // (в нашей программе вызываемый метод ничего не делает)
          StopMessage.setChannel(1);
          pMidiOutput->sendMessageNow(StopMessage);
          // Меняем текст...
          pPlayButton->setButtonText(tr("Играть"));
          // ...и цвет кнопки (новый цвет - светло-зелёный)
          pPlayButton->setColour(
                TextButton::buttonColourId, 
                Colours::lightgreen);
          bIsPlaying = false;
        }
      }
    }
    

    В противном случае мы останавливаем воспроизведение, посылая команду Note off — MidiMessage StopMessage(0x80, 60, 0). Как уже упоминалось выше, остановить проигрывание ноты можно, отправив сообщение MIDI о её воспроизведении с нулевой скоростью, например, так:

    MidiMessage StopMessage(0x90, 60, 0);
    pMidiOutput->sendMessageNow(StopMessage);
    

    Замените в коде нашей программы первый байт сообщения MIDI с 0x80 на 0x90 и пересоберите её. Убедитесь, что работа приложения не изменилась.

    Полный исходный текст этого примера вы можете найти на прилагаемом к книге компакт-диске.

    Программный синтезатор

    Собрав и запустив вышеприведённый пример, пользователи Linux могут столкнуться с тем, что звук при нажатии на кнопку pPlayButton не слышен, несмотря на то, что устройство MIDI (Midi Through) в выпадающем списке отображается.

    Дело в том, что собственно воспроизведение звука при отправке сообщения MIDI звуковой карте компьютера осуществляет её синтезатор, который может быть аппаратным или программным. В современных звуковых картах, особенно интегрированных, как правило аппаратный синтезатор отсутствует, а работа по генерации звука возложена на звуковой драйвер и процессор компьютера.

    В Windows программный синтезатор обычно входит в состав драйвера звуковой карты (рис 19.2). В Linux требуется запустить в качестве ALSA-сервера для входящих MIDI-событий какой-либо сторонний программный синтезатор: например, TiMidity++ или FluidSynth (напомним, что работа Juce со звуком в Linux осуществляется через модули ALSA). Так, если до запуска нашей программы выполнить команду

    timidity -iA 
    

    то ).

    (рис 19.3) Работа программы, демонстрирующей генерацию и обработку сообщения MIDI (в качестве синтезатора выступает TiMidity++)

    Однако библиотека Juce позволяет написать собственный программный синтезатор для того, чтобы использовать его, например, в качестве устройства MIDI по умолчанию в своих программах. Для этой цели она предлагает класс Synthesiser. Для того, чтобы создать программный синтезатор, необходимо, прежде всего, создать класс, наследуемый от SynthesiserSound и описывающий звуки, доступные для синтеза, а также класс, наследуемый от SynthesiserVoice, который будет отвечать за воспроизведение этих звуков.

    Пример программного синтезатора приведён в демонстрационном приложении Juce (выберите в меню Demo > Audio и перейдите на вкладку Synth Playback). Для генерации сообщений MIDI в этом примере используется виртуальная MIDI-клавиатура, являющаяся объектом специального класса Juce, ). Отслеживать состояние клавиатуры (нажатые клавиши) должен объект класса MidiKeyboardState, который посылает сообщения о событиях слушателю, MidiKeyboardStateListener.

    (рис 19.4) Работа демонстрационного приложения Juce, воспроизводящего команды MIDI, генерируемые виртуальной клавиатурой, посредством программного синтезатора

    Посмотреть реализацию примера можно, перейдя в папку <каталог Juce>/juce/extras/JuceDemo/Source/demos и открыв файл AudioDemoSynthPage.cpp. Исходный текст демонстрационного приложения хорошо комментирован и не нуждается в дополнительных пояснениях.

    Воспроизведение MIDI-файлов

    Стандартный MIDI-файл (Standard MIDI File, SMF), имеющий расширение *.mid, — это специальный формат бинарных файлов, которые могут быть записаны или исполнены секвенсером (как программным, так и в виде аппаратного модуля). В этом формате хранятся сообщения MIDI, а также специальные метки для каждого из них, указывающие, какое условное количество времени необходимо подождать перед тем, как выполнить следующую команду MIDI. Формат времени в MIDI-файле — это привычные музыкантам такты и доли. Доля делится на более мелкие части, тики (ticks). В стандартном MIDI-файле доля содержит 96 тиков.

    Файлы MIDI могут быть двух форматов: когда каждому из 16 каналов соответствует свой трек (SMF format 1) или они находятся в одном треке (SMF format 0). Очень часто в начале каждого трека бывают MIDI сообщения, задающие его параметры: Program Change, Volume, Pan, Reverb, Chorus.

    Команда MIDI, сопровождающаяся меткой времени, называется событием (event). Служебное сообщение MIDI, несущее вспомогательную информацию, например, о темпе воспроизведения, называется мета событием (meta event).

    Рассмотрим воспроизведение подобных файлов на примере MIDI-секвенсера, не обладающего возможностью записи (такие секвенсеры традиционно называют MIDI-плейером). Для облегчения задачи воспользуемся готовой программой, Brussels MIDI Player. Это простейший проигрыватель MIDI для Linux; автор настоящего курса внёс незначительные изменения в его код: заменил вызов платформ-зависимого метода создания устройства MIDI static MidiOutput* MidiOutput::createNewDevice(const String deviceName), недоступного под Windows, на рассмотренный выше кроссплатформенный метод static MidiOutput* MidiOutput::openDevice(int deviceIndex), произвёл частичную локализацию программы, а также изменил размеры виджетов с тем, чтобы читались надписи на них.

    Программа имеет довольно необычную архитектуру. За создание пользовательского интерфейса программы отвечает объект класса ).

    #include "../JuceLibraryCode/JuceHeader.h"
    #include "MidiPlayerEngine.h"
    #include "TinyDisplay.cpp"
    
    class BrusselsMidiPlayer : public JUCEApplication {
    public:
      void initialise(const String p) {
        TinyDisplay k;
        k.setBounds(0, 0, 500, 90);
        DialogWindow::showModalDialog(L"Brussels Midi Player", k, 0,
                Colours::silver, false);
        systemRequestedQuit();
      }
    
      void shutdown() {
      }
    
      void systemRequestedQuit() {
          quit();
      }
    
      const String getApplicationName() {
        return String(L"Brussels Midi Player");
      }
      
      const String getApplicationVersion()
      {
        return ProjectInfo::versionString;
      }
    };
    

    Класс ).

    (рис 19.5) Работа программы Brussels MIDI Player

    Кроме того, класс включает MIDI-устройство ).

    TinyDisplay() : mPlayBtn(tr("Играть")),
              mStopBtn(tr("Остановить")),
              mOpenBtn(tr("Открыть...")),
              mSlider (L"Seek")
      {
        mPlayBtn.setBounds(10, 10, 150, 30);
        mPlayBtn.addListener(this);
        mStopBtn.setBounds(170, 10, 150, 30);
        mStopBtn.addListener(this);
        mOpenBtn.setBounds(340, 10, 150, 30);
        mOpenBtn.addListener(this);
        
        mSlider.setBounds(10, 50, 480, 30);
        mSlider.setTextBoxStyle(Slider::NoTextBox, true, 0, 0);
        mSlider.addListener(this);
        
        addAndMakeVisible(mPlayBtn);
        addAndMakeVisible(mStopBtn);
        addAndMakeVisible(mOpenBtn);
        addAndMakeVisible(mSlider);
        
        mSlider.setEnabled(false);
        mStopBtn.setEnabled(false);
        mPlayBtn.setEnabled(false);
        
        // Это платформ-зависимый (Linux) метод. Он не работает под Windows.
        //midiOut = MidiOutput::createNewDevice(
                JUCEApplication::getInstance()->getApplicationName());
        
        // Получаем названия имеющихся устройств MIDI...
        StringArray sMIDIDevices = MidiOutput::getDevices();
        if(sMIDIDevices.size() == 1) midiOut = MidiOutput::openDevice(0);
        else midiOut = MidiOutput::openDevice(1);
        engine  = 0;
      }
    

    За собственно воспроизведение файлов MIDI отвечает класс ).

    Воспроизведение MIDI и управление пользовательским интерфейсом приложения осуществляется в разных нитях (потоках, threads) одного процесса, поэтому MidiPlayerEngine наследуется от класса Juce Thread. Конструктор последнего Thread::Thread(const String threadName) создаёт нить, запуск которой осуществляет метод void Thread::startThread().

    Познакомиться подробнее с использованием методов класса Thread можно в демонстрационном приложении Juce (см. <каталог Juce>/juce/extras/JuceDemo/Source/demos/ThreadingDemo.cpp).

    Новый объект MidiPlayerEngine создаётся в обработчике события нажатия на кнопку ). В качестве параметров конструктор MidiPlayerEngine принимает указатели на поток данных InputStream и устройство MIDI MidiOutput.

    void buttonClicked(Button* button) {
        if (button == (Button*)mOpenBtn) {
          FileChooser fc(tr("Открыть файл MIDI"),
                File::getSpecialLocation(File::userMusicDirectory),
                "*.mid;*.kar");
          if (!fc.browseForFileToOpen())
          return;
          
          // Останавливаем и удаляем MidiPlayerEngine 
          if (engine) {
          engine->stop();
            delete engine;
          }
          
          // В качестве параметра принимается указатель на поток данных,
          // загруженных из файла, а также указатель на устройство MIDI
          engine = new MidiPlayerEngine(
                File(fc.getResult()).createInputStream(), 
                midiOut);
          engine->addChangeListener(this);
          // Делаем доступным переключатель воспроизведения
          mPlayBtn.setEnabled(true);
          // Файл не воспроизводится, флажок переключателя сброшен
          mPlayBtn.setToggleState(false, false);
          // Доступны также кнопка воспроизведения и ползунок
          mStopBtn.setEnabled(true);
          mSlider.setEnabled(true);
          mSlider.setRange(0, engine->getLength());
          Logger::writeToLog(String(L"Total length = ") + 
                String(engine->getLength()));
          
        }
        
    

    За работу с файлами MIDI в Juce отвечает класс MidiFile, который позволяет осуществлять как чтение из файла, так и запись в него. Чтение может производиться как всего потока данных файла, так и отдельного его трека. В первом случае используется метод bool MidiFile::readFrom(InputStream sourceStream), а во втором — const MidiMessageSequence* MidiFile::getTrack(int index) const throw(). Последний метод возвращает ноль в случае, если трек не найден, либо указатель на MidiMessageSequence — последовательность сообщений MIDI с временными метками (timestamps).

    Для того, чтобы записать файл MIDI, необходимо создать объект класса MidiFile и добавить в него несколько объектов MidiMessageSequence путём вызова метода void MidiFile::addTrack(const MidiMessageSequence trackSequence) либо bool MidiFile::writeTo(OutputStream destStream). Первый метод добавляет в файл последовательность MIDI (трек), а второй — записывает в поток данных все треки в виде стандартного файла MIDI.

    Удалить все треки из файла позволяет метод void MidiFile::clear().

    При создании объекта ).

    MidiPlayerEngine::MidiPlayerEngine
      (InputStream* stream, MidiOutput* midiDevice)
      : Thread(JUCEApplication::getInstance()->getApplicationName() + L" MIDI")
    {
      bool readSuccessfully = mFile.readFrom(*stream);
      // Если не удалось загрузить данные, сообщаем, что файл повреждён
      if (!readSuccessfully) throw String(L"Midi file appears corrupted");
      // Формат SMPTE не поддерживается
      if (mFile.getTimeFormat() <= 0) 
        throw String(L"SMPTE format timing is not yet supported");
          
    

    В файлах MIDI применяют два формата расстановки временных меток: SMF, когда указывается, сколько тиков приходится на четвертную длительность, и SMPTE, определяющий число тиков в секунде. Выяснить, какой из форматов используется в конкретном MIDI файле, позволяет метод short MidiFile::getTimeFormat() const throw(). Положительное возвращаемое значение свидетельствует о том, что текущий временной формат — SMF, отрицательное — SMPTE.

    Установить тот или иной формат времени для записываемого файла MIDI позволяют методы void MidiFile::setTicksPerQuarterNote(int ticksPerQuarterNote) throw() и void MidiFile::setSmpteTimeFormat(int framesPerSecond, int subframeResolution) throw(), где framesPerSecond принимает значения 24, 25, 29 и 30, а framesPerSecond4, 8, 10, 80 или 100.

    Данные треков текущего файла MIDI хранятся в члене класса MidiPlayerEngine — последовательности MidiMessageSequence mBuffer, а позиция воспроизведения — в переменной int mPosition.

    Загрузка треков в mBuffer приводится в листинге 19.16 .

      mBuffer.clear();
      mPosition = 0;
      mListener = 0;
      mFilter   = 0;
    
      // Временная переменная для хранения загружаемого трека
      const MidiMessageSequence* oneTrack;
    
      // Начинаем микширование
      for (int track = 0; track < mFile.getNumTracks(); track++) {
        // Получаем трек
        oneTrack = mFile.getTrack(track);
        // Добавляем его в общую последовательность сообщений MIDI
        mBuffer.addSequence(*oneTrack,
                0, // нет смещения относительно временных меток
                0, // используем события MIDI с начала трека...
                // и до его конца
                oneTrack->getEndTime());
      }
      
      mBuffer.updateMatchedPairs();
    
      // Загрузка завершена, устанавливаем устройство MIDI
      if (midiDevice)
        mMidiOut = midiDevice;
      else // null pointer
        throw String(L"Invalid MIDI output device specified");
    
      // All done.
      this->sendActionMessage(L"FileLoaded");
    }
    

    Класс MidiMessageSequence позволяет добавлять новую последовательность событий MIDI с помощью метода void MidiMessageSequence::addSequence(const MidiMessageSequence other, double timeAdjustmentDelta, double firstAllowableDestTime, double endOfAllowableDestTimes), где

  • other — добавляемая последовательность;
  • timeAdjustmentDelta — число, прибавляемое к меткам времени считываемых событий MIDI;
  • firstAllowableDestTime — события MIDI будут добавляться в целевую последовательность лишь в том случае, если их время более позднее, чем время параметра;
  • endOfAllowableDestTimes — события MIDI будут добавляться в целевую последовательность лишь в том случае, если их время более раннее, чем время параметра.
  • Время окончания текущего трека мы определяли с помощью метода double MidiMessageSequence::getEndTime() const, который возвращает временную метку последнего события MIDI последовательности.

    Как мы упоминали выше, при включении ноты она будет звучать до тех пор, пока не будет отправлена команда, её выключающая. Понятно, что в файле MIDI каждому сообщению Note on должно соответствовать сообщение Note off. Проверить соответствие пар этих команд в последовательности сообщений MIDI позволяет метод void MidiMessageSequence::updateMatchedPairs().

    После того, как данные файла MIDI были загружены в последовательность, можно приступить к её воспроизведению. Установка флажка переключателя mPlayBtn класса TinyDisplay вызывает метод void MidiPlayerEngine::play(), который запускает нить посредством перегруженного метода void Thread::startThread(int priority), где priority — приоритет выполнения нити, число от 0 (низший приоритет) до 10 (высший).

    Собственно логика воспроизведения последовательности реализуется в чистом виртуальном методе нити ). Расчёт времени отправки сообщений MIDI понятен из кода; назначение локальных переменных приведено в комментариях.

      void MidiPlayerEngine::run() {
      // Стартовое сообщение, первый байт не может быть нулём
      MidiMessage message(0x80, 0, 0, 0);
      Message* listenerMsg = new Message(0, 0, 0, 0);
      // Получаем число MIDI событий
      int numEvents = mBuffer.getNumEvents();
      // Устанавливаем текущую позицию воспроизведения
      int currentPosition = 0;
      // Получаем число тиков на четверть (ticks per quarter note)
      double TPQN = static_cast<double>(mFile.getTimeFormat());
      // Время начала следующего события MIDI
      double nextEventTime = 0.;
      double msPerTick = 500. / TPQN; // по умолчанию 120 BPM
      // Предыдущая метка времени
      double prevTimestamp = 0.;
      setPosition(0);
    
      sendActionMessage(L"Play");
    
      while (( !threadShouldExit() )  ( currentPosition < numEvents )) {
    
        if (isPaused()) continue;
    
            message = mBuffer.getEventPointer(currentPosition)->message;
    
        if (mFilter) mFilter->filterMessage(message);
    
        nextEventTime = msPerTick *
                (message.getTimeStamp() - prevTimestamp);
    
        Time::waitForMillisecondCounter(Time::getMillisecondCounter()
                + nextEventTime);
    
        if (mListener->isValidMessageListener()) {
          listenerMsg->pointerParameter = (void*) message;
          mListener->postMessage(listenerMsg);
        }
    
        if (message.isTempoMetaEvent()) {
          msPerTick = message.getTempoSecondsPerQuarterNote() * 1000. / TPQN;
        } else if (!message.isMetaEvent()) {
          // Посылаем сообщение MIDI
          mMidiOut->sendMessageNow(message);
        }
    
        if (static_cast<int>(prevTimestamp) % static_cast<int>(TPQN) == 0)
          sendChangeMessage();
    
        prevTimestamp = message.getTimeStamp();
        setPosition(++currentPosition);
    
      }
      delete listenerMsg;
      reset();
      sendActionMessage(L"Stop");
    }
      
    

    При воспроизведении последовательности MIDI были использованы следующие функции:

  • MidiEventHolder* MidiMessageSequence::getEventPointer(int index) const — возвращает указатель на событие MIDI. Структура MidiEventHolder включает MidiMessage message — собственно сообщение MIDI, временная метка которого соответствует событию;
  • double MidiMessage::getTimeStamp() const throw() — возвращает метку времени, соответствующую сообщению MIDI;
  • static void Time::waitForMillisecondCounter(uint32 targetTime) throw() — функция ожидания на число миллисекунд targetTime;
  • bool MidiMessage::isMetaEvent() const throw() — возвращает true в случае, если событие является мета событием;
  • bool MidiMessage::isTempoMetaEvent() const throw() — возвращает true в случае, если событие является мета событием темпа;
  • double MidiMessage::getTempoSecondsPerQuarterNote() const throw() — вычисляет число секунд на четверть, исходя из текущего темпа.
  • Рассмотренная программа Brussels MIDI Player, разумеется, не является полноценным секвенсером, содержит ряд недостатков, но достаточно проста для того, чтобы составить представление о работе с файлами MIDI.

    Особенности Juce 2

    Во 2-й версии библиотеки в метод setSource класса AudioTransportSource был добавлен ещё один параметр — указатель на объект класса TimeSliceThread:

    void AudioTransportSource::setSource(PositionableAudioSource* newSource, int readAheadBufferSize = 0, TimeSliceThread* readAheadThread = nullptr, double sourceSampleRateToCorrectFor = 0.0, int maxNumChannels = 2)

    Класс TimeSliceThread представляет собой нить, сохраняющую список клиентов и вызывающую их по очереди для выполнения какой-то короткой задачи.

    В версии нашего проигрывателя, ориентированной на Juce 2.0, мы должны объявить в закрытой части объявления класса компонента содержимого переменную TimeSliceThread PlayThread.

    Конструктор класса ).

    TCentralComponent::TCentralComponent() : Component("Central Component"),
                 pNameLabel(0),
                 pChooseButton(0),
                 PlayThread("PlayThread")
    {
        pNameLabel = new Label(L"Name Label", tr("Выберите файл..."));
      pNameLabel->setFont(Font(15.0000f, Font::bold));
      pNameLabel->setJustificationType(Justification::centred);
      pNameLabel->setEditable(false, false, false);
      pNameLabel->setColour(Label::textColourId, Colours::black);
      pNameLabel->setColour(Label::backgroundColourId, Colours::azure);
      pNameLabel->setColour(Label::outlineColourId, Colours::black);
      addAndMakeVisible(pNameLabel);
    
      // ...
    
      PlayThread.startThread(3);
    }
    

    Нить запускается в конструкторе класса, а при завершении работы программы должна быть остановлена ().

    TCentralComponent::~TCentralComponent()
    {
      PlayThread.signalThreadShouldExit();
      PlayThread.waitForThreadToExit(100);
      pAudioDeviceManager->closeAudioDevice();
      TransportSource.setSource(0);
      AudioPlayer.setSource(0);
      pAudioDeviceManager->removeAudioCallback( AudioPlayer);
      if(pAudioDeviceManager) delete pAudioDeviceManager;
      if(pCurrentAudioFileSource) delete pCurrentAudioFileSource;
      if(pFileNameDialog) delete pFileNameDialog;
      deleteAllChildren();
    }
    

    Если не считать этого параметра, то передача в AudioTransportSource нового источника аудиоданных осуществляется обычно: TransportSource.setSource(pCurrentAudioFileSource, 32768, PlayThread, pAudioFormatReader->sampleRate);

    Отметим также, что для корректной сборки программ с поддержкой аудио в Juce, помимо собственно аудио-модулей, в проекте должен быть включён модуль juce_gui_extra.

    Краткие итоги

    В этой лекции вы познакомились со взаимодействием классов, необходимых для воспроизведения основных форматов музыкальных файлов (AIFF, FLAC, OGG VORBIS, WAV), а также для файлов MIDI.

    Основным классом, отвечающим за воспроизведение аудио-файлов в Juce является AudioTransportSource.

    Упражнение

    Напишите программу, включающую окно с несколькими кнопками. При нажатии на кнопку происходит воспроизведение ассоциированного с ней аудио-файла, находящегося в каталоге программы.

    Дополнительные материалы

    Архив с исходными текстами примеров Вы можете скачать здесь.

    Страницы:

    Цель лекции: Научиться создавать приложения с поддержкой звука

    Изначально в IBM-совместимых персональных компьютерах вся работа со звуком заключалась в подаче сигналов с помощью встроенного динамика, а каких-либо средств для воспроизведения музыки вовсе не было, поскольку предполагалось, что персональные компьютеры будут использоваться исключительно в деловой сфере. Однако такое положение не устраивало конечных пользователей, и вскоре появилось довольно большое число звуковых карт, позволявших обеспечить качественное воспроизведение звука.

    В наши дни к мультимедийным возможностям программ предъявляются довольно жёсткие требования. К счастью, библиотека Juce, изначально ориентированная именно на создание подобных приложений, предоставляет разработчику множество классов для воспроизведения и записи звука. Детальный их разбор потребовал бы написания отдельной книги, поэтому в данной главе мы коснёмся лишь базовых принципов взаимодействия некоторых из них на простейших примерах, чтобы дать читателю основу для будущей самостоятельной работы с документацией к библиотеке.

    Воспроизведение аудио-файлов базовыми методами

    За воспроизведение аудио-файлов в библиотеке Juce отвечает класс AudioTransportSource, который создаёт собственную нить (поток) и позволяет начинать проигрывание с произвольной позиции, а также в любой момент останавливать его.

    Для того, чтобы объект класса AudioTransportSource мог "понять" данные аудио-файла, последний должен быть прочитан с помощью объекта класса, унаследованного от AudioFormat. Библиотека Juce предоставляет возможность воспроизведения ряда базовых форматов аудио:

  • AiffAudioFormat — читает и записывает файлы несжатого аудио, Audio Interchane File Format (*.aif, *.aiff);
  • FlacAudioFormat — читает и записывает файлы аудио, сжатого без потерь качества, Free Lossless Audio Codec (*.flac);
  • OggVorbisAudioFormat — читает и записывает файлы аудио, сжатого с потерей качества, Ogg Vorbis (*.ogg);
  • WavAudioFormat — читает и записывает файлы несжатого аудио, Waveform Audio File Format (*.wav).
  • Для хранения информации о доступных форматах аудио и принятия решения о том, какой из них использовать для открытия текущего файла, в библиотеке имеется класс AudioFormatManager, который в том числе создаёт чтеца (класс AudioFormatReader), читающего сэмплы из аудио-потока.

    Непосредственно управляет устройством воспроизведения звука (звуковой картой) объект класса AudioSourcePlayer, который служит промежуточным звеном между ним и объектом класса AudioTransportSource.

    Рассмотрим взаимодействие всех этих классов на примере простого проигрывателя, воспроизводящего вышеперечисленные форматы аудио.

    За основу будущего проекта возьмём пример из лекции 17 , в котором для открытия файла использовался ).

    (рис 19.1) Работа программы, демонстрирующей воспроизведение базовых типов аудио-файлов

    Все объекты, отвечающие за воспроизведение аудио-файлов, объявим в качестве закрытых членов компонента содержимого ().

    #ifndef _TCentralComponent_h_
    #define _TCentralComponent_h_
    //--------------------------------------------------
    #include "../JuceLibraryCode/JuceHeader.h"
    //--------------------------------------------------
    class TCentralComponent  : public Component,
                public ButtonListener,
                public ChangeListener
    {
    public:
      TCentralComponent();
      ~TCentralComponent();
    
      void paint(Graphics);
      void resized();
      // Функция, отслеживающая щелчки по кнопке
      void buttonClicked(Button*);
      void changeListenerCallback(ChangeBroadcaster*);
    
    private:
      // Текущий файл для воспроизведения
      File CurrentFile;
    
      // Менеджер аудио- и MIDI-устройств
      AudioDeviceManager* pAudioDeviceManager;
      // Аудио-устройство
      AudioIODevice* pAudioDevice;
      // Промежуточное звено между источником звука
      // и аудио-устройством
      AudioSourcePlayer AudioPlayer;
      // Источник воспроизведения звука
      AudioTransportSource TransportSource;
      // Класс для принятия данных от чтеца аудио-потока
      AudioFormatReaderSource* pCurrentAudioFileSource;
    
      // Ярлык для вывода названия аудио-устройства
      // при запуске программы, а в последующем -
      // названия открытого аудио-файла
      Label* pNameLabel;
      // Кнопка вызова диалога выбора файла
      TextButton* pChooseButton;
      // Кнопка начала / остановки воспроизведения
      TextButton* pPlayButton;
    
      // Компонент выбора файла
      FilenameComponent* pFileNameDialog;
    
      // Предотвращает создание копии конструктора и оператора =
      TCentralComponent(const TCentralComponent);
      const TCentralComponent operator= (const TCentralComponent);
    };
    //--------------------------------------------------
    #endif
    

    Их инициализация и установление связей между ними, необходимых для совместной работы, представлены в листинге 19.2 .

    Листинг

    #include "TCentralComponent.h"
    //----------------------------------------------
    #define tr(s) String::fromUTF8(s)
    //----------------------------------------------
    TCentralComponent::TCentralComponent() : Component("Central Component"),
                pNameLabel(0),
                pChooseButton(0)
    {
      pNameLabel = new Label(L"Name Label", tr("Выберите файл..."));
      pNameLabel->setFont(Font(15.0000f, Font::bold));
      pNameLabel->setJustificationType(Justification::centred);
      pNameLabel->setEditable(false, false, false);
      pNameLabel->setColour(Label::textColourId, Colours::black);
      pNameLabel->setColour(Label::backgroundColourId, Colours::azure);
      pNameLabel->setColour(Label::outlineColourId, Colours::black);
      addAndMakeVisible(pNameLabel);
    
      pChooseButton = new TextButton(L"Choose Button");
      pChooseButton->setButtonText(tr("Выбрать..."));
      pChooseButton->addListener(this);
      addAndMakeVisible(pChooseButton);
    
      pPlayButton = new TextButton(L"Play Button");
      pPlayButton->setButtonText(tr("Играть"));
      pPlayButton->setColour(TextButton::buttonColourId, Colours::lightgreen);
      pPlayButton->addListener(this);
      addAndMakeVisible(pPlayButton);
    
      // Файл для воспроизведения ещё не открыт, аудио-поток пока отсутствует
      pCurrentAudioFileSource = 0;
      
      LastOpenedFile = File::getCurrentWorkingDirectory();
      
      // Создаём пустой менеджер аудио-устройств
      pAudioDeviceManager = new AudioDeviceManager();
      // Инициируем менеджер аудио-устройств
      String sError = pAudioDeviceManager->initialise(1, 2, 0, true);
      // Сохраняем текущее аудио-устройство в переменной
      pAudioDevice = pAudioDeviceManager->getCurrentAudioDevice();
      // Если устройство отсутствует, выводим сообщение об ошибке
      if(!sError.isEmpty()) 
      {
        AlertWindow::showMessageBox(
                AlertWindow::WarningIcon, 
                tr("Ошибка воспроизведения"), 
               (tr("Не удалось открыть аудио-устройство:\n\n") += sError), 
                tr("Принять"), 0
                );
      }
      // Устанавливаем связи между менеджером устройств, 
      // AudioSourcePlayer и AudioTransportSource
      pAudioDeviceManager->addAudioCallback( AudioPlayer);
      TransportSource.addChangeListener(this);
      AudioPlayer.setSource( TransportSource);
    
      // Если есть доступное аудио-устройство...
      if(pAudioDevice != 0) 
      {
        // ...выводим его название на ярлык программы
        String sName = pAudioDevice->getName();
        pNameLabel->setText(sName, false);
      }
      else 
      {
        pNameLabel->setText(tr("Нет устройства!"), false);
      }
    
      setSize(400, 150);
    
      // Создаём компонент для выбора файла
      pFileNameDialog = new FilenameComponent(
               String::empty,
               CurrentFile,
               true,
               false,
               false,
               "*.wav;*.aif;*.aiff;*.ogg;*.flac",
               String::empty,
               tr("Выберите файл...")
               );
      File SearchDir = SearchDir.getSpecialLocation(File::userMusicDirectory);
      pFileNameDialog->setDefaultBrowseTarget(SearchDir);
      // Задаём его размер (иначе его не будет видно)
      pFileNameDialog->setSize(250, 25);
    }
    

    В конструкторе класса компонента содержимого мы создаём и инициализируем менеджер аудио-устройств.

    Метод const String AudioDeviceManager::initialise(int numInputChannelsNeeded, int numOutputChannelsNeeded, const XmlElement* savedState, bool selectDefaultDeviceOnFailure, const String preferredDefaultDeviceName = String::empty, const AudioDeviceSetup* preferredSetupOptions = 0) пытается открыть все доступные для использования аудио-устройства, начиная с устройства по умолчанию. Возвращаемая строка содержит сообщение об ошибке, если что-то пошло не так. В том случае, если открытие аудио-устройств прошло успешно, возвращается пустая строка.

    Метод AudioIODevice* AudioDeviceManager::getCurrentAudioDevice() const throw() возвращает указатель на текущее аудио-устройство. Мы сохраняем его в члене класса компонента содержимого pAudioDevice для последующих обращений.

    Затем мы устанавливаем связь между менеджером аудио-устройств и AudioSourcePlayer посредством вызова метода void AudioDeviceManager::addAudioCallback(AudioIODeviceCallback* newCallback). Удалить связь позволяет метод void AudioDeviceManager::removeAudioCallback(AudioIODeviceCallback* callback).

    Завершаем установку всех необходимых соединений установкой TransportSource в качестве источника воспроизведения для AudioSourcePlayer. Достигается это вызовом метода void AudioSourcePlayer::setSource(AudioSource* newSource).

    Заметим, что в нашей программе используются настройки текущего аудио-устройства по умолчанию. Однако библиотека Juce включает структуру, принадлежащую классу AudioDeviceManager— AudioDeviceSetup — хранящую набор свойств текущего аудио-устройства и дающую возможность пользователю их изменять на этапе выполнения программы. Пример использования AudioDeviceSetup вы можете найти в демонстрационном приложении Juce (выберите в меню Demo > Audio). Посмотреть реализацию примера можно, перейдя в папку <каталог Juce>/juce/extras/JuceDemo/Source/demos и открыв файл AudioDemoSetupPage.cpp.

    При первом запуске нашей программы на её ярлыке мы отображаем имя используемого аудио-устройства. Для его получения воспользуемся методом const String AudioIODevice::getName() const throw().

    Обратите внимание, что при завершении работы нашего приложения необходимо закрыть аудио-устройство и удалить вызов аудио с помощью методов ).

    TCentralComponent::~TCentralComponent()
    {
      pAudioDeviceManager->closeAudioDevice();
      TransportSource.setSource(0);
      AudioPlayer.setSource(0);
      pAudioDeviceManager->removeAudioCallback( AudioPlayer);
      if(pAudioDeviceManager) delete pAudioDeviceManager;
      if(pCurrentAudioFileSource) delete pCurrentAudioFileSource;
      if(pFileNameDialog) delete pFileNameDialog;
      deleteAllChildren();
    } 
    

    Наша программа будет менять цвет и текст кнопки ). Узнать, осуществляется ли воспроизведение в текущий момент, позводяет метод bool AudioTransportSource::isPlaying() const throw().

    void TCentralComponent::changeListenerCallback(ChangeBroadcaster*)
    {
      // Если идёт воспроизведение...
      if(TransportSource.isPlaying())
      {
        pPlayButton->setButtonText(tr("Остановить"));
        // изменяем цвет кнопки на розовый
        pPlayButton->setColour(TextButton::buttonColourId, Colours::pink);
      }
      else
      {
        pPlayButton->setButtonText(tr("Играть"));
        // иначе - меняем на зелёный.
        pPlayButton->setColour(TextButton::buttonColourId, Colours::lightgreen);
      }
    }
    

    Управление воспроизведением осуществляется в обработчике нажатия на кнопку ). В нём мы используем два метода класса AudioTransportSource:

  • void AudioTransportSource::start() — начинает воспроизведение, если его источник был выбран.
  • void AudioTransportSource::stop() — останавливает воспроизведение, посылая при этом сигнал слушателю изменений (ChangeListener), зарегистрированному для текщего объекта класса AudioTransportSource.
  • Перед началом воспроизведения необходимо установить его позицию в текущем аудио-потоке методом ), однако, если сохранять текущую позицию воспроизведения, получаемую методом double AudioTransportSource::getCurrentPosition() const, в какой-либо переменной и начинать в последующем проигрывание именно с этой позиции, то можно реализовать функцию паузы. Возвращаемое методом setPosition значение представляет собой время в секундах.

    else if(pButton == pPlayButton)
    {
      // Если идёт воспроизведение...
      if(TransportSource.isPlaying())
      {
        // останавливаем его
        TransportSource.stop();
      }
      else
      {
        // Иначе - устанавливаем позицию в начало потока воспроизведения...
        TransportSource.setPosition(0);
        // и начинаем проигрывание файла
        TransportSource.start();
      }
    }
        
    

    Загрузка аудио-файла в поток, и подготовка его к воспроизведению реализована в обработчике нажатия на кнопку — pChooseButton (). Выше мы уже описали взаимодействие используемых в нём классов.

    void TCentralComponent::buttonClicked(Button* pButton)
    {
      if(pButton == pChooseButton)
      {
        // Создаём "обёртку" для показа диалога...
        DialogWindow::showModalDialog(tr("Выбор файла"),
                    pFileNameDialog,
                    NULL,
                    Colours::azure,
                    true,
                    false,
                    false);
     
        // Когда файл открыт...
        CurrentFile = pFileNameDialog->getCurrentFile();
        // проверяем, существует ли он
        if(!(CurrentFile == File::nonexistent)) 
        {
          // Выводим на ярлык название файла
          pNameLabel->setText(CurrentFile.getFileName(), false);
    
          // Если ранее осуществлялось воспроизведение - останавливаем его.
          TransportSource.stop();
          TransportSource.setSource(0);
          pCurrentAudioFileSource = 0;
          AudioFormatManager FormatManager;
          // Загрузка файла в поток
          AudioFormatReader* pAudioFormatReader;
          // Регистрируем базовые форматы аудио
          FormatManager.registerBasicFormats();
          / Создаём чтеца для текущего аудио-файла
          pAudioFormatReader = FormatManager.createReaderFor(CurrentFile);
    
          if(pAudioFormatReader != 0)
          {
            // Если чтец существует, 
            // создаём с его помощью новый источник аудио-данных
            pCurrentAudioFileSource = 
            new AudioFormatReaderSource(pAudioFormatReader, true);
            // и передаём их в AudioTransportSource
            TransportSource.setSource(
                pCurrentAudioFileSource, 
                32768, 
                pAudioFormatReader->sampleRate);
            if(pCurrentAudioFileSource == 0)
            {
                AlertWindow::showMessageBox(
                AlertWindow::WarningIcon, 
                tr("Ошибка воспроизведения"), 
                tr("Отсутствует источник воспроизведения аудио-файла."), 
                  tr("Принять"), 0);
              return;
            }
          }
          else AlertWindow::showMessageBox(
                  AlertWindow::WarningIcon, 
                  tr("Ошибка воспроизведения"), 
                  tr("Не инициирован источник чтения аудио-файла.\n
                  Возможно, файл повреждён."), 
                  tr("Принять"), 0
                  );
        }
        else
        {
          pNameLabel->setText(tr("Файл не выбран!"), false);
        }
      }
    // ...
        
    

    Понятно, что для того, чтобы программа, написанная с помощью Juce, могла воспроизводить тот или иной формат аудио, необходимо написать класс, унаследованный от AudioFormat, в котором будет осуществляться чтение и декодирование аудио-данных. В последующем новый аудио-формат должен быть зарегистрирован в AudioFormatManager с помощью метода void AudioFormatManager::registerFormat(AudioFormat* newFormat, bool makeThisTheDefaultFormat). В случае если мы хотим использовать новый формат по умолчанию, параметр makeThisTheDefaultFormat принимает значение true. Как упоминалось, в нашем примере мы зарегистрировали базовые аудио-форматы с помощью метода void AudioFormatManager::registerBasicFormats().

    Членами сообщества уже созданы классы аудио-форматов, связывающие Juce с рядом свободных кодеков (например, avformat), что превращает её практически в универсальную мультимедийную библиотеку.

    Отправка и обработка сообщения MIDI

    MIDI (Musical Instrument Digital Interface) — цифровой интерфейс музыкальных инструментов, созданный в 1982 году и включающий аппаратные и программные средства, предназначенные для управления звуковой платой или звуковым синтезатором. Проще говоря, это способ обмена данными между устройствами либо программами, поддерживающими MIDI. Данными являются ноты, а также дополнительные сведения о них: громкость, длительность и тембр звука, положение в пространстве стереофонического звучания (величина смещения в сторону левой или правой колонки) и другие. Такие данные называются MIDI-сообщениями или MIDI-командами.

    Каждый байт данных начинается стартовым и заканчивается стоповым битом. Важно помнить, что сами данные, передаваемые через MIDI – интерфейс, не являются закодированными звуками ни в аналоговой, ни в цифровой форме.

    К программно-аппаратным средствам, или проще, устройствам MIDI относятся:

    MIDI-клавиатура (MIDI-контроллер) — MIDI-устройство, предназначенное для выдачи MIDI-данных на другие MIDI-устройства. Как правило, представляет собой инструмент с клавиатурой, похожей на фортепьянную; существуют также "ударные", "щипковые" и др. MIDI-контроллеры.

    MIDI-секвенсер (sequencer) — программа для персонального компьютера, обеспечивающая запись, редактирование и размещение нескольких дорожек с MIDI-данными; используется для записи партитуры и воспроизведения музыкальных произведений электронными музыкальными инструментами.

    MIDI-синтезатор (tone module) — автономное внешнее (периферийное) устройство, подсоединяемое к цифровому музыкальному инструменту или компьютеру, обеспечивающее музыкальный синтез.

    Устройства MIDI связываются между собой посредством кабелей, имеющих 5-штырьковые разъёмы стандарта DIN, в которых используются только 3 контакта из пяти (земля, токовая петля и линия данных). Протокол MIDI определяет пересылку цифровых команд по кабелю. В случае работы с MIDI на персональном компьютере устройства (клавиатура, секвенсер, синтезатор) могут быть реализованными программно (виртуальными).

    И в том, и в другом случае MIDI-клавиатура посылает сообщения на секвенсер, который передаёт их музыкальному синтезатору, который, собственно, и обеспечивает воспроизведение звука. Следует понимать, что стандарт MIDI никак не определяет способ создания звука, который может быть применён в той или иной конструкции синтезатора. Поэтому воспроизведение одной и той же последовательности команд MIDI на разных синтезаторах может отличаться.

    В настоящее время существует несколько стандартов MIDI, и самый распространённый — это General MIDI (GM). Стандарт предусматривает 16 независимых и равноправных логических каналов, имеющих собственные режимы работы. Например, канал номер 10 канал отведён для ударных. Все ноты, посылаемые на этот канал, будут воспроизведены виртуальной ударной установкой MIDI-синтезатора. Каждому каналу обычно назначается свой тембр, называемый по традиции "инструментом". Стандарт определяет так называемую таблицу инструментов, которая не что иное, как пронумерованный список из 128 названий инструментов. Например, под номером 1 идёт акустическое пианино.

    Напишем демонстрационную программу, использующую классы ).

    (рис 19.2) Работа программы, демонстрирующей генерацию и обработку сообщения MIDI

    В классе компонента содержимого объявим два выпадающих списка: для выбора пользователем устройств MIDI и инструментов (). Во второй список мы добавим некоторые из 128 стандартных инструментов; в первый же будут добавляться при запуске программы устройства MIDI, имеющиеся на компьютере пользователя. Для удобства включения их в список предусмотрим в нашем классе метод inline void TCentralComponent::AddInstruments().

    #ifndef _TCentralComponent_h_
    #define _TCentralComponent_h_
    //--------------------------------------------------
    #include "../JuceLibraryCode/JuceHeader.h"
    //-------------------------------------------------
    class TCentralComponent : public Component,
                public ComboBoxListener,
                public ButtonListener
    {
    public:
      TCentralComponent();
      ~TCentralComponent();
    
      void paint(Graphics);
      void resized();
      void comboBoxChanged(ComboBox*);
      void buttonClicked(Button*);
    
    private:
      // Флаг включения / выключения ноты
      bool bIsPlaying;
      // Номер текущего инструмента
      int iInstrument;
      // Выпадающий список устройств MIDI
      ComboBox* pMIDIDevicesBox;
      // Выпадающий список инструментов
      ComboBox* pInstrumentsBox;
      TextButton* pPlayButton;
    
      // Устройство вывода
      MidiOutput* pMidiOutput;
    
      // Добавляем новый инструмент в выпадающий список
      inline void AddInstruments();
      // Играем ноту
      void Play();
    };
    //-------------------------------------------------------
    #endif
    

    Когда на входной разъём синтезатора подаётся сообщение Note On (включить ноту), он реагирует на это воспроизведением соответствующего звука, который будет воспроизводиться до тех пор, пока не получит команду Note Off (выключить ноту).

    Команда Note On представляет собой трёхбайтовое сообщение формата 0x90 KK VV, где первый байт (0x90) — это собственно команда включения ноты, второй — номер ноты (точнее, клавиши MIDI-клавиатуры), а третий означает скорость нажатия на клавишу. Номера клавиш варьируют от 0 до 127. В случае, если номер байт KK равен нулю, команда превращается в Note Off.

    Например, если синтезатор получает сообщение 0x90 60 100, он воспроизводит ноту до первой октавы (соответствует клавише C 88-клавишной клавиатуры) со скоростью 100.

    Для управления командами Note On и Note Off предусмотрим в нашем классе флаг состояния ноты (включена / выключена) - bool bIsPlaying.

    Классом-обёрткой для хранения и управления сообщением MIDI в Juce является MidiMessage. В качестве параметров конструктор класса принимает три целых числа — байты сообщения и одну переменную типа double. Последний параметр — время, отводимое сообщению MIDI (по умолчанию принимает значение 0).

    Немедленную отправку сообщения (объект класса ).

    Класс ).

    #include "TCentralComponent.h"
    //---------------------------------------------------
    #define tr(s) String::fromUTF8(s)
    //---------------------------------------------------
    TCentralComponent::TCentralComponent() : Component("CentralComponent"),
                pMIDIDevicesBox(0),
                pPlayButton(0)
    {
      pMIDIDevicesBox = new ComboBox(L"MIDIDevicesBox");
      pMIDIDevicesBox->setEditableText(false);
      pMIDIDevicesBox->setJustificationType(Justification::centredLeft);
      pMIDIDevicesBox->setTextWhenNothingSelected(
                tr("MIDI устройство не выбрано"));
        pMIDIDevicesBox->setTextWhenNoChoicesAvailable(
                tr("Нет доступных устройств"));
      pMIDIDevicesBox->addListener(this);
      addAndMakeVisible(pMIDIDevicesBox);
    
      pInstrumentsBox = new ComboBox(L"InstrumentsBox");
      pInstrumentsBox->setEditableText(false);
      pInstrumentsBox->setJustificationType(Justification::centredLeft);
      pInstrumentsBox->setTextWhenNothingSelected(
                tr("Нет выбранных инструментов"));
      pInstrumentsBox->addListener(this);
      pInstrumentsBox->setEnabled(false);
      addAndMakeVisible(pInstrumentsBox);
    
      // Добавляем инструменты в выпадающий список
      AddInstruments();
    
      pPlayButton = new TextButton(L"PlayButton");
      pPlayButton->setButtonText(tr("Играть"));
      pPlayButton->setColour(TextButton::buttonColourId, Colours::lightgreen);
      pPlayButton->setEnabled(false);
      pPlayButton->addListener(this);
      addAndMakeVisible(pPlayButton);
    
      // Получаем названия имеющихся устройств MIDI...
      StringArray sMIDIDevices = MidiOutput::getDevices();
      for(int i = 0; i < sMIDIDevices.size(); i++)
      {
      // и добавляем их в список
        pMIDIDevicesBox->addItem(tr("MIDI устройство: ") + sMIDIDevices[i], 
                i + 1);
      }
      pMIDIDevicesBox->setSelectedId(1, false);
    
      setSize(600, 200);
    
      // Нота не воспроизводится
      bIsPlaying = false;
      // Инструмент по умолчанию - Acoustic Grand Piano
      iInstrument = 0;
    }
    

    Открыть устройство MIDI с индексом deviceIndex позволяет метод этого же класса static MidiOutput* MidiOutput::openDevice(int deviceIndex). После этого устройству можно посылать сообщения и удалить его, когда оно больше не будет нужно. Индекс устройства представляет собой его позицию в списке, возвращаемом функцией getDevices.

    В том случае, если устройство невозможно открыть, метод openDevice возвращает нулевой указатель.

    В нашем примере открытие устройства MIDI происходит после его выбора пользователем в выпадающем списке ().

    void TCentralComponent::comboBoxChanged(ComboBox* pComboBox)
    {
      if(pComboBox == pMIDIDevicesBox)
      {
        // Открываем выбранное пользователем устройство MIDI
        pMidiOutput = MidiOutput::openDevice(
                pMIDIDevicesBox->getSelectedId() - 1);
        // Если нет доступных устройств, выводим сообщение об этом
        if(pMidiOutput == NULL)
        {
          AlertWindow::showMessageBox(
                AlertWindow::WarningIcon, tr("Ошибка"),
                tr("Невозможно открыть MIDI устройство!"), 
                tr("Принять"), 0);
                pPlayButton->setEnabled(false);
          pInstrumentsBox->setEnabled(false);
            }
        else 
        {
          pPlayButton->setEnabled(true);
          // Иначе даём пользователю выбрать инструмент
          pInstrumentsBox->setEnabled(true);
        }
      }
      else if(pComboBox == pInstrumentsBox)
      {
        // Пользователь выбрал инструмент
        iInstrument = pInstrumentsBox->getSelectedId();
      }
    }
    

    Наша программа при нажатии на кнопку ). Цвет кнопки будет меняться при воспроизведении ноты и его остановке.

    В нашем примере отправка сообщения Note on реализована в методе ).

    void TCentralComponent::Play()
    {
      // Задаём тембр воспроизведения
      pMidiOutput->sendMessageNow(MidiMessage::programChange(1, iInstrument));
      // Команда Note on
      MidiMessage PlayMessage(0x90, 60, 100, 0);
      // Выбираем канал (в нашей программе вызываемый метод ничего не делает)
      PlayMessage.setChannel(1);
      // Отправляем сообщение
      pMidiOutput->sendMessageNow(PlayMessage);
    }
    

    Как уже упоминалось выше, команды MIDI посылаются одному из 16 логических каналов, отвечающих за режим воспроизведения нот тем или иным инструментом. Так, для смены инструмента на первом канале протокол MIDI предусматривает команду ).

    Задать канал для отправки сообщений MIDI позволяет метод void MidiMessage::setChannel(int newChannelNumber) throw(). Принимаемый параметр должен, разумеется, находиться в пределах от 1 до 16. В нашем примере вызов этого метода не нужен и приведён исключительно в иллюстративных целях. Если мы закомментируем строку PlayMessage.setChannel(1), то никаких изменений в работе нашей программы не произойдёт, поскольку сообщение MidiMessage PlayMessage(0x90, 60, 100, 0) и означает воспроизведение ноты до на первом канале. Для проигрывания её на втором канале конструктор сообщения должен в качестве первого параметра принимать значение 0x91, на третьем — 0x92 и т.п. Но попробуйте изменить параметр метода setChannel с 1 на 10 и пересоберите программу. Нота будет воспроизводиться ударной установкой синтезатора, а выбор инструмента пользователем будет проигнорирован.

    Метод ). Цвет кнопки при этом меняется со светло-зелёного на розовый.

    void TCentralComponent::buttonClicked(Button* pButton)
    {
      if(pButton == pPlayButton)
      {
        if(bIsPlaying == false)
        {
          Play();
          // Меняем текст...
          pPlayButton->setButtonText(tr("Остановить"));
          // ...и цвет кнопки (новый цвет - розовый)
          pPlayButton->setColour(TextButton::buttonColourId, Colours::pink);
          bIsPlaying = true;
        }
        else
        {
          // Команда Note off
          MidiMessage StopMessage(0x80, 60, 0);
          // Выбираем канал 
          // (в нашей программе вызываемый метод ничего не делает)
          StopMessage.setChannel(1);
          pMidiOutput->sendMessageNow(StopMessage);
          // Меняем текст...
          pPlayButton->setButtonText(tr("Играть"));
          // ...и цвет кнопки (новый цвет - светло-зелёный)
          pPlayButton->setColour(
                TextButton::buttonColourId, 
                Colours::lightgreen);
          bIsPlaying = false;
        }
      }
    }
    

    В противном случае мы останавливаем воспроизведение, посылая команду Note off — MidiMessage StopMessage(0x80, 60, 0). Как уже упоминалось выше, остановить проигрывание ноты можно, отправив сообщение MIDI о её воспроизведении с нулевой скоростью, например, так:

    MidiMessage StopMessage(0x90, 60, 0);
    pMidiOutput->sendMessageNow(StopMessage);
    

    Замените в коде нашей программы первый байт сообщения MIDI с 0x80 на 0x90 и пересоберите её. Убедитесь, что работа приложения не изменилась.

    Полный исходный текст этого примера вы можете найти на прилагаемом к книге компакт-диске.

    Программный синтезатор

    Собрав и запустив вышеприведённый пример, пользователи Linux могут столкнуться с тем, что звук при нажатии на кнопку pPlayButton не слышен, несмотря на то, что устройство MIDI (Midi Through) в выпадающем списке отображается.

    Дело в том, что собственно воспроизведение звука при отправке сообщения MIDI звуковой карте компьютера осуществляет её синтезатор, который может быть аппаратным или программным. В современных звуковых картах, особенно интегрированных, как правило аппаратный синтезатор отсутствует, а работа по генерации звука возложена на звуковой драйвер и процессор компьютера.

    В Windows программный синтезатор обычно входит в состав драйвера звуковой карты (рис 19.2). В Linux требуется запустить в качестве ALSA-сервера для входящих MIDI-событий какой-либо сторонний программный синтезатор: например, TiMidity++ или FluidSynth (напомним, что работа Juce со звуком в Linux осуществляется через модули ALSA). Так, если до запуска нашей программы выполнить команду

    timidity -iA 
    

    то ).

    (рис 19.3) Работа программы, демонстрирующей генерацию и обработку сообщения MIDI (в качестве синтезатора выступает TiMidity++)

    Однако библиотека Juce позволяет написать собственный программный синтезатор для того, чтобы использовать его, например, в качестве устройства MIDI по умолчанию в своих программах. Для этой цели она предлагает класс Synthesiser. Для того, чтобы создать программный синтезатор, необходимо, прежде всего, создать класс, наследуемый от SynthesiserSound и описывающий звуки, доступные для синтеза, а также класс, наследуемый от SynthesiserVoice, который будет отвечать за воспроизведение этих звуков.

    Пример программного синтезатора приведён в демонстрационном приложении Juce (выберите в меню Demo > Audio и перейдите на вкладку Synth Playback). Для генерации сообщений MIDI в этом примере используется виртуальная MIDI-клавиатура, являющаяся объектом специального класса Juce, ). Отслеживать состояние клавиатуры (нажатые клавиши) должен объект класса MidiKeyboardState, который посылает сообщения о событиях слушателю, MidiKeyboardStateListener.

    (рис 19.4) Работа демонстрационного приложения Juce, воспроизводящего команды MIDI, генерируемые виртуальной клавиатурой, посредством программного синтезатора

    Посмотреть реализацию примера можно, перейдя в папку <каталог Juce>/juce/extras/JuceDemo/Source/demos и открыв файл AudioDemoSynthPage.cpp. Исходный текст демонстрационного приложения хорошо комментирован и не нуждается в дополнительных пояснениях.

    Воспроизведение MIDI-файлов

    Стандартный MIDI-файл (Standard MIDI File, SMF), имеющий расширение *.mid, — это специальный формат бинарных файлов, которые могут быть записаны или исполнены секвенсером (как программным, так и в виде аппаратного модуля). В этом формате хранятся сообщения MIDI, а также специальные метки для каждого из них, указывающие, какое условное количество времени необходимо подождать перед тем, как выполнить следующую команду MIDI. Формат времени в MIDI-файле — это привычные музыкантам такты и доли. Доля делится на более мелкие части, тики (ticks). В стандартном MIDI-файле доля содержит 96 тиков.

    Файлы MIDI могут быть двух форматов: когда каждому из 16 каналов соответствует свой трек (SMF format 1) или они находятся в одном треке (SMF format 0). Очень часто в начале каждого трека бывают MIDI сообщения, задающие его параметры: Program Change, Volume, Pan, Reverb, Chorus.

    Команда MIDI, сопровождающаяся меткой времени, называется событием (event). Служебное сообщение MIDI, несущее вспомогательную информацию, например, о темпе воспроизведения, называется мета событием (meta event).

    Рассмотрим воспроизведение подобных файлов на примере MIDI-секвенсера, не обладающего возможностью записи (такие секвенсеры традиционно называют MIDI-плейером). Для облегчения задачи воспользуемся готовой программой, Brussels MIDI Player. Это простейший проигрыватель MIDI для Linux; автор настоящего курса внёс незначительные изменения в его код: заменил вызов платформ-зависимого метода создания устройства MIDI static MidiOutput* MidiOutput::createNewDevice(const String deviceName), недоступного под Windows, на рассмотренный выше кроссплатформенный метод static MidiOutput* MidiOutput::openDevice(int deviceIndex), произвёл частичную локализацию программы, а также изменил размеры виджетов с тем, чтобы читались надписи на них.

    Программа имеет довольно необычную архитектуру. За создание пользовательского интерфейса программы отвечает объект класса ).

    #include "../JuceLibraryCode/JuceHeader.h"
    #include "MidiPlayerEngine.h"
    #include "TinyDisplay.cpp"
    
    class BrusselsMidiPlayer : public JUCEApplication {
    public:
      void initialise(const String p) {
        TinyDisplay k;
        k.setBounds(0, 0, 500, 90);
        DialogWindow::showModalDialog(L"Brussels Midi Player", k, 0,
                Colours::silver, false);
        systemRequestedQuit();
      }
    
      void shutdown() {
      }
    
      void systemRequestedQuit() {
          quit();
      }
    
      const String getApplicationName() {
        return String(L"Brussels Midi Player");
      }
      
      const String getApplicationVersion()
      {
        return ProjectInfo::versionString;
      }
    };
    

    Класс ).

    (рис 19.5) Работа программы Brussels MIDI Player

    Кроме того, класс включает MIDI-устройство ).

    TinyDisplay() : mPlayBtn(tr("Играть")),
              mStopBtn(tr("Остановить")),
              mOpenBtn(tr("Открыть...")),
              mSlider (L"Seek")
      {
        mPlayBtn.setBounds(10, 10, 150, 30);
        mPlayBtn.addListener(this);
        mStopBtn.setBounds(170, 10, 150, 30);
        mStopBtn.addListener(this);
        mOpenBtn.setBounds(340, 10, 150, 30);
        mOpenBtn.addListener(this);
        
        mSlider.setBounds(10, 50, 480, 30);
        mSlider.setTextBoxStyle(Slider::NoTextBox, true, 0, 0);
        mSlider.addListener(this);
        
        addAndMakeVisible(mPlayBtn);
        addAndMakeVisible(mStopBtn);
        addAndMakeVisible(mOpenBtn);
        addAndMakeVisible(mSlider);
        
        mSlider.setEnabled(false);
        mStopBtn.setEnabled(false);
        mPlayBtn.setEnabled(false);
        
        // Это платформ-зависимый (Linux) метод. Он не работает под Windows.
        //midiOut = MidiOutput::createNewDevice(
                JUCEApplication::getInstance()->getApplicationName());
        
        // Получаем названия имеющихся устройств MIDI...
        StringArray sMIDIDevices = MidiOutput::getDevices();
        if(sMIDIDevices.size() == 1) midiOut = MidiOutput::openDevice(0);
        else midiOut = MidiOutput::openDevice(1);
        engine  = 0;
      }
    

    За собственно воспроизведение файлов MIDI отвечает класс ).

    Воспроизведение MIDI и управление пользовательским интерфейсом приложения осуществляется в разных нитях (потоках, threads) одного процесса, поэтому MidiPlayerEngine наследуется от класса Juce Thread. Конструктор последнего Thread::Thread(const String threadName) создаёт нить, запуск которой осуществляет метод void Thread::startThread().

    Познакомиться подробнее с использованием методов класса Thread можно в демонстрационном приложении Juce (см. <каталог Juce>/juce/extras/JuceDemo/Source/demos/ThreadingDemo.cpp).

    Новый объект MidiPlayerEngine создаётся в обработчике события нажатия на кнопку ). В качестве параметров конструктор MidiPlayerEngine принимает указатели на поток данных InputStream и устройство MIDI MidiOutput.

    void buttonClicked(Button* button) {
        if (button == (Button*)mOpenBtn) {
          FileChooser fc(tr("Открыть файл MIDI"),
                File::getSpecialLocation(File::userMusicDirectory),
                "*.mid;*.kar");
          if (!fc.browseForFileToOpen())
          return;
          
          // Останавливаем и удаляем MidiPlayerEngine 
          if (engine) {
          engine->stop();
            delete engine;
          }
          
          // В качестве параметра принимается указатель на поток данных,
          // загруженных из файла, а также указатель на устройство MIDI
          engine = new MidiPlayerEngine(
                File(fc.getResult()).createInputStream(), 
                midiOut);
          engine->addChangeListener(this);
          // Делаем доступным переключатель воспроизведения
          mPlayBtn.setEnabled(true);
          // Файл не воспроизводится, флажок переключателя сброшен
          mPlayBtn.setToggleState(false, false);
          // Доступны также кнопка воспроизведения и ползунок
          mStopBtn.setEnabled(true);
          mSlider.setEnabled(true);
          mSlider.setRange(0, engine->getLength());
          Logger::writeToLog(String(L"Total length = ") + 
                String(engine->getLength()));
          
        }
        
    

    За работу с файлами MIDI в Juce отвечает класс MidiFile, который позволяет осуществлять как чтение из файла, так и запись в него. Чтение может производиться как всего потока данных файла, так и отдельного его трека. В первом случае используется метод bool MidiFile::readFrom(InputStream sourceStream), а во втором — const MidiMessageSequence* MidiFile::getTrack(int index) const throw(). Последний метод возвращает ноль в случае, если трек не найден, либо указатель на MidiMessageSequence — последовательность сообщений MIDI с временными метками (timestamps).

    Для того, чтобы записать файл MIDI, необходимо создать объект класса MidiFile и добавить в него несколько объектов MidiMessageSequence путём вызова метода void MidiFile::addTrack(const MidiMessageSequence trackSequence) либо bool MidiFile::writeTo(OutputStream destStream). Первый метод добавляет в файл последовательность MIDI (трек), а второй — записывает в поток данных все треки в виде стандартного файла MIDI.

    Удалить все треки из файла позволяет метод void MidiFile::clear().

    При создании объекта ).

    MidiPlayerEngine::MidiPlayerEngine
      (InputStream* stream, MidiOutput* midiDevice)
      : Thread(JUCEApplication::getInstance()->getApplicationName() + L" MIDI")
    {
      bool readSuccessfully = mFile.readFrom(*stream);
      // Если не удалось загрузить данные, сообщаем, что файл повреждён
      if (!readSuccessfully) throw String(L"Midi file appears corrupted");
      // Формат SMPTE не поддерживается
      if (mFile.getTimeFormat() <= 0) 
        throw String(L"SMPTE format timing is not yet supported");
          
    

    В файлах MIDI применяют два формата расстановки временных меток: SMF, когда указывается, сколько тиков приходится на четвертную длительность, и SMPTE, определяющий число тиков в секунде. Выяснить, какой из форматов используется в конкретном MIDI файле, позволяет метод short MidiFile::getTimeFormat() const throw(). Положительное возвращаемое значение свидетельствует о том, что текущий временной формат — SMF, отрицательное — SMPTE.

    Установить тот или иной формат времени для записываемого файла MIDI позволяют методы void MidiFile::setTicksPerQuarterNote(int ticksPerQuarterNote) throw() и void MidiFile::setSmpteTimeFormat(int framesPerSecond, int subframeResolution) throw(), где framesPerSecond принимает значения 24, 25, 29 и 30, а framesPerSecond4, 8, 10, 80 или 100.

    Данные треков текущего файла MIDI хранятся в члене класса MidiPlayerEngine — последовательности MidiMessageSequence mBuffer, а позиция воспроизведения — в переменной int mPosition.

    Загрузка треков в mBuffer приводится в листинге 19.16 .

      mBuffer.clear();
      mPosition = 0;
      mListener = 0;
      mFilter   = 0;
    
      // Временная переменная для хранения загружаемого трека
      const MidiMessageSequence* oneTrack;
    
      // Начинаем микширование
      for (int track = 0; track < mFile.getNumTracks(); track++) {
        // Получаем трек
        oneTrack = mFile.getTrack(track);
        // Добавляем его в общую последовательность сообщений MIDI
        mBuffer.addSequence(*oneTrack,
                0, // нет смещения относительно временных меток
                0, // используем события MIDI с начала трека...
                // и до его конца
                oneTrack->getEndTime());
      }
      
      mBuffer.updateMatchedPairs();
    
      // Загрузка завершена, устанавливаем устройство MIDI
      if (midiDevice)
        mMidiOut = midiDevice;
      else // null pointer
        throw String(L"Invalid MIDI output device specified");
    
      // All done.
      this->sendActionMessage(L"FileLoaded");
    }
    

    Класс MidiMessageSequence позволяет добавлять новую последовательность событий MIDI с помощью метода void MidiMessageSequence::addSequence(const MidiMessageSequence other, double timeAdjustmentDelta, double firstAllowableDestTime, double endOfAllowableDestTimes), где

  • other — добавляемая последовательность;
  • timeAdjustmentDelta — число, прибавляемое к меткам времени считываемых событий MIDI;
  • firstAllowableDestTime — события MIDI будут добавляться в целевую последовательность лишь в том случае, если их время более позднее, чем время параметра;
  • endOfAllowableDestTimes — события MIDI будут добавляться в целевую последовательность лишь в том случае, если их время более раннее, чем время параметра.
  • Время окончания текущего трека мы определяли с помощью метода double MidiMessageSequence::getEndTime() const, который возвращает временную метку последнего события MIDI последовательности.

    Как мы упоминали выше, при включении ноты она будет звучать до тех пор, пока не будет отправлена команда, её выключающая. Понятно, что в файле MIDI каждому сообщению Note on должно соответствовать сообщение Note off. Проверить соответствие пар этих команд в последовательности сообщений MIDI позволяет метод void MidiMessageSequence::updateMatchedPairs().

    После того, как данные файла MIDI были загружены в последовательность, можно приступить к её воспроизведению. Установка флажка переключателя mPlayBtn класса TinyDisplay вызывает метод void MidiPlayerEngine::play(), который запускает нить посредством перегруженного метода void Thread::startThread(int priority), где priority — приоритет выполнения нити, число от 0 (низший приоритет) до 10 (высший).

    Собственно логика воспроизведения последовательности реализуется в чистом виртуальном методе нити ). Расчёт времени отправки сообщений MIDI понятен из кода; назначение локальных переменных приведено в комментариях.

      void MidiPlayerEngine::run() {
      // Стартовое сообщение, первый байт не может быть нулём
      MidiMessage message(0x80, 0, 0, 0);
      Message* listenerMsg = new Message(0, 0, 0, 0);
      // Получаем число MIDI событий
      int numEvents = mBuffer.getNumEvents();
      // Устанавливаем текущую позицию воспроизведения
      int currentPosition = 0;
      // Получаем число тиков на четверть (ticks per quarter note)
      double TPQN = static_cast<double>(mFile.getTimeFormat());
      // Время начала следующего события MIDI
      double nextEventTime = 0.;
      double msPerTick = 500. / TPQN; // по умолчанию 120 BPM
      // Предыдущая метка времени
      double prevTimestamp = 0.;
      setPosition(0);
    
      sendActionMessage(L"Play");
    
      while (( !threadShouldExit() )  ( currentPosition < numEvents )) {
    
        if (isPaused()) continue;
    
            message = mBuffer.getEventPointer(currentPosition)->message;
    
        if (mFilter) mFilter->filterMessage(message);
    
        nextEventTime = msPerTick *
                (message.getTimeStamp() - prevTimestamp);
    
        Time::waitForMillisecondCounter(Time::getMillisecondCounter()
                + nextEventTime);
    
        if (mListener->isValidMessageListener()) {
          listenerMsg->pointerParameter = (void*) message;
          mListener->postMessage(listenerMsg);
        }
    
        if (message.isTempoMetaEvent()) {
          msPerTick = message.getTempoSecondsPerQuarterNote() * 1000. / TPQN;
        } else if (!message.isMetaEvent()) {
          // Посылаем сообщение MIDI
          mMidiOut->sendMessageNow(message);
        }
    
        if (static_cast<int>(prevTimestamp) % static_cast<int>(TPQN) == 0)
          sendChangeMessage();
    
        prevTimestamp = message.getTimeStamp();
        setPosition(++currentPosition);
    
      }
      delete listenerMsg;
      reset();
      sendActionMessage(L"Stop");
    }
      
    

    При воспроизведении последовательности MIDI были использованы следующие функции:

  • MidiEventHolder* MidiMessageSequence::getEventPointer(int index) const — возвращает указатель на событие MIDI. Структура MidiEventHolder включает MidiMessage message — собственно сообщение MIDI, временная метка которого соответствует событию;
  • double MidiMessage::getTimeStamp() const throw() — возвращает метку времени, соответствующую сообщению MIDI;
  • static void Time::waitForMillisecondCounter(uint32 targetTime) throw() — функция ожидания на число миллисекунд targetTime;
  • bool MidiMessage::isMetaEvent() const throw() — возвращает true в случае, если событие является мета событием;
  • bool MidiMessage::isTempoMetaEvent() const throw() — возвращает true в случае, если событие является мета событием темпа;
  • double MidiMessage::getTempoSecondsPerQuarterNote() const throw() — вычисляет число секунд на четверть, исходя из текущего темпа.
  • Рассмотренная программа Brussels MIDI Player, разумеется, не является полноценным секвенсером, содержит ряд недостатков, но достаточно проста для того, чтобы составить представление о работе с файлами MIDI.

    Особенности Juce 2

    Во 2-й версии библиотеки в метод setSource класса AudioTransportSource был добавлен ещё один параметр — указатель на объект класса TimeSliceThread:

    void AudioTransportSource::setSource(PositionableAudioSource* newSource, int readAheadBufferSize = 0, TimeSliceThread* readAheadThread = nullptr, double sourceSampleRateToCorrectFor = 0.0, int maxNumChannels = 2)

    Класс TimeSliceThread представляет собой нить, сохраняющую список клиентов и вызывающую их по очереди для выполнения какой-то короткой задачи.

    В версии нашего проигрывателя, ориентированной на Juce 2.0, мы должны объявить в закрытой части объявления класса компонента содержимого переменную TimeSliceThread PlayThread.

    Конструктор класса ).

    TCentralComponent::TCentralComponent() : Component("Central Component"),
                 pNameLabel(0),
                 pChooseButton(0),
                 PlayThread("PlayThread")
    {
        pNameLabel = new Label(L"Name Label", tr("Выберите файл..."));
      pNameLabel->setFont(Font(15.0000f, Font::bold));
      pNameLabel->setJustificationType(Justification::centred);
      pNameLabel->setEditable(false, false, false);
      pNameLabel->setColour(Label::textColourId, Colours::black);
      pNameLabel->setColour(Label::backgroundColourId, Colours::azure);
      pNameLabel->setColour(Label::outlineColourId, Colours::black);
      addAndMakeVisible(pNameLabel);
    
      // ...
    
      PlayThread.startThread(3);
    }
    

    Нить запускается в конструкторе класса, а при завершении работы программы должна быть остановлена ().

    TCentralComponent::~TCentralComponent()
    {
      PlayThread.signalThreadShouldExit();
      PlayThread.waitForThreadToExit(100);
      pAudioDeviceManager->closeAudioDevice();
      TransportSource.setSource(0);
      AudioPlayer.setSource(0);
      pAudioDeviceManager->removeAudioCallback( AudioPlayer);
      if(pAudioDeviceManager) delete pAudioDeviceManager;
      if(pCurrentAudioFileSource) delete pCurrentAudioFileSource;
      if(pFileNameDialog) delete pFileNameDialog;
      deleteAllChildren();
    }
    

    Если не считать этого параметра, то передача в AudioTransportSource нового источника аудиоданных осуществляется обычно: TransportSource.setSource(pCurrentAudioFileSource, 32768, PlayThread, pAudioFormatReader->sampleRate);

    Отметим также, что для корректной сборки программ с поддержкой аудио в Juce, помимо собственно аудио-модулей, в проекте должен быть включён модуль juce_gui_extra.

    Краткие итоги

    В этой лекции вы познакомились со взаимодействием классов, необходимых для воспроизведения основных форматов музыкальных файлов (AIFF, FLAC, OGG VORBIS, WAV), а также для файлов MIDI.

    Основным классом, отвечающим за воспроизведение аудио-файлов в Juce является AudioTransportSource.

    Упражнение

    Напишите программу, включающую окно с несколькими кнопками. При нажатии на кнопку происходит воспроизведение ассоциированного с ней аудио-файла, находящегося в каталоге программы.

    Дополнительные материалы

    Архив с исходными текстами примеров Вы можете скачать здесь.

    Вернуться к учебному плану