Merge pull request #172 from informatik-ag-ngl/f/voice_messages

Voice Messaging
This commit is contained in:
2020-07-05 14:03:00 +00:00
committed by GitHub
10 changed files with 397 additions and 77 deletions

View File

@ -0,0 +1,64 @@
package envoy.client.data.audio;
import javax.sound.sampled.*;
import envoy.exception.EnvoyException;
/**
* Plays back audio from a byte array.
* <p>
* Project: <strong>envoy-client</strong><br>
* File: <strong>AudioPlayer.java</strong><br>
* Created: <strong>05.07.2020</strong><br>
*
* @author Kai S. K. Engelbart
* @since Envoy Client v0.1-beta
*/
public final class AudioPlayer {
private final AudioFormat format;
private final DataLine.Info info;
private Clip clip;
/**
* Initializes the player with the default audio format.
*
* @since Envoy Client v0.1-beta
*/
public AudioPlayer() { this(AudioRecorder.DEFAULT_AUDIO_FORMAT); }
/**
* Initializes the player with a given audio format.
*
* @param format the audio format to use
* @since Envoy Client v0.1-beta
*/
public AudioPlayer(AudioFormat format) {
this.format = format;
info = new DataLine.Info(Clip.class, format);
}
/**
* @return {@code true} if audio play back is supported
* @since Envoy Client v0.1-beta
*/
public boolean isSupported() { return AudioSystem.isLineSupported(info); }
/**
* Plays back an audio clip.
*
* @param data the data of the clip
* @throws EnvoyException if the play back failed
* @since Envoy Client v0.1-beta
*/
public void play(byte[] data) throws EnvoyException {
try {
clip = (Clip) AudioSystem.getLine(info);
clip.open(format, data, 0, data.length);
clip.start();
} catch (final LineUnavailableException e) {
throw new EnvoyException("Cannot play back audio", e);
}
}
}

View File

@ -0,0 +1,122 @@
package envoy.client.data.audio;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import javax.sound.sampled.*;
import envoy.exception.EnvoyException;
/**
* Records audio and exports it as a byte array.
* <p>
* Project: <strong>envoy-client</strong><br>
* File: <strong>AudioRecorder.java</strong><br>
* Created: <strong>02.07.2020</strong><br>
*
* @author Kai S. K. Engelbart
* @since Envoy Client v0.1-beta
*/
public final class AudioRecorder {
/**
* The default audio format used for recording and play back.
*
* @since Envoy Client v0.1-beta
*/
public static final AudioFormat DEFAULT_AUDIO_FORMAT = new AudioFormat(16000, 16, 1, true, false);
private final AudioFormat format;
private final DataLine.Info info;
private TargetDataLine line;
private Path tempFile;
/**
* Initializes the recorder with the default audio format.
*
* @since Envoy Client v0.1-beta
*/
public AudioRecorder() { this(DEFAULT_AUDIO_FORMAT); }
/**
* Initializes the recorder with a given audio format.
*
* @param format the audio format to use
* @since Envoy Client v0.1-beta
*/
public AudioRecorder(AudioFormat format) {
this.format = format;
info = new DataLine.Info(TargetDataLine.class, format);
}
/**
* @return {@code true} if audio recording is supported
* @since Envoy Client v0.1-beta
*/
public boolean isSupported() { return AudioSystem.isLineSupported(info); }
/**
* @return {@code true} if the recorder is active
* @since Envoy Client v0.1-beta
*/
public boolean isRecording() { return line != null && line.isActive(); }
/**
* Starts the audio recording.
*
* @throws EnvoyException if starting the recording failed
* @since Envoy Client v0.1-beta
*/
public void start() throws EnvoyException {
try {
// Open the line
line = (TargetDataLine) AudioSystem.getLine(info);
line.open(format);
line.start();
// Prepare temp file
tempFile = Files.createTempFile("recording", "wav");
// Start the recording
final var ais = new AudioInputStream(line);
AudioSystem.write(ais, AudioFileFormat.Type.WAVE, tempFile.toFile());
} catch (IOException | LineUnavailableException e) {
throw new EnvoyException("Cannot record voice", e);
}
}
/**
* Stops the recording.
*
* @return the finished recording
* @throws EnvoyException if finishing the recording failed
* @since Envoy Client v0.1-beta
*/
public byte[] finish() throws EnvoyException {
try {
line.stop();
line.close();
final byte[] data = Files.readAllBytes(tempFile);
Files.delete(tempFile);
return data;
} catch (final IOException e) {
throw new EnvoyException("Cannot save voice recording", e);
}
}
/**
* Cancels the active recording.
*
* @since Envoy Client v0.1-beta
*/
public void cancel() {
line.stop();
line.close();
try {
Files.deleteIfExists(tempFile);
} catch (IOException e) {}
}
}

View File

@ -0,0 +1,11 @@
/**
* Contains classes related to recording and playing back audio clips.
* <p>
* Project: <strong>envoy-client</strong><br>
* File: <strong>package-info.java</strong><br>
* Created: <strong>05.07.2020</strong><br>
*
* @author Kai S. K. Engelbart
* @since Envoy Client v0.1-beta
*/
package envoy.client.data.audio;

View File

@ -51,16 +51,25 @@ public class Receiver extends Thread {
@Override
public void run() {
try {
while (true) {
while (true) {
try {
// Read object length
final byte[] lenBytes = new byte[4];
in.read(lenBytes);
final int len = SerializationUtils.bytesToInt(lenBytes, 0);
logger.log(Level.FINEST, "Expecting object of length " + len + ".");
// Read object into byte array
final byte[] objBytes = new byte[len];
in.read(objBytes);
final byte[] objBytes = new byte[len];
final int bytesRead = in.read(objBytes);
logger.log(Level.FINEST, "Read " + bytesRead + " bytes.");
// Catch LV encoding errors
if (len != bytesRead) {
logger.log(Level.WARNING,
String.format("LV encoding violated: expected %d bytes, received %d bytes. Discarding object...", len, bytesRead));
continue;
}
try (ObjectInputStream oin = new ObjectInputStream(new ByteArrayInputStream(objBytes))) {
final Object obj = oin.readObject();
@ -69,16 +78,16 @@ public class Receiver extends Thread {
// Get appropriate processor
@SuppressWarnings("rawtypes")
final Consumer processor = processors.get(obj.getClass());
if (processor == null)
logger.log(Level.WARNING, String.format(
"The received object has the class %s for which no processor is defined.", obj.getClass()));
if (processor == null) logger.log(Level.WARNING,
String.format("The received object has the class %s for which no processor is defined.", obj.getClass()));
else processor.accept(obj);
}
} catch (final SocketException e) {
// Connection probably closed by client.
return;
} catch (final Exception e) {
logger.log(Level.SEVERE, "Error on receiver thread", e);
}
} catch (final SocketException e) {
// Connection probably closed by client.
} catch (final Exception e) {
logger.log(Level.SEVERE, "Error on receiver thread", e);
}
}

View File

@ -0,0 +1,49 @@
package envoy.client.ui;
import java.util.logging.Level;
import java.util.logging.Logger;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.Button;
import javafx.scene.layout.HBox;
import envoy.client.data.audio.AudioPlayer;
import envoy.exception.EnvoyException;
import envoy.util.EnvoyLog;
/**
* Enables the play back of audio clips through a button.
* <p>
* Project: <strong>envoy-client</strong><br>
* File: <strong>AudioControl.java</strong><br>
* Created: <strong>05.07.2020</strong><br>
*
* @author Kai S. K. Engelbart
* @since Envoy Client v0.1-beta
*/
public final class AudioControl extends HBox {
private AudioPlayer player = new AudioPlayer();
private static final Logger logger = EnvoyLog.getLogger(AudioControl.class);
/**
* Initializes the audio control.
*
* @param audioData the audio data to play.
* @since Envoy Client v0.1-beta
*/
public AudioControl(byte[] audioData) {
var button = new Button("Play");
button.setOnAction(e -> {
try {
player.play(audioData);
} catch (EnvoyException ex) {
logger.log(Level.SEVERE, "Could not play back audio: ", ex);
new Alert(AlertType.ERROR, "Could not play back audio").showAndWait();
}
});
getChildren().add(button);
}
}

View File

@ -20,6 +20,7 @@ import javafx.scene.paint.Color;
import envoy.client.data.Chat;
import envoy.client.data.LocalDB;
import envoy.client.data.Settings;
import envoy.client.data.audio.AudioRecorder;
import envoy.client.event.MessageCreationEvent;
import envoy.client.net.Client;
import envoy.client.net.WriteProxy;
@ -30,10 +31,12 @@ import envoy.client.ui.listcell.ContactListCellFactory;
import envoy.client.ui.listcell.MessageControl;
import envoy.client.ui.listcell.MessageListCellFactory;
import envoy.data.*;
import envoy.data.Attachment.AttachmentType;
import envoy.event.EventBus;
import envoy.event.MessageStatusChange;
import envoy.event.UserStatusChange;
import envoy.event.contact.ContactOperation;
import envoy.exception.EnvoyException;
import envoy.util.EnvoyLog;
/**
@ -58,6 +61,9 @@ public final class ChatScene implements Restorable {
@FXML
private Button postButton;
@FXML
private Button voiceButton;
@FXML
private Button settingsButton;
@ -78,9 +84,11 @@ public final class ChatScene implements Restorable {
private WriteProxy writeProxy;
private SceneContext sceneContext;
private boolean postingPermanentlyDisabled = false;
private Chat currentChat;
private Chat currentChat;
private AudioRecorder recorder;
private boolean recording;
private Attachment pendingAttachment;
private boolean postingPermanentlyDisabled = false;
private static final Settings settings = Settings.getInstance();
private static final EventBus eventBus = EventBus.getInstance();
@ -172,6 +180,8 @@ public final class ChatScene implements Restorable {
contactLabel.setText(localDB.getUser().getName());
MessageControl.setUser(localDB.getUser());
if (!client.isOnline()) updateInfoLabel("You are offline", "infoLabel-info");
recorder = new AudioRecorder();
}
@Override
@ -205,11 +215,20 @@ public final class ChatScene implements Restorable {
logger.log(Level.WARNING, "Could not read current chat.", e);
}
// Discard the pending attachment
if (recorder.isRecording()) {
recorder.cancel();
recording = false;
voiceButton.setText("Record Voice Message");
}
pendingAttachment = null;
remainingChars.setVisible(true);
remainingChars
.setText(String.format("remaining chars: %d/%d", MAX_MESSAGE_LENGTH - messageTextArea.getText().length(), MAX_MESSAGE_LENGTH));
}
messageTextArea.setDisable(currentChat == null || postingPermanentlyDisabled);
voiceButton.setDisable(!recorder.isSupported());
}
/**
@ -234,6 +253,26 @@ public final class ChatScene implements Restorable {
sceneContext.<ContactSearchScene>getController().initializeData(sceneContext, localDB);
}
@FXML
private void voiceButtonClicked() {
new Thread(() -> {
try {
if (!recording) {
recording = true;
Platform.runLater(() -> voiceButton.setText("Recording..."));
recorder.start();
} else {
pendingAttachment = new Attachment(recorder.finish(), AttachmentType.VOICE);
recording = false;
Platform.runLater(() -> { voiceButton.setText("Record Voice Message"); checkPostConditions(false); });
}
} catch (EnvoyException e) {
logger.log(Level.SEVERE, "Could not record audio: ", e);
Platform.runLater(new Alert(AlertType.ERROR, "Could not record audio")::showAndWait);
}
}).start();
}
/**
* Checks the text length of the {@code messageTextArea}, adjusts the
* {@code remainingChars} label and checks whether to send the message
@ -256,11 +295,14 @@ public final class ChatScene implements Restorable {
*/
@FXML
private void checkPostConditions(KeyEvent e) {
checkPostConditions(settings.isEnterToSend() && e.getCode() == KeyCode.ENTER
|| !settings.isEnterToSend() && e.getCode() == KeyCode.ENTER && e.isControlDown());
}
private void checkPostConditions(boolean sendKeyPressed) {
if (!postingPermanentlyDisabled) {
if (!postButton.isDisabled() && (settings.isEnterToSend() && e.getCode() == KeyCode.ENTER
|| !settings.isEnterToSend() && e.getCode() == KeyCode.ENTER && e.isControlDown()))
postMessage();
postButton.setDisable(messageTextArea.getText().isBlank() || currentChat == null);
if (!postButton.isDisabled() && sendKeyPressed) postMessage();
postButton.setDisable((messageTextArea.getText().isBlank() && pendingAttachment == null) || currentChat == null);
} else {
final var noMoreMessaging = "Go online to send messages";
if (!infoLabel.getText().equals(noMoreMessaging))
@ -315,12 +357,14 @@ public final class ChatScene implements Restorable {
return;
}
final var text = messageTextArea.getText().strip();
if (text.isBlank()) throw new IllegalArgumentException("A message without visible text can not be sent.");
try {
// Create and send message
final var message = new MessageBuilder(localDB.getUser().getID(), currentChat.getRecipient().getID(), localDB.getIDGenerator())
.setText(text)
.build();
var builder = new MessageBuilder(localDB.getUser().getID(), currentChat.getRecipient().getID(), localDB.getIDGenerator()).setText(text);
if (pendingAttachment != null) {
builder.setAttachment(pendingAttachment);
pendingAttachment = null;
}
var message = builder.build();
// Send message
writeProxy.writeMessage(message);

View File

@ -9,6 +9,7 @@ import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.VBox;
import envoy.client.ui.AudioControl;
import envoy.client.ui.IconUtil;
import envoy.data.Message;
import envoy.data.Message.MessageStatus;
@ -38,6 +39,20 @@ public class MessageControl extends VBox {
public MessageControl(Message message) {
// Creating the underlying VBox, the dateLabel and the textLabel
super(new Label(dateFormat.format(message.getCreationDate())));
// Handling message attachment display
if (message.hasAttachment()) switch (message.getAttachment().getType()) {
case PICTURE:
break;
case VIDEO:
break;
case VOICE:
getChildren().add(new AudioControl(message.getAttachment().getData()));
break;
case DOCUMENT:
break;
}
final var textLabel = new Label(message.getText());
textLabel.setWrapText(true);
getChildren().add(textLabel);