Codificador / decodificador de código Morse con capacidad de reproducción

Aug 19 2020

Recientemente quise crear un codificador / decodificador morse con capacidad de reproducción, el programa debe java version >= 11ejecutarse.

el programa requiere un par de jars:

  • com.google.common.collect.BiMap

  • javazoom.jl.player.Player

Usé el BiMappor la siguiente razón:

Un bimap (o "mapa bidireccional") es un mapa que conserva la singularidad de sus valores así como la de sus claves. Esta restricción permite que los bimaps admitan una "vista inversa", que es otro bimapa que contiene las mismas entradas que este bimapa pero con claves y valores invertidos. árbitro

Como muchos traductores de Morse en línea, use el carácter '/'o ','para traducir al espacio que usé '\t'.

En cuanto a la estructura, utilicé el patrón de diseño Singleton para permitir al usuario tener una cantidad limitada de objetos, por lo que no es necesario crear un objeto para codificar / decodificar si ya existe.

El programa presenta lo siguiente:

  1. Flexible, por lo que puede leer de la base de datos deseada.

  2. Compatible con todo tipo de permitidos CharSetrespaldados por java (cuando se usa el juego de caracteres correcto para leer un determinado archivo).

  3. Reproducción de audio para ayudar a las personas a aprender a comprender el código morse al escuchar.

  4. Capacidad para escribir resultados en un archivo por la ruta deseada.

  5. El programa tiene en cuenta la expresión regular cuando se trata de leer el archivo de la base de datos, ya que la expresión regular actuaría como un separador entre la letra real y la secuencia de puntos y guiones.

Así que aquí está el Código:

import com.google.common.collect.BiMap;
import com.google.common.collect.HashBiMap;
import javazoom.jl.decoder.JavaLayerException;
import javazoom.jl.player.Player;

import java.io.FileInputStream;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import java.util.stream.Collectors;

import static com.google.common.collect.Maps.unmodifiableBiMap;

/**
 * This class represents Encoder and Decoder for Morse code.
 * @author  Kazem Aljalabi.
 */
public final class Morse {

    private Path dataBaseFile;
    private BiMap<String, String> data;
    private Charset cs = StandardCharsets.UTF_8;
    private String charSeparationRegex = " ";

    //Singleton Pattern via Lazy Instantiation = private constructor + static object that will be created once!.
    private static Morse defaultObj, pathObj, objWithSeparator, objWithCharSet;

    /**
     * This Method creates a class instance of type {@link Morse} if not created before else return the already created object.
     * @return a class instance of type {@link Morse}.
     */
    public static Morse getInstance() {
        if (null == defaultObj)
            defaultObj = new Morse();
        return defaultObj;
    }

    /**
     * This Method creates a class instance of type {@link Morse} if not created before else return the already created object.
     * @param dataBaseFile the path to the database which contains the actual decoding and encoding table of the morse code.
     * @return a class instance of type {@link Morse} linked with a database of user's choice via a {@link Path}.
     */
    public static Morse getInstance(final Path dataBaseFile) {
        if (null == pathObj)
            pathObj = new Morse(dataBaseFile);
        return pathObj;
    }


    /**
     * This Method creates a class instance of type {@link Morse} if not created before else return the already created object.
     * @param dataBaseFile the {@link Path} to the database which contains the actual decoding and encoding table of the morse code.
     * @param separator the regex which will act as a separator between the actual letter and its representation in morse code.
     * @return a class instance of type {@link Morse} linked with database path and a separator.
     */
    public static Morse getInstance(final Path dataBaseFile, final String separator) {
        if (null == objWithSeparator)
            objWithSeparator = new Morse(dataBaseFile, separator);
        return objWithSeparator;
    }
    
    /**
     * This Method creates a class instance of type {@link Morse} if not created before else return the already created object.
     * @param dataBaseFile the path to the database which contains the actual decoding and encoding table of the morse code.
     * @param separator the regex which will act as a separator between the actual letter and its representation in morse code.
     * @param cs the {@link Charset} in which the database is written with.
     * @return a class instance of type {@link Morse} linked with the database with a specific path, charset, and separator.
     */
    public static Morse getInstance(final Path dataBaseFile, final String separator, final Charset cs) {
        if (null == objWithCharSet)
            objWithCharSet = new Morse(dataBaseFile, separator, cs);
        return objWithCharSet;
    }

    /**
     * @param dataBaseFile path to the new dataBaseFile to be set.
     */
    public void setDataBaseFile(Path dataBaseFile) {
        this.dataBaseFile = dataBaseFile;
        checkForDataBase();
    }

    /**
     * Constructor to create a class instance of type {@link Morse} with a default database called "Code.txt" placed in the same dir with the class.
     */
    private Morse() {
        dataBaseFile = Paths.get(Morse.class.getResource( "Morse.class" ).getPath()).toAbsolutePath().normalize().getParent().resolve("Code.txt");
        checkForDataBase();
    }


    /**
     * Constructor creates a class instance of type {@link Morse} with a custom database provided by the user via a valid path.
     * @param dataBaseFile the path to the database which contains the actual decoding and encoding table of the morse code.
     */
    private Morse(final Path dataBaseFile) {
        this.dataBaseFile = dataBaseFile;
        checkForDataBase();
    }

    /**
     * Constructor creates a class instance of type {@link Morse} with a custom database with a specific separator provided by the user via a valid path.
     * @param dataBaseFile the {@link Path} to the database which contains the actual decoding and encoding table of the morse code.
     * @param separator the regex which will act as a separator between the actual letter and its representation in morse code.
     */
    private Morse(final Path dataBaseFile, final String separator) {
        this (dataBaseFile);
        assert separator != null;
        if ( checkForRegexValidity(separator) && !separator.contains(".") && !separator.contains("_") ) //those are reserved to the morse code!
            this.charSeparationRegex = separator;
    }

    /**
     * Constructor creates a class instance of type {@link Morse} with a custom database with a specific separator provided by the user via a valid path.
     * The database file is written in a specific CharSet.
     * @param dataBaseFile the path to the database which contains the actual decoding and encoding table of the morse code.
     * @param separator the regex which will act as a separator between the actual letter and its representation in morse code.
     * @param cs the {@link Charset} in which the database is written with.
     */
    private Morse(final Path dataBaseFile, final String separator, final Charset cs) {
        this (dataBaseFile, separator);
        this.cs = cs;
    }


    /**
     * Method to check the existence of database path.
     */
    private void checkForDataBase () {
        if (!Files.exists(dataBaseFile))
            System.exit(1);
        data = unmodifiableBiMap(populateFromDataBase());
    }

    /**
     * Method to check if the separator provided by the user is a valid regex.
     * @param regex database separator provided by the user.
     * @return true if the regex is valid else false.
     */
    private boolean checkForRegexValidity (String regex) {
        PatternSyntaxException flag = null;
        try {
            Pattern.compile(regex);
        } catch (PatternSyntaxException exception) { flag=exception; }
        return flag == null;
    }

    /**
     * Method to populate the Database from the database {@link java.io.File}.
     * @return a {@link BiMap} which contains the encoding/decoding schema of the Morse code based on the database file.
     */
    private BiMap<String, String> populateFromDataBase () {
        List<String> encodingSchema = new ArrayList<>();

        try {
            encodingSchema = Files.readAllLines(dataBaseFile, cs);
        } catch (IOException e) { e.printStackTrace(); }

        //To prevent the empty of being inserted inside the Hash we need to filter it out!
        return encodingSchema.stream().filter(s -> !s.equals(""))
                .collect(Collectors.toMap(
                        e -> e.replaceAll(charSeparationRegex," ").strip().split("\\s+")[0]
                        ,  e -> e.replaceAll(charSeparationRegex," ").strip().split("\\s+")[1]
                        , (e1, e2) -> e2
                        , HashBiMap::create)
                );
    }

    /**
     * Method which will write a specific message to a given file.
     * @param data The data to be written to a file. the data can be an already encoded message or the decoded message of an already encoded message!.
     * @param resultsPath the path where the results would be written, if it doesn't exist it will be created.
     */
    public void writeResultsToFile (String data, Path resultsPath) {
        try {
            Files.writeString(resultsPath, data, StandardOpenOption.CREATE);
        } catch (IOException e) { e.printStackTrace(); }
    }

    /**
     * Method to decode a given Message based on the given database and the morse code logic.
     * @param message to be decoded assuming that the message contains only '_' and '.', assuming that the message given contains no foreign chars that don't exist in the database given.
     * @return a decoded version of the provided message.
     */
    public String decodeMessage(String message) {
        var builder = new StringBuilder();

        for (var str : message.strip().split("\t"))
            builder.append(decodeHelper(str)).append(" ");

        return builder.toString().strip();
    }

    /**
     * A helper method to decode One Word at a time.
     * @param word which consists of '_' and '.' which will be encoded accordingly to the given database.
     * @return a valid decoded word.
     */
    private StringBuilder decodeHelper (String word) {
        return Arrays.stream(word.split(" "))
                .collect(StringBuilder::new
                        , (builder, s) -> builder.append(data.inverse().getOrDefault(s, " "))
                        , StringBuilder::append
                );
    }

    /**
     * Method to encode a certain message based on the provided database.
     * @param message to be encoded assuming that the message given contains no foreign chars that don't exist in the database given.
     * @return an encoded version to the provided message which consists of only '_' and '.'.
     */
    public String encodeMessage (String message) {

        var builder = new StringBuilder();

        for (var str : message.toUpperCase().strip().split("")) {
            builder.append(data.getOrDefault(str, ""));
            if (!str.equals(" "))
                builder.append(" ");
            else
                builder.append("\t");//insert tap to tell when word ends!.
        }
        return builder.toString().strip();
    }

    /**
     * Method to play the actual sound of a certain message while being encoded.
     * @param data to be encoded.
     */
    public void encodeAndPlayAudio (String data) {
        var encoded = encodeMessage(data).split("\t");
        var tabsNumber = encoded.length-1;

        for (var c : encoded) {
            playAudio(c);

            if (tabsNumber-- > 0){
                System.out.print("\t");
                try { Thread.sleep(1000); } catch (InterruptedException ignored) {  }
            }
        }
        System.out.println();
    }

    /**
     * @param filename of the soundtrack to be played.
     */
    private void playMp3 (String filename) {
        try (var fis = new FileInputStream(Morse.class.getResource(filename).getPath())) {
            new Player(fis).play();
        } catch (IOException | JavaLayerException e) { e.printStackTrace(); }
    }

    /**
     * Method to decide which soundtrack will get played based on the current char.
     * @param encodeMessage which will be played.
     */
    private void playAudio (String encodeMessage) {
        for (var c : encodeMessage.strip().toCharArray()){
            if (c == '.')
                playMp3("di.mp3");
            else if (c == '_')
                playMp3("dah.mp3");

            System.out.print(c);
        }
    }


    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Morse morse = (Morse) o;
        return dataBaseFile.equals(morse.dataBaseFile) &&
                data.equals(morse.data);
    }

    @Override
    public int hashCode() { return Objects.hash(dataBaseFile, data); }

    @Override
    public String toString() {
        return "Morse{" +
                "dataBaseFile=" + dataBaseFile +
                ", data=" + data +
                '}';
    }
}

Ejemplo de base de datoscode.txt (por supuesto, esto se puede ampliar cuando se desee):

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   __..
1   .____
2   ..___
3   ...__
4   ...._
5   .....
6   _....
7   __...
8   ___..
9   ____.
0   _____

El usuario principal se vería así :

public class Main {

    public static void main(String[] args) {
        var obj = Morse.getInstance();
        System.out.println(obj.encodeMessage("cool java"));
        obj.encodeAndPlayAudio("cool java");

    }
}

Los archivos de audio se pueden encontrar en Wikipedia

  1. dot sound que es básicamente un 'E'se puede encontrar aquí !
  2. ¡El sonido del tablero que es básicamente un 'T'se puede encontrar aquí !

Qué revisar:

Me gustaría una revisión de estilo, diseño y funcionalidad. ¿Qué se hace bien, qué se debe hacer mejor o de otra manera? ¿Qué solución alternativa propondría?

Tenga en cuenta que este proyecto está hecho con fines divertidos y educativos y no es parte de una tarea universitaria.

Como lo explicó @ Sᴀᴍ Onᴇᴌᴀ en los comentarios, no actualizaré mi código para incorporar comentarios de las respuestas "hacerlo va en contra del estilo Pregunta + Respuesta de Revisión de código", por lo que aquí está el estado actual de mi Github .

Gracias por adelantado :)

Respuestas

3 RoToRa Aug 19 2020 at 22:02

Los getInstancemétodos limitan severamente la clase y son una fuente de errores potenciales. No hay ninguna razón por la que no debería ser posible, por ejemplo, crear dos objetos con acceso a dos archivos de base de datos diferentes:

Morse morse1 = Morse.getInstance(Paths.get("file1"));
Morse morse2 = Morse.getInstance(Paths.get("file2"));

Sin embargo, en este ejemplo, morse2inesperadamente no se usa "file2", sino que es la misma instancia morse1que usa "file1".

(EDITAR: debe evitar los establecedores, si puede. Las clases inmutables suelen ser preferibles. Si, por ejemplo, desea cambiar las bases de datos en tiempo de ejecución, es preferible crear un nuevo objeto utilizando esa otra base de datos, que cambiar un objeto existente).


Los constructores deben estructurarse de manera diferente, de modo que toda la lógica / validación solo ocurra en uno solo y los otros constructores solo llamen a ese constructor con los valores predeterminados.

EDITAR: Actualmente tienes dos constructores que llaman checkForDataBase(), y otro que valida el separador. En su lugar, debería tener un único constructor "principal" (probablemente Morse(final Path dataBaseFile, final String separator, final Charset cs)), que contenga toda la validación y haga que los demás lo llamen utilizando los valores predeterminados para los parámetros faltantes. Por ejemplo:

private final static String DEFAULT_SEPARATOR = " ";
private final static CharSet DEFAULT_CHARSET = StandardCharsets.UTF_8;

public Morse(final Path dataBaseFile, final String separator, final Charset cs) {
   // All validation and setting instance fields here
}

public Morse() {
  this(defaultDatabaseFile());
  // or: this(defaultDatabaseFile(), DEFAULT_SEPARATOR, DEFAULT_CHARSET)
}

public Morse(final Path dataBaseFile) {
  this(dataBaseFile, DEFAULT_SEPARATOR);
  // or: this(dataBaseFile, DEFAULT_SEPARATOR, DEFAULT_CHARSET)
}

public Morse(final Path dataBaseFile, final String separator) {
  this(dataBaseFile, separator, DEFAULT_CHARSET);
}

Recuperar el archivo de base de datos predeterminado parece un poco complicado, especialmente con el nombre del archivo de clase codificado de forma rígida "Morse.class", que puede pasarse por alto fácilmente, si alguna vez se cambia el nombre de la clase.

A menos que me equivoque (no me gusta manejar recursos), debería ser posible con:

Paths.get(Morse.class.getResource("../Code.txt").toURI());

La assertpalabra clave no es para validar parámetros. Se utiliza durante el desarrollo para detectar estados que nunca deberían ocurrir. assertnormalmente estaría deshabilitado en tiempo de ejecución de producción. En su lugar utilice Objects.requireNonNull.


separator.contains(".")es una forma poco confiable de verificar si una expresión regular coincide con un punto, porque es un carácter especial en expresiones regulares que coincide con cualquier carácter. Probablemente sería mejor verificar \.( "\\."como una cadena de Java). O tal vez no permita que el usuario asigne directamente una expresión regular como separador, sino una matriz de caracteres / cadenas, a partir de la cual se construye una expresión regular.


Usar System.exit(1)dentro de una clase de utilidad como esta es inesperado y, por lo tanto, una mala idea. Debería lanzar una excepción aquí, que podría atrapar main()y posiblemente usar System.exit()allí.


checkForRegexValidityparece inquietantemente complejo. No es necesario almacenar la excepción lanzada. Simplemente regrese directamente trueo false:

private boolean checkForRegexValidity (String regex) {
    try {
        Pattern.compile(regex);
        return true;
    } catch (PatternSyntaxException exception) { 
        return false;
    }
}

Cuando encuentre una excepción al leer el archivo de la base de datos, no solo imprima el seguimiento de la pila y, de lo contrario, ignore el error. Personalmente, dejaría pasar la excepción y la detectaría fuera de esta clase. En realidad, podría soltar checkForDataBasey simplemente pasar la IOException debido al archivo que falta.


Durante el llenado del mapa, está limpiando y dividiendo las líneas dos veces innecesariamente. Con un .mappaso adicional en la secuencia que se puede evitar:

return encodingSchema.stream().filter(s -> !s.equals(""))
      .map(e -> e.replaceAll(charSeparationRegex," ").strip().split("\\s+"))
      .filter(e -> e.length < 2) // also skip invalid lines
      .collect(Collectors.toMap(
                    e -> e[0]
                    ,  e -> e[1]
                    , (e1, e2) -> e2
                    , HashBiMap::create)
            );

Realmente no veo el sentido de usar un BiMapaquí. Si constantemente agregas o quitas entradas, sería una buena idea, sin embargo, en este caso, el mapa es estático, así que solo crearía dos mapas normales.