Создание сайтов в Белозерском, ДНР. Как записывать аудио с помощью API MediaStream

 
 

Media Capture and Streams API (он же MediaStream API) позволяет записывать аудио с микрофона пользователя, а затем получать записанные аудио или мультимедийные элементы в виде дорожек. Затем вы можете воспроизвести эти треки сразу после их записи или загрузить медиафайл на свой сервер.

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

Вы можете найти полный код этого руководства в этом репозитории GitHub.

Настройка сервера

Сначала мы начнем с создания сервера Node.js и Express. Поэтому сначала обязательно загрузите и установите Node.js, если его нет на вашем компьютере.

Создать каталог

Создайте новый каталог, в котором будет храниться проект, и перейдите в этот каталог:

mkdir recording-tutorial

cd recording-tutorial

Инициализировать проект

Затем инициализируйте проект с помощью npm:

npm init -y

Опция -yсоздается package.jsonсо значениями по умолчанию.

Установите зависимости

Затем мы установим Express для создаваемого нами сервера и nodemon для перезапуска сервера при любых изменениях:

npm i express nodemon

Создайте экспресс-сервер

Теперь мы можем начать с создания простого сервера. Создать index.jsв корне проекта со следующим содержимым:

const path = require ('path’) ;

const express = require ('express’) ;

const app = express () ;

const port = process.env.PORT || 3000;

app.use (express.static ('public/assets’));

app.listen (port, () => {

console.log (`App listening at http: //localhost: ${port}`) ;

}) ;

Это создает сервер, который будет работать на порту 3000, если порт не установлен в среде, и предоставляет каталог public/assets— который мы скоро создадим — который будет содержать файлы и изображения JavaScript и CSS.

Добавить скрипт

Наконец, добавьте startскрипт в scriptsin package.json:

«scripts»: {

«start»: «nodemon index.js»

},

Запустите веб-сервер

Давайте протестируем наш сервер. Выполните следующее, чтобы запустить сервер:

npm start

И сервер должен запускаться с порта 3000. Вы можете попробовать получить к нему доступ localhost:3000через, но вы увидите сообщение «Cannot GET /», поскольку у нас еще нет определенных маршрутов.

Создание страницы записи

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

Создайте publicкаталог и внутри него создайте index.htmlфайл со следующим содержимым:

<! DOCTYPE html>

 

 

http-equiv="X-UA-Compatible" content="IE=edge">

 

<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css" rel="stylesheet"

integrity="sha384-KyZXEAg3QhqLMpG8r+8fhAXLRk2vvoC2f3B09zVXn8CA5QIVfZOJ3BCsw2P0p/We" crossorigin="anonymous">

 

Record Your Voice

Эта страница использует Bootstrap 5 для стилизации. На данный момент на странице просто отображается кнопка, которую пользователь может использовать для записи.

Обратите внимание, что мы используем изображение для микрофона. Скачать иконку можно на Iconscout, а можно использовать модифицированную версию в репозитории GitHub.

Загрузите иконку и поместите ее внутрь public/assets/imagesс названием microphone.png.

Добавление стилей

Мы также связываем таблицу стилей index.css, поэтому создайте public/assets/css/index.cssфайл со следующим содержимым:

.record-button {

height: 8em;

width: 8em;

border-color: #f3f3f3! important;

}

.record-button: hover {

box-shadow: 0.5rem 1rem rgba (0,0,0,. 15)! important;

}

Создание маршрута

Наконец, нам просто нужно добавить новый маршрут в index.js. Добавьте следующее перед app.listen:

app.get ('/', (req, res) => {

res.sendFile (path.join (__dirname, 'public/index.html’));

}) ;

Если сервер еще не запущен, запустите сервер с расширением npm start. Затем зайдите localhost:3000в свой браузер. Вы увидите кнопку записи.

Страница записи

Кнопка пока ничего не делает. Нам нужно привязать событие клика, которое вызовет запись.

Создайте public/assets/js/record.jsфайл со следующим содержимым:

//initialize elements we’ll use

const recordButton = document.getElementById ('recordButton’) ;

const recordButtonImage = recordButton.firstElementChild;

let chunks = []; //will be used later to record audio

let mediaRecorder = null; //will be used later to record audio

let audioBlob = null; //the blob that will hold the recorded audio

Мы инициализируем переменные, которые будем использовать позже. Затем создайте recordфункцию, которая будет прослушивателем событий для события click recordButton:

function record () {

//TODO start recording

}

recordButton.addEventListener ('click’, record) ;

Мы также прикрепляем эту функцию в качестве прослушивателя событий к кнопке записи.

Медиазапись

Чтобы начать запись, нам нужно использовать метод mediaDevices.getUserMedia ().

Этот метод позволяет нам получать поток и записывать аудио и/или видео пользователя только после того, как пользователь предоставит веб-сайту разрешение на это. Метод getUserMediaпозволяет нам получить доступ к локальным устройствам ввода.

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

Если значение равно false, это означает, что мы не заинтересованы в доступе к этому устройству или записи этого носителя.

getUserMediaвозвращает обещание. Если пользователь разрешает веб-сайту запись, обработчик выполнения обещания получает объект MediaStream, который мы можем использовать для захвата мультимедийных видео- или аудиопотоков пользователя.

Захват мультимедиа и потоки

Чтобы использовать объекты API MediaStream для захвата дорожек мультимедиа, нам нужно использовать интерфейс MediaRecorder. Нам нужно создать новый объект интерфейса, который принимает объект MediaStream в конструкторе и позволяет нам легко управлять записью через его методы.

Внутри recordфункции добавьте следующее:

//check if browser supports getUserMedia

if (! navigator.mediaDevices ||! navigator.mediaDevices.getUserMedia) {

alert ('Your browser does not support recording!') ;

return;

}

// browser supports getUserMedia

// change image in button

recordButtonImage.src = `/images/${mediaRecorder && mediaRecorder.state === 'recording’? 'microphone’: 'stop’}.png`;

if (! mediaRecorder) {

// start recording

navigator.mediaDevices.getUserMedia ({

audio: true,

})

.then ((stream) => {

mediaRecorder = new MediaRecorder (stream) ;

mediaRecorder.start () ;

mediaRecorder.ondataavailable = mediaRecorderDataAvailable;

mediaRecorder.onstop = mediaRecorderStop;

})

.catch ((err) => {

alert (`The following error occurred: ${err}`) ;

// change image in button

recordButtonImage.src = '/images/microphone.png’;

}) ;

} else {

// stop recording

mediaRecorder.stop () ;

}

Поддержка браузера

Сначала мы проверяем, определены ли navigator.mediaDevicesи navigator.mediaDevices.getUserMedia, поскольку существуют такие браузеры, как Internet Explorer, Chrome на Android и другие, которые его не поддерживают.

Кроме того, для использования getUserMediaтребуются безопасные веб-сайты, что означает, что страница загружается с использованием HTTPS file: //, или с localhost. Итак, если страница загружена небезопасно, mediaDevicesона getUserMediaбудет неопределенной.

Начать запись

Если условие ложно (то есть поддерживаются оба mediaDevicesи getUserMedia), мы сначала меняем изображение кнопки записи на stop.png, которое вы можете скачать с Iconscout или репозитория GitHub и поместить в public/assets/images.

Затем мы проверяем, является ли значение, mediaRecorderкоторое мы определили в начале файла, нулевым или нет.

Если он равен нулю, это означает, что запись не ведется. Итак, мы получаем экземпляр MediaStream для начала записи с использованием getUserMedia.

Мы передаем ему объект только с ключом audioи значением true, так как мы просто записываем звук.

Здесь браузер предлагает пользователю разрешить веб-сайту доступ к микрофону. Если пользователь это разрешит, будет выполнен код внутри обработчика выполнения:

mediaRecorder = new MediaRecorder (stream) ;

mediaRecorder.start () ;

mediaRecorder.ondataavailable = mediaRecorderDataAvailable;

mediaRecorder.onstop = mediaRecorderStop;

Здесь мы создаем новый MediaRecorder, назначая его тому, mediaRecorderчто мы определили в начале файла.

Мы передаем конструктору поток, полученный от getUserMedia. Затем мы начинаем запись с помощью mediaRecorder.start ().

Наконец, мы привязываем обработчики событий (которые мы скоро создадим) к двум событиям dataavailableи stop.

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

Остановить запись

Все это происходит, если mediaRecorderне равно нулю. Если он равен нулю, это означает, что запись продолжается, и пользователь завершает ее. Итак, мы используем метод mediaRecorder.stop () для остановки записи:

} else {

//stop recording

mediaRecorder.stop () ;

}

Обработка событий записи мультимедиа

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

По доступным данным

Событие dataavailableзапускается либо при выполнении полной записи, либо на основе необязательного параметра timeslice, передаваемого mediaRecorder.start () для указания количества миллисекунд, в течение которого должно запускаться это событие. Передача кванта времени позволяет нарезать запись и получать ее фрагментами.

Создадим mediaRecorderDataAvailableфункцию, которая будет обрабатывать dataavailableсобытие, просто добавив звуковую дорожку Blob в полученном параметре BlobEvent в chunksмассив, который мы определили в начале файла:

function mediaRecorderDataAvailable (e) {

chunks.push (e.data) ;

}

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

На остановке

Прежде чем мы создадим mediaRecorderStop, который будет обрабатывать событие остановки, давайте сначала добавим контейнер HTML-элемента, в котором будет храниться записанный звук, с помощью кнопок «Сохранить «и «Отменить».

Добавьте следующее public/index.htmlнепосредственно перед закрывающим тегом:

<div class="recorded-audio-container mt-5 d-none flex-column justify-content-center align-items-center"

id="recordedAudioContainer">

 

 

 

 

 

Затем в начале public/assets/js/record.jsдобавьте переменную, которая будет экземпляром Node #recordedAudioContainerэлемента:

const recordedAudioContainer = document.getElementById ('recordedAudioContainer’) ;

Теперь мы можем реализовать mediaRecorderStop. Эта функция сначала удалит любой аудиоэлемент, который был ранее записан и не сохранен, создаст новый аудиоэлемент мультимедиа, установит srcзначение Blob записанного потока и покажет контейнер:

function mediaRecorderStop () {

//check if there are any previous recordings and remove them

if (recordedAudioContainer.firstElementChild.tagName === 'AUDIO’) {

recordedAudioContainer.firstElementChild.remove () ;

}

//create a new audio element that will hold the recorded audio

const audioElm = document.createElement ('audio’) ;

audioElm.setAttribute ('controls’, '') ; //add controls

//create the Blob from the chunks

audioBlob = new Blob (chunks, { type: 'audio/mp3' }) ;

const audioURL = window.URL.createObjectURL (audioBlob) ;

audioElm.src = audioURL;

//show audio

recordedAudioContainer.insertBefore (audioElm, recordedAudioContainer.firstElementChild) ;

recordedAudioContainer.classList.add ('d-flex’) ;

recordedAudioContainer.classList.remove ('d-none’) ;

//reset to default

mediaRecorder = null;

chunks = [];

}

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

Последнее, что нам нужно сделать, это создать ссылку на record.jsфайл index.html. Добавьте scriptв конце body:

Тестовая запись

Давайте посмотрим это сейчас. Зайдите localhost:3000в свой браузер и нажмите на кнопку записи. Вам будет предложено разрешить веб-сайту использовать микрофон.

Разрешить запрос

Убедитесь, что вы загружаете веб-сайт либо на локальный хост, либо на HTTPS-сервер, даже если вы используете поддерживаемый браузер. MediaDevices и getUserMediaнедоступны при других условиях.

Нажмите Разрешить. Затем изображение с микрофона изменится на стоп-изображение. Кроме того, вы должны увидеть значок записи в адресной строке в зависимости от вашего браузера. Это указывает на то, что микрофон в настоящее время используется веб-сайтом.

Значок записи

Попробуйте записать несколько секунд. Затем нажмите на кнопку остановки. Изображение кнопки снова изменится на изображение микрофона, а аудиоплеер появится с двумя кнопками — «Сохранить «и «Отменить «.

Аудиоплеер

Далее мы реализуем события нажатия кнопок «Сохранить «и «Отменить «. Кнопка «Сохранить «должна загрузить аудио на сервер, а кнопка «Отменить «должна удалить его.

Отменить обработчик события клика

Сначала мы реализуем обработчик событий для кнопки Discard. Нажатие этой кнопки должно сначала показать пользователю приглашение подтвердить, что он хочет отказаться от записи. Затем, если пользователь подтвердит, он удалит аудиоплеер и скроет кнопки.

Добавьте переменную, которая будет удерживать кнопку Discard, в начало public/assets/js/record.js:

const discardAudioButton = document.getElementById ('discardButton’) ;

Затем добавьте в конец файла следующее:

function discardRecording () {

//show the user the prompt to confirm they want to discard

if (confirm ('Are you sure you want to discard the recording?')) {

//discard audio just recorded

resetRecording () ;

}

}

function resetRecording () {

if (recordedAudioContainer.firstElementChild.tagName === 'AUDIO’) {

//remove the audio

recordedAudioContainer.firstElementChild.remove () ;

//hide recordedAudioContainer

recordedAudioContainer.classList.add ('d-none’) ;

recordedAudioContainer.classList.remove ('d-flex’) ;

}

//reset audioBlob for the next recording

audioBlob = null;

}

//add the event listener to the button

discardAudioButton.addEventListener ('click’, discardRecording) ;

Теперь вы можете попробовать записать что-нибудь, а затем нажать кнопку «Отменить «. Аудиоплеер будет удален, а кнопки скрыты.

Загрузить на сервер

Сохранить обработчик события клика

Теперь мы реализуем обработчик кликов для кнопки «Сохранить «. Этот обработчик будет загружать на audioBlobсервер с помощью Fetch API, когда пользователь нажимает кнопку «Сохранить «.

Если вы не знакомы с Fetch API, вы можете узнать больше в нашем руководстве «Введение в Fetch API «.

Начнем с создания uploadsкаталога в корне проекта:

mkdir uploads

Затем в начале record.jsдобавьте переменную, которая будет содержать элемент кнопки «Сохранить «:

const saveAudioButton = document.getElementById ('saveButton’) ;

Затем, в конце, добавьте следующее:

function saveRecording () {

//the form data that will hold the Blob to upload

const formData = new FormData () ;

//add the Blob to formData

formData.append ('audio’, audioBlob, 'recording.mp3') ;

//send the request to the endpoint

fetch ('/record’, {

method: 'POST’,

body: formData

})

.then ((response) => response.json ())

.then (() => {

alert («Your recording is saved») ;

//reset for next recording

resetRecording () ;

//TODO fetch recordings

})

.catch ((err) => {

console.error (err) ;

alert («An error occurred, please try again later») ;

//reset for next recording

resetRecording () ;

})

}

//add the event handler to the click event

saveAudioButton.addEventListener ('click’, saveRecording) ;

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

Создать конечную точку API

Теперь нам нужно реализовать конечную точку API. Конечная точка загрузит аудио в uploadsкаталог.

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

Запустите следующее, чтобы установить его:

npm i multer

Затем в index.js, добавьте в начало файла следующее:

const fs = require ('fs’) ;

const multer = require ('multer’) ;

const storage = multer.diskStorage ({

destination (req, file, cb) {

cb (null, 'uploads/') ;

},

filename (req, file, cb) {

const fileNameArr = file.originalname.split ('.') ;

cb (null, `${Date.now () }. ${fileNameArr[fileNameArr.length — 1]}`) ;

},

}) ;

const upload = multer ({ storage }) ;

Мы объявили storageиспользование multer.diskStorage, которое мы настраиваем для хранения файлов в uploadsкаталоге, и мы сохраняем файлы на основе текущей метки времени с расширением.

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

Далее мы хотим сделать файлы внутри uploadsкаталога общедоступными. Итак, добавьте следующее перед app.listen:

app.use (express.static ('uploads’));

Наконец, мы создадим конечную точку загрузки. Эта конечная точка будет просто использовать uploadпромежуточное ПО для загрузки аудио и возврата ответа JSON:

app.post ('/record’, upload.single ('audio’), (req, res) => res.json ({ success: true }));

Промежуточное uploadПО будет обрабатывать загрузку файла. Нам просто нужно передать имя поля файла, который мы загружаем в upload.single.

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

Тестовая загрузка

Давайте проверим это. Снова зайдите localhost:3000в свой браузер, запишите что-нибудь и нажмите кнопку «Сохранить «.

Запрос будет отправлен на конечную точку, файл будет загружен, а пользователю будет показано предупреждение о том, что запись сохранена.

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

Показать записи

Создайте конечную точку API

Последнее, что мы сделаем, это покажем все записи пользователю, чтобы он мог их воспроизвести.

Во-первых, мы создадим конечную точку, которая будет использоваться для получения всех файлов. Добавьте следующее перед app.listenв index.js:

app.get ('/recordings’, (req, res) => {

let files = fs.readdirSync (path.join (__dirname, 'uploads’));

files = files.filter ((file) => {

// check that the files are audio files

const fileNameArr = file.split ('.') ;

return fileNameArr[fileNameArr.length — 1] === 'mp3';

}).map ((file) => `/${file}`) ;

return res.json ({ success: true, files }) ;

}) ;

Мы просто читаем файлы внутри uploadsкаталога, фильтруем их, чтобы получить только mp3файлы, и добавляем /к каждому имени файла. Наконец, мы возвращаем объект JSON с файлами.

Добавьте элемент контейнера записей

Далее мы добавим HTML-элемент, который будет контейнером записей, которые мы покажем. Добавьте следующее в конец тела перед record.jsскриптом:

Saved Recordings

Получить файлы из API

Также добавьте в начало record.jsпеременной, которая будет содержать #recordingsэлемент:

const recordingsContainer = document.getElementById ('recordings’) ;

Затем мы добавим fetchRecordingsфункцию, которая вызовет конечную точку, которую мы создали ранее, а затем с помощью этой createRecordingElementфункции отобразит элементы, которые будут аудиоплеерами.

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

Добавьте следующее в конце record.js:

function fetchRecordings () {

fetch ('/recordings’)

.then ((response) => response.json ())

.then ((response) => {

if (response.success && response.files) {

//remove all previous recordings shown

recordingsContainer.innerHTML = '';

response.files.forEach ((file) => {

//create the recording element

const recordingElement = createRecordingElement (file) ;

//add it the recordings container

recordingsContainer.appendChild (recordingElement) ;

})

}

})

.catch ((err) => console.error (err));

}

//create the recording element

function createRecordingElement (file) {

//container element

const recordingElement = document.createElement ('div’) ;

recordingElement.classList.add ('col-lg-2', 'col’, 'recording’, 'mt-3') ;

//audio element

const audio = document.createElement ('audio’) ;

audio.src = file;

audio.onended = (e) => {

//when the audio ends, change the image inside the button to play again

e.target.nextElementSibling.firstElementChild.src = 'images/play.png’;

};

recordingElement.appendChild (audio) ;

//button element

const playButton = document.createElement ('button’) ;

playButton.classList.add ('play-button’, 'btn’, 'border’, 'shadow-sm’, 'text-center’, 'd-block’, 'mx-auto’) ;

//image element inside button

const playImage = document.createElement ('img’) ;

playImage.src = '/images/play.png’;

playImage.classList.add ('img-fluid’) ;

playButton.appendChild (playImage) ;

//add event listener to the button to play the recording

playButton.addEventListener ('click’, playRecording) ;

recordingElement.appendChild (playButton) ;

//return the container element

return recordingElement;

}

function playRecording (e) {

let button = e.target;

if (button.tagName === 'IMG’) {

//get parent button

button = button.parentElement;

}

//get audio sibling

const audio = button.previousElementSibling;

if (audio && audio.tagName === 'AUDIO’) {

if (audio.paused) {

//if audio is paused, play it

audio.play () ;

//change the image inside the button to pause

button.firstElementChild.src = 'images/pause.png’;

} else {

//if audio is playing, pause it

audio.pause () ;

//change the image inside the button to play

button.firstElementChild.src = 'images/play.png’;

}

}

}

Обратите внимание, что внутри playRecordingфункции мы проверяем, воспроизводится ли звук, используя функцию, audio.pausedкоторая вернет true, если в данный момент звук не воспроизводится.

Мы также используем значки воспроизведения и паузы, которые будут отображаться внутри каждой записи. Получить эти иконки можно из Iconscout или репозитория GitHub.

Мы будем использовать fetchRecordingsпри загрузке страницы и при загрузке новой записи.

Итак, вызовите функцию в конце record.jsи внутри обработчика выполнения saveRecordingвместо TODOкомментария:

.then (() => {

alert («Your recording is saved») ;

//reset for next recording

resetRecording () ;

//fetch recordings

fetchRecordings () ;

})

Добавление стилей

Последнее, что нам нужно сделать, это добавить стиль к элементам, которые мы создаем. Добавьте следующее public/assets/css/index.css:

.play-button: hover {

box-shadow: 0.5rem 1rem rgba (0,0,0,. 15)! important;

}

.play-button {

height: 8em;

width: 8em;

background-color: #5084d2;

}

Тестируйте все

Теперь все готово. Откройте веб-сайт localhost:3000в своем браузере, и если вы загружали какие-либо записи ранее, вы увидите их сейчас. Вы также можете попробовать загрузить новые и посмотреть, как обновляется список.

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

Сохраненные записи

Заключение

Использование API MediaStream позволяет нам добавлять мультимедийные функции для пользователя, например запись звука. Веб-API MediaStream также позволяет записывать видео, делать снимки экрана и многое другое. Следуя информации, представленной в этом руководстве, а также полезным руководствам, предоставленным MDN и SitePoint, вы также сможете добавить на свой веб-сайт целый ряд других мультимедийных функций.

3D-печать5GABC-анализAndroidAppleAppStoreAsusCall-центрChatGPTCRMDellDNSDrupalExcelFacebookFMCGGoogleHuaweiInstagramiPhoneLinkedInLinuxMagentoMicrosoftNvidiaOpenCartPlayStationPOS материалPPC-специалистRuTubeSamsungSEO-услугиSMMSnapchatSonyStarlinkTikTokTwitterUbuntuUp-saleViasatVPNWhatsAppWindowsWordPressXiaomiYouTubeZoomАвдеевкаАктивные продажиАкцияАлександровск ЛНРАлмазнаяАлчевскАмвросиевкаАнализ конкурентовАнализ продажАнтимерчандайзингАнтрацитАртемовскАртемовск ЛНРАссортиментная политикаБелгородБелицкоеБелозерскоеБердянскБизнес-идеи (стартапы)БрендБрянкаБукингВахрушевоВендорВидеоВикипедияВирусная рекламаВирусный маркетингВладивостокВнутренние продажиВнутренний маркетингВолгоградВолновахаВоронежГорловкаГорнякГорскоеДебальцевоДебиторкаДебиторская задолженностьДезинтермедитацияДзержинскДивизионная система управленияДизайнДимитровДирект-маркетингДисконтДистрибьюторДистрибьюцияДобропольеДокучаевскДоменДружковкаЕкатеринбургЕнакиевоЖдановкаЗапорожьеЗимогорьеЗолотоеЗоринскЗугрэсИжевскИловайскИрминоКазаньКалининградКировскКировскоеКомсомольскоеКонстантиновкаКонтент-маркетингКонтент-планКопирайтингКраматорскКрасноармейскКрасногоровкаКраснодарКраснодонКраснопартизанскКрасный ЛиманКрасный ЛучКременнаяКураховоКурскЛисичанскЛуганскЛутугиноМакеевкаМариупольМаркетингМаркетинговая информацияМаркетинговые исследованияМаркетинговый каналМаркетинг услугМаркетологМарьинкаМедиаМелекиноМелитопольМенеджментМерчандайзерМерчандайзингМиусинскМолодогвардейскМоскваМоспиноНижний НовгородНиколаевНиколаевкаНишевой маркетингНовоазовскНовогродовкаНоводружескНовосибирскНумерическая дистрибьюцияОдессаОмскОтдел маркетингаПартизанский маркетингПервомайскПеревальскПетровскоеПлата за кликПоисковая оптимизацияПопаснаяПравило ПаретоПривольеПрогнозирование продажПродвижение сайтов в ДонецкеПроизводство видеоПромоПромоушнПрямой маркетингРабота для маркетологаРабота для студентаРазработка приложенийРаспродажаРегиональные продажиРекламаРеклама на асфальтеРемаркетингРетро-бонусРибейтРитейлРовенькиРодинскоеРостов-на-ДонуРубежноеСамараСанкт-ПетербургСаратовСватовоСвердловскСветлодарскСвятогорскСевастопольСеверодонецкСеверскСедовоСейлз промоушнСелидовоСимферопольСинергияСколковоСлавянскСнежноеСоздание сайтов в ДонецкеСоледарСоциальные сетиСочиСтаробельскСтаробешевоСтахановСтимулирование сбытаСуходольскСчастьеТелемаркетингТельмановоТираспольТорговый представительТорезТрейд маркетингТрейд промоушнТюменьУглегорскУгледарУкраинскХабаровскХарцызскХерсонХостингЦелевая аудиторияЦифровой маркетингЧасов ЯрЧелябинскШахтерскЮжно-СахалинскЮнокоммунаровскЯндексЯсиноватая