Трек на 46 минут долго молчал: отдаём перекодированный звук, пока ffmpeg его ещё пишет - QubStore

Трек на 46 минут долго молчал: отдаём перекодированный звук, пока ffmpeg его ещё пишет

Содержание статьи

Я делаю Nami: музыкальный плеер со своим сервером на Rust. Библиотека лежит дома или на VPS, телефон и компьютер слушают её по сети.

Трек с сервера на телефоне очень долго не начинал играть. Я переключил качество потока на самое экономное, Opus 96 кбит/с, и стало не лучше. Трек длился 46 минут. Даже на моём ноутбуке с i7-11800H первый байт приходил через 19,3 секунды.

Экономное качество тут ухудшало ситуацию. Ниже о том, почему, и как я это починил, сохранив кеш перекодированных файлов.

Как было

Сервер отдаёт трек как есть или перекодирует его под слабую сеть. Профили простые:

Profile { name: «opus96», codec: «libopus», bitrate_kbps: 96, ext: «opus», mime: «audio/ogg» }, Profile { name: «opus128», codec: «libopus», bitrate_kbps: 128, ext: «opus», mime: «audio/ogg» }, Profile { name: «aac192», codec: «aac», bitrate_kbps: 192, ext: «m4a», mime: «audio/mp4» },

Запрос GET /api/tracks/{id}/stream?quality=opus96 обрабатывался так:

  1. Сервер считает имя файла в кеше: id трека, размер и время изменения исходника, профиль.

  2. Если такой файл есть, сервер отдаёт его с Content-Length и поддержкой Range.

  3. Если нет, запускает ffmpeg во временный файл, ждёт, пока тот закончит, переименовывает файл в кеш и только тогда отдаёт.

let ready = tokio::task::spawn_blocking(move || { transcode::ensure(&cache_dir, &src, id, size, mtime, p, range) }).await;

На четырёхминутном треке ожидание было 1,7 секунды, это терпимо. Но оно растёт с длиной трека. ffmpeg перекодирует FLAC в Opus примерно в 140 раз быстрее реального времени, и 46 минут звука всё равно занимают 19 секунд. Всё это время телефон показывал крутилку. Буфер ExoPlayer тут не помогает, потому что первый байт ещё не пришёл.

Почему экономный режим хуже оригинала? Оригинал сервер читает с диска и отдаёт сразу. Перекодирование нужно только в экономном режиме, и только в нём пользователь ждёт ffmpeg. Человек снижает битрейт, чтобы грузилось быстрее, и получает обратное.

Что хотелось получить

  • Первый байт через доли секунды при любой длине трека.

  • Кеш остаётся: при повторном прослушивании трек отдаётся готовым файлом, с Range и перемоткой.

  • Если телефон запросил тот же трек ещё раз (ExoPlayer так делает, например, при перемотке), второй ffmpeg не запускается.

  • Если ffmpeg упал, в кеше не остаётся битого файла.

Обычный способ — пайп: ffmpeg … -f ogg pipe:1, stdout сразу в тело ответа. Тогда пропадают кеш и общий прогон на два запроса. Чтобы их вернуть, пришлось бы делить поток на две копии, в сокет и в файл, и решать, что делать, если клиент отключился на середине: убивать ffmpeg и терять работу или дописывать файл без слушателя.

Я сделал проще: ffmpeg пишет в файл как раньше, а HTTP-ответ читает этот файл, пока тот растёт. По сути это tail -f, отданный по HTTP.

Было: телефон молчит, пока ffmpeg пишет весь файл. Стало: телефон читает файл вслед за ffmpeg

Ждать ffmpeg читателю почти не приходится: тот пишет примерно в 140 раз быстрее, чем телефон проигрывает, и буфер плеера заполняется за доли секунды.

Живой файл

Запущенное перекодирование описывается двумя полями: путь к растущему файлу и флаг «ffmpeg закончил».

#[derive(Clone)] pub struct Live { pub part: PathBuf, pub done: Arc<AtomicBool>, } static LIVE: Mutex<Option<HashMap<String, Live>>> = Mutex::new(None);

Глобальная таблица по имени кеш-файла нужна, чтобы второй запрос того же трека получил уже идущий Live и не запускал новый ffmpeg:

let mut live = LIVE.lock().unwrap(); let map = live.get_or_insert_with(Default::default); if let Some(l) = map.get(&name) { return Ok(l.clone()); } // … запускаем ffmpeg в `{name}.live.opus` map.insert(name.clone(), l.clone());

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

Ожидание ffmpeg вынесено в отдельный поток. Когда процесс закончил, поток ставит флаг, переименовывает .live в кеш-файл и убирает запись из таблицы:

std::thread::spawn(move || { let ok = child.wait().is_ok_and(|s| s.success()); done.store(true, Ordering::Release); if ok { for _ in 0..600 { if std::fs::rename(&part, &out).is_ok() { break; } std::thread::sleep(Duration::from_millis(500)); } let _ = evict(&dir, limit_mb); } else { std::thread::sleep(Duration::from_secs(5)); let _ = std::fs::remove_file(&part); } LIVE.lock().unwrap().as_mut().map(|m| m.remove(&name)); });

Повторы rename нужны для Windows: сервер работает и там, как служба. Windows не переименует файл, если его держит открытым процесс, который не разрешил удаление (антивирус, индексатор). Поэтому сервер пробует раз в полсекунды, до пяти минут. На Linux переименование проходит с первой попытки, а уже открытые дескрипторы продолжают читать тот же inode.

Если ffmpeg упал, файл удаляется через 5 секунд. За это время читатель успевает дочитать то, что есть, и увидеть флаг done.

Отдаём растущий файл

Ответ — поток кусков по 64 КБ. Чтение устроено так:

  • прочитали байты — отдаём;

  • прочитали 0 байт, ffmpeg ещё работает — ждём 100 мс и читаем снова;

  • прочитали 0 байт, ffmpeg закончил — читаем ещё один раз и завершаем поток.

let stream = futures_util::stream::unfold((file, live.done), |(mut f, done)| async move { let mut buf = vec![0u8; 64 * 1024]; loop { match f.read(&mut buf).await { Ok(0) if done.load(Ordering::Acquire) => { // One more read: the last bytes may have landed after the check above. return match f.read(&mut buf).await { Ok(n) if n > 0 => { buf.truncate(n); Some((Ok(Bytes::from(buf)), (f, done))) } _ => None, }; } Ok(0) => tokio::time::sleep(Duration::from_millis(100)).await, Ok(n) => { buf.truncate(n); return Some((Ok(Bytes::from(buf)), (f, done))); } Err(e) => return Some((Err(e), (f, done))), } } }); let mut resp = Body::from_stream(stream).into_response();

Дополнительное чтение закрывает гонку. read вернул 0, затем ffmpeg дописал последний кусок и вышел, флаг стал true, и только после этого мы проверили флаг. Без второго чтения последний кусок трека не дошёл бы до клиента.

ffmpeg создаёт выходной файл не сразу после запуска. Поэтому перед стримом сервер пытается открыть файл раз в 50 мс, до 5 секунд, и выходит раньше, если ffmpeg уже упал.

Контейнер должен играть с первых байтов

Ogg потоковый по устройству, поэтому Opus в Ogg играет с первых байтов без настроек. С AAC в M4A сложнее: по умолчанию ffmpeg пишет индекс (moov) в конец файла, а без индекса плеер не может начать. Помогает фрагментированный MP4:

if p.ext == «m4a» { cmd.args([«-movflags», «frag_keyframe+empty_moov»]); }

moov с описанием дорожек, но без таблицы сэмплов, идёт в начало файла, дальше фрагменты moof + mdat. ExoPlayer и браузер играют такой файл с первого фрагмента.

Чем пришлось заплатить

Пока файл растёт, его итоговая длина неизвестна. Ответ идёт без Content-Length и без Range. Для ExoPlayer такой Ogg-поток неперематываемый: перемотка в нём возвращает трек в начало. Это касается только первого прослушивания. Когда ffmpeg закончил и файл переехал в кеш, следующий запрос идёт по старому пути: готовый файл, Range, мгновенная перемотка.

Что вышло

Было

Стало

Первый байт, трек 4 мин

1,67 с

0,06 с

Первый байт, трек 46 мин

19,3 с

0,07 с

Весь ответ, трек 46 мин

19,4 с

19,3 с

Размер ответа, трек 46 мин

32,5 МБ

32,5 МБ

Два запроса одного трека

два ffmpeg

один

Повторное прослушивание

из кеша

из кеша

Замерял curl на том же ноутбуке. Сервер собран в release, кеш перед каждым прогоном пустой. Треки — FLAC 44,1 кГц / 16 бит, склеенные из обычного альбома. По два прогона на каждую версию, разброс меньше 0,1 с. Общее время и размер ответа остались прежними: ffmpeg делает ту же работу, а телефон начинает играть раньше.

На сервере изменилось около 150 строк, клиенты я не трогал. Телефону и компьютеру обновляться не нужно, они получают тот же HTTP-ответ.

Если ваш сервер перекодирует трек в файл и отдаёт его только целиком, длинные треки у вас стартуют так же медленно. Чтобы проверить, включите перекодирование и засеките время до первого байта на треке длиной в час.

Что можно доделать

Перемотка в первом прослушивании. Пока ffmpeg пишет файл, перемотка возвращает трек в начало. Можно отдавать клиенту длительность заранее или на перемотку запускать второй ffmpeg с -ss от нужного места. На Opus 96 кбит/с сорокашестиминутный трек дописывается за 19 секунд, поэтому я пока оставил как есть.

Лишняя работа ffmpeg. Если пользователь переключил трек через секунду, ffmpeg всё равно дописывает файл до конца. Для кеша это полезно, но на слабом VPS занимает процессор. Перекодирование можно останавливать, если его никто не читает и файл не дописан хотя бы наполовину.

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

Попробовать

  • Сайт: nami-music.ru

  • Android (8.0 и новее): скачать APK

  • Windows и Linux: скачать для компьютера

  • Сервер: инструкция по установке, ставится одной командой

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

Ссылка на основную публикацию