Пишем переводчик фраз на азбуку Морзе
Для одного из проектов мне понадобилось поэкспериментировать с Web Audio API и генерацией звуковых последовательностей. В рамках этого эксперимента я написал небольшой (и не очень полезный) генератор морзянки. В этом посте покажу, как он устроен.

Алфавит и модуль перевода
Первое, что нам понадобится, — это переводчик обычного текста в азбуку Морзе. Он нам будет нужен, чтобы разбивать введённый пользователем текст на символы и заменять их на точки и тире.
Создадим объект, в котором укажем соответствия между символами и знаками из азбуки Морзе:
const defaultAlphabet = {
'.-': 'a',
'-...': 'b',
'-.-.': 'c',
'-..': 'd',
'.': 'e',
// ...Остальные буквы.
'-----': '0',
'.----': '1',
// ...Остальные цифры.
'·-·-·-': '.',
'--··--': ',',
'-·-·--': '!',
' ': ' '
};
Далее создадим модуль перевода, который будет использовать эти соответствия для перевода текстов:
class Translator {
constructor({ alphabet = defaultAlphabet, space = ' ' }) {
this.space = space;
this.alphabet = alphabet;
}
}
Мы передаём алфавит как часть конфигурации в конструкторе класса Translator
, чтобы при необходимости его можно было заменить. Это не так уже нужно, но будет полезно, если мы, например, захотим менять алфавит в зависимости от локали пользователя.
Кроме алфавита мы также указываем знак пробела. Его мы будем использовать для разбиения фраз на слова, и композиции слов обратно во фразы. (Об этом чуть дальше.)
Чтобы кодировать текст в азбуку Морзе, мы напишем метод encode
:
class Translator {
// ...
encode = (message) => {
return message
.toLowerCase()
.split('')
.reduce((encoded, char) => {
// TODO: Превратить символы в морзянку.
}, '');
};
}
Метод encode
будет принимать строку, разбивать её на отдельные символы, находить соответствующие им символы из морзянки, а затем склеивать их в текст из точек и тире.
Чтобы найти соответствие между буквой или цифрой и символом из азбуки Морзе, нам понадобится инвертированный дубликат алфавита, где ключами будут символы морзянки. Для этого напишем метод inverse
:
class Translator {
constructor({ alphabet = defaultAlphabet, space = ' ' }) {
this.space = space;
this.alphabet = alphabet;
this.inverted = this.inverse(alphabet);
}
inverse = (alphabet) =>
Object.keys(alphabet).reduce(
(inverted, key) => ({
...inverted,
[alphabet[key]]: key
}),
{}
);
// ...
}
Инвертированный алфавит тогда мы сможем использовать в методе encode
:
class Translator {
// ...
encode = (message) => {
return message
.toLowerCase()
.split('')
.reduce((encoded, char) => {
// Для каждой буквы, цифры или знака препинания
// находим соответствующий символ из Морзе:
const code = this.inverted[char] || '';
// Добавляем пробел между символами,
// чтобы результат не слипался с сплошную
// мешанину из точек и тире:
const part = code + this.space;
// Наращиваем текст-результат:
return (encoded += part);
}, '');
};
}
Мы ставим пробел между символами азбуки Морзе, чтобы они не слипались в одну неразборчивую строчку. Между символами мы оставляем один пробел, между словами — два.
Закончим модуль перевода последним методом для декодирования азбуки Морзе:
class Translator {
// ...
decode = (message) => {
message += this.space;
let decoded = '';
let currentCode = '';
let spaceCount = 0;
for (const char of message) {
// Разбиваем строку на отдельные символы:
// точки, тире и пробелы
// и проверяем каждый символ.
// Если это не пробел, значит,
// надо «дослушать» символ азбуки до конца:
if (char !== this.space) {
currentCode += char;
spaceCount = 0;
continue;
}
// Если это был пробел, проверяем,
// сколько пробелов мы послушали.
// Если прослушано 2 пробела,
// то закончилось слово, и надо
// добавить пробел в результирующую строку:
spaceCount += 1;
if (spaceCount === 2) {
decoded += this.space;
continue;
}
// Если был всего один пробел,
// то мы прослушали символ, соответствующий
// букве, цифре или знаку препинания.
// Находим совпадение в алфавите и
// добавляем в результирующую строку:
decoded += this.alphabet[currentCode];
currentCode = '';
}
return decoded;
};
}
Осциллятор и генератор звука
Чтобы переводимые фразы можно было не только видеть на экране, но и послушать, нам понадобится Web Audio API.
Напишем модуль генератора звуков:
class SoundEmitter {
constructor(config) {
// ...
}
play = (durationMS) => {
// ...
};
}
Сперва получим доступ к аудио-контексту. Через него мы будем стучаться в звуковое API:
class SoundEmitter {
constructor(config = {}) {
const { glbl = window } = config;
const AudioContext = glbl.AudioContext || glbl.webkitAudioContext;
if (!AudioContext) throw new Error('Failed to access Audio Context.');
this.audioCtx = new AudioContext();
}
// ...
}
Настроим частоту звуковой волны, которую собираемся воспроизводить. Обычно азбука Морзе воспроизводится на частоте 600–1000 Гц. Эту частоту и укажем в настройках:
class SoundEmitter {
constructor(config = {}) {
const { glbl = window, frequencyHZ = 600 } = config;
// ...
this.frequencyHZ = frequencyHZ;
}
// ...
}
Для собственно генерации звука с Web Audio API нам понадобится осциллятор — репрезентация волны определённой частоты. Добавим метод для его создания:
class SoundEmitter {
// ...
createOscillator = () => {
const oscillator = this.audioCtx.createOscillator();
oscillator.frequency.value = this.frequencyHZ;
oscillator.connect(this.audioCtx.destination);
return oscillator;
};
}
Наконец создадим метод для проигрывания звуковой волны, который будет принимать на вход её длительность в миллисекундах:
class SoundEmitter {
// ...
play = (durationMS) => {
const oscillator = this.createOscillator();
oscillator.start();
oscillator.stop(this.audioCtx.currentTime + durationMS / 1000);
};
}
Проигрыватель фраз
Последнее, что осталось сделать, — написать модуль, который будет запускать и останавливать воспроизведение звука по точкам и тире из сообщения.
Длительность точки обычно равна 50 мс, а тире по длительности равно 3 точкам. Пробел между словами сделаем паузой длительностью в 5 точек, а пробел между символами — паузой в 3. Внутри символа точки и тире отделим паузой длительностью в одну точку.
class MorseCodePlayer {
constructor({ translator, soundEmitter, dotLengthMS = 50 }) {
this.isPlaying = false;
this.translator = translator;
this.soundEmitter = soundEmitter;
this.durations = {
dot: dotLengthMS,
dash: 3 * dotLengthMS
};
this.gaps = {
part: dotLengthMS,
char: 3 * dotLengthMS,
word: 5 * dotLengthMS
};
}
}
В конструкторе мы получаем доступ к экземплярам ранее созданных классов: translator
и soundEmitter
. Это так называемое внедрение зависимостей. С его помощью мы не прицепляемся к конкретным и заранее созданным объектам, а делаем эту связь настраиваемой.
Далее добавим методы для поиска длительностей по переданным строкам:
class MorseCodePlayer {
// ...
getSignalDuration = (smbl) => {
const { dot, dash } = this.durations;
switch (smbl) {
case '.':
return dot;
case '-':
return dash;
default:
return 0;
}
};
getGapDuration = (smbl) => {
const { word, char, part } = this.gaps;
switch (smbl) {
case ' ':
return word;
case ' ':
return char;
default:
return part;
}
};
}
Чтобы преобразовать последовательность символов во временную последовательность, используем асинхронный генератор. Когда мы будем по нему итерироваться, он будет управлять «расписанием» пауз и звуков, откладывая их с помощью таймера:
class MorseCodePlayer {
// ...
*generateSequence(symbols) {
for (const smbl of symbols) {
const signal = this.getSignalDuration(smbl);
const gap = this.getGapDuration(smbl);
yield Promise.resolve({ signal });
yield new Promise((resolve) => setTimeout(() => resolve({ gap }), signal + gap));
}
}
}
Теперь осталось только написать метод, который будет запускать воспроизведение волны осциллятора по этому расписанию:
class MorseCodePlayer {
// ...
togglePlayingState = () => {
this.isPlaying = !this.isPlaying;
};
playMessage = async (message) => {
// Запрещаем «перебивать» играющие сообщения:
if (this.isPlaying) return false;
this.togglePlayingState();
// Переводим сообщение в азбуку Морзе:
const morseString = this.translator.encode(message);
const symbols = morseString.split('');
// Проходим по каждому символу и составляем
// «расписание» для проигрывания звуков:
for await (const bit of this.generateSequence(symbols)) {
const { signal } = bit;
// Если в текущий момент расписания
// мы видим «сигнал», то проигрываем его.
if (signal) this.soundEmitter.play(signal);
// Если вместо сигнала мы видим паузу,
// не проигрываем ничего, просто ждём
// следующего по расписанию сигнала.
}
// Записываем в состоянии проигрывателя,
// что он занят воспроизведением сообщения:
this.togglePlayingState();
};
}
Всё вместе
Последнее, что нам надо сделать, — скомпоновать приложение и настроить все зависимости:
// Создадим экземпляры переводчика и генератора звуков:
const translator = new Translator();
const soundEmitter = new SoundEmitter();
// Создадим экземпляр приложения
// и передадим все зависимости в конструкторе:
const codePlayer = new MorseCodePlayer({
soundEmitter,
translator
});
Далее настроим обработчики событий на отправку формы, которая будет запускать перевод сообщения и проигрывание звука, и используем в нём codePlayer
:
const form = document.getElementById('form');
const input = document.getElementById('message');
const output = document.getElementById('translated');
form.addEventListener('submit', (e) => {
e.preventDefault();
const message = input.value;
codePlayer.playMessage(message);
const encoded = translator.encode(message);
output.innerHTML = encoded;
});
Готово! 🙃