Live Streaming on iOS

В нашей работе нередко возникает необходимость реализовать отправку видеопотока с iOS-устройства в реальном – или близком к реальному – времени. Самый частый пример — использование iOS-устройства в качестве камеры слежения или создание стриминговых приложений наподобие Periscope. Как правило, при возникновении подобной задачи ставятся дополнительные условия — например, возможность проигрывания потока на другом устройстве (или в другом приложении) без возникновения лишних проблем в браузере или VLC-плеере, малые задержки (видеопоток должен транслироваться практически в режиме реального времени), низкая нагрузка на устройство (возможность длительной работы от батареи), отсутствие необходимости в специализированном медиасервере для обслуживания передачи потока, и т.п.

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

  • HTTP Live Streaming (HLS)
  • HTTP Dynamic Streaming (HDS)
  • MPEG-DASH
  • fragmented MP4 (fMP4)
  • RTSP
  • WebRTC
  • и т.д.

Каждый подход обладает со своими плюсами и минусами. Например, в одной из наших прошлых, но не потерявшей актуальность, статье Реализация лайв-стриминга снимаемого видео в приложении для iOS, мы рассмотрели возможность генерации на устройстве готового HLS-потока. Это позволяет в принципе избавиться от необходимости в медиасервере, и сразу загружать контент в CDN (например, Amazon S3 + CloudFront). Однако такой, основанный на HLS, подход изначально имеет недостатки (о которых мы расскажем ниже), поэтому в этот раз мы предлагаем вам два новых варианта – генерацию на устройстве готового FMP4-потока и генерацию RTP-стрима с поддержкой локального RTSP-сервера.

HTTP Live Streaming

Протокол HLS появился в 2009 году и довольно быстро добился неплохой популярности. Этому способствовала его полная поддержка в экосистеме Apple и довольно понятная структура – есть текстовый мастер-файл, содержащий список ссылок на кусочки видеопотока, которые постоянно добавляются (в случае online-трансляции). В HLS также была предусмотрена возможность определять сразу несколько потоков для клиентов с разными требованиями к видео (быстрый канал/медленный канал и т.п.). Однако на практике такая двухступенчатая система и необходимость обновлять «мастер-файл» ограничивает сферу применения HLS. Как показывает практика, HLS слабо приспособлен для трансляций в режиме реального времени из-за следующих проблем:

  1. Трансляция нарезается на отдельные файлы небольшой длины (рекомендуется несколько секунд), в результате чего отставание трансляции от режима реального времени заложено в самой идее подобной «нарезки».
  2. При этом стандартная рекомендация по буферизации в видеоплеерах – наличие, как минимум, трех буферизованных сегментов, то есть на уровне плеера задержка из пункта 1 будет утроена;
  3. Кроме того, чтобы плеер узнал о появлении новых сегментов трансляции, ему необходимо регулярно перезапрашивать мастер-файл, что создает еще один фактор задержки (не обновив мастер-файл, плеер попросту не знает, что воспроизводить дальше).
  4. Каждый сегмент (chunk) образован как MPEG-TS файл, что означает существенный оверхед к основному медиаконтенту.

Для уменьшения задержек в HLS вполне естественно уменьшать размеры сегментов до минимума (1 секунда). Однако с такими маленькими размерами файлов на первый план выходят естественные нестабильности в работе сети. И в средних, «естественных» условиях добиться плавности воспроизведения практически невозможно — плеер больше оказывается занят перезапросами мастер-файла и очередного сегмента, чем показом видеопотока.

В попытке решить эти проблемы в 2011-2012 годах были предложены аналогичные подходы – MPEG-DASH от MPEG, Smooth Streaming от Microsoft и HDS от Adobe. Но ни одно из них не стало «стандартом де-факто» (хотя из этих трех MPEG-DASH является полноценным стандартом ISO) и на детальном уровне эти решения страдали теми же недостатками, а решения Adobe и Microsoft требовали, к тому же, особой поддержки на стороне сервера. Тут можно посмотреть таблицу сравнения между этими форматами.

Fragmented MP4

Время шло, и в итоге для простой организации трансляций приобрел популярность несколько другой подход – fMP4 (fragmented MP4). Это было небольшое (но существенное) расширение известного и хорошо поддерживаемого формата MP4, который уже тогда воспроизводился практически везде, поэтому широкая поддержка fMP4 тоже не заставила себя ждать.

Вся разница между обычным файлом MP4 и fMP4 заключается в расположении элементов, описывающих видео- и аудиостримы. В обычном MP4-файле подобные элементы расположены в конце, а в fMP4 – в начале. И так как MP4 изначально мог содержать несколько стримов, поделенных на отдельные блоки данных, – то это несложное изменение позволило создать “бесконечный файл” с точки зрения плеера.

Именно этим свойством fMP4 и пользуются при лайв-стриминге. Плеер, прочитав описание видео- или аудиопотока, начинает ждать данные и показывать (проигрывать) их по мере поступления. И если блоки с кадрами генерировать на лету, плеер автоматически будет проигрывать realtime-стрим без дополнительных усилий.

И это действительно работает! Конечно, при реализации генератора fMP4 могут возникнуть некоторые проблемы. Давайте рассмотрим их на конкретном примере – приложении DemoFMP4.

fMP4 Live Streaming on iOS

Это демонстрационное приложение берет кадры с камеры и отправляет в виде fMP4 любому зрителю, подключившемуся к устройству. Для подключения достаточно запросить у устройства виртуальный файл “MP4”, который устройство будет автоматически генерировать по адресу: http://<ip-адрес>:7000/index.mp4.
Для этого приложение запускает небольшой web-сервер на базе GCDWebServer, который слушает порт 7000 и отвечает на запросы на загрузку файла index.mp4.

Здесь есть ряд проблем, с которыми можно столкнуться.

  1. Во-первых, видеокамера выдает “сырые” кадры, которые нельзя просто так отправить плееру — они должны быть сжаты и собраны в правильном формате. К счастью, начиная с версии iOS 8.0, компания Apple открыла программный доступ к аппаратному сжатию видео, которое умеет генерировать H.264-блоки на лету. Для этого используются семейства функций VTCompressionSessionCreate и VTCompressionSessionEncodeFrame из фреймворка VideoToolbox.
    1. Аналогично сжимается и аудио   функциями семейства AudioConverterNewSpecific / AudioConverterFillComplexBuffer из фреймворка AudoToolbox — в результате чего мы
      получаем блоки данных в формате AAC.
  2. Во-вторых, камера выдает кадры с довольно высокой скоростью. Чтобы не терять их, мы сохраняем кадры в кольцевых буферах (CBCircularData), из которых блоки кадров по мере заполнения отправляются на сжатие. Эти же кольцевые буфера используются для генерации chunked-ответа, поэтому приложение не хранит в памяти больше наперед заданного числа кадров (иначе для бесконечной трансляции потребовался бы бесконечный объем памяти).
  3. В третьих, для корректной работы fMP4 необходимо правильно устанавливать начальные данные потока (включая Sps/Pps) в блоке moov MP4-файла. Для этого приложение просматривает H.264-блоки, которые генерирует hardware-кодировщик, находит очередной ключевой кадр и вытаскивает значения Sps/Pps. А при генерации moov-заголовка использует их, чтобы заставить плеер отсчитывать поток от правильного момента во времени. Таким образом, с точки зрения плеера файл всегда показывается “с самого начала”.
  4. Есть еще одна проблема — формат MP4 имеет свои требования к данным, и не может включать в себя блоки H.264 без правильного оформления. Мы решили эту проблему, подключив замечательную библиотеку Bento4, которая позволяет перепаковывать блоки H.264 в корректные MP4-atoms на лету.

flowchart-livestreaming-ru
Таким образом у нас получилось приложение, способное транслировать стрим с видеокамеры устройства практически без задержек, в реальном времени, отправляя “бесконечный MP4 файл” любому стандартному HTTP-клиенту. Такой подход дает довольно небольшое отставание: 1-2 секунды. В сочетании с широкой поддержкой fMP4 и простотой организации подобной трансляции (не требуется отдельный сервер) – генерация fMP4 на клиенте становится простым и надежным решением.

True Real-Time Live Streaming

Однако как быть, если нам нужен “настоящий реалтайм”, как в Skype? Для таких ситуаций fMP4, увы, уже не очень подходит. Несмотря на отсутствие мастер-файла (как в HLS) и небольшие размеры блоков, на которые делится видеопоток, эти блоки все еще присутствуют внутри MP4-файла. И пока такой блок не будет собран целиком, клиент никак не сможет получить кадры, что неизбежно создаст небольшую задержку при воспроизведении.

RTSP Live Streaming on iOS

Поэтому для полноценного реалтайма – когда плеер получает кадр практически сразу после его генерации в камере – больше подходит другой формат, изначально разработанный для стриминга. Речь идет о RTSP, который также обладает хорошей поддержкой среди плееров (к примеру, легко проигрывается популярным плеером VLC) и относительно не сложен в реализации. Рассмотрим пример приложения, которое обеспечивает трансляцию по стандарту RTSP – DemoRTSP.

В отличии от HLS и fMP4, которые полагаются для обмена данными на HTTP-протокол, – RTSP изначально использует свой собственный формат поверх “голых сокетов”. Кроме этого, для работы RTSP требуется два канала — один сигнальный, по которому клиент и сервер обмениваются управляющей информацией, а второй “для данных”, по которому сервер отправляет исключительно сжатые данные. Это немного усложняет схему обмена, однако позволяет добиться минимальных задержек – клиент по определению получает данные сразу после того, как сервер отправляет их по сети. В RTSP просто не предусмотрено никаких промежуточных звеньев!

DemoRTSP реализует минимальный набор для такого обмена. При запуске приложение начинает слушать сервисный порт (554) на предмет обращений от клиентов-плееров. При получении обращения, DemoRTSP отправляет в ответ простую строчку с указанием кодека, который будет использован для сжатия видео и аудио (для iOS это стандартная пара H.264/AAC) и номера порта данных, по которому сервер будет отправлять сжатые кадры. После этого клиент-плеер подключается к “порту данных” и проигрывает все, что получает от сервера. В этой схеме сервер никогда ничего не ждет и все сжатые кадры сразу уходят плееру, гарантируя минимальную задержку из возможных.

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

Live Video Effects on iOS

В нашем демо-приложении DemoRTSP использован весьма простой подход на базе PBVision, но в ваших приложениях вы можете использовать существенно более продвинутые решения на базе фреймворка GPUImage, который позволяет применять к видеопотоку целую цепочку OpenGL эффектов, при этом сведя задержку практически к нулю.
В DemoRTSP можно увидеть пример наложения на стрим простого Blur-эффекта.
Кто сказал, что делать Prisma-like video на лету невозможно!?

Можно пойти еще дальше, и использовать наиболее “быстрый” способ работы с изображениями в iOS на текущий момент — Metal, пришедший на смену OpenGL (в последних версиях iOS). Для примера можно посмотреть в MetalVideoCapture, где показано, как с помощью API-интерфейса CVMetalTextureCache передать результат захвата камеры в Metal Render Pass.

Кроме Metal, iOS последних версий появилась еще несколько интересных возможностей. Одна из них — ReplayKit, позволяющий в несколько строчек организовать трансляцию экрана устройства. Это довольно интересная платформа, однако на текущий момент она не позволяет “вмешиваться” в процесс создания кадра, не позволяет записывать изображение с камеры (только с экрана!), а получение записи ограничено штатными средствами. На текущий момент этот фреймворк не предназначен для полноценного создания управляемых трансляций, и больше похож на “задел на будущее”. Аналогично можно сказать и про другую популярную “новинку”, iOS-поддержку нового языка программирования Swift. К сожалению, он недостаточно хорошо интегрируется с низкоуровневыми возможностями, требуемыми для работы с видео и данными, поэтому в ближайшей перспективе его вряд ли можно будет использовать в подобных задачах (исключая интерфейс).

Ссылки

  1. iOS Live Streaming
  2. HLS
  3. HDS
  4. MPEG-DASH
  5. MP4/fMP4
  6. RTSP
  7. WebRTC
  8. Сравнение HLS и MPEG-DASH
  9. Metal app
  10. ReplayKit
  11. DemoFMP4 и DemoRTSP
  12. VideoToolbox
  13. AudioToolbox
  14. Bento4
  15. GCDWebServer
  16. PBJVision

Оптимизация отрисовки waveform на iOS

В статье пойдёт речь о том, как визуализировать вейвформу оптимальным образом, что для этого подходит лучше – CAShapeLayer или drawRect(), а также о некоторых тонкостях работы со Swift. Информация будет полезна не только тем, кто разрабатывает сложные кастомные UI компоненты и работает со звуком, но и любым iOS-разработчикам для расширения кругозора.

Постановка задачи

Разработчики DENIVIP создали приложение для фильтрации шумов в аудиозаписи – Denoise. Для пользователя это выглядит примерно так: он выбирает видео из своего альбома, приложение строит вейвформу звуковой дорожки и пользователь может выбрать на ней область с шумом, который нужно подавить. После небольшой магии пользователь получает видео с отфильтрованным звуком.

Алгоритм подавления шумов в данной статье подробно не обсуждается, но по большому счету он не выходит за рамки университетской программы по цифровой обработке сигналов. Теоретическую базу можно найти в удобном виде на сайте MITOpenCourseWare. Если говорить очень кратко, когда пользователь выбирает участок с шумом, мы, используя прямое преобразование Фурье, получаем частотный спектр «шумного участка». Запомнив эти частоты как «плохие» и требующие подавления, алгоритм проходит по всей аудиодорожке, на каждом участке получает частотный спектр и применяет подавляющие коэффициенты к «плохим» частотам. Затем, после применения обратного преобразования Фурье, получается новая вейвформа (дискретные звуки), в которой шумы существенно подавлены. Вся сложность состоит лишь в том, как качественно выбрать «шумный» участок. При этом чем длиннее видео, тем важнее становится интерактивность вейвформы, т.к. пользователю может потребоваться выделить несколько секунд аудиодорожки в середине двухчасового видео.

В первую очередь нам необходимо отобразить звуковую дорожку в понятной для пользователя форме и при этом дать ему возможность манипулировать ею (изменять масштаб, перемещаться по ней, выделять фрагмент). Сразу замечу, что если мы попытаемся отобразить все сэмплы нашей аудиодорожки, то может оказаться, что, во-первых, нам не хватит памяти, а, во-вторых, процессор (а заодно и GPU) “захлебнется” от такого количества точек.

Пример. Для 10-минутного видео 16-битная стерео аудиодорожка с частотой дискретизации 44,1 кГц будет «весить» 10 * 60 * 2 * 44100 * 2 ~ 100 MB. При этом большая нагрузка ложится и на GPU.

Очевидным решением будет разбить сигнал на равные интервалы, посчитать, например, средние значения на этих интервалах и полученный набор (средних) использовать для отображения вейвформы. На деле будем использовать средние и максимальные значения на интервалах — для каждой аудиодорожки будем строить 2 вейвформы одна над другой. Плюс может потребоваться добавлять вейвформы «на лету” (для отображения отфильтрованного сигнала поверх оригинального). Существенным усложнением данного подхода является необходимость изменять масштаб вейформы, а также «перемещаться» по оси времени для выбора нужной области.

Замечу, что чтение сэмплов из аудиофайла и построение вейвформы может занимать продолжительное время. Для примера, на iPhone 5s вейвформа для 10-минутной записи строится порядка 10 секунд. Как известно, хуже ожидания для пользователя может быть только отсутствие информации о том, сколько еще нужно ждать. Поэтому мы решили сделать отрисовку загружающейся вейвформы анимированной, чтобы пользователь видел прогресс в интуитивном формате.

Итого для решения задачи нужно:

  1. Уметь читать одиночные сэмплы аудиодорожки и строить по ним данные для отображения вейвформы

  2. Уметь рисовать несколько вейвформ (с возможностью добавлять вейфвмормы в любой момент).

  3. Иметь возможность рисовать вейвформу “анимированно” по ходу чтения сэмплов.

  4. Добавить возможность масштабировать вейвформу и смещать её по времени.

Существующие решения

Лучшее из найденных решений: EZAudio. В целом интересный фреймворк, но он не совсем подходит для нашей задачи. Например, в нем нет возможности масштабирования графика.

Для отрисовки графика можно было использовать CorePlot. Но в целом это решение показалось громоздким для нашей задачи (также, возможно, потребовалось бы отдельно поработать над оптимизацией отрисовки большого набора значений).

Разработка

Архитектура waveform-модуля

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

Smooth waveform UI architecture

Примерно так упрощенно выглядят связи между классами, занятыми в отрисовке вейвформ.

Здесь

  1. Diagram View — контейнер одиночных вейвформ. Он также реализует синхронизацию отрисовки вейвформ, для чего использует CADisplayLink.

  2. Plot View —  класс, занимающийся отрисовкой. Этот класс должен знать только ту информацию, которую необходимо отобразить в данный момент времени, и получать её он будет из соответствующего Plot ViewModel.

  3. Diagram ViewModel — контейнер вью-моделей отдельных вейвформ.

  4. Plot ViewModel — источник данных для одиночной вейвформы. Этот класс будет решать, какие семплы и в каком количестве отдавать на отрисовку, когда это необходимо

Здесь опущены протоколы, через которые на самом деле реализована связь от View к ViewModel. Замечу только, что архитектурой не предусмотрено обратных связей от ViewModel к View.

Благодаря такому подходу мы получили возможность оптимизировать построение данных для вейвформы и отрисовку вейвформы независимо друг от друга. Также это дало возможность внести серьезные изменения в построение данных без воздействия на слой View. Но обо всем по порядку.

Чтение сэмплов

Для построения вейвформы необходимо получить несжатые семплы аудиодорожки. Сделать это можно с помощью инструментов, предоставляемых фреймворками AVFoundation и CoreMedia.

Чтение данных целиком представлено в классе:

Не буду очень подробно останавливаться на этом коде. Скажу только, что перед началом чтения сэмплов можно узнать параметры аудиозаписи, такие как частота дискретизации и количество каналов (функция readAudioFormat), и использовать их при чтении. Но можно и пропустить этот момент и при настройке чтения использовать частоту, равную 44100 Гц, и читать по двум каналам. Глубину кодирования придется задать вручную (16 бит).

Также отмечу, что чтение сэмплов может занимать продолжительное время. Для видео в несколько минут время чтения будет порядка нескольких секунд. Поэтому алгоритм нужно использовать в фоновом потоке.

Обработка сэмплов

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

import Foundation
import AVFoundation

protocol AudioSamplesHandler: class {
    func willStartReadSamples(estimatedSampleCount estimatedSampleCount: Int)
    func didStopReadSamples(count: Int)
    func handleSamples(samples: UnsafePointer<Int16>, length: Int, channelsCount: Int)
}

class SamplesHandler: AudioSamplesHandler {
    
    var asset: AVAsset
    init(asset: AVAsset) {
        self.asset = asset
    }
    
    var neededSamplesCount = 0

    var estimatedSampleCount: Int = 0
    var sampleBlockLength: Int    = 0
    var currentAvgDouble: Double  = 0
    var currentMax: Int16         = 0
    var globalMax: Int16          = 0
    var globalBlockIndex: Int     = 0
    
    var avgPulsesBuffer: [Int16] = []
    var maxPulsesBuffer: [Int16] = []
    
    func willStartReadSamples(estimatedSampleCount estimatedSampleCount: Int) {
        
        self.estimatedSampleCount = estimatedSampleCount
        
        sampleBlockLength = Int(Double(estimatedSampleCount) / Double(neededSamplesCount))
        
        currentAvgDouble = 0
        currentMax       = 0
        globalMax        = 0
        globalBlockIndex = 0
    }
    
    func didStopReadSamples(count: Int) {
        //
    }
    
    func handleSamples(samples: UnsafePointer<Int16>, length: Int, channelsCount: Int) {
        var currentBlock = globalBlockIndex / sampleBlockLength
        
        for index in 0..<length {
            
            let sample = samples[channelsCount * index]
            currentAvgDouble += fabs(Double(sample) / Double(sampleBlockLength))
            
            if currentMax < sample {
                currentMax = sample
            }
            
            if (globalMax < sample) {
                globalMax = sample
            }
            
            globalBlockIndex += 1
            
            let oldBlock = currentBlock
            currentBlock = globalBlockIndex / sampleBlockLength
            
            if currentBlock > oldBlock {
                self.avgPulsesBuffer[oldBlock] = Int16(currentAvgDouble)
                self.maxPulsesBuffer[oldBlock] = currentMax
                currentAvgDouble = 0
                currentMax       = 0
            }
        }
    }
}

Swift vs ObjC. Что лучше?

Поскольку приложение уже было написано на Objective-C, начинать писать новый модуль на Swift было неочевидным решением. Например, что касается построения данных для графиков, требовалось убедиться, что Swift не создаст проблем с производительностью. Мы провели проверку аналогичных алгоритмов, написанных на обоих языках и использующих аналогичные типы данных (например, если в Swift использовался тип UnsafePointer<Double>, то в алгоритме на Objective-C — массив double*).

Результаты были следующие:

Данные по работе алгоритмов чтения аудиосемплов и построения данных для графика.

iPhone 5

Swift (Whole-Module-Optimization):  20.1 c

Objective-C (-Ofast):  41.4 c

iPhone 6s

Swift (Whole-Module-Optimization):  5.1 c

Objective-C (-Ofast):  4.1 c

Тесты проводились и на других моделях iPhone. Результаты показали сильные расхождения на моделях с 32-разрядными процессорами и небольшие — на 64-разрядных моделях. (Для дальнейшей работы над оптимизацией в основном использовался iPhone 5s).

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

Масштабирование вейвформы

В первоначальном варианте для зума использовалась возможность чтения сэмплов из произвольного отрезка аудиодорожки. См. свойство объекта AVAssetReader

assetReader.timeRange = CMTimeRange(start: kCMTimeZero, duration: asset.duration)

И при каждом зуме графика чтение начиналось снова.

pre_mov_crop

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

Рефакторинг обработки сэмплов

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

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

В качестве массива обработанных данных был использован UnsafeMutablePointer<T>, который по своей сути является аналогом массива на языке C (см. документацию (англ.): Структура указателя UnsafeMutablePointer, Взаимодействие с API языка C). Пример реализации брался отсюда.

Примечание. Здесь опущен очень важный момент: этот класс будет использоваться параллельно в разных потоках (запись в бэкграунде, чтение в главном потоке). В Swift встроенных средств для работы с многопоточностью нет, так что атомарное поведение буфера придется реализовать вручную (например, вот так). В нашем случае, при использовании Array<T> вместо UnsafeMutablePointer<T> и без синхронизации мы периодически сталкивались с выходом за границы массива. С UnsafeMutablePointer<T> такая проблема не возникала.

Про числовые типы

Пару слов скажу про ассоциированный тип T в классе Channel<T>.

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

Теперь в коде класса Channel можно указать следующее:

Параллельные расчеты для нескольких масштабов

Теперь можно в класс Channel добавить обработку сэмплов (например, нахождение максимумов).

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

import Foundation

protocol ChannelSource {
    var channelsCount: Int { get }
    var onChannelsChanged: () -> () { get set }
    func channelAtIndex(index: Int) -> Channel<Int16>
}

class SamplesHandler: ChannelSource, AudioSamplesHandler {
    
    var scaleInterLevelFactor = 2
    var numberOfScaleLevels   = 10
    var neededSamplesCount    = 2000
    
    var channels = [Channel<Int16>]()
    
    var scaleIndex = 0
    var onChannelsChanged: () -> () = {_ in}
    
    var channelsCount: Int = 1
    
    func channelAtIndex(index: Int) -> Channel<Int16> {
        return channels[index + scaleIndex * channelsCount]
    }
    
    func createChannelsForDefaultLogicTypes() {
        
        var channels = [Channel<Int16>]()
        
        for _ in 0..<numberOfScaleLevels {
            let channel = Channel<Int16>()
            channels.append(channel)
        }
    }
    
    func willStartReadSamples(estimatedSampleCount estimatedSampleCount: Int) {
        for index in 0..<numberOfScaleLevels {
            
            var totalCount = Int(Double(neededSamplesCount) * pow(Double(scaleInterLevelFactor), Double(index)))
            let blockSize  = Int(ceil(Double(estimatedSampleCount)/Double(totalCount)))
            totalCount = Int(Double(estimatedSampleCount)/Double(blockSize))
            
            let channel = channels[index]
            
            channel.totalCount = totalCount
            channel.blockSize  = blockSize
        }
    }
    
    func reset(scale: Double) {
    
        var scaleIndex = Int(floor(log(scale)/log(Double(scaleInterLevelFactor))))
        scaleIndex     = min(self.numberOfScaleLevels - 1, scaleIndex)
        if scaleIndex != self.scaleIndex {
            self.scaleIndex = scaleIndex
            self.onChannelsChanged()
        }
    }
    
    func didStopReadSamples(count: Int) {
        for channel in channels {
            channel.complete()
        }
    }
    
    func handleSamples(samples: UnsafePointer<Int16>, length: Int, channelsCount: Int) {
    
        for channelIndex in 0..<numberOfScaleLevels {
            
            let channel = channels[channelIndex * channelsCount]

            for sampleIndex in 0..<length {
                let sample = samples[channelsCount * sampleIndex]
                channel.handleValue(sample)
            }
        }
    }
}

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

По такому же принципу можно добавить вычисление средних значений. А чтобы все это работало вместе, можно создать один абстрактный класс Channel и 2 наследника MaxChannel и AvgChannel (для максимумов и средних значений, соответственно).

После всех манипуляций зум выглядит так:

post_mov_crop

Оптимизация

Построение набора данных для вейвформы

В общем, этого достаточно для решения задачи, однако мы попробовали оптимизировать алгоритм. Что касается Swift, если вам требуется высокая производительность того или иного алгоритма, то одними из главных препятствий станут ARC и динамическая диспетчеризация. Вообще говоря, в Swift есть ряд возможностей помочь компилятору опустить некоторые проверки. Здесь мы рассмотрим один из подходов.

Для оптимизации было сделано следующее:

  1. Создали базовый класс, который инкапсулирует логику обработки сэмплов.

  2. В классе-канале создали свойство этого типа.

  3. В классе-логике создали слабую ссылку на класс-канал.

  4. Отнаследовали от этого типа конкретные обработчики (для средних и для максимумов).

  5. Сам класс-канал сделали ненаследуемым (с помощью ключевого слова final).

  6. Включили оптимизацию Whole-Module-Optimization.

Про 2 последних пункта читайте здесь.

Режим Whole-Module-Optimization включается в настройках проекта.

whole-module-optimization

 

Результаты сравнения скорости работы алгоритма (продолжительность видеоролика 10 мин):

В случае с наследованием каналов и логикой обработки внутри каналов время построения данных: 27.95 с

Для варианта с “внешней” логикой — 6.66 с

В конечном итоге, буфер также решено было спрятать внутрь отдельного класса для того, чтобы подружить класс Channel с ObjC (ObjC не умеет работать с обобщенными типами Swift).

Графика

Немного расскажем про работу с графикой.

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

Исходный вариант

Изначальный вариант базировался на использовании CAShapeLayer и отрисовки с помощью линии обводки (stroke).

import UIKit

protocol PlotDataSource: class {
    var dataSourceFrame: CGRect { get }
    var pointsCount: Int { get }
    func pointAtIndex(index: Int) -> CGPoint
}

class Plot: UIView {
    weak var dataSource: PlotDataSource?
    var lineColor = UIColor.blackColor()
    var pathLayer = CAShapeLayer()
    
    convenience init(){
        self.init(frame: CGRectZero)
        self.setupPathLayer()
    }

    func setupPathLayer() {
        self.pathLayer.strokeColor = UIColor.blackColor().CGColor
        self.pathLayer.lineWidth   = 1.0
        self.layer.addSublayer(self.pathLayer)
        
        self.pathLayer.drawsAsynchronously = true
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        self.pathLayer.frame = self.bounds
        self.redraw()
    }
    
    func redraw() {
        self.pathLayer.path = self.newPathPart()
    }
    
    func newPathPart() -> CGPathRef {
        
        guard let dataSource = self.dataSource else {
            return CGPathCreateMutable()
        }
        
        let currentCount = dataSource.pointsCount
        let sourceBounds = dataSource.dataSourceFrame.size
        
        let mPath        = CGPathCreateMutable()
        
        CGPathMoveToPoint(mPath, nil, 0, self.bounds.midY)
        
        let wProportion = self.bounds.size.width / sourceBounds.width
        let hPropostion = self.bounds.size.height / sourceBounds.height
        
        for index in 0..<currentCount {
            let point         = dataSource.pointAtIndex(index)
            let adjustedPoint = CGPoint(
                x: point.x * wProportion,
                y: point.y * hPropostion / 2.0)
            
            CGPathMoveToPoint(mPath, nil, adjustedPoint.x, self.bounds.midY - adjustedPoint.y)
            CGPathAddLineToPoint(mPath, nil, adjustedPoint.x, self.bounds.midY + adjustedPoint.y)
            CGPathMoveToPoint(mPath, nil, adjustedPoint.x, self.bounds.midY)
        }
        CGPathMoveToPoint(mPath, nil, CGPathGetCurrentPoint(mPath).x, self.bounds.midY)
        
        return mPath
    }
}

Время отрисовки для 2000 точек на каждую из 2 вейвформ, на iPhone 5s: 18 с

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

Вариант с оптимизацией №1  (построение CGPath без CGPathMoveToPoint)

Для начала избавились от использования функции CGPathMoveToPoint и там, где нужно, стали просто добавлять лишнюю линию вместо перемещения между точками.

import UIKit
class _Plot: Plot {
    
    func newPathPart() -> CGPathRef {
        
        guard let dataSource = self.dataSource else {
            return CGPathCreateMutable()
        }
        
        let currentCount = dataSource.pointsCount
        let sourceBounds = dataSource.dataSourceFrame.size
        
        let mPath        = CGPathCreateMutable()
        
        CGPathMoveToPoint(mPath, nil, 0, self.bounds.midY)
        
        let wProportion = self.bounds.size.width / sourceBounds.width
        let hPropostion = self.bounds.size.height / sourceBounds.height
        
        for index in 0..<currentCount {
            let point         = dataSource.pointAtIndex(index)
            let adjustedPoint = CGPoint(
                x: point.x * wProportion,
                y: point.y * hPropostion / 2.0)
            
            CGPathAddLineToPoint(mPath, nil, adjustedPoint.x, self.bounds.midY)
            CGPathAddLineToPoint(mPath, nil, adjustedPoint.x, self.bounds.midY - adjustedPoint.y)
            CGPathAddLineToPoint(mPath, nil, adjustedPoint.x, self.bounds.midY + adjustedPoint.y)
            CGPathAddLineToPoint(mPath, nil, adjustedPoint.x, self.bounds.midY)
        }
        CGPathMoveToPoint(mPath, nil, CGPathGetCurrentPoint(mPath).x, self.bounds.midY)
        
        return mPath
    }
}

Время отрисовки для тех же исходных данных: 14.8 с. 

Вариант с оптимизацией №2 (stroke против fill)

Вейвформы у нас довольно простые: строим линию от середины вверх в соответствии с уровнем сэмпла, повторяем симметрично вниз, возвращаемся на середину, переходим к следующему значению. Это позволило совсем отказаться от stroke и строить линии, закрашивая нужные области (fill). При этом по прежнему рисуем замкнутую фигуру.

В этом случае получаем:  12.4 с.

Прирост скорости небольшой и заметен только на относительно старых устройствах (до iPhone 5s включительно).

Вариант с оптимизацией №3 (drawRect против CAShapeLayer)

Также попробовали вместо CAShapeLayer использовать отрисовку того же самого CGPath в drawRect (алгоритм построения CGPath как варианте № 1)

class _Plot: Plot {
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.opaque = false
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        self.opaque = false
    }
    
    func redraw() {
        self.setNeedsDisplay()
    }
    
    override func drawRect(rect: CGRect) {
        guard let context = UIGraphicsGetCurrentContext() else {
            return
        }
        
        CGContextSetLineWidth(context, 1/UIScreen.mainScreen().scale)
        CGContextAddPath(context, self.newPathPart())
        CGContextSetStrokeColorWithColor(context, self.lineColor.CGColor)
        CGContextSetInterpolationQuality(context, .None);
        CGContextSetAllowsAntialiasing(context, false);
        CGContextSetShouldAntialias(context, false);
        CGContextStrokePath(context)
    }
}

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

Результат теста: 9.46 c. 

В итоге, в результате оптимизации построения CGPath и переходе на отрисовку в drawRect(), можно ускорить построение вейвформы почти в 2 раза. Полученного результата уже достаточно для плавной отрисовки нашей вейвформы.

Вместо заключения

  1. Как видим, Swift вполне подходит для задач подобного уровня.
  2. Оптимизация алгоритмов на Swift – это одна большая тема, которую мы еще постараемся осветить.
  3. Для достижения оптимальных параметров отрисовки вашей графики не обязательно переходить на OpenGL или Metal.
  4. MVVM-подобный подход к построению архитектуры с разделением ответственностей View и ViewModel хорошо подходит для реализации  сложного интерактивного интерфейса.

Целиком проект с демоприложением можно найти на GitHub по ссылке.

Плеер плейлистов для iOS

DVPlaylistPlayer Title

Одной из основных частей приложения, так или иначе работающего с видео, является видеоплеер. Если вы хотя бы раз имели дело с интеграцией плеера в своё приложение, то наверняка знаете, что его настройка и кастомизация занимает определённое время. Также мы готовы поспорить, что чаще всего вам не нужен был плеер, который играл бы только одно видео за всё время работы с приложением. В большинстве случаев вы бы хотели использовать плейлисты. В этой статье мы хотим рассказать вам о разработанном нами открытом компоненте, который мы используем для воспроизведения видео в нашем проекте Together. Речь пойдёт о плеере плейлистов DVPlaylistPlayer.
Continue reading

AirPlay и HDMI

Если вы разработчик игровых или мультимедиа-приложений для iOS, тогда вы просто не сможете обойтись без технологии AirPlay, которая в данной области стала уже почти стандартом. В самом деле, что может быть лучше для пользователя, чем наблюдать любимые видео или фотографии на большом экране плазменного ТВ или играть на нем в современные игры, при этом держа iPhone/iPad/iPod в руках в качестве мультитач-джойстика? AirPlay предназначен именно для этого. Continue reading

Возможности Screen Capture в iOS

Функция Screen Capture, или, попросту говоря, «снятие изображения экрана», в мобильном приложении представляет собой мощный вспомогательный инструмент и может использоваться в самых разнообразных задачах. В этой статье мы подробно рассмотрим механизмы, доступные для реализации данного функционала. Continue reading

Защищенная доставка и воспроизведение видео в iOS: поддержка DRM

Протокол HTTP Live Streaming (HTTP LS) рекомендуется и всячески продвигается компанией Apple в качестве наиболее оптимального решения для онлайн-доставки видеоконтента на мобильные устройства. Но ни сам этот протокол, ни средства для работы с ним, имеющиеся в API операционной системы, не реализуют полноценную поддержку систем DRM. Новая версия системы Adobe Access 4.0 добавляет на iOS-устройства эту недостающую функциональность. Continue reading

Разработка крупных JavaScript приложений

Создание интерактивных Интернет сервисов http://tutrostov.ru/  подразумевает широкое применение JavaScript при их разработке. Если 10 лет назад этот язык имел скудным функционал, но в данный момент  JS существенно преобразился. Все больше логики сервисов переносится в клиентскую часть. В статье мы поделимся с вами нашим опытом проектирования и реализации крупных веб приложений. Continue reading

HTML5 <video> аналитика

Если Вы работаете в области online-video сервисов, то наверняка задумывались об аналитике просмотров видео пользователями. Для платформы OSMF (фреймворк, написанный на Flash технологии) существует плагин GTrackPlugin осуществляющий сборку статистики по просмотру контента пользователем. Также можно использовать нашу собственную разработку OSMF Video Analytics.

Однако если сервис основан на HTML5 видео, то необходимо решение, реализующее аналитику просмотров видео для данной технологии. Реализация данного плагина основана на работе с HTML5 Media API. (Подробнее о работе с данным API Вы моете прочитать в статье о реализации HTML5 плеера). Continue reading

Разработка HTML5 плеера

HTML5 JS Player

С каждым годом медиа технологии все больше http://tutbelgorod.ru/  и больше входят в жизнь каждого человека. Глобальная сеть также подвержена данной тенденции, о чем свидетельствуют  успех и популярность таких сервисов как YouTube. Но работа подобных систем невозможна без технологий, позволяющих реализовать воспроизведение видео контента для пользователя.

Классическим подходом для реализации проигрывателя видео является использование технологии Flash.  Пожалуй, сложно найти более распространенный плагин для веб-браузера, чем Flash Player. Однако стоит заметить, что слабым местом подобного подхода является необходимость установки Flash plugin’а. А для многих устройств он вообще отсутствует. К примеру, настолько популярные iPhone и  iPad не имеют возможности запускать Flash-приложения в браузере. Выходом из подобной ситуации является использование HTML5. Continue reading

Разработка AIR видео приложений под Android

android video apps
Сегодня смартфоны и планшеты стали неотъемлемой  частью нашей повседневной жизни. Люди взаимодействуют с этими устройствами много раз за день и проводят много времени за этим занятием. Мобильные и планшетные приложения стали важными каналами распространения контента. Запуск практически любого крупного сервиса включает в себя специализированное приложение. Сегментация мобильных приложений сильно увеличивает затраты на реализацию мобильной стратегии. Adobe AIR позволяет снизить затраты на запуск кроссплатформенных сервисов, разработанное один раз приложение проще адаптировать под разные устройства, экраны (кроме того, хороших Flash разработчиков гораздо больше чем хороших iOS и Android специалистов). В этой статье мы расскажем про некоторые особенности разработки мобильных приложений при помощи Adobe AIR. Continue reading