Совсем недавно мы купили новый ноутбук на Windows человеку, который до сих пор пользовался Mac'ами. Одна из замеченных проблем - при просмотре видео звук в Bluetooth наушниках запаздывает по отношению к видео, причём заметно. Используя slow motion видео на телефоне, мы выяснили, что задержка составляет порядка 200мс. Это много, и это мешает воспринимать речь в видео.

200мс даже нет на шкале
200мс даже нет на шкале
Windows: 200ms, Mac: 0ms
Windows: 200ms, Mac: 0ms

В то же время, на маке нет задержки. Почему так? Давайте разбираться.

Причины задержки звука

Воспроизводимый звук всегда будет отставать от источника. Часть этого отставания вызвана вполне физическими процессами - информация о звуке (в виде электронных сигналов) и собственно звук (в виде колебаний среды) должны дойти до наших ушей. Например, наблюдаемые 200мс - это время, за которое звук преодолевает примерно 70 метров в воздухе. Иными словами, если бы мы сидели в 70 метрах от ноутбука, 200мс было бы вполне ожидаемой задержкой.

Однако, мы не сидим в 70 метрах. А свет распространяется настолько быстро, что временем на распространение электронных сигналов можно пренебречь. Тут на первый план выходит задержка на программную обработку сигнала и буферизацию. Bluetooth - беспроводная связь, подверженная шумам и требующая разных согласований и разделений между устройствами, поэтому данные нельзя передавать в реальном времени. Чтобы звук шёл плавно, нужно на устройстве иметь достаточно большой буфер, который (почти) никогда не заканчивается - если передача застряла по любой причине, устройство продолжит играть звук из буфера.

Это очень краткое и грубое объяснение, если хотите подробностей - см. здесь. Главное, что нам нужно знать - через Bluetooth задержка неизбежна. Это вам не проводные наушники (хотя и там есть небольшая задержка).

Есть aptX LL, который должен снижать задержку для интерактивных приложений, но ни наушники, ни ноут этого протокола не поддерживают. Значит, так или иначе у нас будет минимум ~130мс задержки.

А Mac?

Окей, но ведь мак выводит звук без задержки? На самом деле, это не так. Если провести эксперимент и, написав примитивный HTML, заставить мак вывести аудио неожиданно, мы увидим те же самые ~200мс задержки.

Задержка ~200ms
Задержка ~200ms

Когда воспроизводится видео (ютуб), мак задерживает видеопоток, чтобы синхронизировать картинку и звук. Это нормально, потому что если видео начнёт воспроизводиться на 200мс позже, вы этого не заметите. Из разных источников, которые сейчас я уже не найду, я выяснил, что мак спрашивает у наушников, через сколько времени они выведут звук, и выводит видео с такой же задержкой.

То есть, мак обманывает нас и тем самым устраняет видимую задержку, хотя физически задержка всё ещё на месте. Я хочу быть обманутым!

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

Цель: заставить хром под виндой выводить видео с задержкой, чтобы аудио и видео синхронизировались.

При этом хотелось бы, чтобы при обновлении хрома/винды всё не сломалось.

Мы не будем пытаться узнать у bluetooth устройства, какая у него задержка, потому что писать драйвера под винду у меня сертификата нет.

Chrome

Мак не может задерживать всё видео подряд, потому что тогда весь интерфейс будет на 200мс в прошлом. Значит, он сообщает в Chrome задержку, и тот уже задерживает видео.

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

Под виндой, как ни странно, тоже есть похожий код, который опирается на функцию IAudioClock::GetPosition. Вот только винда не сообщает реальную позицию на аудио-устройстве, а учитывает только время на формирование потока данных. Я потратил пару часов на анализ того, как GetPosition работает, и что означают записи в реестре по адресу HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\MMDevices\Audio\Render\{GUID}\Parameter

Что это?
Что это?

Коротко говоря, я так и не понял. Разбираться в кишках GetPosition и её взаимодействий с остальным стеком без исходников очень тяжело. Это COM, весь построенный на интерфейсах, и там чёрт ногу сломит. Обидно: если бы можно было изменить какое-нибудь значение в реестре и указать задержку, проблема была бы решена...

Но ладно, мы выяснили, что хром готов к задержке под виндой, вот только она о ней не сообщает. А давайте проверим, что будет, если бы сообщала? Качаем исходники хрома, настраиваем сборку, и патчим этот код вот так:

Собираем и запускаем по оф. инструкции. Прошло 6 часов сборки, хром запустился. И видео стало опережать аудио на ~200 мс! Значит, предположение верно, хром может делать задержку.

Мы теперь можем пропатчить хром и, скажем, добавить флаг в chrome://flags, чтобы задавать задержку. Но я могу себе представить, насколько такой флаг понравится хрому. И времени это займёт уйму, и шанс на успех не очень уж большой. Особенно если учесть аналогичный баг под линуксом.

Hack

COM можно перехватывать, если внедрить в Chrome свою DLL, и тогда можно будет управлять возвращаемым из GetPosition значением, чтобы подкручивать задержку так, как нам хочется. Это решение хорошо по нескольким причинам:

  • Не сломается с обновлением хрома.

  • Будет работать с любой программой, использующей такой же метод доступа к аудио.

  • Быстро реализуется.

Но есть одна большая проблема: мы собираемся внедрять DLL в Chrome. Chrome - это браузер, и он очень активно защищается от любого вида внедрений кода, по понятным причинам. Например, здесь описываются защиты, и они довольно серьёзные. Главное ограничение, которое для нас важно - можно динамически грузить только библиотеки, подписанные ключом Microsoft. Шансов, что MS подпишет нашу библиотеку, не то что бы совсем нет, но величина эта из разряда особо малых.

С другой стороны, есть гитхаб проекта, который внедряет DLL в хром! См. тут. Качаем проект, запускам-проверяем, и видим любопытное:

Список DLL chrome.exe
Список DLL chrome.exe

Значит, хром не так уж и защищается? Потираем руки и пишем свою DLL.

DLL

Если коротко, работу DLL можно описать так: построить IAudioClock как-нибудь, и пропатчить его vftable (таблицу виртуальных функций), что автоматически изменит все остальные IAudioClock.

Строим IAudioClock как-нибудь:

CoInitialize(NULL); // Поднимаем инфру COM
CoCreateInstance(
	CLSID_MMDeviceEnumerator, NULL,
	CLSCTX_ALL, IID_IMMDeviceEnumerator,
	(void**)&pEnumerator); // Получаем IMMDeviceEnumerator

pEnumerator->GetDefaultAudioEndpoint(
	eRender, eConsole, &pDevice); // Получаем audio endpoint

pDevice->Activate(
	IID_IAudioClient, CLSCTX_ALL,
	NULL, (void**)&pAudioClient); // Получаем аудио клиент

pAudioClient->GetMixFormat(&pwfx); // Нам нужен формат, получим

pAudioClient->Initialize(
	AUDCLNT_SHAREMODE_SHARED,
	0,
	hnsRequestedDuration,
	0,
	pwfx,
	NULL); // Инициализируем аудио-клиент

pAudioClient->GetService(IID_PPV_ARGS(&pClock)); // Получаем часы

InstallComInterfaceHooks(pClock, IID_IAudioClock); // И патчим их

Всё довольно просто и, в целом, следует гайду MS по получению аудио клиента (например, этому).

Замечательно, теперь давайте пропатчим полученный вышеpClock:

// Патчит один метод
HRESULT HookMethod(IUnknown * original, PVOID proxyMethod, PVOID * originalMethod, DWORD vtableOffset)
{
  // Получаем vftable
	PVOID* originalVtable = *(PVOID**)original;
  // Сохраняем оригинальный метод
	*originalMethod = originalVtable[vtableOffset];
  // Патчим!
	originalVtable[vtableOffset] = proxyMethod;
	return S_OK;
}

HRESULT InstallComInterfaceHooks(IUnknown * originalInterface, REFIID riid)
{
	HRESULT hr = S_OK;
  // Это часы?
	if (riid == IID_IAudioClock)
	{
		ATL::CComPtr<IAudioClock> so;
    // Получим интерфейс часов из IUnknown - проверка, что он есть
		HRESULT hr = originalInterface->QueryInterface(IID_IAudioClock, (void**)&so);
		if (FAILED(hr)) return hr; // Это что такое? Нам часы нужны.

	  // Снимаем защиту с памяти
		DWORD dwOld = 0;
		::VirtualProtect(*(PVOID**)(originalInterface), sizeof(LONG_PTR)*5, PAGE_READWRITE, &dwOld);
		
		DWORD tmp;
    // Патчим метод GetPosition
    // Его номер в таблице - 4
		HookMethod(so, (PVOID)Hook::GetPositionOriginal, &g_Context->m_OriginalGetPosition, 4);
    // Возвращаем защиту памяти как была
		::VirtualProtect(*(PVOID**)(originalInterface), sizeof(LONG_PTR)*5, dwOld, &tmp);
	}
	return hr;
}

Единственное, что неочевидно - откуда брать эту четвёрку, номер функции в vftable. Если посмотреть на IAudioClock(тут), видно, что GetPosition в нём под вторым номером ( на MSDN по алфавиту). При этом IAudioClock наследует от IUnknown, в котором три функции (QueryInterface, AddRef, Release). Значит, наша GetPosition - пятая, и имеет смещение 4.

DLL в первом приближении готова, и надо проверить её. Я написал примитивную программу, которая ничего особо не делает, только вызывает GetPosition, и внедрил DLL в неё. И всё отработало!

Injection

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

Внедрение происходит более-менее стандартным способом: выделяется участок памяти под путь к файлу с DLL, и создаётся поток с точкой входа на LoadLibrary. Поскольку эта функция принимает ровно один аргумент, его можно передать в CreateRemoteThread, и поток загрузит DLL. Из забавного только то, что kernel32.dll, в которой LoadLibrary находится, не подвержена ASLR, и поэтому её функции всегда имеют один и тот же адрес во всех процессах (одинаковой архитектуры). Поэтому можно найти LoadLibrary в своём процессе и вызвать по тому же адресу в другом.

Итак, я загрузил DLL, и... ничего не произошло. Библиотека внедрилась в хром, но GetPosition не вызывался!

Я приаттачился к chrome.exe дебаггером, и увидел, что GetPosition действительно не вызывается. Что за дела?

Оказывается, за звук в Chrome отвечает особая подсистема, называемая audio.mojom.AudioService. И это отдельный процесс хрома, в который инжектор не инжектит, из-за вот этой вот проверки. Если проверить в этом процессе дебаггером, видно, что GetPosition вызывается. Не беда, убираем проверку, запускаем, и... ничего! Библиотека не появляется в списке библиотек.

Вы знаете, как удобно отлаживать внедрение библиотеки? Учитывая, что это извне созданный поток, который выполняет одну единственную функцию LoadLibrary? И что эта функция возвращает bool, а ошибка получается через GetLastError, которая для каждого потока своя? Я отвечу - вообще неудобно! Пришлось приаттачиться к процессу chrome.exe, поставить точку останова в LoadLibrary, и после этого вызвать создание потока. Ошибка - STATUS_INVALID_IMAGE_HASH, и это означает, что включилась защита хрома. Эта ошибка возвращается, если библиотека не подписана Microsoft.

См. здесь, страница 29. Получается, хром не защищает корневой процесс, но защищает детей, включая аудио-подсистему.

Ой.

Но ведь мне дают создать поток в хроме и память читать-писать! И вообще, а как же отладчики это всё делают?

Если гора не идёт к Магомету... Давайте сделаем ручками.

Ручной импорт DLL

Что такое импорт DLL? Что такое делает LoadLibrary, чего мы не можем сделать вручную?

Для нормальной работы DLL нужны следующие действия по её загрузке:

  1. Загрузить DLL в память процесса.

  2. Настроить reloc'и.

  3. Настроить импорты.

  4. Передать управление в DllMain.

И всё это мы, в целом, можем сделать сами. Часть операций будет производить внешний процесс (выделение памяти, копирование, создание потока), а часть (настройка reloc'ов и импортов, вызов DllMain) - код загрузчика, который внешний процесс внедряет в chrome.exe.

Код загрузчика (loader'а) должен быть PIC, то есть, poisition-independent, и не должен вызывать никаких библиотечных функций. Тогда этот код будет выглядеть совершенно одинаково в обоих процессах.

Наша библиотека, в свою очередь, не должна ссылаться ни на какие библиотеки, которые не подписаны Microsoft. Но это не проблема - нам нужны две с половиной библиотеки, и они подписаны.

Код загрузчика - многабукав

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

// Структура с данными для лоадера 
typedef struct _MANUAL_INJECT
{
    PVOID ImageBase;
    PIMAGE_NT_HEADERS NtHeaders;
    PIMAGE_BASE_RELOCATION BaseRelocation;
    PIMAGE_IMPORT_DESCRIPTOR ImportDirectory;
    pLoadLibraryA fnLoadLibraryA;
    pGetProcAddress fnGetProcAddress;
} MANUAL_INJECT, * PMANUAL_INJECT;
// код лоадера, получает указатель на _MANUAL_INJECT
DWORD WINAPI LoadDll(PVOID p)
{
		// пропущены объявления

    ManualInject = (PMANUAL_INJECT)p;

    pIBR = ManualInject->BaseRelocation;
  
    // смещение загрузки модуля относительно ImageBase из файла
    delta = (ULONGLONG)((LPBYTE)ManualInject->ImageBase - ManualInject->NtHeaders->OptionalHeader.ImageBase); 

    // Reloc'и
    // Пока есть блоки...
    while (pIBR->VirtualAddress)
    {
        // Если есть reloc'и
        if (pIBR->SizeOfBlock >= sizeof(IMAGE_BASE_RELOCATION))
        {
            // Каждый reloc - это слово.
            count = (pIBR->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD);
            list = (PWORD)(pIBR + 1);

            for (i = 0; i < count; i++)
            {
                if (list[i])
                {
                    ptr = (PULONGLONG)((LPBYTE)ManualInject->ImageBase + (pIBR->VirtualAddress + (list[i] & 0xFFF)));
                    // Патчим память!
                    *ptr += delta;
                }
            }
        }
				// Следующий блок
        pIBR = (PIMAGE_BASE_RELOCATION)((LPBYTE)pIBR + pIBR->SizeOfBlock);
    }
  
		// Теперь разбираемся с импортами
    pIID = ManualInject->ImportDirectory;
    while (pIID->Characteristics)
    {
        OrigFirstThunk = (PIMAGE_THUNK_DATA)((LPBYTE)ManualInject->ImageBase + pIID->OriginalFirstThunk);
        FirstThunk = (PIMAGE_THUNK_DATA)((LPBYTE)ManualInject->ImageBase + pIID->FirstThunk);
      
				// Загрузим библиотеку. Тут важно, чтобы она была подписана MS!
        hModule = ManualInject->fnLoadLibraryA((LPCSTR)ManualInject->ImageBase + pIID->Name);

        while (OrigFirstThunk->u1.AddressOfData)
        {
            if (OrigFirstThunk->u1.Ordinal & IMAGE_ORDINAL_FLAG)
            {
                // Числовой импорт
                Function = (ULONGLONG)ManualInject->fnGetProcAddress(hModule, (LPCSTR)(OrigFirstThunk->u1.Ordinal & 0xFFFF));
              
								// Патчим!
                FirstThunk->u1.Function = Function;
            }

            else
            {
                // Импорт по имени
                pIBN = (PIMAGE_IMPORT_BY_NAME)((LPBYTE)ManualInject->ImageBase + OrigFirstThunk->u1.AddressOfData);
                Function = (ULONGLONG)ManualInject->fnGetProcAddress(hModule, (LPCSTR)pIBN->Name);

								// Патчим!
                FirstThunk->u1.Function = Function;
            }

            OrigFirstThunk++;
            FirstThunk++;
        }

        pIID++;
    }
  
		// Ищем DllMain
    if (ManualInject->NtHeaders->OptionalHeader.AddressOfEntryPoint)
    {
        EntryPoint = (PDLL_MAIN)((LPBYTE)ManualInject->ImageBase + ManualInject->NtHeaders->OptionalHeader.AddressOfEntryPoint);
      
        // И вызываем её!
        return EntryPoint((HMODULE)ManualInject->ImageBase, DLL_PROCESS_ATTACH, NULL); // Call the entry point
    }

    return TRUE;
}

// Маркер конца кода лоадера
DWORD WINAPI LoadDllEnd()
{
    return 0;
}

// Собственно injector
int Inject(DWORD pid, std::wstring dll)
{
  	// Нам нужны SE_DEBUG_NAME привилегии
    if (OpenProcessToken((HANDLE)-1, TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &hToken))
    {
        tp.PrivilegeCount = 1;
        tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;

        tp.Privileges[0].Luid.LowPart = 20;
        tp.Privileges[0].Luid.HighPart = 0;

        AdjustTokenPrivileges(hToken, FALSE, &tp, 0, NULL, NULL);
        CloseHandle(hToken);
    }
  
  	// Будем читать DLL
    hFile = CreateFile(dll.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL); // Open the DLL
    FileSize = GetFileSize(hFile, NULL);
  
  	// Местный буфер для DLL
    buffer = VirtualAlloc(NULL, FileSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);

    // Читаем DLL
    ReadFile(hFile, buffer, FileSize, &read, NULL);

    CloseHandle(hFile);

  	// Парсим DLL
    pIDH = (PIMAGE_DOS_HEADER)buffer;
    pINH = (PIMAGE_NT_HEADERS)((LPBYTE)buffer + pIDH->e_lfanew);

    printf("\nOpening target process.\n");
    hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);

  	// Теперь память в процессе под DLL
    image = VirtualAllocEx(hProcess, NULL, pINH->OptionalHeader.SizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); 

    // Скопируем хедеры
    WriteProcessMemory(hProcess, image, buffer, pINH->OptionalHeader.SizeOfHeaders, NULL);

    pISH = (PIMAGE_SECTION_HEADER)(pINH + 1);

    // Теперь саму DLL, посекционно
    for (i = 0; i < pINH->FileHeader.NumberOfSections; i++)
    {
        WriteProcessMemory(hProcess, (PVOID)((LPBYTE)image + pISH[i].VirtualAddress), (PVOID)((LPBYTE)buffer + pISH[i].PointerToRawData), pISH[i].SizeOfRawData, NULL);
        // Если это код, защитим, чтобы DEP не ругался
        if (pISH[i].Characteristics & IMAGE_SCN_CNT_CODE) {
            VirtualProtectEx(hProcess, (PVOID)((LPBYTE)image + pISH[i].VirtualAddress), ((pISH[i].SizeOfRawData - 1) | 4095 + 1), PAGE_EXECUTE_READ, NULL);
        }
    }
  
    // Теперь будем копировать loader
    mem = VirtualAllocEx(hProcess, NULL, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); // Allocate memory for the loader code
		
    // Заполним структуру для loader'а
    memset(&ManualInject, 0, sizeof(MANUAL_INJECT));

    ManualInject.ImageBase = image;
    ManualInject.NtHeaders = (PIMAGE_NT_HEADERS)((LPBYTE)image + pIDH->e_lfanew);
    ManualInject.BaseRelocation = (PIMAGE_BASE_RELOCATION)((LPBYTE)image + pINH->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress);
    ManualInject.ImportDirectory = (PIMAGE_IMPORT_DESCRIPTOR)((LPBYTE)image + pINH->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress);
    ManualInject.fnLoadLibraryA = LoadLibraryA;
    ManualInject.fnGetProcAddress = GetProcAddress;

		// Копируем loader и параметры
    WriteProcessMemory(hProcess, mem, &ManualInject, sizeof(MANUAL_INJECT), NULL); // Write the loader information to target process
    WriteProcessMemory(hProcess, (PVOID)((PMANUAL_INJECT)mem + 1), LoadDll, (ULONGLONG)LoadDllEnd - (ULONGLONG)LoadDll, NULL); // Write the loader code to target process

    // Снова успокоим DEP
  	VirtualProtectEx(hProcess, (PVOID)((PMANUAL_INJECT)mem), (ULONGLONG)LoadDllEnd - (ULONGLONG)LoadDll + sizeof(MANUAL_INJECT), PAGE_EXECUTE_READ, NULL);
  
		// Вызовем loader - поехали!
    hThread = CreateRemoteThread(hProcess, NULL, 0, (LPTHREAD_START_ROUTINE)((PMANUAL_INJECT)mem + 1), mem, 0, NULL); // Create a remote thread to execute the loader code

    // Ждём, пока поток завершится
    WaitForSingleObject(hThread, INFINITE);
    GetExitCodeThread(hThread, &ExitCode);

    CloseHandle(hThread);
    VirtualFreeEx(hProcess, mem, 0, MEM_RELEASE);

    CloseHandle(hProcess);

    VirtualFree(buffer, 0, MEM_RELEASE);
    return 0;
}

Если отладка загрузки DLL через LoadLibrary была болью, то отлаживать вот это вот - 10/10 боли. Ни один дебаггер не понимает, что этот код - DLL, и не маппит проанализированную DLL с сырцами/PDB на код в процессе. Поэтому отладка без анализа. Приходится параллельно сопоставлять отлаживаемый код и проанализрованный.

Отладка с помощью printf()

Альтернатива отладчику - логирование. И здесь бы оно очень подошло, потому что отладчик - боль. Проблема в том, что логирование - тоже боль. Процесс аудио подсистемы chrome не имеет прав на запись файлов (даже в Temp) и на создание ключей реестра - это из того, что я проверил.

Этот процесс точно имеет доступ к CoreAudio, но мне сходу не приходит в голову, как выводить логи при помощи аудио девайсов.

Изучая разные подсистемы, обнаружил, что мне дают создавать сокеты и подключаться к внешним ресурсам. Бинго, пишем логгер, который льёт данные в localhost:7777 и слушаем его через nc -kl 7777.

Но это не главное. Главное - это работает!

Поиск chrome.exe

Осталось всего ничего - находить процесс хрома и внедряться в него. Это сделано при помощи ETW логов, которые, в частности, сообщают о создании процесса. Для них есть библиотека krabs. И отвратительная документация.

    krabs::kernel_trace trace(L"My magic trace");
    krabs::kernel::process_provider provider;
    provider.add_on_event_callback([](const EVENT_RECORD& record, const krabs::trace_context& trace_context) {
        krabs::schema schema(record, trace_context.schema_locator);
        if (schema.event_opcode() == 1) { // процесс запускается
            krabs::parser parser(schema);
            DWORD pid = parser.parse<uint32_t>(L"ProcessId");
            auto cmdline = GetProcessCommandLine(pid);
          	// А chrome ли ты?
            if (cmdline.find(L"chrome") != std::wstring::npos) {
              	// А audio подсистема ли ты?
                if (cmdline.find(L"--utility-sub-type=audio.mojom.AudioService") != std::wstring::npos) {
                    std::wcout << "Found process " << pid << ", injecting...\n";
                    Inject(pid, L"ChromePatcherDll.dll");
                }
            }
        }
    });
    trace.enable(provider);
    trace.start();
Дополнительные главы

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

Это приводит к двум проблемам:

  1. Нужна программа, настраивающая задержки аудиоустройств

  2. Загруженная DLL должна знать, какому устройству принадлежит IAudioClock

Если первая проблема решается довольно просто (см. код здесь), то вторая оказалась крепким орешком. СамIAudioClock не предоставляет ровно никаких способов достать из негоIMMDevice (у которого есть GetId) или самid.

Есть два способа решения этой проблемы.

Отследить построение IAudioClock

Это один из возможных вариантов сопоставления IAudioClock -> IMMDevice. Можно просто перехватить все вызовы создания IMMDevice -> IAudioClient -> IAudioClock, и построить внутренние соответствия между ними.

Это, однако, приводит к ряду проблем. Во-первых, становится необходимо перехватить гораздо больше функций (IMMDevice::Activate, IAudioClient::GetService, Release для всех трёх), и приходится отслеживать создание и удаление объектов, чтобы память не потекла.

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

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

Недокументированные подходы

// магия, не трогать

Логика подсказывает, что если IAudioClock не будет знать, какому IAudioClient он принадлежит, он не сможет вычислять положение в потоке. Значит, где-то внутри IAudioClock содержится какая-то ссылка на IAudioClient.

Если раскопать отладчиком, видно, что IAudioClient действительно сохраняется в IAudioClock:

Аналогично, если посмотреть на IAudioClient, нам повезёт, и там сразу будет ссылка на Unicode строку с id девайса:

Окей, чтобы хоть немножко повысить переносимость, давайте не будем фиксировать смещения, а поищем их в динамике:

// Храним смещения (в единицах указателей)
struct Offsets {
  // Адрес клиента внутри часов
	int clockToClientPtr = -1;
  // Адрес строки с id внутри клиента
	int clientToIdPtr = -1;
};
Offsets offsets;
void FillOffsets(IMMDevice* device, IAudioClient* client, IAudioClock* clock) {
  // Ищем известный IAudioClient в IAudioClock
	for (int i = 0; i < 100; ++i) {
		if (*(((void**)clock) + i) == client) {
			offsets.clockToClientPtr = i;
			break;
		}
	}
	LPWSTR id;
	device->GetId(&id);
	auto len = lstrlenW(id);
  // Ищем известный id в IAudioClient
	for (int i = 0; i < 100; ++i) {
		LPWSTR ptr = *((LPWSTR*)client + i);
		if (IsBadReadPtr(ptr, len * 2 + 2)) { // Если в вашем коде есть IsBadReadPtr, что-то не так
			continue;
		}
		if (memcmp(id, ptr, len * 2) == 0) {
			offsets.clientToIdPtr = i;
			break;
		}
	}
	CoTaskMemFree(id);
}

И дальше что-то такое:

if (offsets.clientToIdPtr != -1 && offsets.clockToClientPtr != -1) {
  if (!IsBadReadPtr(*((void**)This + offsets.clockToClientPtr), offsets.clientToIdPtr * sizeof(void*))) {
    void* clientPtr = *((void**)This + offsets.clockToClientPtr);
    if (!IsBadReadPtr(*((void**)clientPtr + offsets.clientToIdPtr), 20)) {
      LPWSTR idPtr = *((LPWSTR*)clientPtr + offsets.clientToIdPtr);
      std::wstring idStr = idPtr;
      clockToDeviceMap[This] = idStr;
      myDeviceStr = &clockToDeviceMap[This];
    }
  }
}

Ужас, конечно, но работает.

Итог

В итоге я использовал оба подхода - если успели перехватить, записываем в словарь. Если не успели, используем подход с поиском клиента и id по смещениям.

Код разбросан по этому файлу.

Результаты

После патчинга видео и аудио синхронизировались идеально:

Так была исправлена неприятная проблема, и речь в Bluetooth наушниках теперь воспринимается отлично.

Работоспособность проверена только под последними версиями Win10 и Win11, на последнем же Chrome.

Links

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


  1. Evengard
    20.05.2022 20:17

    Кхм, интересно, а через ETW можно отслеживать создание любого процесса?


    1. wataru
      20.05.2022 20:18

      Через ETW можно отслеживать вообще все. Включая полную трассировку любого процесса и команды к GPU.


      1. Evengard
        20.05.2022 23:25

        Честно, не знал. Думал в своё время о том как внедряться в (почти) любые процессы запущенные на компьютере, размышлял в сторону AppInit_Dlls и SetWindowsHookEx... Можно было просто слушать все новые процессы по ETW.


  1. wataru
    20.05.2022 20:18

    Мое уважение! Высший пилотаж какой-то.


    Забавно, я и не думал, что оказывается в винде можно запустить поток в другом процессе! Только интересно — зачем это может быть нужно, кроме патчинга костылей и вирусни.


    А не думали патчить сам исполняемывй файл хрома? Казалось бы захардкодить 200мс в том коде вообще просто. Или там какая-то защита это все не дает делать? Или нужна гибкость? Возможно ли, например, перехвотить CreateProcess, и если создается audio service то патчить его на лету?


    1. gurux13 Автор
      20.05.2022 20:55

      Спасибо :)

      Создавать поток в другом процессе нужно, как минимум, для отладки этого процесса. Внедрение DLL, опять же, это сравнительно стандартный подход (через поток на LoadLibrary), им пользуются всякие hotkey'и, антивири и т.д.

      Если пропатчить chrome.dll (именно там код), то с апдейтом хрома всё сломается. Плюс подпись потратится. Про перехват CreateProcess - читайте доп. главы :)
      Ну и да, себе хром себя патчить тоже не даст: не важно, какой процесс патчит.


  1. Krasnoarmeec
    20.05.2022 20:19
    +3

    Слов нет, снимаю шляпу! Измерения это всегда сложно, а измерения синхронности это вообще на грани возможного (моё мнение).


  1. Anastasia_K
    20.05.2022 22:21
    +2

    Вы проделали огромную работу, и не хочется Вас расстраивать. Но когда я столкнулась с аналогичной проблемой, было установлено дополнение для ютуба и проблема ушла. что то вроде этого: https://chrome.google.com/webstore/detail/youtube-audiovideo-sync/mknmhikmjljhpccebpnplhicmcfjkgbk


    1. windoozatnick
      20.05.2022 22:35
      +8

      для самурая путь важнее цели


    1. gurux13 Автор
      20.05.2022 23:09
      +4

      Ссылка на расширение у меня фиолетовая, поэтому я не расстроился :) Это расширение не работает нормально (по крайней мере, у нас). То звука нет, то видео не воспроизводится. Плюс, оно исключительно для ютуба. И плюс, оно задерживает постоянно, а не в зависимости от аудиоустройства.


    1. ReRayne
      20.05.2022 23:14
      +1

      Ну вы установите его и посмотрите как оно хорошо и стабильно работает :)


  1. VoffkaDub
    20.05.2022 23:07
    +2

    я на маке тоже такое замечаю периодически, что на хроме, что на сафари, явление не регулярное


  1. NewDevLab
    21.05.2022 09:16

    Почему для дебага не использовать OutputDebugString?


    1. gurux13 Автор
      21.05.2022 15:24

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


      1. NewDevLab
        21.05.2022 16:31

        1. gurux13 Автор
          21.05.2022 23:35

          Да, должно сработать. Если честно, я про этот способ отладки забыл. Нашёл какой-нибудь способ и успокоился.


  1. Kazzman
    21.05.2022 19:25

    Отличная статья и исследование! Автор, может вы сможете что подсказать? После обновления до 11 винды у меня перестали работать bt уши. Причем на двух разных машинках с интеловским bt. Выглядит это так - после подключения ушей звук есть секунды две-три, потом в том же хроме видно подергивание видео (если ютуб) и всё, до переподключения звука нет. Единственный способ продлить вывод звука - это скайп. Запускаем в скайпе тестирование звука в течение трех секунд после подключения ушей. Они начинают пиликать. Всё, можно запускать ютуб и прочее, а скайп теперь можно останавливать. Но работает только до остановки воспроизведения. После паузы по новой. Равно и в скайпе - после начала звонка подключаем уши, и они работают до окончания звонка. Следующий звонок через переподключение.


    1. Zoim
      21.05.2022 20:49
      +1

      Это связано с тем, что у блютус наушников обычно два профиля аудио: hands free (телефонный, плохое качество звука + работает микрофон) и stereo (хорошее качество звука, микрофон не поддерживается). Скайп принудительно включает запрос микрофона, драйвер переключает наушники в режим hands free, и далее ютуб постоянным потоком данных на аудиоканал не даёт драйверу переключиться обратно. Вердикт - у вас не работает поддержка профиля stereo, попробуйте подобрать другой драйвер для своей блютус-платы (если это ноутбук - вероятно это wifi драйвер, они зачастую идут вместе т.к. чип общий).

      Я сам использую macbook с буткампом под win10, там из трёх драйверов для wifi только один хорошо работает (самый старый, тот что apple предоставила). А всё что скачивает винда самостоятельно - тупо убивает и wifi, и bluetooth.


      1. Kazzman
        23.05.2022 15:23

        Из-за высокоинтелектуальной системы кармы я могу только раз в день отвечать.

        Вероятно вы правы. Только в новых профилях handsfree качество звука уже не уровне и стерео, так что не поймешь режим. Но, да, пока скайп держит микрофон, всё работает. Как только отпускает, так отрубается и микрофон и звук. Я думал вдруг кто подскажет как это поотлаживать. Поставил свежайшие драйвера интела, у меня ax201 карта, не помогло.