Как правильно транслировать mp3 на iPhone или Не так страшен HLS (HTTP Live Streaming), как его малюют

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

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

О том, как я это реализовывал и как докатился до HLS и пойдет речь дальше.

readfile

О банальных вещах обмолвлюсь кратко. Загрузка и публикация песен происходила через панель менеджера. Информацию о песне подгружал из ID3-тегов песен и частично из Last.fm API. Сохранял я их по началу в исходном виде. API для iOS приложения было сделано через HTTP REST JSON.

После авторизации, выбора плейлиста и выбора песни по специальному адресу отдавался сам файл песни.

В самом первом варианте там были следующие заголовки:

api::i()->response->headers->set('Content-Description', 'File Transfer');
api::i()->response->headers->set('Content-Type', $content_type);
api::i()->response->headers->set('Content-Disposition','inline; filename='.$name);
api::i()->response->headers->set('Content-Transfer-Encoding','binary');
api::i()->response->headers->set('Expires','0');
api::i()->response->headers->set('Cache-Control','must-revalidate');
api::i()->response->headers->set('Pragma','public');
api::i()->response->headers->set('Content-Length',filesize($file));
readfile($file);

X-Accel-Redirect

Конечно же это не подошло. Файлы размером 40 мб и выше не обрабатывались. В ответ приходила ошибка 500, а в логах белым по синему была записана нехватка памяти. Объем выделяемой памяти я решил не менять — никогда не считал это решением. По крайней мере в первую очередь никогда не рассматривал.

Этот раз не стал исключением. В процессе небольшого исследования выяснилось, что Apache и Nginx поддерживают отправку статических файлов собственными силами. Для Apache есть mod_xsendfile с заголовком X-Sendfile. Для Nginx есть X-accel (у меня был уже собран, но кажется он идет в стандартной поставке) с заголовком X-Accel-Redirect, который я собственно и использовал. Этот заголовок избавляет приложение от отправки файла. То есть мы отправляем все те же заголовки, кроме Content-Length, который сервер добавит сам, и собственно сам файл. В заголовок я передал путь к файлу относительно корня сайта. Сам заголовок X-Accel-Redirect пользователю не будет передан.

Вот как изменился код:

api::i()->response->headers->set('Content-Description', 'File Transfer');
api::i()->response->headers->set('Content-Type', $content_type);
api::i()->response->headers->set('Content-Disposition','inline; filename='.$name);
api::i()->response->headers->set('Content-Transfer-Encoding','binary');
api::i()->response->headers->set('Expires','0');
api::i()->response->headers->set('Cache-Control','must-revalidate');
api::i()->response->headers->set('Pragma','public');
api::i()->response->headers->set('X-Accel-Redirect', get_relative_path($file));

Content-range

Все заработало. API отдавало данные, сервер отдавал файл, в приложении запускалось проигрывание. Новая проблема встала в том, что работало это только для скоростных соединений. Воспроизведение начиналось слишком поздно даже через Wifi, на 3G медленнее, на GPRS не играло вовсе, хотя допустим радио от PromoDJ с немалой задержкой, но стартовало.

Идея посетившая меня была простой. Во-первых конвертировать песни после загрузки. Этот шаг был очевиден еще с самого начала. Ну и частичная отдача данных через Content-Range заголовок. Я ошибочно решил, что мне это поможет. В логах сервера я видел, что iPhone делает запрос с Content-Range 0-1, получает в ответ полный файл, и качает его. Я подумал, что если я добавлю поддержку Content-Range на стороне сервера, то это решит проблему. Но это не решило. Даже если сервер возвращал первые два байта отдельно, следующим делом iPhone запрашивал весь остаток файла. Это было не то.

HLS (HTTP Live Streaming)

Я уже задумался о вещании через RTMP, когда нашел HLS. Конвертация и отправка файла по частям. Я мыслил в верную сторону. Именно так работает принцип HLS. HLS — это такой стандарт от Apple, для вещания видео и/или аудио на iOS устройства. Ничего сложного в нем нет. Я рекомендую почитать, или в крайнем случае ознакомиться с примерами — Описание стандарта HTTP Live Streaming. Файл заранее или на лету конвертируется в определенном качестве, конвертируемое делится на фрагменты скажем по 10 секунда каждый, для них составляется плейлист в формате m3u8. Такие плейлисты создаются для каждой пропускной полосы, которую вы планируете поддерживать. Для каждой полосы пропускания вы конвертируете песню в своем формате. Для мобильных скажем — 64kb, для wifi можете оставить кодек как есть. В итоге у вас получится по плейлисту на каждое качество звучания. Все эти плейлисты объединяются в единый master-плейлист. Вот например master-плейлист с сайта стандарта:

#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=1280000
http://example.com/low.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2560000
http://example.com/mid.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=7680000
http://example.com/hi.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS="mp4a.40.5"
http://example.com/audio-only.m3u8

BANDWIDTH — здесь в качестве условия, в псевдокоде читается примерно так

if (BANDWIDTH == 128kb) {
    load playlist "http://example.com/low.m3u8"
}

Мое описание немного упрощенное. Вот полное описание технологии от Apple https://developer.apple.com/library/ios/documentation/networkinginternet/conceptual/streamingmediaguide/Introduction/Introduction.html

Готовые решения

Там же они предлагают набор утилит для автоматизации процесса: Media Encoder, Stream Segmenter, File Segmenters. Вы можете скачать их с сайта developer.apple.com, но не спешите радоваться, если у вас при этом нет устройства с MacOS. Версий под *nix там нет, а значит вы не сможете установить данные утилиты на свой Ubuntu/CentOS/ваш_любимый_дистрибутив-сервер.

По этому поводу я расстроился… но не очень. Пришлось искать альтернативы, чем я и занялся.

Мне встретились и популярная библиотека на Ruby carsonmcdonald/HTTP-Live-Video-Stream-Segmenter-and-Distributor, которую я даже не стал ковырять, и NodeJS danscan/HLSKit, и модуль для nginx, поддерживающий RTMP и HLS. Последний показался мне невероятно уместным за одним лишь исключением — нам не нужно было создавать интернет-вещание в прямом смысле этого слова. То есть не было общего потока, поток был на каждую песню и у разных пользователей в разный момент времени могла играть другая песня. Возможно я что-то упустил, но все примеры использования, что я понял этого подхода не предусматривали. Кроме того генерация на лету никогда не бывала мгновенной, а значит мы все равно теряли драгоценные секунды в таком варианте.

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

FFMpeg

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

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

ffmpeg -y -i file.mp3 \
-acodec aac -strict experimental \
-map 0:0 \
-f segment \
-segment_list_type hls \
-segment_time 10 \
-segment_format mpegts \
-b:a 64k -segment_list "/path/to/hls/low.m3u8" "/path/to/hls/low/%03d.ts"
  • -y — отвечает «yes» на все возможные вопросы, в моем случае если файлы с такими именами уже существовали, то опция ответит «да» на вопрос перезаписывать существующие файлы или нет
  • -i file.mp3 — имя исходного файла
  • -acodec — я использовал aac, вы можете подобрать другой, -strict experimental для многих других кодеков писать не обязательно
  • -map 0:0 — берет все потоки из исходного файла
  • -f segment
  • -segment_list_type hls — для HLS собственно
  • -segment_time — время отрезка для итоговых файлов
  • -b:a 64k -segment_list «/path/to/hls/low.m3u8» «/path/to/hls/low/%03d.ts» — эта строка будет менятся для других качеств, скажем для medium вы поставите 128kb и назовете плейлист и папку не low, а medium.

Таким образом ffmpeg создаст плейлист и набор файлов. Вы можете протестировать его, открыв скажем из Safari на вашем iPhone. Если музыка играет — значит все в порядке.

После того, как вы создадите достаточный ассортимент качеств нужного трека, объедините их в master-плейлист и уже его отдавайте в ваше iOS приложение или протестируйте в Safari. При переключении полос доступа во время проигрывания вы сможете увидеть в access.log сервера примерно следующее:

192.168.1.101 - - [26/Sep/2014:15:44:43 +0300] "GET /pls.m3u8 HTTP/1.1" 200 237 "-" "Mozilla/5.0 (iPhone; CPU iPhone OS 7_1_2 like Mac OS X) AppleWebKit/537.51.2 (KHTML, like Gecko) Version/7.0 Mobile/11D257 Safari/9537.53"
192.168.1.101 - - [26/Sep/2014:15:44:44 +0300] "GET /pls.m3u8 HTTP/1.1" 200 237 "-" "AppleCoreMedia/1.0.0.11D257 (iPhone; U; CPU OS 7_1_2 like Mac OS X; ru_ru)"
192.168.1.101 - - [26/Sep/2014:15:44:44 +0300] "GET /low.m3u8 HTTP/1.1" 200 961 "-" "AppleCoreMedia/1.0.0.11D257 (iPhone; U; CPU OS 7_1_2 like Mac OS X; ru_ru)"
192.168.1.101 - - [26/Sep/2014:15:44:45 +0300] "GET /out_64_000.ts HTTP/1.1" 200 171080 "-" "AppleCoreMedia/1.0.0.11D257 (iPhone; U; CPU OS 7_1_2 like Mac OS X; ru_ru)"
192.168.1.101 - - [26/Sep/2014:15:44:46 +0300] "GET /medium.m3u8 HTTP/1.1" 200 1065 "-" "AppleCoreMedia/1.0.0.11D257 (iPhone; U; CPU OS 7_1_2 like Mac OS X; ru_ru)"
192.168.1.101 - - [26/Sep/2014:15:44:47 +0300] "GET /out_medium_000.ts HTTP/1.1" 200 253988 "-" "AppleCoreMedia/1.0.0.11D257 (iPhone; U; CPU OS 7_1_2 like Mac OS X; ru_ru)"
192.168.1.101 - - [26/Sep/2014:15:44:48 +0300] "GET /out_medium_001.ts HTTP/1.1" 200 249852 "-" "AppleCoreMedia/1.0.0.11D257 (iPhone; U; CPU OS 7_1_2 like Mac OS X; ru_ru)"
192.168.1.101 - - [26/Sep/2014:15:44:49 +0300] "GET /out_medium_002.ts HTTP/1.1" 200 244588 "-" "AppleCoreMedia/1.0.0.11D257 (iPhone; U; CPU OS 7_1_2 like Mac OS X; ru_ru)"
192.168.1.101 - - [26/Sep/2014:15:44:50 +0300] "GET /hi.m3u8 HTTP/1.1" 200 961 "-" "AppleCoreMedia/1.0.0.11D257 (iPhone; U; CPU OS 7_1_2 like Mac OS X; ru_ru)"
192.168.1.101 - - [26/Sep/2014:15:44:50 +0300] "GET /out_hi_001.ts HTTP/1.1" 200 490116 "-" "AppleCoreMedia/1.0.0.11D257 (iPhone; U; CPU OS 7_1_2 like Mac OS X; ru_ru)"
...
192.168.1.101 - - [26/Sep/2014:15:45:11 +0300] "GET /out_hi_011.ts HTTP/1.1" 200 489928 "-" "AppleCoreMedia/1.0.0.11D257 (iPhone; U; CPU OS 7_1_2 like Mac OS X; ru_ru)"
192.168.1.101 - - [26/Sep/2014:15:46:12 +0300] "GET /out_64_015.ts HTTP/1.1" 200 168448 "-" "AppleCoreMedia/1.0.0.11D257 (iPhone; U; CPU OS 7_1_2 like Mac OS X; ru_ru)"
192.168.1.101 - - [26/Sep/2014:15:46:12 +0300] "GET /out_hi_016.ts HTTP/1.1" 200 362025 "-" "AppleCoreMedia/1.0.0.11D257 (iPhone; U; CPU OS 7_1_2 like Mac OS X; ru_ru)"
192.168.1.101 - - [26/Sep/2014:15:46:12 +0300] "GET /out_64_016.ts HTTP/1.1" 200 168636 "-" "AppleCoreMedia/1.0.0.11D257 (iPhone; U; CPU OS 7_1_2 like Mac OS X; ru_ru)"
192.168.1.101 - - [26/Sep/2014:15:46:14 +0300] "GET /out_64_017.ts HTTP/1.1" 200 169012 "-" "AppleCoreMedia/1.0.0.11D257 (iPhone; U; CPU OS 7_1_2 like Mac OS X; ru_ru)"
192.168.1.101 - - [26/Sep/2014:15:46:14 +0300] "GET /out_64_015.ts HTTP/1.1" 200 144109 "-" "AppleCoreMedia/1.0.0.11D257 (iPhone; U; CPU OS 7_1_2 like Mac OS X; ru_ru)"
192.168.1.101 - - [26/Sep/2014:15:46:16 +0300] "GET /out_medium_016.ts HTTP/1.1" 200 247032 "-" "AppleCoreMedia/1.0.0.11D257 (iPhone; U; CPU OS 7_1_2 like Mac OS X; ru_ru)"
192.168.1.101 - - [26/Sep/2014:15:46:16 +0300] "GET /out_medium_017.ts HTTP/1.1" 200 251356 "-" "AppleCoreMedia/1.0.0.11D257 (iPhone; U; CPU OS 7_1_2 like Mac OS X; ru_ru)"
192.168.1.101 - - [26/Sep/2014:15:46:16 +0300] "GET /out_medium_018.ts HTTP/1.1" 200 250416 "-" "AppleCoreMedia/1.0.0.11D257 (iPhone; U; CPU OS 7_1_2 like Mac OS X; ru_ru)"
192.168.1.101 - - [26/Sep/2014:15:46:17 +0300] "GET /out_medium_019.ts HTTP/1.1" 200 250980 "-" "AppleCoreMedia/1.0.0.11D257 (iPhone; U; CPU OS 7_1_2 like Mac OS X; ru_ru)"
192.168.1.101 - - [26/Sep/2014:15:46:18 +0300] "GET /out_hi_018.ts HTTP/1.1" 200 488800 "-" "AppleCoreMedia/1.0.0.11D257 (iPhone; U; CPU OS 7_1_2 like Mac OS X; ru_ru)"
192.168.1.101 - - [26/Sep/2014:15:46:23 +0300] "GET /out_hi_019.ts HTTP/1.1" 200 490868 "-" "AppleCoreMedia/1.0.0.11D257 (iPhone; U; CPU OS 7_1_2 like Mac OS X; ru_ru)"
192.168.1.101 - - [26/Sep/2014:15:46:23 +0300] "GET /out_hi_020.ts HTTP/1.1" 200 489364 "-" "AppleCoreMedia/1.0.0.11D257 (iPhone; U; CPU OS 7_1_2 like Mac OS X; ru_ru)"
192.168.1.101 - - [26/Sep/2014:15:46:24 +0300] "GET /out_hi_021.ts HTTP/1.1" 200 492936 "-" "AppleCoreMedia/1.0.0.11D257 (iPhone; U; CPU OS 7_1_2 like Mac OS X; ru_ru)"
192.168.1.101 - - [26/Sep/2014:15:46:25 +0300] "GET /out_hi_022.ts HTTP/1.1" 200 493124 "-" "AppleCoreMedia/1.0.0.11D257 (iPhone; U; CPU OS 7_1_2 like Mac OS X; ru_ru)"
192.168.1.101 - - [26/Sep/2014:15:46:33 +0300] "GET /out_hi_023.ts HTTP/1.1" 200 489740 "-" "AppleCoreMedia/1.0.0.11D257 (iPhone; U; CPU OS 7_1_2 like Mac OS X; ru_ru)"

Как вы можете заметить, первым получает master-плейлист Safari, затем передает управление проигрывателю. Проигрыватель скачивает master-плейлист, за ним скачивает плейлист в низком качестве, скачивает первый фрагмент, затем скачивает плейлист в среднем качестве, проигрывает несколько куском из него, понимает, что может лучше, качает в высоком качестве И так далее.

В итоге получилось конечно же лучше, чем было изначально. И оказалось, что в HLS никакой магии нет, как и в остальном.

Полезно(3)Бесполезно(0)
Комментарии закрыты.