У многих пользователей Windows в наши дни есть музыкальная библиотека (Music Library) на их жестких дисках, содержащая, возможно, десятки тысяч MP3- и WMA-файлов. Чтобы проигрывать эту музыку на ПК, такие пользователи обычно запускают Windows Media Player или приложение Music в Windows 8. Но для программистов полезно знать, как писать собственные программы для воспроизведения подобных файлов. Windows 8 предоставляет программные интерфейсы для доступа к Music Library, получения информации об индивидуальных музыкальных файлах (например, об исполнителе, названии и длительности звучания) и проигрывания этих файлов с помощью MediaElement.
MediaElement — простой подход, и, конечно, имеются альтернативы, которые усложнят работу, но в то же время обеспечат намного большую гибкость. С помощью двух компонентов DirectX — Media Foundation и XAudio2 — можно сделать так, чтобы приложение в гораздо большей степени участвовало в этом процессе. Вы можете загружать порции распакованных аудиоданных из музыкальных файлов и анализировать эти данные или манипулировать ими тем или иным способом перед воспроизведением музыки (или вместо него). Вас никогда не интересовало, как звучит один из этюдов Шопена, если его проигрывать в обратном направлении на половине скорости? Ну, меня это тоже не интересовало, но одна из программ в этой статье позволит вам провести такое исследование.
Средства выбора и более широкого доступа
Определенно самый простой способ для программы в Windows 8 обращаться к Music Library — делать это через FileOpenPicker, который в программе на C++ можно инициализировать для загрузки аудиофайлов следующим образом:
FileOpenPicker^ fileOpenPicker = ref new FileOpenPicker();
fileOpenPicker->SuggestedStartLocation =
PickerLocationId::MusicLibrary;
fileOpenPicker->FileTypeFilter->Append(".wma");
fileOpenPicker->FileTypeFilter->Append(".mp3");
fileOpenPicker->FileTypeFilter->Append(".wav");
Вызовите PickSingleFileAsync, чтобы отобразить FileOpenPicker и дать возможность пользователю выбрать какой-нибудь файл.
Для просмотра папок и файлов в свободной форме можно указать в файле манифеста приложения, что ему нужен более полный доступ к Music Library. После этого программа может использовать классы в пространстве имен Windows::Storage::BulkAccess для самостоятельного перечисления папок и музыкальных файлов.
Независимо от выбранного подхода каждый файл представлен объектом StorageFile. Из этого объекта вы можете получить эскиз (thumbnail), который является изображением обложки музыкального альбома (если таковое имеется). Свойство Properties объекта StorageFile позволяет получить объект MusicProperties, который предоставляет сведения об исполнителе, альбоме, названии трека, длительности звучания и другую стандартную информацию, связанную с музыкальным файлом.
Вызвав OpenAsync в этом StorageFile, вы также можете открыть его для чтения и получить объект IRandomAccessStream и даже считать весь файл в память. Если это WAV-файл, то, возможно, вы решите разобрать его, чтобы извлечь волновые данные (waveform data) и воспроизвести звук через XAudio2, как я уже описывал в недавней статье из этой рубрики.
Но в случае MP3- или WMA-файла это не так просто. Вам понадобится распаковать аудиоданные, а это как раз та работа, которую вы вряд ли захотите брать на себя. К счастью, Media Foundation API включает средства для распаковки MP3- и WMA-файлов и преобразование данных в форму, пригодную для прямой передачи в XAudio2 с целью последующего воспроизведения.
Другой способ получить доступ к распакованным аудиоданным — использовать аудиоэффект, подключенный к MediaElement. Я надеюсь продемонстрировать этот подход в одной из будущих статей.
Media Foundation
Чтобы задействовать функции и интерфейсы Media Foundation, которые я буду рассматривать здесь, вам потребуется скомпоновать свою программу для Windows 8 с библиотеками импорта mfplat.lib и mfreadwrite.lib, а также поместить в файл pch.h выражения #include для включения файлов mfapi.h, mfidl.h и mfreadwrite.h. (Кроме того, имейте в виду, что initguid.h следует включать до mfapi.h, а иначе вы получите ошибки при компоновке, которые могут надолго оставить вас в недоумении.) Если вы тоже используете XAudio2 для воспроизведения файлов (как это буду делать я), то вам понадобится библиотека импорта xaudio2.lib и заголовочный файл xaudio2.h.
В сопутствующем этой статье исходном коде присутствует проект Windows 8 с именем StreamMusicFile, который демонстрирует минимальный код, необходимый для загрузки файла из Music Library, находящейся на ПК, его распаковки через Media Foundation и воспроизведение через XAudio2. Кнопка запускает FileOpenPicker, и программа после выбора вами файла отображает некоторую стандартную информацию (рис. 1), а затем немедленно начинает воспроизводить его. По умолчанию Slider, регулирующий громкость, выставлен в 0, поэтому вам придется увеличить его значение, чтобы появился звук. Никакого способа приостановить или остановить проигрывание файла нет — можно лишь завершить эту программу или сделать активной другую программу.
Рис. 1. Программа StreamMusicFile, проигрывающая музыкальный файл
По сути, программа не прекращает воспроизведение музыкального файла, даже если вы щелкаете кнопку и загружаете второй файл. Вместо этого вы обнаружите, что оба файла воспроизводятся одновременно, но безо всякого упорядочения. А значит, даже такая примитивная программа может делать кое-что, чего не умеют приложение Music в Windows 8 и Media Player: проигрывать одновременно несколько музыкальных файлов!
Метод, показанный на рис. 2, иллюстрирует, как программа использует IRandomAccessStream из StorageFile для создания объекта IMFSourceReader, способного читать аудиофайл и доставлять порции несжатых аудиоданных.
Рис. 2. Создание и инициализация IMFSourceReader
ComPtr<IMFSourceReader> MainPage::CreateSourceReader(IRandomAccessStream^ randomAccessStream)
{
// Запускаем Media Foundation
HRESULT hresult = MFStartup(MF_VERSION);
// Создаем IMFByteStream для обертывания IRandomAccessStream
ComPtr<IMFByteStream> mfByteStream;
hresult = MFCreateMFByteStreamOnStreamEx((IUnknown *)randomAccessStream,
&mfByteStream);
// Создаем атрибут операции с малой задержкой
ComPtr<IMFAttributes> mfAttributes;
hresult = MFCreateAttributes(&mfAttributes, 1);
hresult = mfAttributes->SetUINT32(MF_LOW_LATENCY, TRUE);
// Создаем IMFSourceReader
ComPtr<IMFSourceReader> mfSourceReader;
hresult = MFCreateSourceReaderFromByteStream(mfByteStream.Get(),
mfAttributes.Get(),
&mfSourceReader);
// Создаем IMFMediaType для задания нужного формата
ComPtr<IMFMediaType> mfMediaType;
hresult = MFCreateMediaType(&mfMediaType);
hresult = mfMediaType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Audio);
hresult = mfMediaType->SetGUID(MF_MT_SUBTYPE, MFAudioFormat_Float);
// Задаем медийный тип в читателе источника
hresult = mfSourceReader->SetCurrentMediaType(MF_SOURCE_READER_FIRST_AUDIO_STREAM,
0, mfMediaType.Get());
return mfSourceReader;
}
Для упрощения картины на рис. 2 исключен весь код, обрабатывающий возвращаемые HRESULT-значения, которые указывают на ошибки. В реальном коде генерируются исключения типа COMException, но эта программа не перехватывает такие исключения, как это делалось бы в настоящем приложении.
В двух словах, этот метод использует IRandomAccessStream для создания объекта IMFByteStream, инкапсулирующего входной поток, а затем создает на его основе IMFSourceReader, который может выполнять распаковку (декомпрессию).
Обратите внимание на использование объекта IMFAttributes, указывающего операцию с малой задержкой. Это не обязательно, и можно передать nullptr во втором аргументе функции MFCreateSourceReaderFromByteStream. Однако при чтении и проигрывании файла происходит постоянное обращение к диску, и вы вряд ли захотите, чтобы эти дисковые операции создавали слышимые задержки в воспроизведении. Если вас реально нервирует эта проблема, вы могли бы считывать весь файл в объект InMemoryRandomAccessStream и использовать его для создания IMFByteStream.
Применяя Media Foundation для распаковки аудиофайла, программа теряет контроль за частотой дискретизации распакованных данных, получаемых из файла, или за количеством каналов. Все определяется самим файлом. Но программа может указывать, что выборки должны быть в одном из двух форматов: как 16-битные целые значения (используются для звука качества CD) или как 32-битные значения с плавающей точкой (C-тип float). На внутреннем уровне XAudio2 использует 32-битные выборки с плавающей точкой, поэтому в случае передачи таких 32-битных выборок в XAudio2 для проигрывания файла потребуется меньше внутренних преобразований. Я решил в своей программе пойти тем же путем. Соответственно метод на рис. 2 указывает нужный формат аудиоданных, используя два идентификатора: MFMediaType_Audio и MFAudioFormat_Float. Если нужны распакованные данные, то единственная альтернатива второму идентификатору — MFAudioFormat_PCM для 16-битных целочисленных выборок.
К этому моменту мы имеем объект типа IMFSourceReader, готовый к чтению и распаковке порций аудиофайла.
Воспроизведение файла
Изначально я хотел поместить весь код этой первой программы в класс MainPage, но в то же время мне нужно было использовать функцию обратного вызова XAudio2. Это проблема, потому что (как я обнаружил) такой тип Windows Runtime, как MainPage, не может реализовать интерфейс, отличный от Windows Runtime, вроде IXAudio2VoiceCallback, а значит, требуется второй класс, который я назвал AudioFilePlayer.
Получив объект IMFSourceReader от метода, показанного на рис. 2, MainPage создает новый объект AudioFilePlayer, также передавая его в объект IXAudio2, созданный в конструкторе MainPage:
new AudioFilePlayer(pXAudio2, mfSourceReader);
С этого момента объект AudioFilePlayer начинает полностью самостоятельную жизнь и во многом является самодостаточным. Вот почему эта программа способна проигрывать несколько файлов одновременно.
Чтобы воспроизвести музыкальный файл, AudioFilePlayer должен создать объект IXAudio2SourceVoice. Для этого требуется структура WAVEFORMATEX, указывающая формат аудиоданных, передаваемых голосу-источнику (source voice), и он должен соответствовать аудиоданным, предоставляемым объектом IMFSourceReader. Возможно, вы догадались, какими должны быть правильные параметры (например, два канала и частота дискретизации 44 100 Гц), но, если вы укажете неправильную частоту дискретизации, XAudio2 выполняет на внутреннем уровне необходимые преобразования частоты дискретизации. Тем не менее, лучше всего получить структуру WAVEFORMATEX из IMFSourceReader и использовать ее, как показано в конструкторе AudioFilePlayer на рис. 3.
Рис. 3. Конструктор AudioFilePlayer в StreamMusicFile
AudioFilePlayer::AudioFilePlayer(ComPtr<IXAudio2> pXAudio2,
ComPtr<IMFSourceReader> mfSourceReader)
{
this->mfSourceReader = mfSourceReader;
// Получаем медийный тип Media Foundation
ComPtr<IMFMediaType> mfMediaType;
HRESULT hresult = mfSourceReader->GetCurrentMediaType(MF_SOURCE_READER_
FIRST_AUDIO_STREAM,
&mfMediaType);
// Создаем WAVEFORMATEX на основе медийного типа
WAVEFORMATEX* pWaveFormat;
unsigned int waveFormatLength;
hresult = MFCreateWaveFormatExFromMFMediaType(mfMediaType.Get(),
&pWaveFormat,
&waveFormatLength);
// Создаем голос-источник XAudio2
hresult = pXAudio2->CreateSourceVoice(&pSourceVoice, pWaveFormat,
XAUDIO2_VOICE_NOPITCH, 1.0f, this);
// Освобождаем память, выделенную функцией
CoTaskMemFree(pWaveFormat);
// Передаем два буфера
SubmitBuffer();
SubmitBuffer();
// Начинаем воспроизведение голоса-источника
pSourceVoice->Start();
endOfFile = false;
}
Получение этой структуры WAVEFORMATEX — дело не такое уж и простое, требующее создания блока памяти, который потом должен быть освобожден явным образом, но к концу выполнения конструктора AudioFilePlayer файл готов к воспроизведению.
Чтобы такая программа занимала минимальный объем памяти, файл следует читать и воспроизводить малыми порциями. Как Media Foundation, так и XAudio2 в высокой степени благоприятствуют этому подходу. При каждом вызове метода ReadSample объекта IMFSourceReader вы получаете доступ к очередному блоку несжатых данных до тех пор, пока файл не будет полностью считан. При частоте дискретизации 44 100 Гц, двух каналах и 32-битных выборках с плавающей точкой эти блоки, как показывает мой опыт, обычно имеют размер 16 384 или 32 768 байт, а иногда даже 12 288 байт (но всегда кратно 4096), что соответствует примерно от 35 до 100 миллисекунд воспроизводимого звука.
Вслед за каждым вызовом метода ReadSample объекта IMFSourceReader программа может просто выделять локальный блок памяти, копировать данные в него, а затем передавать этот локальный блок объекту IXAudio2SourceVoice с помощью SubmitSourceBuffer.
AudioFilePlayer использует подход к воспроизведению файла на основе двух буферов: пока один буфер заполняется данными, другой — воспроизводится. Весь процесс представлен на рис. 4, и вновь обработка ошибок опущена, чтобы не усложнять общую картину.
Рис. 4. Конвейер потоковой передачи аудиоданных в StreamMusicFile
void AudioFilePlayer::SubmitBuffer()
{
// Получаем следующий блок аудиоданных
int audioBufferLength;
byte * pAudioBuffer = GetNextBlock(&audioBufferLength);
if (pAudioBuffer != nullptr)
{
// Создаем XAUDIO2_BUFFER для передачи аудиоданных
XAUDIO2_BUFFER buffer = {0};
buffer.AudioBytes = audioBufferLength;
buffer.pAudioData = pAudioBuffer;
buffer.pContext = pAudioBuffer;
HRESULT hresult = pSourceVoice->SubmitSourceBuffer(&buffer);
}
}
byte * AudioFilePlayer::GetNextBlock(int * pAudioBufferLength)
{
// Получаем объект IMFSample
ComPtr<IMFSample> mfSample;
DWORD flags = 0;
HRESULT hresult = mfSourceReader->ReadSample(MF_SOURCE_READER_FIRST_AUDIO_STREAM,
0, nullptr, &flags, nullptr,
&mfSample);
// Проверяем, достигнут ли конец файла
if (flags & MF_SOURCE_READERF_ENDOFSTREAM)
{
endOfFile = true;
*pAudioBufferLength = 0;
return nullptr;
}
// Если нет, преобразуем данные в смежный буфер
ComPtr<IMFMediaBuffer> mfMediaBuffer;
hresult = mfSample->ConvertToContiguousBuffer(&mfMediaBuffer);
// Блокируем аудиобуфер и копируем выборки в локальную память
uint8 * pAudioData = nullptr;
DWORD audioDataLength = 0;
hresult = mfMediaBuffer->Lock(&pAudioData, nullptr, &audioDataLength);
byte * pAudioBuffer = new byte[audioDataLength];
CopyMemory(pAudioBuffer, pAudioData, audioDataLength);
hresult = mfMediaBuffer->Unlock();
*pAudioBufferLength = audioDataLength;
return pAudioBuffer;
}
// Методы обратного вызова из IXAudio2VoiceCallback
void _stdcall AudioFilePlayer::OnBufferEnd(void* pContext)
{
// Не забудьте освободить аудиобуфер!
delete[] pContext;
// Либо передаем новый буфер, либо выполняем очистку
if (!endOfFile)
{
SubmitBuffer();
}
else
{
pSourceVoice->DestroyVoice();
HRESULT hresult = MFShutdown();
}
}
Чтобы получить временный доступ к аудиоданным, программе нужно вызвать метод Lock, а затем Unlock объекта IMFMediaBuffer, представляющего новый блок данных. Между этими вызовами метод GetNextBlock на рис. 4 копирует блок в только что созданный байтовый массив.
Метод SubmitBuffer на рис. 4 отвечает за инициализацию полей структуры XAUDIO2_BUFFER при подготовке аудиоданных к передаче для воспроизведения. Заметьте, что этот метод также присваивает полю pContext указатель на созданный аудиобуфер. Этот указатель передается методу обратного вызова OnBufferEnd, который можно увидеть ближе к концу кода на рис. 4, после чего этот метод может удалить память, выделенную под массив.
Когда файл полностью прочитан, следующий вызов ReadSample выставляет флаг MF_SOURCE_READERF_ENDOFSTREAM, и объект IMFSample становится null. Программа реагирует на это установкой значения поля endOfFile. К этому моменту другой буфер еще воспроизводится, и произойдет последний вызов OnBufferEnd, который освободит некоторые системные ресурсы.
Кроме того, вызывается метод обратного вызова OnStreamEnd, который инициирует установку флага XAUDIO2_END_OF_STREAM в XAUDIO2_BUFFER, но его трудно использовать в этом контексте. Проблема в том, что этот флаг нельзя задать, пока не будет получен флаг MF_SOURCE_READERF_ENDOFSTREAM из вызова ReadSample. Но SubmitSourceBuffer не разрешает работать с null-буферами или буферами нулевого размера, а значит, вам придется в любом случае передать непустой буфер, хотя никаких данных больше нет!
Метафора вращающейся пластинки
Конечно, передача аудиоданных из Media Foundation в XAudio2 не столь проста, как использование MediaElement в Windows 8, и вряд ли стоит усилий, если только вы не собираетесь делать что-то интересное с аудиоданными. С помощью XAudio2 вы можете задавать некоторые особые эффекты (например, эхо или реверберацию), и в следующей статье в этой рубрике я применю к звуковым файлам фильтры XAudio2.
Тем временем, взгляните на рис. 5, где показана программа DeeJay, которая отображает на экране запись в виде пластинки и вращает ее со скоростью по умолчанию — 33 и 1/3 оборотов в минуту.aa
Рис. 5. Программа DeeJay
На иллюстрации не показана панель приложения с кнопкой Load File и двумя ползунками: один — для регулировки громкости, а другой — для управления скоростью воспроизведения. Ползунок работает со значениями в диапазоне от –3 до 3 и указывает относительную частоту вращения (speed ratio). По умолчанию это значение равно 1. Значение, равное 0.5, приведет к проигрыванию файла на половине скорости, значение 3 — к проигрыванию файла в три раза быстрее нормальной, нулевое значение фактически поставит воспроизведение на паузу, а отрицательные значения будут воспроизводить файл в обратном направлении (что, возможно, позволит вам услышать скрытые послания, закодированные в музыке).
Поскольку это Windows 8, вы, конечно, можете вращать пластинку пальцами, тем самым подтверждая название программы. DeeJay поддерживает вращение одним пальцем с инерцией, поэтому вы можете прилично крутануть пластинку в любом направлении. Кроме того, можно постукивать по пластинке и перемещать «иглу» в это место.
Я очень-очень хотел реализовать эту программу в стиле, аналогичном проекту StreamMusicFile с альтернативными вызовами ReadSample и SubmitSourceBuffer. Но при попытке воспроизведения файла в обратном направлении возникли проблемы. Мне нужен был IMFSourceReader для поддержки метода ReadPreviousSample, но ее нет.
IMFSourceReader поддерживает метод SetCurrentPosition, позволяющий переходить в предыдущее место в файле. Однако последующие вызовы ReadSample начинают возвращать блоки, предшествующие этой позиции. По большей части серия вызовов ReadSample в конечном счете встречается в том же блоке, что и последний вызов ReadSample до SetCurrentPosition, но иногда этого не происходит, и такое поведение создает слишком много проблем.
В конце концов я сдался, и программа просто загружает весь несжатый аудиофайл в память. Чтобы использовать меньше памяти, я предпочел задействовать 16-битный целочисленные выборки вместо 32-битных выборок с плавающей точкой, но все равно программа занимает около 10 Мб памяти на каждую минуту музыки, и загрузка длинного произведения вроде какой-нибудь симфонии Малера потребовала бы примерно 300 Мб.
Эти симфонии Малера также требуют, чтобы весь метод загрузки файла выполнялся во вторичном потоке; эта задача сильно упрощается благодаря функции create_task, доступной в Windows 8.
Чтобы было легче работать с индивидуальными выборками, я создал простую структуру AudioSample:
struct AudioSample
{
short Left;
short Right;
};
Поэтому вместо использования байтового массива класс AudioFilePlayer в этой программе работает с массивом значений AudioSample. Однако это означает, что программа фактически жестко настроена только на стереозвук. Если она загружает аудиофайл, в котором больше или меньше двух каналов, его воспроизведение невозможно!
Асинхронный метод чтения файла хранит получаемые данные в структуре LoadedAudioFileInfo:
struct LoadedAudioFileInfo
{
AudioSample* pBuffer;
int bufferLength;
WAVEFORMATEX waveFormat;
};
Поле pBuffer — это указатель на большой блок памяти, а bufferLength — результат перемножения частоты дискретизации (возможно, 44 100 Гц) на длительность звучания файла в секундах. Эта структура передается непосредственно классу AudioFilePlayer. Новый экземпляр AudioFilePlayer создается для каждого загруженного файла, и он заменяет любой предыдущий экземпляр AudioFilePlayer. Для очистки в AudioFilePlayer имеется деструктор, который удаляет большой массив, хранящий весь файл, а также два меньших массива, используемых для передачи буферов в объект IXAudio2SourceVoice.
Ключевыми в проигрывании файла в двух направлениях (вперед и назад) на разных скоростях являются два поля типа double в AudioFilePlayer: audioBufferIndex и speedRatio. Переменная audioBufferIndex указывает на участок внутри большого массива, хранящего весь несжатый файл, а переменная speedRatio принимает те же значения, что и ползунок: от –3 до 3. Когда AudioFilePlayer нужно передать аудиоданные из большого буфера в меньшие буферы, он увеличивает audioBufferIndex на speedRatio для каждой выборки. Результирующий audioBufferIndex находится (как правило) между двумя выборками файла, поэтому метод на рис. 6 осуществляет интерполяцию для получения значения, которое затем перемещается в буфер передачи.
Рис. 6. Интерполяция между двумя выборками в DeeJay
AudioSample AudioFilePlayer::InterpolateSamples()
{
double left1 = 0, left2 = 0, right1= 0, right2 = 0;
for (int i = 0; i < 2; i++)
{
if (pAudioBuffer == nullptr)
break;
int index1 = (int)audioBufferIndex;
int index2 = index1 + 1;
double weight = audioBufferIndex - index1;
if (index1 >= 0 && index1 < audioBufferLength)
{
left1 = (1 - weight) * pAudioBuffer[index1].Left;
right1 = (1 - weight) * pAudioBuffer[index1].Right;
}
if (index2 >= 0 && index2 < audioBufferLength)
{
left2 = weight * pAudioBuffer[index2].Left;
right2 = weight * pAudioBuffer[index2].Right;
}
}
AudioSample audioSample;
audioSample.Left = (short)(left1 + left2);
audioSample.Right = (short)(right1 + right2);
return audioSample;
}
Сенсорный интерфейс
Чтобы не усложнять программу, весь сенсорный интерфейс состоит из события Tapped (для позиционирования «иглы» в различных местах на пластинке) и трех событий Manipulation: обработчик ManipulationStarting инициализирует вращение одним пальцем, обработчик ManipulationDelta задает относительную скорость вращения для AudioFilePlayer, который переопределяет скорость, заданную через ползунок, а обработчик ManipulationCompleted восстанавливает скорость в AudioFilePlayer до значения ползунка по окончании инерционного движения.
Значения скорости вращения доступны напрямую из аргументов события в обработчике ManipulationDelta. Эти значения измеряются в градусах угла поворота в миллисекунду. Стандартная скорость вращения долгоиграющей пластинки 33 и 1/3 оборотов в минуту эквивалентна 200° в секунду, или 0.2° в миллисекунду, и мне остается лишь поделить значение в событии ManipulationDelta на 0.2, чтобы получить нужную мне относительную скорость вращения (speed ratio).
Однако я обнаружил, что скорости, сообщаемые событием ManipulationDelta, довольно неточны, поэтому мне пришлось сглаживать их, используя весьма простую логику на основе значения поля smoothVelocity:
smoothVelocity = 0.95 * smoothVelocity +
0.05 * args->Velocities.Angular / 0.2;
pAudioFilePlayer->SetSpeedRatio(smoothVelocity);
На реальной вертушке вы можете остановить вращение, просто прижав палец к пластинке. Но здесь это не работает. Движения вашего пальца необходимы для генерации событий Manipulation, поэтому для остановки пластинки нужно нажать, а затем немного провести пальцем (или мышью, или пером).
Логика инерционного замедления тоже не совпадает с тем, что происходит в реальности. Эта программа позволяет полностью закончить инерционное движение до восстановления относительной скорости вращения к значению, указываемому ползунком. На практике это значение ползунка должно оказывать своего рода тормозящее действие на инерцию, но это слишком сильно усложнило бы логику.
Кроме того, я не смог уловить «ненатуральность» инерционного эффекта. Но настоящий диджей, несомненно, сразу же почувствовал бы разницу.