
Азбука Морзе — один из самых старых, но не уходящих на заслуженный отдых телекоммуникационных стандартов. При подготовке данной публикации я нашел Парижскую конвенцию по телеграфии от 17 мая 1865 года, которую, несмотря на 160-летний юбилей, нельзя считать полностью устаревшей.
КОНВЕНЦИЯ

Конвенция
Его Величество Император Австрии, Король Венгрии и Богемии, Его Королевское Высочество Великий Герцог Баденский, Его Величество Король Баварии, Его Величество Король Бельгии, Его Величество Король Дании, Её Величество Королева Испании, Его Величество Император Франции, Его Величество Король Эллинов (Греции), Свободный город Гамбург, Его Величество Король Ганновера, Его Величество Король Италии, Его Величество Король Нидерландов, Его Величество Король Португалии и Алгарве, Его Величество Король Пруссии, Его Величество Император всех Русских, Его Величество Король Саксонии, Его Величество Король Швеции и Норвегии, Швейцарская Конфедерация, Его Величество Император Османов, Его Величество Король Вюртемберга,
Вдохновлённые стремлением обеспечить телеграфным сообщениям, обмениваемым между их государствами, преимущества простого и сниженного тарифа, стремясь улучшить существующие условия международной телеграфной связи, и установить постоянное соглашение между их государствами — при сохранении свободы действий по вопросам, не касающимся всей службы в целом...
Время первых
Первые эксперименты по передаче телеграфных сообщений по проводам делались с начала 19 века. Начиная с 1832 года многие изобретатели предлагали действующие модели телеграфных аппаратов: сначала российские ученые немецкого происхождения П. В. Шиллинг и Б. С. Якоби, затем английские У. Ф. Кук и Ч. Уитстон.
В 1843 года с помощью своего телеграфного аппарата Шиллинг и Якоби передали первую телеграмму по проводам на 25 км из Петербурга в Царское Село. Однако, наиболее удачным из всех созданных устройств для телеграфии оказался аппарат Морзе, доживший до наших дней.
Сэ́мюэль Фи́нли Бриз Мо́рзе (Samuel Finley Breese Morse)
Художник, инженер, бизнесмен, юрист.

Морзе начинал как признанный портретист (писал, среди прочих, портреты Джеймса Монро и Лафайета), активно продвигал в США дагерротипную фотографию.
Возвращаясь из Европы, в 1832 году, Морзе заинтересовался идеей телеграфа на основе электромагнетизма, и по приезду домой начал работу над прототипом.
Подобные идеи предлагались и ранее, однако Морзе усердно работал над практической реализацией своего аппарата и проводил публичные демонстрации новой техники связи. В результате ему удалось существенно усовершенствовать эту разработку и получить финансирование от Конгресса для раннего внедрения своей технологии.
В 1844 году он провёл первый телеграфный сеанс между Вашингтоном и Балтимором, отстоящими друг от друга на 60 км, передав фразу "What hath God wrought!" (“Что сотворил Бог!”*).
Несмотря на выдающийся успех, Морзе пришлось отстаивать свои права через судебные иски в течение последующих десятилетий (особенно известен случай O'Reilly v. Morse).
К моменту смерти Морзе (1872 г.) телеграф охватил всю Евразию, Америку и Атлантический океан, сформировав инфраструктуру, которая преобразила коммуникации, политику, бизнес и журналистику.
По статье Time "What the Digital Age Owes to the Inventor of Morse Code"
*) Варианты русского перевода https://bible.by/verse/4/23/23/, варианты на английском https://biblehub.com/numbers/23-23.htm
Система телеграфии Морзе
Система Морзе состоит из нескольких основных компонентов.
1. Телеграфный ключ
При помощи телеграфного ключа специально обученный человек, телеграфист, отправляет тексты телеграмм читая их с бумаги или под диктовку, переводя буквы в длинные и короткие нажатия ключа — тире и точки.

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

Обратите внимание: при нажатии ключа происходит замыкание контакта на рычаге (рис. 4, деталь 3) с контактом под рукояткой (рис. 4, контакт 6). При отпускании ключа происходит замыкание другого контакта на рычаге с контактом на дальней от рукоятки стороне ключа (рис. 4, контакт 6'). То есть, ключ является переключателем с нормально-разомкнутым и нормально-замкнутым контактами.
Такая схема позволяет автоматически переключать аппаратуру в режим приёма телеграмм когда ключ не нажат.
2. Телеграфный аппарат
На приемной стороне устанавливался телеграфный аппарат, механизм которого протягивал бумажную ленту через пишущий узел, состоящий из перемещающегося вверх-вниз прижимного ролика и грифеля или чернильницы с пером. Когда при замыкании цепи в аппарат поступал электрический сигнал от удаленного телеграфного ключа, включался лентопротяжный механизм, ролик прижимал движущуюся ленту к перу, которое оставляло короткую (точка) или более длинную (тире) черточку.

Механика телеграфного аппарата
Для изучения работы первых версий телеграфных аппаратов Морзе обратимся к оригинальному документу, датированному 11 апреля 1846 года, который доступен онлайн в Национальном архиве США.

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

При помощи набора шестеренок и "пассиков" вращающий момент передается на валики e и d, тянущие ленту. Шток m, связанный с рычагом F, при отсутствии тока в электромагните E давит на "тормоз" и лента из барабана C не вытягивается.
При подаче тока в электромагнит, шток m поднимается вслед за рычагом F и снимает блокировку с лентопротяжного механизма. Лента начинает вытягиваться из барабана C и прижимаемое к ней перо S оставляет отметку.
Я не вполне уверен, является ли само перо подвижным в этой конструкции, или бумажная лента прижимается к нему роликом. Возможно, Читатель лучше разберется с этим вопросом и напишет о своих находках в комментариях.
Таков, в общих чертах, принцип работы телеграфного аппарата Морзе.
Иногда вместо полноценного аппарата ставилось более простое устройство: зуммер или звонок, которое издавало звук при появлении тока в телеграфной цепи. В этом случае запись телеграфного сообщения на бумагу делал телеграфист, принимая точки и тире на слух. Подобная методика широко применялась при передаче телеграфных сообщений по радио.
3. Батарея
Чтобы по телеграфным проводам, ключам и электромагнитам шел электрический ток, в цепи должна быть батарея. Во второй половине 19 века чаще всего применялись сборки из гальванических элементов Даниэля и его более поздних модификаций — элементов Калло.

Гальванический элемент создает электрический ток во внешней цепи за счет окислительно-восстановительных реакций, происходящих на его электродах. Элемент Даниэля состоит из двух электродов: медного катода - "плюса" и цинкового анода — "минуса". Оба погружены в разные электролиты: медь - в раствор сульфата меди (медного купороса), а цинк — в раствор сульфата цинка. Электролиты разделяет пористая перегородка, предотвращающая перемешивание жидкостей, но пропускающая ионы.
Пока внешнаяя электрическая цепь разомкнута, на поверхностях обоих электродов элемента Даниэля устанавливается равновесие между нейтральными атомами металла электрода и ионами этого же металла в растворе. Поскольку ионы меди не могут проникнуть к цинковому электроду чтобы окислить его напрямую:
реагенты, цинк и медный купорос, не расходуются.
Как только электроды соединяются через внешнюю электрическую цепь, так в силу более отрицательного потенциала цинкового электрода по отношению к медному по цепи начинает течь ток. Электроны перемещаются от цинкового электрода к медному через соединивший их проводник .
Уравнения реакций в работающем элементе Даниэля выглядят так:
На поверхности медного катода происходит восстановление меди из ионов, плавающих в окружающем электролите, за счёт электронов, приходящих от цинкового электрода по проводу из внешней цепи:
На цинковом аноде происходит окисление цинка с переходом его ионов с поверхности металла в окружающий электролит. Электроны уходят в провод внешней цепи:
В ходе выработки электроэнергии цинковый электрод постепенно растворяется, а медный, наоборот, увеличивает свою массу. Через пористую перегородку между электролитами происходит обмен ионами (см. рис. 7 схема слева).
ЭДС элемента Даниэля составляет около 1 вольта, поэтому их соединяли в батареи последовательно по несколько десятков банок.
Справа на рис. 7 показана современная конструкция элемента Даниэля, которую можно найти в продаже и купить в хоббийных целях. Корпус элемента 1 сделан из меди и работает в роли катода, пористый керамический стаканчик 2 выполняет роль разделителя электролитов. В стаканчик помещается цинковый анод 3. Элемент заполняется электролитами и после этого готов к эксплуатации.
4. Линии связи
О телеграфных проводах можно написать сотни страниц, но здесь я лишь кратко упомяну о них.
Для прокладки наземных телеграфных линий ставили специальные столбы.

Провод был медный или медно-стальной, когда для повышенной прочности медные жилы навивают на стальную проволоку-сердцевину.
В качестве изоляционных материалов для телеграфных кабелей использовались гуттаперча, пенька, джут, битум.
Самые экстремальные испытания людей и техники случались при прокладке первых подводных кабелей телеграфной связи в 1850-60 годах. Об этих преодолениях и достижениях можно прочитать в книге Артура Кларка "Голос через океан", доступной здесь. В отличие от большинства других произведений известного фантаста, эта книга повествует о совершенно реальных событиях.
Схема телеграфа

В левой части рис. 8 мы видим гальванометр G1 телеграфный аппарат R1, активный (нажатый) ключ M1, батарею B1 и пластину заземления (закопана внизу). Нажатый ключ своим нормально-разомкнутым контактом (который замкнут при нажатии) соединяет цепь, состоящую из электрического провода, приходящего от правой стороны, гальванометра G1 самого ключа, батареи B1 и заземления.
В правой части рис. 8 мы видим гальванометр G2 телеграфный аппарат R2, неактивный (не нажатый) ключ M2, батарею B2 и пластину заземления. Неактивный ключ M2 соединяет своим нормально-замкнутым контактом цепь, включающую в себя заземление, телеграфный аппарат R2, сам ключ, гальванометр G2 и электрический провод, идущий к левой стороне.
Таким образом, левая сторона схемы через заземление и гальванометр G1 при помощи нажатого ключа подключает к батарее B1 электрический провод, соединённый с правой частью, где к нему через гальванометр G2, и не нажатый ключ M2 присоединён телеграфный аппарат R2.
В итоге, под действием батареи B1 в цепи возникает ток, проходящий через землю, оба гальванометра, оба ключа, провод и телеграфный аппарат R2. Происходит передача сигнала от ключа M1 к аппарату R2.
Чтобы передать сигнал в обратную сторону, справа налево, нужно отпустить ключ M1 и нажать M2.
Выглядело это всё примерно как на рис. 9. Под столом оператора в левой половине рисунка можно заметить открытый ящик с электрическими батареями.

Работая по очереди, телеграфисты с каждого из ключей могли передавать сообщения противоположной стороне используя код Морзе.
Азбука Морзе (Morse Code)
Те самые точки и тире Морзе придумал не единолично. Ходят слухи, что значительный, а, возможно, и основной вклад в это изобретение внёс другой американский бизнесмен и изобретатель Альфред Вейл (Alfred Lewis Vail, 1807-1859).

Вот что об азбуке Морзе (Morse Code) написано в Britannica:
Азбука Морзе — одна из двух систем представления букв алфавита, цифр и знаков препинания с помощью точек, тире и пробелов. Коды передаются в виде электрических импульсов различной длины или аналогичных механических или визуальных сигналов, таких как мигающие огни.
Первая система была изобретена в Соединенных Штатах американским художником и изобретателем Сэмюэлем Ф. Б. Морзе в 1830-х годах для электрической телеграфии. Эта версия была дополнительно усовершенствована американским ученым и бизнесменом Альфредом Льюисом Вейлом, помощником и партнером Морзе.
Вскоре после её появления в Европе стало очевидно, что исходная азбука Морзе не подходит для передачи большей части неанглийского текста, поскольку в ней отсутствуют коды для букв с диакритическими знаками. Чтобы исправить этот недостаток, на конференции европейских стран в 1851 году был разработан вариант, названный Международной азбукой Морзе. Этот новый код также называется Континентальной азбукой Морзе.
Код Морзе передаётся следующими сигналами:
Точка. Длительность передачи сигнала "точка" принимается за 1.
Тире. Длительность передачи сигнала "тире" равна 3.
Тире и точки внутри внутри одного знака разделены коротким промежутком тишины, длительностью 1.
Знаки отделяются друг от друга средним промежутком тишины длительностью 3.
Отдельные слова передаются с долгим промежутком тишины, равным 7.

Как видите, код весьма прост. Существуют локализованные варианты, например русский:

В русской кодировке для знаков, имеющих эквивалент в латинице, используются коды из международной таблицы (см. рис 11).
Чашечку Java?

Из 19 века вернёмся в настоящее и запрограммируем телеграфный аппарат ключ и телеграфиста на языке программирования Java.
Как установить Java?
Если у вас Debian-based дистрибутив ОС Linux:
# устанавливаем Java Development Kit
sudo apt install default-jdk
# устанавливаем инструмент Maven, который нужен для сборки проекта
sudo apt install maven
# устанавливаем git чтобы загрузить исходники проекта с github.com
sudo apt install git
Если у вас RedHat-based дистрибутив:
# устанавливаем Java Development Kit
sudo dnf install java-21-openjdk
# Устанавливаем инструмент Maven, который нужен для сборки проекта
sudo dnf install maven
# устанавливаем git чтобы загрузить исходники проекта с github.com
sudo dnf install git
Если у вас Windows:
Дистрибутив OpenJDK от Microsoft можно скачать по ссылке https://learn.microsoft.com/ru-ru/java/openjdk/download. Я проверял https://aka.ms/download-jdk/microsoft-jdk-21.0.7-windows-x64.msi. Все шаги инсталляции оставил с настройками по-умолчанию.
Git скачиваем по этой ссылке https://git-scm.com/downloads/win , мне подошла версия https://github.com/git-for-windows/git/releases/download/v2.50.0.windows.2/Git-2.50.0.2-64-bit.exe. Все шаги инсталляции оставил с настройками по-умолчанию.
Перезагружаем Windows и проверяем, что java установилась командой
java -version
C:\Users\myname>java -version
openjdk version "21.0.7" 2025-04-15 LTS
OpenJDK Runtime Environment Microsoft-11369940 (build 21.0.7+6-LTS)
OpenJDK 64-Bit Server VM Microsoft-11369940 (build 21.0.7+6-LTS, mixed mode, sharing)
C:\Users\myname>

Теперь нам нужен maven. Для этого скачиваем .ZIP архив отсюда https://maven.apache.org/download.cgi, я взял вот этот https://dlcdn.apache.org/maven/maven-3/3.9.10/binaries/apache-maven-3.9.10-bin.zip.
Распаковываем, например, в свою домашнюю директорию. У меня получился такой путь к распакованным файлам:
C:\Users\alexa\apache-maven-3.9.10
Добавляем путь к запускаемым файлам maven в переменную PATH среды окружения Windows. Для этого нажимаем в UI Windows кнопку главного меню, затем пишем environment в строке поиска по всем разделам (All). В русскоязычной версии Windows, возможно, это не сработает. Тогда попробуйте написать окружени или переменные. Получится как на рис. 15:

Кликаем мышкой на Edit environment variables for your account.
Появится окошко как на рис. 16:

Выбираем в верхней части окошка (рис. 16) переменную Path и нажимаем кнопку Edit...

В появившемся окне нажимаем New и копируем путь, где у вас распакован maven, добавив к нему bin как на рис. 17.
Далее всё сохраняем, нажав OK и снова OK.
Перезагружаем Windows. Теперь всё готово к сборке проекта из исходников.
Как собрать и запустить проект с примером приложения из статьи?
# клонируем исходники из GitHub
git clone https://github.com/galilov/HabrMorse.git
# заходим в директорию с исходниками
cd HabrMorse
# при помощи maven собираем сразу файл JAR со всеми зависимостями внутри
mvn package
# запускаем с аргументом Hello world
java -jar target/morse-1.1-SNAPSHOT-jar-with-dependencies.jar "Hello world"
Эти команды одинаковы для Linux и для Windows.
Вот этот самый morse-1.1-SNAPSHOT-jar-with-dependencies.jar и есть приложение на Java в одном файле-архиве (JAva aRchive).
Информация на вход кодировщика Морзе поступает в виде символов. У нас это будет некоторое подмножество ASCII символов, которое вы с лёгкостью сможете расширить при необходимости.
Используем вот такое подмножество
A.-
B-...
C-.-.
D-..
E.
F..-.
G--.
H....
I..
J.---
K-.-
L.-..
M--
N-.
O---
P.--.
Q--.-
R.-.
S...
T-
U..-
V...-
W.--
X-..-
Y-.--
Z--..
0-----
1.----
2..---
3...--
4....-
5.....
6-....
7--...
8---..
9----.
..-.-.-
,--..--
?..--..
'.----.
;.-.-.-
:---...
--....-
~-.-.-
На выходе кодировщика будет текст, состоящий из точек . , тире -
, разделителя знаков в виде |
и пробелов в роли разделителя слов.

Бесплатная миграция в Selectel
Начислим до 1 000 000 бонусов на два месяца. А наши инженеры подготовят план и поддержат на всех этапах миграции.
И тут мне на голову упал мешок с Java Stream API
Этот Java Stream API очень даже хорош для потоковой обработки данных, но иногда он бывает весьма неудобным. Я покажу, что у меня получается с его обжаркой и помолом. Когда мне будет казаться, что Stream API становится невкусным, я воспользуюсь альтернативным подходом на классической Java без изысков.
Готовим словарь с соответствиями "буква" --> "код Морзе"
Исходный материал для словаря расположен в ресурсах (resources) проекта в файле morsecodes
с содержимым в виде строк формата БукваКодМорзе
без каких-либо разделителей, например F..-.
.
Весь код преобразования входного текста в азбуку Морзе находится в одном классе MorseProcessor
.
За загрузку и парсинг morsecodes
отвечает метод init()
:
/**
* MorseProcessor class for processing text to morse code.
*/
class MorseProcessor {
/**
* The Morse codes map
*/
private static final String MORSECODES = "morsecodes";
private Map<Integer, String> morseCodes;
/**
* Initialize the morse codes map
*
* @throws IOException if an I/O error occurs
*/
void init() throws IOException {
// get the input stream from the resource MORSECODES
var inputStream = getClass().getClassLoader().getResourceAsStream(MORSECODES);
// check input stream is not null
if (inputStream == null) {
throw new NullPointerException("Resource " + MORSECODES + " not found");
}
// read the lines from the input stream
try (var bufferedReader = new BufferedReader(new InputStreamReader(inputStream))) {
// create stream of lines
Stream<String> lines = bufferedReader.lines();
Map<Integer, String> map =
lines.collect( // collect the lines into a map
// typical pairs look like that:
// A.-
// B-...
// C-.-.
// ...
// there is no space between the key and the value
Collectors.toMap(
// key for the map
// convert the first character to uppercase and get the integer value
s -> (int) Character.toUpperCase(s.charAt(0)),
// value for the map
// get the substring from the second character
s -> s.substring(1)));
this.morseCodes = Collections.unmodifiableMap(map); // create an unmodifiable map
}
}
. . .
Работа метода init()
начинается с получения ресурса MORSECODES
в виде потока данных InputStream:
// get the input stream from the resource MORSECODES
var inputStream = getClass().getClassLoader().getResourceAsStream(MORSECODES));
Вызовы getClass().getClassLoader().getResourceAsStream(MORSECODES) означают, что нам нужен "классический" поток данных, связанный с ресурсом. Загрузкой ресурса занимается class loader. Это работает независимо от того, собран наш проект в JAR-архив или запускается в виде отдельных .class файлов.
Метод getResourceAsStream(String) может вернуть null
, если ресурс не найден или недоступен.
Конструкция try(. . .) { }
называется "try-with-resources". Она гарантирует, что у полученного по ссылке bufferedReader
экземпляра класса BufferedReader при выходе из блока кода (в фигурных скобках) будет вызван метод close() и связанные с bufferedReader
системные ресурсы, например дескриптор открытого файла или буферы в памяти, могут быть закрыты и/или освобождены в этом самом close().
Чтобы механизм try-with-resources работал...
...класс BufferedReader должен прямо либо косвенно реализовывать интерфейс java.lang.AutoCloseable и метод close() как часть интерфейса.
Начиная с Java версии 7 интерфейс java.lang.AutoCloseable является предком для интерфейса java.io.Closeable, который уже сам является традиционным предком множества классов, связанных с обработкой ввода-вывода и буферизацией.
Вызов метода lines() класса BufferedReader возвращает готовый к использованию поток строк Stream API:
// create stream of lines
Stream<String> lines = bufferedReader.lines();
В этот момент никакой особенной обработки данных не выполняется. Потоки - вещь ленивая. Чтобы там что-то начало происходить, нужно вызвать какой-нибудь терминальный метод, который явным образом вынужден запустить преобразование данных. У нас это метод collect, объявленный в интерфейсе Stream<T>. В качестве аргумента он получает объект-коллектор в который по очереди складываются объекты из потока. В нашем случае коллектором является объект, создаваемый вызовом статического метода toMap класса Collectors:
Collectors.toMap(
// key for the map
// convert the first character to uppercase and get the equivalent integer value
s -> Integer.valueOf(Character.toUpperCase(s.charAt(0))),
// value for the map
// get the substring starting from the second character (we count from 0)
s -> s.substring(1))
Используется вариант toMap, принимающий в качестве аргументов две реализации функционального интерфейса Function<T, R>, а проще говоря - две lambda-функции. Первая преобразует полученную из потока строку String
в ключ для словаря Map<K, V>, а вторая функция ту же самую строку преобразует в значение, на которое должен указывать ключ. Посмотрим на эти функции немного внимательнее.
Ключ получается из нулевого (самого начального) символа строки. Получить этот символ из строки s
очень легко: s.charAt(0)
,
// key for the map
// convert the first character to uppercase and get the equivalent integer value
s -> Integer.valueOf(Character.toUpperCase(s.charAt(0)))
При помощи Character.toUpperCase(c) символ переводится в верхний регистр (большие буквы) чтобы не было разницы между A и a. Затем Integer.valueOf(i) делает из символа char
объект целого числа Integer. Внутри этого Integer сохраняется эквивалентное char
значение, но в виде примитивного типа int
. Полученный Integer и есть ключ в Map<Integer, String>.
Почему бы не использовать в качестве ключа Character?
Хороший вопрос! Причина в том, что дальнейшие манипуляции на базе Stream API проще выполнять с int
, чем с char
.
Например, вот такой вызов возвращает IntStream:
String s = "My text";
IntStream stream = s.chars();
В API предусмотрели специальный тип потока IntStream для int
, а для char
ничего похожего нет.
Строчка из соответствующих ключу точек и тире получается вызовом второй функции-аргумента метода toMap:
// value for the map
// get the substring skipping the very first character (we count from 0)
s -> s.substring(1)
Здесь всё просто: возвращаем подстроку, полученную из исходной строки s
пропуском начального символа.
Созданный вызовом toMap коллектор вызывается внутри collect для каждого объекта из потока lines
, а результаты выполнения функций вычисления ключа и значения автоматически складываются в созданный коллектором объект с интерфейсом Map<K, V>.
Когда все строки из потока обработаны, collect возвращает заполненный Map:

В финале сохраняем в поле morseCodes
объекта класса MorseProcessor
ссылку на немодифицируемый вариант Map<Integer, String>:
this.morseCodes = Collections.unmodifiableMap(map); // create an unmodifiable map
Collections.unmodifiableMap(map) создаёт новый объект с интерфейсом Map. Отличие от обычного HashMap в том, что вызов любого метода модификации у такого объекта приведёт к выбросу исключения UnsupportedOperationException, что предотвращает изменение данных, которые мы явно хотим оставить неизменными.
Формируем сообщение в коде Морзе из входного текста
Словарь у нас есть, на него ссылается поле morseCodes
в MorseProcessor
. Теперь нужно из входной строки получить текст в виде точек, тире и пробелов двух типов: среднего между отдельными знаками (буквами) и длинного между словами. Эту работу делает метод String textToMorse(String text)
класса MorseProcessor
:
/**
* MorseProcessor class for processing text to morse code.
*/
class MorseProcessor {
/**
* The Morse codes map
*/
private Map<Integer, String> morseCodes;
. . .
/**
* Convert text to morse code
*
* @param text the text to convert
* @return the morse code string, for example: ".-|...|.-."
*/
String textToMorse(String text) {
return text
// convert the text to uppercase
.toUpperCase()
// convert the text to a stream of characters
.chars()
// convert the stream of characters to a stream of strings
.mapToObj((c) -> {
if (c == ' ') { // if the character is a space
return " "; // return a space
}
// get the morse code for the character
String code = morseCodes.get(c);
// return the morse code or a space if the code is not found (==null)
return code != null ? code : " ";
})
.collect(
Collector.<String, ArrayList<String>, String>of(
// Supplier: Creates a new ArrayList
() -> {
// reserve space for strings
return new ArrayList<String>(text.length());
},
// Accumulator: Adds strings to the list skipping duplicated spaces
(list, s) -> {
if (!s.equals(" ") || (!list.isEmpty() && !list.getLast().equals(" "))) {
list.addLast(s);
}
},
// Combiner: Combines two lists (for parallel streams)
(list1, list2) -> {
list1.addAll(list2);
return list1;
},
// Finisher: Joins the list elements into a single string
list -> {
if (!list.isEmpty()) {
int to = list.getLast().equals(" ") ? list.size() - 1 : list.size();
return String.join("|", list.subList(0, to));
}
return "";
}
)
);
}
. . .
В этом методе вызов chars() создаёт IntStream - специальную реализацию Stream
для примитивного типа int
. Нет, я ничего не путаю: CharStream
не существует.
mapToObj получает на вход ссылку на объект, реализующий интерфейс IntFunction, который преобразует элементы потока типа int
в некий объект (не примитив, как int
).
IntFunction
/**
* Represents a function that accepts an int-valued argument and produces a
* result. This is the {@code int}-consuming primitive specialization for
* {@link Function}.
*/
@FunctionalInterface
public interface IntFunction<R> {
/**
* Applies this function to the given argument.
*
* @param value the function argument
* @return the function result
*/
R apply(int value);
}
Наша функция преобразования имплементирует IntFunction используя синтаксис lambda-функций:
(c) -> {
if (c == ' ') { // if the character is a space
return " "; // return a space
}
// get the morse code for the character
String code = morseCodes.get(c);
// return the morse code or a space if the code is not found (==null)
return code != null ? code : " ";
}
Она получает int
-параметр при вызове, проверят, не пробел ли это и, используя параметр как ключ, достаёт из morseCodes
соответствующую строку, содержащую последовательность точек и тире - код из азбуки Морзе. Если нужной строки в morseCodes
по ключу не нашлось, функция преобразования возвращает пробел. Ну и если на вход пришёл код пробела, то тоже возвращается пробел.
Результатом работы mapToObj является Stream<String> со строками, содержащими пробел или код Морзе из точек и тире для соответствующего символа из входной строки. На этом потоке вызывается метод collect c аргументом-коллектором, который, в свою очередь, создаётся на месте вызовом Collector.of с четырьмя аргументами-функциями (там может быть пятый аргумент, но мы его не используем).
Рассмотрим этого кастомного коллектора чуть ближе. Прототип статического метода of класса java.util.stream.Collector выглядит устрашающе:
/**
* Returns a new {@code Collector} described by the given {@code supplier},
* {@code accumulator}, {@code combiner}, and {@code finisher} functions.
*
* @param supplier The supplier function for the new collector
* @param accumulator The accumulator function for the new collector
* @param combiner The combiner function for the new collector
* @param finisher The finisher function for the new collector
* @param characteristics The collector characteristics for the new
* collector
* @param <T> The type of input elements for the new collector
* @param <A> The intermediate accumulation type of the new collector
* @param <R> The final result type of the new collector
* @throws NullPointerException if any argument is null
* @return the new {@code Collector}
*/
public static<T, A, R> Collector<T, A, R> of(
Supplier<A> supplier,
BiConsumer<A, T> accumulator,
BinaryOperator<A> combiner,
Function<A, R> finisher,
Characteristics... characteristics);
Но на самом деле всё не настолько ужасно. Параметры-типы T
, A
, R
класса-дженерика Collector определяют:
T
- тип элементов исходного потока,A
- тип контейнера-аккумулятора для накопления промежуточного результата,R
- тип результата, отдаваемого коллектором.
Первый параметр метода of - supplier
должен ссылаться на объект с функциональным интерфейсом
Supplier
Supplier в справочной системе
/**
* Represents a supplier of results.
*
* <p>There is no requirement that a new or distinct result be returned each
* time the supplier is invoked.
*
* <p>This is a <a href="package-summary.html">functional interface</a>
* whose functional method is {@link #get()}.
*
* @param <T> the type of results supplied by this supplier
*
* @since 1.8
*/
@FunctionalInterface
public interface Supplier<T> {
/**
* Gets a result.
*
* @return a result
*/
T get();
}
который может быть создан на месте благодаря синтаксису lambda-функций. Функция должна вернуть объект-контейнер для накопления результата работы коллектора. В нашем случае это реализовано весьма просто:
// Supplier: Creates a new ArrayList
() -> {
// reserve space for strings
return new ArrayList<String>(text.length());
}
При создании ArrayList его конструктору передаётся количество элементов, под которое нужно зарезервировать место во внутреннем массиве. Если ничего не передавать, то конструктор по-умолчанию зарезервирует место на 10 элементов и внутренний массив будет автоматически расширяться при добавлении 11-го элемента. Да, ArrayList - это больше array, чем list, поэтому его расширение происходит путём создания внутри ArrayList нового массива большего размера, последующим копированием в него существующих элементов и добавлением после них нового элемента. Эти манипуляции весьма затратны и я решил их избежать, выделив место под строки с кодами Морзе (на самом деле под ссылки на объекты класса String, размещённые где-то в памяти, но не в самом ArrayList). После создания отдаём контейнер механизму коллектора оператором return
.
Конечно, вместо ArrayList можно использовать и другие контейнеры, например LinkedList. Я выбрал ArrayList по причине более высокой производительности добавления нового элемента и доступа к имеющимся элементам в том случае, когда итоговое количество добавляемых элементов известно заранее и под них можно выделить непрерывное пространство в памяти. В нашем примере это не так уж важно и вы можете использовать тот контейнер, который сочтёте более подходящим.
Второй параметр accumulator
должен быть ссылкой на объект с интерфейсом
BiConsumer
BiConsumer в справочной системе
/**
* Represents an operation that accepts two input arguments and returns no
* result. This is the two-arity specialization of {@link Consumer}.
* Unlike most other functional interfaces, {@code BiConsumer} is expected
* to operate via side-effects.
*
* <p>This is a <a href="package-summary.html">functional interface</a>
* whose functional method is {@link #accept(Object, Object)}.
*
* @param <T> the type of the first argument to the operation
* @param <U> the type of the second argument to the operation
*
* @see Consumer
* @since 1.8
*/
@FunctionalInterface
public interface BiConsumer<T, U> {
/**
* Performs this operation on the given arguments.
*
* @param t the first input argument
* @param u the second input argument
*/
void accept(T t, U u);
/**
* Returns a composed {@code BiConsumer} that performs, in sequence, this
* operation followed by the {@code after} operation. If performing either
* operation throws an exception, it is relayed to the caller of the
* composed operation. If performing this operation throws an exception,
* the {@code after} operation will not be performed.
*
* @param after the operation to perform after this operation
* @return a composed {@code BiConsumer} that performs in sequence this
* operation followed by the {@code after} operation
* @throws NullPointerException if {@code after} is null
*/
default BiConsumer<T, U> andThen(BiConsumer<? super T, ? super U> after) {
Objects.requireNonNull(after);
return (l, r) -> {
accept(l, r);
after.accept(l, r);
};
}
}
Нам нужно написать код метода accept, принимающий на вход ссылку на контейнер ArrayList и на элемент String из потока, с которым работает коллектор.
Реализация BiConsumer в виде lambda-функции выглядит вот так:
// Accumulator: Adds strings to the list skipping duplicated spaces
(list, s) -> {
if (!s.equals(" ") || (!list.isEmpty() && !list.getLast().equals(" "))) {
list.addLast(s);
}
}
На вход функции поступает ссылка на контейнер list
(созданный вызовом supplier
объект класса ArrayList) и на очередной элемент из потока - строку s
, содержащую точки-тире кода Морзе или пробел. В аккумуляторе реализована следующая логика:
Если входная строка
s
не является пробелом, то она добавляется в контейнерlist
.Если контейнер
list
не пуст и его последний элемент не является строкой-пробелом, то добавляем входную строкуs
в контейнерlist
.
Эта логика позволяет избежать добавления в контейнер нескольких пробелов подряд. Необходимость такого подхода возникает по той причине, что:
Исходный текст может содержать кратные пробелы.
Исходный текст может содержать символы, для которых у нас нет соответствующего кода Морзе, и в этом случае функция mapToObj выдаёт пробел в выходной поток строк.
В конечном счёте, мне показалось проще реализовать логику удаления кратных пробелов на уровне коллектора, чем как-то иначе.
Третий параметр метода of называется combiner
и используется в параллельном режиме обработки потока для объединения контейнеров, полученных из разных threads. У нас параллельные потоки не используются, но корректную реализацию combiner
нужно предоставить. combiner
должен ссылаться на объект с интерфейсом
BinaryOperator
BinaryOperator в справочной системе
/**
* Represents an operation upon two operands of the same type, producing a result
* of the same type as the operands. This is a specialization of
* {@link BiFunction} for the case where the operands and the result are all of
* the same type.
*
* <p>This is a <a href="package-summary.html">functional interface</a>
* whose functional method is {@link #apply(Object, Object)}.
*
* @param <T> the type of the operands and result of the operator
*
* @see BiFunction
* @see UnaryOperator
* @since 1.8
*/
@FunctionalInterface
public interface BinaryOperator<T> extends BiFunction<T,T,T> {
/**
* Returns a {@link BinaryOperator} which returns the lesser of two elements
* according to the specified {@code Comparator}.
*
* @param <T> the type of the input arguments of the comparator
* @param comparator a {@code Comparator} for comparing the two values
* @return a {@code BinaryOperator} which returns the lesser of its operands,
* according to the supplied {@code Comparator}
* @throws NullPointerException if the argument is null
*/
public static <T> BinaryOperator<T> minBy(Comparator<? super T> comparator) {
Objects.requireNonNull(comparator);
return (a, b) -> comparator.compare(a, b) <= 0 ? a : b;
}
/**
* Returns a {@link BinaryOperator} which returns the greater of two elements
* according to the specified {@code Comparator}.
*
* @param <T> the type of the input arguments of the comparator
* @param comparator a {@code Comparator} for comparing the two values
* @return a {@code BinaryOperator} which returns the greater of its operands,
* according to the supplied {@code Comparator}
* @throws NullPointerException if the argument is null
*/
public static <T> BinaryOperator<T> maxBy(Comparator<? super T> comparator) {
Objects.requireNonNull(comparator);
return (a, b) -> comparator.compare(a, b) >= 0 ? a : b;
}
}
В этом интерфейсе нет ничего, что мы должны реализовать в своём коде. Но сам BinaryOperator унаследован от
BiFunction
BiFunction в справочной системе
/**
* Represents a function that accepts two arguments and produces a result.
* This is the two-arity specialization of {@link Function}.
*
* <p>This is a <a href="package-summary.html">functional interface</a>
* whose functional method is {@link #apply(Object, Object)}.
*
* @param <T> the type of the first argument to the function
* @param <U> the type of the second argument to the function
* @param <R> the type of the result of the function
*
* @see Function
* @since 1.8
*/
@FunctionalInterface
public interface BiFunction<T, U, R> {
/**
* Applies this function to the given arguments.
*
* @param t the first function argument
* @param u the second function argument
* @return the function result
*/
R apply(T t, U u);
/**
* Returns a composed function that first applies this function to
* its input, and then applies the {@code after} function to the result.
* If evaluation of either function throws an exception, it is relayed to
* the caller of the composed function.
*
* @param <V> the type of output of the {@code after} function, and of the
* composed function
* @param after the function to apply after this function is applied
* @return a composed function that first applies this function and then
* applies the {@code after} function
* @throws NullPointerException if after is null
*/
default <V> BiFunction<T, U, V> andThen(Function<? super R, ? extends V> after) {
Objects.requireNonNull(after);
return (T t, U u) -> after.apply(apply(t, u));
}
}
В котором объявлен метод apply. Наша lambda-функция должна имплементировать этот метод. Код здесь прост и незатейлив:
// Combiner: Combines two lists (for parallel streams)
(list1, list2) -> {
list1.addAll(list2);
return list1;
},
Добавляем элементы второго списка к первому и возвращаем первый список.
Четвёртый параметр метода of, finisher
, формирует итоговый результат работы всего коллектора. Через finisher
в метод of передаётся объект с интерфейсом
Function
Function в справочной системе по
/**
* Represents a function that accepts one argument and produces a result.
*
* <p>This is a <a href="package-summary.html">functional interface</a>
* whose functional method is {@link #apply(Object)}.
*
* @param <T> the type of the input to the function
* @param <R> the type of the result of the function
*
* @since 1.8
*/
@FunctionalInterface
public interface Function<T, R> {
/**
* Applies this function to the given argument.
*
* @param t the function argument
* @return the function result
*/
R apply(T t);
/**
* Returns a composed function that first applies the {@code before}
* function to its input, and then applies this function to the result.
* If evaluation of either function throws an exception, it is relayed to
* the caller of the composed function.
*
* @param <V> the type of input to the {@code before} function, and to the
* composed function
* @param before the function to apply before this function is applied
* @return a composed function that first applies the {@code before}
* function and then applies this function
* @throws NullPointerException if before is null
*
* @see #andThen(Function)
*/
default <V> Function<V, R> compose(Function<? super V, ? extends T> before) {
Objects.requireNonNull(before);
return (V v) -> apply(before.apply(v));
}
/**
* Returns a composed function that first applies this function to
* its input, and then applies the {@code after} function to the result.
* If evaluation of either function throws an exception, it is relayed to
* the caller of the composed function.
*
* @param <V> the type of output of the {@code after} function, and of the
* composed function
* @param after the function to apply after this function is applied
* @return a composed function that first applies this function and then
* applies the {@code after} function
* @throws NullPointerException if after is null
*
* @see #compose(Function)
*/
default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) {
Objects.requireNonNull(after);
return (T t) -> after.apply(apply(t));
}
/**
* Returns a function that always returns its input argument.
*
* @param <T> the type of the input and output objects to the function
* @return a function that always returns its input argument
*/
static <T> Function<T, T> identity() {
return t -> t;
}
}
И нам нужно реализовать метод apply интерфейса Function чтобы преобразовать переданный в finisher
заполненный контейнер ArrayList<String> в результирующую строку String:
// Finisher: Joins the list elements into a single string
list -> {
if (!list.isEmpty()) {
int to = list.getLast().equals(" ") ? list.size() - 1 : list.size();
return String.join("|", list.subList(0, to));
}
return "";
}
Здесь получается вот что: из строк контейнера, содержащих код Морзе или пробел, формируем одну строку, в которой коды Морзе и пробелы разделены символом |
. Перед этим действием проверяем, нет ли в конце списка исходных строк строки-пробела, а поскольку такой пробел на выходе нам не нужен, не включаем его в результат.
Посмотрим, что у нас получается
Просто создаём объект класса MorseProcessor
и вызываем его метод textToMorse
:
/**
* Main class for the morse code transmitter.
*/
public class Main {
/**
* Main method
*
* @param args the arguments
*/
public static void main(String[] args) {
// create a new MorseProcessor
MorseProcessor mp = new MorseProcessor();
// convert the text to Morse code
String morse = mp.textToMorse("Hello world");
// print
System.out.println("Morse code: " + morse);
}
}
В результате получаем
Morse code: ....|.|.-..|.-..|---| |.--|---|.-.|.-..|-..
Символы |
разделяют буквы, пробел отделяет друг от друга слова. Разные разделители формируют паузы разной длительности при передаче сигнала в линию или в эфир.
На мой взгляд, метод textToMorse
получился не очень читаемым. И несколько великоват. Кажется, что его вариант в императивном стиле, с использованием цикла, короче и понятнее:
/**
* Convert text to morse code
*
* @param text the text to convert
* @return the morse code string, for example: ".-|...|.-."
*/
String textToMorse(String text) {
ArrayList<String> acc = new ArrayList<>(text.length());
for (int c : text.toUpperCase().toCharArray()) {
String code;
if (c == ' ') { // if the character is a space
code = " ";
} else {
// get the morse code for the character
code = morseCodes.get(c);
// code may be null
if (code == null) code = " ";
}
// if `code` is not " " or the last String in accumulator is not " "...
if (!code.equals(" ") || (!acc.isEmpty() && !acc.getLast().equals(" "))) {
// ... add `code` to accumulator
acc.add(code);
}
}
if (acc.isEmpty()) return "";
int to = acc.getLast().equals(" ") ? acc.size() - 1 : acc.size();
return String.join("|", acc.subList(0, to));
}
Либо я не умею готовить Stream API ?.
Генерируем аналоговый сигнал для передачи по радио или через канал телефонной связи
Для начала расскажу о некоторых базовых понятиях, которые, в силу специфичности и не-мейнстримности этой области, не всем программистам известны.
При передаче по радиотелефонному каналу или через обычную телефонную связь точки и тире азбуки Морзе часто кодируются сигналом "тональной частоты". То есть таким, который соответствует параметрам пропускания связного оборудования. Обычно это сигнал в диапазоне от 300 до 3400 Гц. Вот он уже модулируется точками, тире и пробелами, и поступает в оборудование связи вместо голоса. В большей степени такой способ передачи Морзе характерен именно для каналов, предназначенных для телефонной/голосовой связи. Для чисто телеграфных радиоканалов может использоваться модуляция радиочастоты по принципу включения-выключения радиоизлучения. В этом случае никакого "пип-пип-пип" на частоте передачи мы просто так не услышим и тут нужен специальный приёмник.
Мы напишем код, который генерирует сигнал типа "пип-пип-пип" вроде такого:
Как генерировать данные для "пип"?
Для генерации массива сэмплов, которые затем будут отправлены аудиодрайверу, нужно заранее знать частоту дискретизации и разрядность требуемых цифровых данных.
В нашем проекте используется одна из стандартных частот дискретизации (SampleRate), равная половине принятых в Audio CD 44100 сэмплов/с, а именно
сэмплов/с и разрядность 1 байт со знаком на сэмпл. Для генерации гармонического (синусоидального) сигнала частотой герц мы исходим из того, что полный период синусоиды длится
секунд, что даёт
сэмплов на полный период синусоиды.
Ну и угол в радианах (изменение фазы сигнала) между соседними сэмплами составит
радиан.
Выпив пару чашек Java, получаем вот такие методы класса Transmitter
:
/**
* Transmitter class for transmitting morse code to the audio system.
*/
class Transmitter {
private static final int FREQ = 800; // Hertz
private static final int SAMPLE_RATE = 22050; // samples per second
/**
* Get the number of samples for a given duration
*
* @param durationMilliseconds the duration in milliseconds
* @return the number of samples
*/
private static int getNumOfSamples(float durationMilliseconds) {
return (int) Math.round(SAMPLE_RATE * durationMilliseconds / 1000.0);
}
/**
* Generates a pause of the given duration
*
* @param durationMilliseconds the duration in milliseconds
* @param consumer the consumer to receive the samples
*/
private static void generatePause(float durationMilliseconds, IntConsumer consumer) {
int len = getNumOfSamples(durationMilliseconds);
for (int i = 0; i < len; i++) {
consumer.accept(0);
}
}
/**
* Generates sine wave samples and provides them to the consumer
* @param durationMilliseconds
* @param consumer
*/
private static void generateSineWave(float durationMilliseconds, IntConsumer consumer) {
// get the number of samples
int len = getNumOfSamples(durationMilliseconds);
// align len to fit the wave period to avoid sound distortion at the end of the
// wave
len = len - (len % (SAMPLE_RATE / FREQ));
// calculate the phase delta for the sine wave
final double delta = 2 * Math.PI * FREQ / SAMPLE_RATE;
// initialize the angle
double angle = 0;
// generate the sine wave
for (int n = 0; n < len; n++) {
// calculate the value of the sine wave sample
consumer.accept((int) (Byte.MAX_VALUE * Math.sin(angle)));
// increment the angle
angle += delta;
}
}
Метод generateSineWave
получает в параметрах требуемую длительность сигнала в миллисекундах durationMilliseconds
и консьюмера consumer
с интерфейсом IntConsumer.
IntConsumer
IntConsumer в документации.
/**
* Represents an operation that accepts a single {@code int}-valued argument and
* returns no result. This is the primitive type specialization of
* {@link Consumer} for {@code int}. Unlike most other functional interfaces,
* {@code IntConsumer} is expected to operate via side-effects.
*
* <p>This is a <a href="package-summary.html">functional interface</a>
* whose functional method is {@link #accept(int)}.
*
* @see Consumer
* @since 1.8
*/
@FunctionalInterface
public interface IntConsumer {
/**
* Performs this operation on the given argument.
*
* @param value the input argument
*/
void accept(int value);
/**
* Returns a composed {@code IntConsumer} that performs, in sequence, this
* operation followed by the {@code after} operation. If performing either
* operation throws an exception, it is relayed to the caller of the
* composed operation. If performing this operation throws an exception,
* the {@code after} operation will not be performed.
*
* @param after the operation to perform after this operation
* @return a composed {@code IntConsumer} that performs in sequence this
* operation followed by the {@code after} operation
* @throws NullPointerException if {@code after} is null
*/
default IntConsumer andThen(IntConsumer after) {
Objects.requireNonNull(after);
return (int t) -> { accept(t); after.accept(t); };
}
}
Уши Stream API, торчащие из generateSineWave
видны невооружённым глазом. Вот этому консьюмеру передаётся каждый рассчитанный сэмпл через вызов consumer.accept.
generatePause
работает проще: в consumer.accept передаётся нужное количество нулей по одному за вызов.
Небольшая магия есть в расчёте длительности точки (в миллисекундах) на основании заданной константы скорости телеграфирования Speed, которая измеряется в Words per minute (WPM). Вы можете менять эту константу как хотите, исходное значение установлено в 20 wpm.
Обычная практика в телеграфии заключается в использовании двух различных стандартных фраз для измерения скорости передачи азбуки Морзе в словах в минуту. Стандартными словами являются PARIS и CODEX. В азбуке Морзе PARIS имеет длительность 50 точек, а CODEX - 60.
На основе стандартного слова длительностью 50 точек, например "PARIS", длительность одной точки в миллисекундах можно вычислить по формуле:
где Speed - скорость телеграфирования в wpm (стандартных слов в минуту), 1200 - количество миллисекунд на телеграфирование одной точки при скорости 1 wpm и длительности стандартного слова 50 точек.
/*
* Based upon a 50 dot duration standard word such as PARIS, the time for one
* dot duration or one unit can
* be computed by the formula:
* dotDurationMilliseconds = 1200.0 / SPEED
* https://en.wikipedia.org/wiki/Morse_code
*/
private static final float DOT_DURATION_MILLISECONDS = 1200.0f / SPEED;
Теперь у нас есть всё, чтобы сформировать полный цифровой образ нашей телеграммы:
/**
* Transmitter class for transmitting morse code to the audio system.
*/
class Transmitter {
private static final int SPEED = 20; // words per minute
private static final int FREQ = 800; // Hertz
private static final int SAMPLE_RATE = 22050; // samples per second
/*
* Based upon a 50 dot duration standard word such as PARIS, the time for one
* dot duration or one unit can
* be computed by the formula:
* dotDurationMilliseconds = 1200.0 / SPEED
* https://en.wikipedia.org/wiki/Morse_code
*/
private static final float DOT_DURATION_MILLISECONDS = 1200.0f / SPEED;
. . .
/**
* Generates sine wave samples and provides them to the consumer
* @param durationMilliseconds
* @param consumer
*/
private static void generateSineWave(float durationMilliseconds, IntConsumer consumer) {
. . .
}
/**
* Generates a pause of the given duration
* @param durationMilliseconds the duration in milliseconds
* @param consumer the consumer to receive the samples
*/
private static void generatePause(float durationMilliseconds, IntConsumer consumer) {
. . .
}
/**
* Generates the image of the Morse code for the given string which is a sequence of dots, dashes, pipes and spaces.
*
* @param morseEncoded the Morse encoded string
* @return the image of the Morse code for the given string
*/
private byte[] generateSignalImage(String morseEncoded) {
// convert Morse encoded string to byte array
return morseEncoded.chars() // convert to stream of characters(=ints)
.mapMulti(
(c, consumer) -> {
switch (c) {
case '.':
// generate the dot wave and the space wave (zeroes)
generateSineWave(DOT_DURATION_MILLISECONDS, consumer);
generatePause(DOT_DURATION_MILLISECONDS, consumer);
break;
case '-':
// generate the dash wave and the space wave (zeroes)
generateSineWave(3 * DOT_DURATION_MILLISECONDS, consumer);
generatePause(DOT_DURATION_MILLISECONDS, consumer);
break;
case '|':
// generate the space (zeroes) 2 times (+1 spaceWave comes from the previous dot or dash)
generatePause(2 * DOT_DURATION_MILLISECONDS, consumer);
break;
case ' ':
// generate the long space (zeroes) 6 times (+1 spaceWave comes from the previous dot or dash)
generatePause(6 * DOT_DURATION_MILLISECONDS, consumer);
break;
default:
throw new IllegalArgumentException("Unsupported symbol: " + c);
}
}
)
.collect(
() -> {
// the approximate buffer size
final int approxBufferSize =
(int) (4 * DOT_DURATION_MILLISECONDS * morseEncoded.length() * SAMPLE_RATE / 1000);
return new ByteArrayOutputStream(approxBufferSize);
},
(buf, val) -> {
buf.write(val);
},
(buf1, buf2) -> {
buf1.writeBytes(buf2.toByteArray());
}
)
.toByteArray();
}
Формирование цифрового образа сигнала выполняет метод generateSignalImage
. Из нового здесь используется IntStream.mapMulti, который в ответ на одно значение из входного потока может выдать несколько значений в выходной поток.
При вызове на потоке IntStream мы должны предоставить методу mapMulti унаследованный от интерфейса IntMapMultiConsumer обработчик.
IntMapMultiConsumer
Документация по IntMapMultiConsumer
/**
* Represents an operation that accepts an {@code int}-valued argument
* and an IntConsumer, and returns no result. This functional interface is
* used by {@link IntStream#mapMulti(IntMapMultiConsumer) IntStream.mapMulti}
* to replace an int value with zero or more int values.
*
* <p>This is a <a href="../function/package-summary.html">functional interface</a>
* whose functional method is {@link #accept(int, IntConsumer)}.
*
* @see IntStream#mapMulti(IntMapMultiConsumer)
*
* @since 16
*/
@FunctionalInterface
interface IntMapMultiConsumer {
/**
* Replaces the given {@code value} with zero or more values by feeding the mapped
* values to the {@code ic} consumer.
*
* @param value the int value coming from upstream
* @param ic an {@code IntConsumer} accepting the mapped values
*/
void accept(int value, IntConsumer ic);
}
И это весьма просто, вот нужная lambda-функция:
(c, consumer) -> {
switch (c) {
case '.':
// generate the dot wave and the space wave (zeroes)
generateSineWave(DOT_DURATION_MILLISECONDS, consumer);
generatePause(DOT_DURATION_MILLISECONDS, consumer);
break;
case '-':
// generate the dash wave and the space wave (zeroes)
generateSineWave(3 * DOT_DURATION_MILLISECONDS, consumer);
generatePause(DOT_DURATION_MILLISECONDS, consumer);
break;
case '|':
// generate the space (zeroes) 2 times (+1 spaceWave comes from the previous dot or dash)
generatePause(2 * DOT_DURATION_MILLISECONDS, consumer);
break;
case ' ':
// generate the long space (zeroes) 6 times (+1 spaceWave comes from the previous dot or dash)
generatePause(6 * DOT_DURATION_MILLISECONDS, consumer);
break;
default:
throw new IllegalArgumentException("Unsupported symbol: " + c);
}
}
Параметр c
имеет тип int
и получает элемент из входного потока, а параметр consumer
имеет тип IntConsumer и наш обработчик должен вызвать у него метод void accept(int value), которому передаётся значение int
для выходного потока IntStream. Вызывать void IntConsumer.accept(int value) надо столько раз, сколько значений int
требуется выдать в выходной поток.
Вся логика формирования выходного потока размещена в операторе switch
. В зависимости от входного символа выбирается вид сигнала и его длительность. Все генерируемые сэмплы скармливаются void IntConsumer.accept(int value) внутри методов generatePause
и generateSineWave
без особого стеснения.
На выходном IntStream вызывается специфический именно для IntStream вариант метода collect с тремя параметрами уже знакомого нам назначения:
<R> R collect(Supplier<R> supplier,
ObjIntConsumer<R> accumulator,
BiConsumer<R, R> combiner);
supplier создаёт контейнер для сборки единого результата
accumulator выполняет добавление очередного значения в контейнер
combiner используется при объединении результатов из разных threads в режиме parallel. Мы такой режим в нашем приложении не используем.
Все эти дженерик-интерфейсы имеют параметр типа R - тип результата работы коллектора.
Из нового здесь ObjIntConsumer<R>, но он вряд ли вызовет непонимание.
ObjIntConsumer
Документация по ObjIntConsumer
/**
* Represents an operation that accepts an object-valued and a
* {@code int}-valued argument, and returns no result. This is the
* {@code (reference, int)} specialization of {@link BiConsumer}.
* Unlike most other functional interfaces, {@code ObjIntConsumer} is
* expected to operate via side-effects.
*
* <p>This is a <a href="package-summary.html">functional interface</a>
* whose functional method is {@link #accept(Object, int)}.
*
* @param <T> the type of the object argument to the operation
*
* @see BiConsumer
* @since 1.8
*/
@FunctionalInterface
public interface ObjIntConsumer<T> {
/**
* Performs this operation on the given arguments.
*
* @param t the first input argument
* @param value the second input argument
*/
void accept(T t, int value);
}
Покажу в одном блоке как сделаны все три функции:
// Supplier: Creates a new ByteArrayOutputStream
() -> {
// the approximate buffer size
final int approxBufferSize =
(int) (4 * DOT_DURATION_MILLISECONDS * morseEncoded.length() * SAMPLE_RATE / 1000);
return new ByteArrayOutputStream(approxBufferSize);
},
// Accumulator: Adds samples to the buffer
(buf, val) -> {
buf.write(val);
},
// Combiner: Combines two buffers (for parallel streams)
(buf1, buf2) -> {
buf1.writeBytes(buf2.toByteArray());
}
В supplier
создаётся ByteArrayOutputStream с начальным размером внутреннего буфера approxBufferSize
. Значение считается на основании личного жизненного эмпирического опыта. При недостаточном размере буфер расширяется путём создания нового и забывания старого byte[]
, живущего внутри буфера. Точно как в ArrayList.
accumulator
совершенно прямолинейно пишет очередной байт в буфер. Да, write принимает на вход int
, а в буфер кладёт только байт, гляньте под спойлер, если не верите.
Вообще, в Java почти всегда так с write и read
/**
* Writes the specified byte to this {@code ByteArrayOutputStream}.
*
* @param b the byte to be written.
*/
@Override
public synchronized void write(int b) {
ensureCapacity(count + 1);
buf[count] = (byte) b;
count += 1;
}
А combiner
добавит второй переданный буфер в первый если его попросят.
В итоге IntStream.collect возвращает нам заполненный ByteArrayOutputStream на котором только и остаётся, что вызвать toByteArray для получения долгожданного результата в виде массива байт byte[]
, который мы и отдадим аудиосистеме.
Аудиосистема
Я не знаю других таких странных людей, кому понадобилось бы проигрывать оцифрованный звук в классической (не Android) Java. Если вам тоже приходилось - пишите в комментариях.
Поскольку статья не про аудиосистему Java и уже получилась большой, позволю себе сразу показать код и кратко объяснить что он делает
// clip for playing the sound
private Clip clip;
/**
* Transmit the data to the audio system and wait for the clip to finish
*
* @param outputData the data to transmit
* @throws LineUnavailableException if the line is unavailable
* @throws IOException if an I/O error occurs
* @throws InterruptedException if the thread is interrupted
*/
private void transmitData(byte[] outputData) throws LineUnavailableException, IOException, InterruptedException {
if (outputData == null || outputData.length == 0)
return;
// if the clip is not null, stop and close it
if (clip != null) {
clip.stop();
clip.close();
}
// get the clip
clip = AudioSystem.getClip();
// object for synchronization
Object playSync = new Object();
// listener for the line event
LineListener listener = event -> {
// if the event is a stop event
if (event.getType() == LineEvent.Type.STOP) {
// stop and close the clip
clip.stop();
clip.close();
clip = null;
logger.log(Level.INFO, "Data has been transmitted.");
// notify all the threads that are waiting for the clip to stop
synchronized (playSync) {
playSync.notify();
}
}
};
// add the listener to the clip
clip.addLineListener(listener);
// create the audio format
AudioFormat af = new AudioFormat(
SAMPLE_RATE, // sample rate
8, // bits per sample
1, // channels
true, // signed
false); // big endian
// create the audio input stream
AudioInputStream ais = new AudioInputStream(
new ByteArrayInputStream(outputData), // input stream
af, // audio format
outputData.length); // length of the output data
// open the clip
clip.open(ais);
// log the start of the data transmitting
logger.log(Level.INFO, "Start data transmitting...");
// start the clip
clip.start();
// wait for the clip to stop
synchronized (playSync) {
// wait for the clip to finish
playSync.wait();
}
}
Вся работа построена вокруг объекта класса javax.sound.sampled.Clip. Работает он асинхронно, но я использую его здесь в синхронном окружении, поэтому пришлось обложить этот код notify
/wait
. Самые важные моменты:
Создать объект класса Clip
Добавить в него listener, который будет вызываться асинхронно при изменении состояния Clip
Подготовить AudioFormat с нужными параметрами
Открыть AudioInputStream в конструктор которого передать аудиоформат и InputStream с оцифрованным звуком
вызвать open у объекта класса Clip и передать ему созданный ранее AudioInputStream
вызвать start у объекта класса Clip
ждать сигнала завершения от listener
Ну и, кажется, вот оно уже готово запиликать
/**
* Transmit the data to the audio system and wait for the clip to stop
*
* @param morseEncoded the morse encoded string
*/
void transmit(String morseEncoded) {
try {
// transmit the data
transmitData(generateSignalImage(morseEncoded));
} catch (LineUnavailableException | IOException | InterruptedException e) {
// log the error
logger.log(Level.SEVERE, "Can't play sound", e);
// throw a runtime exception
throw new RuntimeException(e);
}
}
Да, оно просто берёт строку кодов Морзе, генерирует массив сэмплов и проигрывает их через аудиосистему Java. Для завершённости картины вот class Main:
/**
* Main class for the morse code transmitter.
*/
public class Main {
/**
* Main method
*
* @param args the arguments
*/
public static void main(String[] args) {
if (args.length == 0) {
System.out.println("Usage: add a text line to send, use \"your text\" to send the line with spaces");
return;
}
// create a new MorseProcessor
MorseProcessor mp = new MorseProcessor();
// convert the text to morse code
String morse = mp.textToMorse(String.join(" ", args));
// log the morse code
System.out.println("Morse code: " + morse);
// create a new Transmitter
Transmitter transmitter = new Transmitter();
transmitter.transmit(morse);
}
}
К звёздам
Первым словом, специально отправленным в дальний космос, было слово МИР (рис. 19). Политоту можно пропустить, важное я выделил красным.

Передача осуществлена через антенны планетарного радара/радиотелескопа АДУ-1000 в Евпатории (Крым, Украинская ССР) 19 ноября 1962 г. передатчиком мощностью до 250 МВт в стерадиан в импульсном режиме.

Расширяясь в пространстве, радиолуч только малой своей частью попал на Венеру. Большая часть луча пролетела мимо, по разные стороны от планеты, и продолжает двигаться со скоростью света предположительно в направлении звезды HD 131336 в созвездии Весов, которую радиопередача достигнет примерно через 1220 лет.

Кино
Меня просто мёдом не корми, дай сняться в кино. И в этот раз у меня есть для вас кино! Пока не написан декодер, я использовал в качестве такового мобильное приложение Morse expert, которое умеет декодировать морзянку "на слух" через микрофон. Смотрите, что у меня получилось:
На этом первую часть статьи считаю завершённой, но не исчезаю, а ухожу писать код для второй части.?
Исходник проекта на GitHub.
Книга Артура Кларка "Голос через океан"
С уважением,
А. Галилов ?
Sau
Код для русских букв, который вы показали, не стандартный. Отличаются коды для букв Э и Ъ:
Э кодируется ..-.. у вас на картинке ...-...
Ъ кодируется --.-- у вас на картинке .--.-.
В современном коде .--.-. кодирует символ @.
AGalilov Автор
Спасибо за замечание! Опять мне где-то подложили осетрину второй свежести...
Nurked
Ну вы и придрались. По-сравнению с теми какашками, которые обычно постят компании под видом "статей" на Хабру - это вообще чудо.
AGalilov Автор
Я написал ДЛЯ компании, а не КАК компания :) поэтому претензии принимаю на свой счёт и стараюсь не молчать в ответ. Компания готова оплачивать мои старания, а меня это мотивирует стараться больше.
Sau
Я согласен что статья хорошая, просто конкретно эта картинка мне сразу режет глаз, как любителю азбуки.