Пишем VoIP iOS чат на CORE AUDIO для конкурса VK Mobile Challenge

в 12:56, , рубрики: core audio, iOS, objective-c, storyboard, xcode, Вконтакте API, конкурс, мобильные приложения, Работа со звуком, разработка мобильных приложений, разработка под iOS, эхоподавление

Недавно, команда ВК объявила конкурс на разработку мобильного приложения, которое бы расширяло возможности соцсети "ВКонтакте", и я решил принять участие, так как по условиям конкурса можно придумать свою идею приложения. У меня было три идеи, и нужно было выбрать, за какую из них взяться.

image


Уважаемые читатели «Хабрахабра», все ошибки и правки по данной статье присылайте, пожалуйста, в личные сообщения.

Идея 1

Мне очень нравятся групповые чаты в ВК, очень жаль, что нельзя в этих чатах общаться голосом. Такие групповые публичные аудио чаты могут помочь геймерам найти друзей для онлайн iOS игр. Например, я создаю аудиочат с названием «Asphalt 8» — и все, кто хочет сыграть со мной — присоединяются к моей аудиотусовке в приложении, и мы играем вместе, общаясь голосом. Аналогичные «аудиотусовки» есть на консоли PlayStation 4 — и я даже знаю людей, которые включают PS4 не для игр, а только для общения с друзьями, которые сидят в этих аудиотусовках. Зачем делать отдельное приложение, если можно созвониться в Viber или Whatsapp и играть в игры, общаясь в этих приложениях? А вот и нельзя, попробуйте созвониться в Viber и запустить, например, игру Deepworld – звонок в Viber тут же слетит, так как поток из Viber прерверся аудиопотоком из игры. В Skype ситуация для геймеров лучше, аудиосессия Skype останется активной, даже если включить музыку, музыка лишь немного «приглушиться». Но в наши дни считается плохим тоном звонить кому-то без предупреждения, и вдруг я позвоню другу, а он не хочет сейчас играть в игру, которую я предложу? Выход такой – создаем аудиотусовку, и все друзья получат уведомление: «Ваш друг Иван Иванов создал тусовку Hearthstone». Те из друзей, кто захочет присоединиться – жмут на уведомление и выходят на голосовую связь! Один клик на уведомление – и вы в тусовке, больше не нужно обзванивать друзей.

Идея 2

ВКонтакте есть раздел документы, так почему-бы не сделать аналог Dropbox для ВК? Да, придется сделать Windows/Mac клиент, помимо мобильного, на ПК пользователя будет создана папка, все файлы из которой будут синхронизироваться с документами ВКонтакте, и папкой на мобильном устройсте. Получается некий аналог Dropbox с backend ВКонтакте.

Идея 3

Существует «Теория шести рукопожатий» — теория, согласно которой любые два человека на Земле разделены не более чем пятью уровнями общих знакомых (и, соответственно, шестью уровнями связей). Так почему бы не сделать приложение, в котором можно узнать, сколько человек меня разделяют в VK, например, с Павлом Дуровым? То есть мы вписываем двух пользователей в окно мобильного приложения — и получаем цепочку друзей, через которых мы можем выйти на контакт с нужным нам человеком. Для реализации идеи придется скачать все профили пользователей ВКонтакте, перебирая их по ID.

Core Audio

Внимание! Core Audio не зря славится своей сложностью! Попытки загуглить проблемы на stackoverflow.com часто приводят к вопросам, на которые на данном портале никто так и не ответил! Платная поддержка Apple тоже разводит руками! Подводные камни всплывают на каждом шаге разработки!

Выбор пал на первую идею, так как она мне показалась более сложной в реализации, и чтобы усложнить процесс, я решил сделать реализацию на Core Audio, по которой практически отсутствует документация, так что придется экспериментировать. ВКонтакте давно бы уже пора добавить аудиозвонки, ведь даже у Facebook в мобильном клиенте есть возможность позвонить голосом! Чем ВК хуже? Команда ВК уже пыталась запустить видеозвонки в web клиенте, сделала alfa версию, но на этом все и закончилось. Я считаю, что нужно добавлять возможность позвонить в мобильный клиент ВК в обязательном порядке! И в рамках этой статьи я постараюсь рассказать, как это нужно сделать.

Что я вообще знаю о звуке? Как передается звук по сети? С видео все проще, каждый пиксель можно закодировать в RGB и передавать изменения матрицы пикселей в массиве. Но что из себя представляет «слепок звука» за единицу времени? А представляет он собой вот такой массив из чисел типа Float:

image

Причем, если мы сложим (Float 1) + (Float 2) + (Float 3) +… + (Float (n)) и разделим сумму на количество элементов (n) — то мы получим громкость данного слепка!

Чтобы увеличить уроверь звука в два раза, мы должны всего лишь умножить все элементы этого массива на 2:

(Float 1)*2 + (Float 2)*2 + (Float 3)*2 +… + (Float (n))*2

Но что делать, если в нашем случае звук приходит от нескольких пользователей, как нам «склеить» два аудио потока? Ответ прост — нужно просто сложить попарно элементы этих двух массивов.

В Mac OS X в обоих форматах kAudioFormatFlagsCanonical и kAudioFormatFlagsAudioUnitCanonical один элемент массива представляет из себя Float с плавающей точкой, но вычисления с плавающей точкой обходились слишком дорого для кристаллов с процессорами ARM, поэтому в iOS отсчеты в формате kAudioFormatFlagsCanonical представлены целыми со знаком, а в формате kAudioFormatFlagsAudioUnitCanonical — целыми числами с фиксированной точкой. «8.24». Это означает, что слева от десятичной точки находятся 8 бит (целая часть), а справа — 24 бита (дробная часть).

Выбираем название приложения и иконку:

У меня в голове было два названия, первое из них — «Tusa», второе «Wassap». Приложение представляет из себя групповой аудиочат, так что было бы здорово, если бы участники здоровались при входе фразой «Wassaaaap!», но из-за схожести названия с «WhatsApp» я выбрал название «Tusa». В качестве иконки я выбрал сначала микрофон, но потом заменил его на камушек:
image

Как работает приложение «Tusa»

image

  • Для начала пользователь попадает на стартовый экран, где ему предлагается авторизоваться с помощью VK кнопки. На этом этапе приложение получает информацию о пользователе и список друзей (только публичная информация).
  • Затем приложение отправляет информация о пользователе и список друзей на PHP сервер, PHP сервер в свою очередь возвращает список аудио чатов друзей пользователя, причем каждой «тусовке» присвоен IP и порт Python сервера, на котором и происходит обмен звуком.
  • Пользователь выбирает «аудио тусовку», и приложение коннектится на нужный Python сервер, либо пользовать выбирает «создать новую тусовку», и уже другие пользователь в дальнейшем заходят в этот чат.

Зачем вообще использовать PHP сервер? Почему нельзя получить список чатов на том же Python сервере? Сделал я PHP сервер для того, чтобы была возможность распараллелить «аудио тусовки» по разным Python серверам, и если интернет канал на одном Python сервере заполниться, то PHP сервер будет создавать аудио комнаты на другом Python сервере с отдельным IP адресом. Так же PHP часть будет ответственна за рассылку IN-APP уведомлений.

Небольшой эксперимент — предыстория

Перед тем, как ознакомиться с Core Audio, я решил провести небольшой эксперимент со своими возможностями. Я представил такую ситуацию — мой самолет потерпел авиакатастрофу, и я с другими пассажирами оказался на необитаемом острове с Macbook, роутером, XCODE из коробки и дюжиной iOS девайсов, заряжающихся от солнечных батарей. Никакой документации по Core Audio у меня бы не было, а так как на тот момент я вообще не знал, как цифруется звук, смог бы я в этих условиях написать аудиочат? Все что я знал на тот момент — это как записывать .wav (.caf) файлы и их же воспроизводить. Недавно я разработал iOS риалтайм мультиплеерную игру «танчики с денди», где на одной карте играют до 100 танчиков вместе. Я решил в несколько строк кода превратить игру в аудиочат, записывая в цикле звук в файл, потом пересылая этот файл другим пользователям, и у пользователей создавать playlist из этих файлов! Это полный идиотизм — слать файлы со звуком, и я провел этот экперимент только благодаря моему уже существующему сетевому движку, хотелось узнать показатели задержки в этом случае и проверить работу моего сетевого кода в условиях пересылки большого количества данных, но в результате, помимо выявленных багов сетевого кода, я получил интересные подробности работы стардатного аудиоплеера в iOS, которые возможно пригодятся и читателям.

Как проиграть звук в iOS? С помощью AVAudioPlayer

AVAudioPlayer *avPlayer = [[AVAudioPlayer alloc] initWithContentsOfURL:
    [NSURL fileURLWithPath:@"имя_файла.caf"] error:nil];
[avPlayer play];

Звук от других пользователей у нас приходит в формате NSData и добавляется в массив плейлиста, так что с помощью AVAudioPlayer можно проигрывать не файл из папки, а звук из NSData напрямую:

AVAudioPlayer *avPlayer = [[AVAudioPlayer alloc] initWithData:
    data fileTypeHint:AVFileTypeCoreAudioFormat error:nil];
[avPlayer play];
Как узнать, что AVAudioPlayer закончил воспроизведение? Через callback audioPlayerDidFinishPlaying:

- (void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player 
    successfully:(BOOL)flag
{
    // AVAudioPlayer завершил проигрывание, 
    // берем следующий звук из плейлиста
}

Я запустил этот вариант на iPhone и iPad — но вот разочарование, звук воспроизводился с прерываниями. Дело в том, что инициализация AVAudioPlayer занимает до 100 миллисекунд, отсюда и лаги со звуком.

Решением оказался AVQueuePlayer, который специально был сделал для воспроизведения плейлистов без задержки между треками, инициализируем AVQueuePlayer:

AVQueuePlayer avPlayer = [[AVQueuePlayer alloc] initWithItems:
    playerItems];
avPlayer.volume = 1;
[avPlayer play];

Чтобы добавить файл в плейлист, используем AVPlayerItem:

NSURL *url = [NSURL fileURLWithPath:pathForResource];
AVPlayerItem *item = [AVPlayerItem playerItemWithURL:url];
NSArray *playerItems = [NSArray arrayWithObjects:item, nil];
[avPlayer insertItem:item afterItem:nil];

Запустив этот вариант я услышал четкий звук между моими устройствами, задержка была около 250 миллисекунд, так как файлы более короткого размера записать не получалось, вылетала ошибка. Ну и конечно, данный вариант был прожорливый до траффика, ведь помимо нужных звуков, по сети несколько раз в секунду передавался .wav (.caf) файл, который содержал заголовок. Так же данный метод не работает в фоновом режиме, так в фоне iOS нельзя начать воспроизводить новые звуки. На этом закончим эксперимент и начнем программировать приложение.

Что мы знаем про Core Audio?

На сайте Apple есть пример записи звука в аудиофайл, используя Core Audio, скачать его можно на странице:

https://developer.apple.com/library/ios/samplecode/AVCaptureToAudioUnit/Introduction/Intro.html

После изучения данного исходника, мне стало понятно, что при записи звука много раз в секунду вызывается Callback

#pragma mark ======== AudioUnit recording callback =========
static OSStatus PushCurrentInputBufferIntoAudioUnit(void * inRefCon,
    AudioUnitRenderActionFlags * ioActionFlags,
    const AudioTimeStamp * inTimeStamp,
    UInt32 inBusNumber,
    UInt32 inNumberFrames,
    AudioBufferList * ioData)
{
    // AudioBufferList *ioData - это и есть наш звук 
    // за промежуток времени
    // Упакуем звук в NSData для отправки на удаленный сервер
    NSMutableData * soundData = [NSMutableData dataWithCapacity:0];
    for( int y=0; y<ioData->mNumberBuffers; y++ )
    {
        AudioBuffer audioBuff = ioData->mBuffers[y];
        // Вот он звук, в виде массива из Float
        Float32 *frame = (Float32*)audioBuff.mData;
        // Упаковываем звук в бинарные данные для пересылки
        [soundData appendBytes:&frame length:sizeof(float)];
    }
    return noErr;
}

Разобрав формат AudioBufferList, который содержал звук в виде списка цифр, я переконвертировал AudioBufferList в NSData, выстроив все цифры в цепочку по 4 байта — и через python сервер в цикле передал этот буффер на удаленное устройство. Но как воспроизвети AudioBufferList на удаленном девайсе? В официальных исходниках на сайте Apple я не нашел ответа, ответ саппорта Apple тоже не дал мне нужной информации. Но проведя достаточно времени по принципу «научного тыка», я понял, что для этой цели существует аналогичный callback, в который нужно подставлять AudioBufferList и он будет воспроизводиться на лету:

#pragma mark ======== AudioUnit playback callback =========
static OSStatus playbackCallback(void *inRefCon,
    AudioUnitRenderActionFlags *ioActionFlags,
     const AudioTimeStamp *inTimeStamp,
     UInt32 inBusNumber,
    UInt32 inNumberFrames,
    AudioBufferList *ioData) 
{
    // Заполняем *ioData нашим массивом из Floats, 
    // который мы получили с удаленного сервера
    return noErr;
}

Как активировать данные callbacks? Для начала переименуйте ваш .m файл проекта в .mm и импортируйте все нужные C++ библиотеки из проекта AVCaptureToAudioUnit. После этого создаем, настраиваем и запускаем наш аудиопоток с помощью данного кода:

    // Объявляем переменные
    OSStatus status;
    AudioComponentInstance audioUnit;

    // Настраиваем аудио компоненты
    AudioComponentDescription desc;
    desc.componentType = kAudioUnitType_Output;
    desc.componentSubType = kAudioUnitSubType_RemoteIO;
    desc.componentFlags = 0;
    desc.componentFlagsMask = 0;
    desc.componentManufacturer = kAudioUnitManufacturer_Apple;
    
    AudioComponent inputComponent = AudioComponentFindNext(NULL, &desc);
    status = AudioComponentInstanceNew(inputComponent, &audioUnit);
    
    // Активируем IO для записи звука
    UInt32 flag = 1;
    status = AudioUnitSetProperty(audioUnit,
        kAudioOutputUnitProperty_EnableIO,
        kAudioUnitScope_Input,
        1, // Input
        &flag,
        sizeof(flag));

    // Активируем IO для проигрывания звука
    status = AudioUnitSetProperty(audioUnit,
        kAudioOutputUnitProperty_EnableIO,
        kAudioUnitScope_Output,
        0, // Output
        &flag,
        sizeof(flag));
    
    AudioStreamBasicDescription audioFormat;
    
    // Описываем формат звука
    audioFormat.mSampleRate = 8000.00;
    audioFormat.mFormatID = kAudioFormatLinearPCM;
    audioFormat.mFormatFlags = kAudioFormatFlagIsSignedInteger |        
        kAudioFormatFlagIsPacked;
    audioFormat.mFramesPerPacket = 1;
    audioFormat.mChannelsPerFrame = 1;
    audioFormat.mBitsPerChannel = 16;
    audioFormat.mBytesPerPacket = 2;
    audioFormat.mBytesPerFrame = 2;
    
    // Apply format
    status = AudioUnitSetProperty(audioUnit,
        kAudioUnitProperty_StreamFormat,
        kAudioUnitScope_Output,
        1, // Input
        &audioFormat,
        sizeof(audioFormat));
    
    status = AudioUnitSetProperty(audioUnit,
        kAudioUnitProperty_StreamFormat,
        kAudioUnitScope_Input,
        0, // Output
        &audioFormat,
        sizeof(audioFormat));
    
    // Активируем Callback для записи звука
    AURenderCallbackStruct callbackStruct;
    callbackStruct.inputProc = recordingCallback;
    callbackStruct.inputProcRefCon = (__bridge void * _Nullable)(self);
    status = AudioUnitSetProperty(audioUnit,
        kAudioOutputUnitProperty_SetInputCallback,
        kAudioUnitScope_Global,
        1, // Input
        &callbackStruct,
        sizeof(callbackStruct));
    
    // Активируем Callback для воспроизведения звука
    callbackStruct.inputProc = playbackCallback;
    callbackStruct.inputProcRefCon = (__bridge void * _Nullable)(self);
    status = AudioUnitSetProperty(audioUnit,
        kAudioUnitProperty_SetRenderCallback,
        kAudioUnitScope_Global,
        0, // Output
        &callbackStruct,
        sizeof(callbackStruct));
    
    // Отключаем инициализацию буферов для записи
    flag = 0;
    status = AudioUnitSetProperty(audioUnit,
        kAudioUnitProperty_ShouldAllocateBuffer,
        kAudioUnitScope_Output, 
        1, // Input
        &flag, 
        sizeof(flag));
    // Инициализируем
    status = AudioUnitInitialize(audioUnit);
    // Запускаем
    status = AudioOutputUnitStart(audioUnit);

Кстати, в качестве эксперимента, я изучил формат caf файла, просидев тучу времени с HEX редактором и попробовал на удаленном девайсе взять AudioBufferList, добавить к нему побайтово header (заголовок) .caf файла, затем сохранить этот AudioBufferList в .caf файл, и воспроизвести с помощью AVQueuePlayer. И самое странное, что у меня это получилось!

Novocaine

Итак, мы уже разобрались с Core Audio, но как сделать процесс еще проще и нагляднее? И ответ есть, нужно использовать Novocaine!

https://github.com/alexbw/novocaine

Что представляет из себя Novocaine? Три года три кодера оформляли Core Audio в отдельный класс, и у них здорово получилось! Novocaine реализован на C++, так что для подключения C++ класса с нашему Objective C файлу, нужно переименовать его из .m в .mm — и все import производить в начале .mm файла.

Как считать аудио в буффер?

Novocaine *audioManager = [Novocaine audioManager];
[audioManager setInputBlock:^(float *newAudio, UInt32 numSamples, UInt32 numChannels) {
    // Здесь мы получаем аудио с микрофона примерно каждые 20 миллисекунд
    // Если numChannels = 2, значит newAudio[0] это канал 1,
    // newAudio[1] - канал 2, newAudio[2] - канал 1 и т.д.
}];
[audioManager play];
Как воспроизвести буффер?

Novocaine *audioManager = [Novocaine audioManager];
[audioManager setOutputBlock:^(float *audioToPlay, 
    UInt32 numSamples, 
    UInt32 numChannels) 
    {
        // Все, что нужно - это поместить здесь 
        // массив с float звуками в audioToPlay
    }];
[audioManager play]; 

Вот так просто!

Пробуем собрать все это на iPhone и iPad, запускаем аудиозвонок — и… Эхо! Писк! Убийственное эхо проходит многократно через канал связи и врезается писком в мозг! Я рассчитывал, что пользователи будут общаться в том числе и без гарнитуры на громкой связи, но звук шел от меня на удаленное устройство, из динамика удаленного девайса попадал в микрофон и возвращался ко мне. Неприятно. Как реализовать эхоподавление в iOS, используя Core Audio?

Нужно использовать параметр kAudioUnitSubType_VoiceProcessingIO для аудиопотока, вместо стандартного kAudioUnitSubType_RemoteIO. Открываем файл Novocaine.m, находим строку:

inputDescription.componentSubType = kAudioUnitSubType_RemoteIO;

заменяем на:

inputDescription.componentSubType = kAudioUnitSubType_VoiceProcessingIO;

Пробуем собрать и видим ошибку. Дело в том, что по умолчанию наш аудиопоток работал на частоте 44100.0 hz, а я для работы kAudioUnitSubType_VoiceProcessingIO нужна более низкая частота.

Я поменял значение 44100.0 на 8000.0 — во всех файлах, но аудиопоток продолжал создаваться с частотой 44100.0. После парсинга информации на просторах интернета, я обнаружил, что у проекта Novocaine на github есть три «Pull request» от сторонних пользователей, и один из них имел описание:

Fixed Crash when launching from background while audio playing; Ability to manage Sample Rate

Скопировав все измененные строки из этого запроса, мне удалось запустить аудиопоток на частоте 8000.0 и эхоподавление работало! Задержка звука составляла 15-25 мс! Приложение работало в свернутом виде, даже с выключенным экраном на заблокированном iPhone!

Дело в том, что iOS не позволяет запускать новые звуки, когда приложение свернуто, для проверки можете запустить песню в Safari из ВК и свернуть браузер. Как только трек закончится, новый трек из плейлиста не включится до тех пор, пока вы не сделаете браузер активным! Если использовать аудиопотоки в iOS — то приложение отлично справится с задачей воспроизведения новых звуков из бэкграунда!

Как передается звук от устройства к устройству в приложении «Tusa»

На удаленном сервере я открываю с помощью python скрипта TCP порт 7878 и из iOS приложения создаю TCP соединение с этим сервером:

Затем, собрав звук в массив float — я конвертирую его в NSMutableData, выстраивая float в
цепочку по 4 байта:

NSMutableData * soundData = [NSMutableData dataWithCapacity:0];
for (int i=0; i < numFrames; ++i) 
{
    for (int iChannel = 0; iChannel < numChannels; ++iChannel) 
    {
        float theta = data[i*numChannels + iChannel];
        [soundData appendBytes:&theta length:sizeof(float)];
    }
}

Теперь звук находится в soundData, мы передаем его на сервер в формате:

LENGTH(soundData)+A+soundData

где A — байт-индификатор того, что на сервер пришел звук, LENGTH(soundData) — длина пакета (4 байта), soundData — сами данные в формате NSData.

Так же я попробовал шифровать весь аудиопоток по секретному ключу, объем трафика увеличился на 50-100% — но по производительности iOS девайсы справляются с этим на ура. Хотя для тех, кто пользуется 3G в условиях плохого приема – такой прирост интернет канала может оказаться неподъемным.

Самое неприятное, что весь проект изначально я реализовал на библиотеке Cocos2D, предназначенной для игр, и выяснилось, что VK SDK не работает с Cocos2D проектами, а поддерживает только ARC режим (Automatic Reference Counting), в котором происходит автоматическая работа с освобождением памяти. В одну из прошлых игр я тоже пытался встроить VK кнопку, но из-за ошибок пришлось заменить ее на Facebook кнопку. Надеюсь, что следующие версии VK SDK будут работать с Cocos2D, а пока мне пришлось переписать весь код на стандартные Storyboard интерфейсы, удалив все освобождения памяти "release" из кода. И если еще несколько дней назад я искал, где бы вставить «release» для того, чтобы избежать утечек памяти, то в ARC режиме вообще этой проблемы нет. Приложение стало занимать всего 10мб оперативной памяти, вместо 30мб на Cocos2D.

Примечание: Мне все-таки удалось «подружить» Storyboard интерфейсы с Cocos2D, и запустить Cocos2D игру прямо в UIViewController, причем Cocos2D запускается в ARC режиме, но это тема для отдельной статьи

Сомнительные инновации

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

  • Если длина входящего плейлиста превышает 2 секунды — то я просто пропускаю «тихие» аудио слепки, вырезая молчание между фразами
  • Если длина входящего плейлиста превышает критический показатель, то я просто увеличиваю скорость аудио потока в 2 раза до тех пор, пока плейлист не будет удовлетворительной длины. В итоге, входящий голос в данной ситуации звучит «ускоренно».

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

Ну вот и все, если вам интересно следить за развитием проекта в рамках конкурса, предоставляю ссылку на VK страничку проекта: http://vk.com/id232953074

В данный момент одна из моих игр («Танчики Онлайн») попала на главную страницу App Store ( УРА! ), все мои сервера заполнились тысячами игроков, так что запуск «Тусы» пришлось отложить на несколько дней. Всю информацию о запуске, я выложу ВКонтакте.

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

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

Так же в комментариях пишите, есть ли аналоги приложения Tusa в App Store ( Помимо платного «Тимспик» ).

Так как конкурс VK был затеян для расширения возможностей клиента ВК, и мое приложение демонстрирует, как добавить звонки в ВК, то прошу принять участие в голосовании, нужны ли аудио/видео звонки в мобильном клиенте ВК по-Вашему мнению? Голосование не сыграет ключевую роль в принятии решения, но разработчики ВК точно заметят его, ведь в Facebook звонки уже есть :)

Автор: megabraza

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js