Опубликован: 13.07.2012 | Доступ: свободный | Студентов: 460 / 8 | Оценка: 5.00 / 5.00 | Длительность: 18:06:00
Специальности: Программист
Лекция 19:

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

< Лекция 18 || Лекция 19: 12345 || Лекция 20 >

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

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

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

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

timidity -iA &

то TiMidity++ будет отображаться в выпадающем списке в качестве устройства MIDI ( рис. 19.3).

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

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

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

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

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

Рис. 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), произвёл частичную локализацию программы, а также изменил размеры виджетов с тем, чтобы читались надписи на них.

Программа имеет довольно необычную архитектуру. За создание пользовательского интерфейса программы отвечает объект класса TinyDisplay (см. файл TinyDisplay.cpp), наследующего Component. Класс приложения отображает его как диалоговое окно ( пример 19.12).

#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.12. Реализация класса приложения BrusselsMidiPlayer (файл Application.cpp)

Класс TinyDisplay включает в себя переключатель ToggleButton mPlayBtn, две кнопки с текстом TextButton mStopBtn и TextButton mOpenBtn, а также ползунок Slider mSlider ( рис. 19.5).

 Работа программы Brussels MIDI Player

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

Кроме того, класс включает MIDI-устройство MidiOutput* midiOut, открытие которого осуществляется в конструкторе ( пример 19.13).

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;
  }
Листинг 19.13. Реализация конструктора класса TinyDisplay (файл TinyDisplay.cpp)

За собственно воспроизведение файлов MIDI отвечает класс MidiPlayerEngine, указатель на который (engine) также является членом класса TinyDisplay ( пример 19.13).

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

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

Новый объект MidiPlayerEngine создаётся в обработчике события нажатия на кнопку mOpen, когда пользователь пытается открыть файл MIDI ( пример 19.14). В качестве параметров конструктор 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()));
      
    }
    
Листинг 19.14. Часть реализации метода buttonClicked класса TinyDisplay (файл TinyDisplay.cpp)

За работу с файлами 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 он пытается загрузить данные из потока в член класса MidiFile mFile, а также определить их временной формат ( пример 19.15).

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");
      
Листинг 19.15. Часть реализации конструктора класса MidiPlayerEngine (файл MidiPlayerEngine.cpp)

В файлах 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");
}
Листинг 19.16. Часть реализации конструктора класса MidiPlayerEngine (файл MidiPlayerEngine.cpp)

Класс 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 (высший).

Собственно логика воспроизведения последовательности реализуется в чистом виртуальном методе нити virtual void Thread::run() ( пример 19.17). Расчёт времени отправки сообщений 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");
}
  
Листинг 19.17. Реализация метода run класса MidiPlayerEngine (файл MidiPlayerEngine.cpp)

При воспроизведении последовательности 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.

< Лекция 18 || Лекция 19: 12345 || Лекция 20 >