Сегодня мы представляем материал от наших давних партнеров, компании Music Paradise, которые, как вы помните, уже делились с Хабром секретами создания музыкальных приложений в нашей совместной статье «Внедрение социальных сервисов в неигровое приложение». На этот раз специалисты из команды расскажут о специфике реализации базовых функций утилиты Audio Editor, изначально написанной для iOS и Mac устройств, на новой платформе — Windows — и о том методе, который они применяли при адаптации.



«В этой статье мы рассмотрим один из самых доступных способов получения аудиоданных из файла. Извлечение аудиоданных является краеугольным камнем для всех разработчиков, которые делают первые шаги в работе со звуком, однако внимания ей уделяется на удивление мало. Проблема ощущается особенно остро при попытках найти готовые решения или инструкции для UWP в интернете: в большинстве случаев ответа не получаешь вообще или же приходится довольствоваться устаревшими решениями. Между тем, при работе со звуком извлечение данных имеет значимый смысл, давая разработчику возможность редактировать данные: копировать, добавлять, изменять, путем наложения эффектов, визуализировать их на экране пользователя. Именно о визуализации сегодня и пойдет речь. Несмотря на существование специальных библиотек для работы с аудиоданными в галерее NuGet, мы построим логику приложения на самостоятельной обработке байтов аудиофайла. Таким образом, в процессе мы узнаем больше о структуре wav-файла и убедимся на практике, что работа с аудио данными — это не так уж сложно.

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

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

Реализуем простейший интерфейс, добавив две кнопки в MainPage.xaml:

 <StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
            <Button Width="250" Height="50" Content="Choose Audio File" Click="ChooseFile_Click" Margin="0,10,0,10"/>
            <Button Width="250" Height="50" Background="Green" Content="Build And Save Image File" Click="BuildAndSaveImageFile_Click" Margin="0,10,0,10"/>
        </StackPanel>

Опишем алгоритм, который будет выполняться по клику на кнопку «Choose Audio File». Для этого добавим в MainPage.xaml.cs следующие строки:

 private StorageFile currentFile;
        private PlottingGraphImg imgFile;
        private async void ChooseFile_Click(object sender, RoutedEventArgs e)
        {
            var picker = new Windows.Storage.Pickers.FileOpenPicker();
            picker.SuggestedStartLocation = Windows.Storage.Pickers.PickerLocationId.MusicLibrary;
            picker.FileTypeFilter.Add(".mp4");
            picker.FileTypeFilter.Add(".mp3");
            picker.FileTypeFilter.Add(".wav");
            StorageFile file = await picker.PickSingleFileAsync();
            await ConvertToWaveFile(file);
        }
        public async Task ConvertToWaveFile(StorageFile sourceFile)
        {
            MediaTranscoder transcoder = new MediaTranscoder();
            MediaEncodingProfile profile = MediaEncodingProfile.CreateWav(AudioEncodingQuality.Medium);
            CancellationTokenSource cts = new CancellationTokenSource();
            //Create temporary file in temporary folder
            string fileName = String.Format("TempFile_{0}.wav", Guid.NewGuid());
            StorageFile temporaryFile = await ApplicationData.Current.TemporaryFolder.CreateFileAsync(fileName);
            currentFile = temporaryFile;
            if (sourceFile == null || temporaryFile == null) 
           {
                return;
            }
            try
            {
                var preparedTranscodeResult = await transcoder.PrepareFileTranscodeAsync(sourceFile, temporaryFile, profile);
                if (preparedTranscodeResult.CanTranscode)
                {
                    var progress = new Progress<double>((percent) => { Debug.WriteLine("Converting file: " + percent + "%"); });
                    await preparedTranscodeResult.TranscodeAsync().AsTask(cts.Token, progress);
                }
                else
                {
                    Debug.WriteLine("Error: Convert fail");
                }
            }
            catch
            {
                Debug.WriteLine("Error: Exception in ConvertToWaveFile");
            }
        }

Примечание: подобный метод можно применять и для видеоформатов, что тоже порой очень полезно.

Теперь, когда пользовательский wav-file стал доступен, необходимо получить из него аудиоданные, ради которых мы всё это и затеяли. Чтобы это сделать, придется вникнуть в структуру файла. Сильно вдаваться в теорию мы не видим смысла — подобной информации в интернете более чем достаточно, вы всегда можете почерпнуть интересующие вас сведения там. Мы же ограничимся тем, что обрисуем общую структуру файла, чтобы прояснить логику дальнейшей реализации.

Итак, структура несжатого аудио файла в формате импульсно-кодовой модуляции (PCM) выглядит следующим образом:


Теперь перейдем собственно к алгоритму получения аудиоданных. В работе мы будем опираться на следующий ресурс.

Опишем класс с символичным названием «WavFile»:

 public class WavFile

    {
        public string PathAudioFile { get; }
        private const int ticksInSecond = 10000000;
        private TimeSpan duration;
        public TimeSpan Duration { get { return duration; } }
        #region AudioData
        private List<float> floatAudioBuffer = new List<float>();
        #endregion
        public WavFile(string _path)
        {
            PathAudioFile = _path;
            ReadWavFile(_path);
        }
        public float[] GetFloatBuffer()
        {
            return floatAudioBuffer.ToArray();
        }
        private void ReadWavFile(string filename)
        {
            try
            {
                using (FileStream fileStream = File.Open(filename, FileMode.Open))
                {
                    BinaryReader reader = new BinaryReader(fileStream);
                    // RIFF
                    int chunkID = reader.ReadInt32();
                    int fileSize = reader.ReadInt32();
                    int riffType = reader.ReadInt32();
                    // Format
                    int fmtID;
                    long _position = reader.BaseStream.Position;
                    while (_position != reader.BaseStream.Length - 1)
                    {
                        reader.BaseStream.Position = _position;
                        int _fmtId = reader.ReadInt32();
                        if (_fmtId == 544501094)
                        {
                            fmtID = _fmtId;
                            break;
                        }
                        _position++;
                    }

                    int fmtSize = reader.ReadInt32();
                    int fmtCode = reader.ReadInt16();
                    int channels = reader.ReadInt16();
                    int sampleRate = reader.ReadInt32();
                    int byteRate = reader.ReadInt32();
                    int fmtBlockAlign = reader.ReadInt16();
                    int bitDepth = reader.ReadInt16();
                    if (fmtSize == 18)
                    {
                        int fmtExtraSize = reader.ReadInt16();
                        reader.ReadBytes(fmtExtraSize);
                    }
              
                    int dataID = reader.ReadInt32();
                    int dataSize = reader.ReadInt32();
                    byte[] byteArray = reader.ReadBytes(dataSize);
                    int bytesInSample = bitDepth / 8;
                    int sampleAmount = dataSize / bytesInSample;
                    float[] tempArray = null;
                    switch (bitDepth)
                    {
                        case 16:
                            Int16[] int16Array = new Int16[sampleAmount];
                            System.Buffer.BlockCopy(byteArray, 0, int16Array, 0, dataSize);
                            IEnumerable<float> tempInt16 =
                                from i in int16Array
                                select i / (float)Int16.MaxValue;
                            tempArray = tempInt16.ToArray();
                            break;
                        default:
                            return;
                    }
                    floatAudioBuffer.AddRange(tempArray);
                    duration = DeterminateDurationTrack(channels, sampleRate);
                }
            }
            catch
            {
                Debug.WriteLine("File error");
                return;
            }
        }
        private TimeSpan DeterminateDurationTrack(int channels, int sampleRate)
        {
            long _duration = (long)(((double)floatAudioBuffer.Count / sampleRate / channels) * ticksInSecond);
            return TimeSpan.FromTicks(_duration);
        }
    }

Примечание: подобный способ даст нам возможность не только визуализировать аудиоданные, но и редактировать их. Кстати, обратный процесс сохранения данных организовать несложно, для этого достаточно снова использовать класс FileStream и последовательно записать полученные данные в файл.

Примечание: вы уже скорее всего обратили внимание, что при описании алгоритма мы отступили от вышеописанной структуры wav-файла. Это связано с тем, что файл, полученный путем конвертирования, имеет несколько отличную структуру. В частности, добавляется секция, не имеющая для нас большого значения — в ней хранится id, размер и информация о формате, после чего следует последовательность из нулевых байтов. Пропуск ненужной секции происходит путем перебора байтов в цикле и сравнения их со значением 544501094 (это необходимое значение поля Subchunk1Id, с которого и начинается секция Format). Подобный перебор необходим и по той причине, что вышеозначенная структура является примерной, но не обязательной и от нее порой отступают.

Наконец, получив данные, мы можем приступить к финальному шагу — построению графика. Тут существует несколько способов, приведем два самых распространенных:

  1. Построение изображения при помощи геометрических фигур.
  2. Создание точечных рисунков.

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

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

Инструкция о том, как работать с точечным изображением в UWP, доступна по ссылке. Описывая свою логику построения изображения аудиофайла, мы будем во многом основываться на этом материале.

Добавим в проект структуру и класс, описанные выше:

public struct GraphicalWavePlot
    {
        private float minValue;
        private float maxValue;
        private float peakValue;
        public GraphicalWavePlot(
            float minValue,
            float maxValue,
            float peakValue
            )
        {
            this.minValue = minValue;
            this.maxValue = maxValue;
            this.peakValue = peakValue;
        }
        public bool CheckArea(int pos, int heightImg)
        {
            double Oh = heightImg / 2;
            double y0 = Oh - Math.Abs(minValue) * Oh / peakValue;
            double y1 = Oh + maxValue * Oh / peakValue;
            return (pos > y0 && pos < y1);
        }
    }
    public class PlottingGraphImg
    {
        private List<GraphicalWavePlot> waveSamples = new List<GraphicalWavePlot>();
        private SoftwareBitmap softwareBitmap;
        private WavFile wavFile;
        private Color backgroundColor = Color.FromArgb(0, 0, 0, 0);
        public Color BackgroundColor
        {
            get { return backgroundColor; }
            set { backgroundColor = value; }
        }
        private Color foregroundColor = Color.FromArgb(255, 255, 255, 255);
        public Color ForegroundColor
        {
            get { return foregroundColor; }
            set { foregroundColor = value; }
        }
        private int image_width;
        public int ImageWidth
        {
            get { return image_width; }
            set { image_width = value; }
        }
        private int image_height;
        public int ImageHeight
        {
            get { return image_height; }
            set { image_height = value; }
        }
        public PlottingGraphImg(WavFile _wavFile, int _image_width, int _image_height)
        {
            this.wavFile = _wavFile;
            this.image_width = _image_width;
            this.image_height = _image_height;
            BuildImage();
            CreateGraphicFile();
        }
        private void BuildImage()
        {
            int xPos = 2;
            int interval = 1;
            var yScale = ImageHeight;
            float[] readBuffer = wavFile.GetFloatBuffer();
            int samplesPerPixel = readBuffer.Length / ImageWidth;
            float negativeLimit = readBuffer.Take(readBuffer.Length).Min();
            float positiveLimit = readBuffer.Take(readBuffer.Length).Max();
            float peakValue = (positiveLimit > negativeLimit) ? (positiveLimit) : (negativeLimit);
            peakValue *= 1.2f;
            for (int i = 0; i < readBuffer.Length; i += samplesPerPixel, xPos += interval)
            {
                float[] partBuffer = new float[samplesPerPixel];
                int lengthPartBuffer = ((i + samplesPerPixel) > readBuffer.Length) ? (readBuffer.Length - i) : (samplesPerPixel);
                Array.Copy(readBuffer, i, partBuffer, 0, lengthPartBuffer);
                var min = partBuffer.Take(samplesPerPixel).Min();
                var max = partBuffer.Take(samplesPerPixel).Max();
                waveSamples.Add(new GraphicalWavePlot(minValue: min, maxValue: max, peakValue: peakValue));
            }
        }

        [ComImport]
        [Guid("5B0D3235-4DBA-4D44-865E-8F1D0E4FD04D")]
        [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
        unsafe interface IMemoryBufferByteAccess
        {
            void GetBuffer(out byte* buffer, out uint capacity);
        }
        public unsafe void CreateGraphicFile()
        {
            softwareBitmap = new SoftwareBitmap(BitmapPixelFormat.Bgra8, ImageWidth, ImageHeight);

            using (BitmapBuffer buffer = softwareBitmap.LockBuffer(BitmapBufferAccessMode.Write))
            {
                using (var reference = buffer.CreateReference())
                {
                    byte* dataInBytes;
                    uint capacity;
                    ((IMemoryBufferByteAccess)reference).GetBuffer(out dataInBytes, out capacity);

                    // Fill-in the BGRA plane
                    BitmapPlaneDescription bufferLayout = buffer.GetPlaneDescription(0);
                    for (int i = 0; i < bufferLayout.Width; i++)
                    {
                        for (int j = 0; j < bufferLayout.Height; j++)
                        {
                            Color tempColor = waveSamples[i].CheckArea(j, ImageHeight) ? ForegroundColor : BackgroundColor;
                            //Blue
                            dataInBytes[bufferLayout.StartIndex + bufferLayout.Stride * j + 4 * i + 0] = (byte)tempColor.B;
                            //Green
                            dataInBytes[bufferLayout.StartIndex + bufferLayout.Stride * j + 4 * i + 1] = (byte)tempColor.G;
                            //Red
                            dataInBytes[bufferLayout.StartIndex + bufferLayout.Stride * j + 4 * i + 2] = (byte)tempColor.R;
                            //Alpha
                            dataInBytes[bufferLayout.StartIndex + bufferLayout.Stride * j + 4 * i + 3] = (byte)tempColor.A;
                        }
                    }
                }
            }
        }
        public async Task SaveGraphicFile(StorageFile outputFile)
        {
            using (IRandomAccessStream stream = await outputFile.OpenAsync(FileAccessMode.ReadWrite))
            {
                BitmapEncoder encoder = await BitmapEncoder.CreateAsync(BitmapEncoder.JpegEncoderId, stream);
                encoder.SetSoftwareBitmap(softwareBitmap);
                encoder.BitmapTransform.InterpolationMode = BitmapInterpolationMode.Fant;
                encoder.IsThumbnailGenerated = true;
                try
                {
                    await encoder.FlushAsync();
                }
                catch (Exception err)
                {
                    switch (err.HResult)
                    {
                        case unchecked((int)0x88982F81): //WINCODEC_ERR_UNSUPPORTEDOPERATION
                                            // If the encoder does not support writing a thumbnail, then try again
                                           // but disable thumbnail generation.
                            encoder.IsThumbnailGenerated = false;
                            break;
                        default:
                            throw err;
                    }
                }
                if (encoder.IsThumbnailGenerated == false)
                {
                    await encoder.FlushAsync();
                }
            }
        }
    }

Примечание: не забудьте в свойствах проекта разрешить использование небезопасного кода (правой кнопкой мыши кликнуть по проекту, в появившемся консольном меню выбрать Properties, далее в открывшемся окне активировать вкладку Build и в поле Allow unsafe code поставить галочку, см. ниже).



Итак, мы рассмотрели схему получения аудиоданных и построения графика. Теперь осталось только вызвать её из класса MainPage. Для этого нам понадобится кнопка «Build And Save Image File», по клику на которую и будет запускаться прописанный нами алгоритм работы. Чтобы её реализовать, к строкам, введённым в MainPage.xaml.cs ранее, добавим метод, представленный выше:

    private async void BuildAndSaveImageFile_Click(object sender, RoutedEventArgs e)
        {
            WavFile wavFile = new WavFile(currentFile.Path.ToString());
            imgFile = new PlottingGraphImg(wavFile, 1000, 100);
            FileSavePicker fileSavePicker = new FileSavePicker();
            fileSavePicker.SuggestedStartLocation = PickerLocationId.PicturesLibrary;
            fileSavePicker.FileTypeChoices.Add("JPEG files", new List<string>() { ".jpg" });
            fileSavePicker.SuggestedFileName = "image";
            var outputFile = await fileSavePicker.PickSaveFileAsync();
            if (outputFile == null)
            {
                // The user cancelled the picking operation
                return;
            }
            await imgFile.SaveGraphicFile(outputFile);
        }

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

Поделиться с друзьями
-->

Комментарии (1)


  1. Optimus_990
    11.04.2017 10:01

    Спасибо! Надо будет заюзать