1. Overview

Morse code encodes text characters using sequences of dots and dashes to represent letters, numbers, and punctuation. Samuel Morse and Alfred Vail developed it in the early 1830s for telegraphy use.

In this tutorial, we’ll write a method that translates from English to Morse code. Then, we’ll write the method which does the opposite.

2. Writing Morse Code

Let’s understand Morse code and its alphabet.

2.1. What Is the Morse Code?

In Morse code, each letter is represented by a unique combination of short signals (dots) and long signals (dashes), allowing for communication through a series of on-off signals. According to the common usage, we’ll represent dots with “*.” and dashes with “–*“. Those two characters are enough to write the whole Morse alphabet.

However, we’ll need something more to write sentences. As Morse indeed targeted non-written communication, the flow is essential to decrypt a Morse message. For this reason, the operator responsible for transmitting a Morse message would leave a short pause between each letter. Additionally, he would leave a longer pause between each word. As a result, a representation that wouldn’t take those pauses into account wouldn’t allow for decoding.

A common choice is to leave a blank space ” ” to represent the pause between each word. We’ll also use a “*/*” to codify the space character between two words. As the slash is also a character, blank spaces will surround it like the others.

2.2. Bidirectional Mapping Between English and Morse

To translate easily from English to Morse and reversely, we’d like to have a bidirectional mapping between both alphabets. Thus, we’ll use Apache Commons Collection’s BidiMap data structure. It’s a Map that allows access by key or by value. This way, we’ll use it for both translating methods. However, if we’re interested in translating only one way, we’d directly use a Map.

First, let’s include the latest version of the library in our pom.xml:

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-collections4</artifactId>
    <version>4.4</version>
</dependency>

We can now create our mapping and initialize it in a static block:

public class MorseTranslator {
    
    private static final BidiMap<String, String> morseAlphabet = new DualHashBidiMap<>();
    
    static {
        morseAlphabet.put("A", ".-");
        morseAlphabet.put("B", "-...");
        morseAlphabet.put("C", "-.-.");
        morseAlphabet.put("D", "-..");
        morseAlphabet.put("E", ".");
        morseAlphabet.put("F", "..-.");
        morseAlphabet.put("G", "--.");
        morseAlphabet.put("H", "....");
        morseAlphabet.put("I", "..");
        // etc
        morseAlphabet.put(" ", "/");
    }
    
}

Let’s note that we added the translation for the blank character. Furthermore, we restrict ourselves to letters, numbers, and punctuation characters. If we want to use also accented characters, we’d need to use another data structure or make choices because various accented characters can match the same Morse code. For instance, “à” and “å” both correspond to “*.–.-*” in Morse.

3. Translating English Into Morse

First, let’s write a method to translate an English sentence into Morse code.

3.1. General Algorithm

Our BidiMap contains only capital letters because capitalization doesn’t change the translation. Thus, we’ll start with capitalizing the word. Then, we’ll iterate over the letters and translate them one by one:

static String englishToMorse(String english) {
    String upperCaseEnglish = english.toUpperCase();
    String[] morse = new String[upperCaseEnglish.length()];
    for (int index = 0; index < upperCaseEnglish.length(); index++) {
        String morseCharacter = morseAlphabet.get(String.valueOf(upperCaseEnglish.charAt(index)));
        morse[index] = morseCharacter;
    }
    return String.join(" ", morse);
}

It’s convenient to store the translations into an array of Morse Strings. This intermediate array has as many values as the number of characters in the input. In the end, we use the String.join() method to concatenate all entries, using a blank space as a delimiter.

We can now test our method. Since we’d like to check that capitalization doesn’t matter, we’ll write a parameterized test with various inputs expecting the same output:

@ParameterizedTest
@ValueSource(strings = {"MORSE CODE!", "morse code!", "mOrSe cOdE!"})
void givenAValidEnglishWordWhateverTheCapitalization_whenEnglishToMorse_thenTranslatedToMorse(String english) {
    assertEquals("-- --- .-. ... . / -.-. --- -.. . -.-.-----.", MorseTranslator.englishToMorse(english));
}

In addition, we can note that the space between the two words translates to “ / ” as expected.

3.2. Edge Cases

For the moment, our program doesn’t take into account potentially malformed inputs. However, we’d like to refuse sentences that contain invalid characters. In such cases, we’ll throw an IllegalArgumentException:

String morseCharacter = morseAlphabet.get(String.valueOf(upperCaseEnglish.charAt(index)));
if (morseCharacter == null) {
    throw new IllegalArgumentException("Character " + upperCaseEnglish.charAt(index) + " can't be translated to morse");
}
morse[index] = morseCharacter;

The modification is pretty straightforward because if a character is invalid, it isn’t present as a key of the bidirectional map. Hence, the get() method returns null. We can also add a null safety check on top of our method. In a nutshell, our final method reads:

static String englishToMorse(String english) {
    if (english == null) {
        return null;
    }
    String upperCaseEnglish = english.toUpperCase();
    String[] morse = new String[upperCaseEnglish.length()];
    for (int index = 0; index < upperCaseEnglish.length(); index++) {
        String morseCharacter = morseAlphabet.get(String.valueOf(upperCaseEnglish.charAt(index)));
        if (morseCharacter == null) {
            throw new IllegalArgumentException("Character " + upperCaseEnglish.charAt(index) + " can't be translated to morse");
        }
        morse[index] = morseCharacter;
    }
    return String.join(" ", morse);
}

Lastly, we can add a unit test with a non-translatable sentence:

@Test
void givenAnEnglishWordWithAnIllegalCharacter_whenEnglishToMorse_thenThrows() {
    String english = "~This sentence starts with an illegal character";
    assertThrows(IllegalArgumentException.class, () -> MorseTranslator.englishToMorse(english));
}

4. Translating Morse Into English

Let’s now write the reverse method. Once again, we’ll focus on the big picture before diving into edge cases.

4.1. General Algorithm

The concept is the same: for each Morse character, we find the English translation in the BidiMap. The getKey() method allows us to do that. Then, we need to iterate over every Morse character:

static String morseToEnglish(String morse) {
    String[] morseUnitCharacters = morse.split(" ");
    StringBuilder stringBuilder = new StringBuilder();
    for (int index = 0; index < morseUnitCharacters.length; index ++) {
        String englishCharacter = morseAlphabet.getKey(morseUnitCharacters[index]);
        stringBuilder.append(englishCharacter);
    }
    return stringBuilder.toString();
}

We isolated every Morse character thanks to the String.split() method. Appending every English translation to a StringBuilder is the most efficient way to concatenate the result.

Let’s now verify that our method returns the correct result:

@Test
void givenAValidMorseWord_whenMorseToEnglish_thenTranslatedToUpperCaseEnglish() {
    assertEquals("MORSE CODE!", MorseTranslator.morseToEnglish("-- --- .-. ... . / -.-. --- -.. . -.-.-----."));
}

Finally, we can recall that the output will always be in capital letters.

4.2. Edge Cases

Additionally, we want to refuse inputs containing invalid Morse characters. Like in englishToMorse(), we’ll throw an IllegalArgumentException in this case. Moreover, we can also handle the specific case of a null input. Here, we also have to deal with an empty input separately because of the internal functioning of the split() method.

To recap, let’s write our final method:

static String morseToEnglish(String morse) {
    if (morse == null) {
        return null;
    }
    if (morse.isEmpty()) {
        return "";
    }
    String[] morseUnitCharacters = morse.split(" ");
    StringBuilder stringBuilder = new StringBuilder();
    for (int index = 0; index < morseUnitCharacters.length; index ++) {
        String englishCharacter = morseAlphabet.getKey(morseUnitCharacters[index]);
        if (englishCharacter == null) {
            throw new IllegalArgumentException("Character " + morseUnitCharacters[index] + " is not a valid morse character");
        }
        stringBuilder.append(englishCharacter);
    }
    return stringBuilder.toString();
}

Dealing with invalid characters was as straightforward as in the previous case because if a Morse code doesn’t match any of the BidiMap‘s values, the getKey() method returns null.

Lastly, we can also test the error case:

@Test
void givenAMorseWordWithAnIllegalCharacter_whenMorseToEnglish_thenThrows() {
    assertThrows(IllegalArgumentException.class, () -> MorseTranslator.morseToEnglish(".!!!!!!!"));
}

5. Conclusion

In this article, we learned about the Morse code and wrote a simple two-way translator between Morse and English. Most considerations aren’t specific to Morse, so we could probably make our code more generic to deal with any language that can define a bidirectional mapping with English.

As always, the code is available over on GitHub.