diff --git a/jpro-media/src/main/java/one/jpro/platform/media/MediaView.java b/jpro-media/src/main/java/one/jpro/platform/media/MediaView.java index 5c71e40a..448d5018 100644 --- a/jpro-media/src/main/java/one/jpro/platform/media/MediaView.java +++ b/jpro-media/src/main/java/one/jpro/platform/media/MediaView.java @@ -15,10 +15,10 @@ import one.jpro.platform.media.player.impl.WebMediaPlayer; import one.jpro.platform.media.player.impl.WebMediaPlayerView; import one.jpro.platform.media.recorder.MediaRecorder; -import one.jpro.platform.media.recorder.impl.FXMediaRecorder; -import one.jpro.platform.media.recorder.impl.FXMediaRecorderView; -import one.jpro.platform.media.recorder.impl.WebMediaRecorder; -import one.jpro.platform.media.recorder.impl.WebMediaRecorderView; +import one.jpro.platform.media.recorder.NativeMediaRecorder; +import one.jpro.platform.media.recorder.NativeMediaRecorderView; +import one.jpro.platform.media.recorder.WebMediaRecorder; +import one.jpro.platform.media.recorder.WebMediaRecorderView; /** * Provides a view of {@link MediaSource} being played by a {@link MediaPlayer} @@ -136,8 +136,8 @@ public static MediaView create(MediaPlayer mediaPlayer) { * @return a {@link MediaView} object. */ public static MediaView create(MediaRecorder mediaRecorder) { - if (mediaRecorder instanceof FXMediaRecorder fxMediaRecorder) { - return new FXMediaRecorderView(fxMediaRecorder); + if (mediaRecorder instanceof NativeMediaRecorder fxMediaRecorder) { + return new NativeMediaRecorderView(fxMediaRecorder); } else if (mediaRecorder instanceof WebMediaRecorder webMediaRecorder) { return new WebMediaRecorderView(webMediaRecorder); } else { diff --git a/jpro-media/src/main/java/one/jpro/platform/media/WebMediaView.java b/jpro-media/src/main/java/one/jpro/platform/media/WebMediaView.java index fc437c18..385a6d57 100644 --- a/jpro-media/src/main/java/one/jpro/platform/media/WebMediaView.java +++ b/jpro-media/src/main/java/one/jpro/platform/media/WebMediaView.java @@ -9,7 +9,7 @@ import javafx.geometry.HPos; import javafx.geometry.VPos; import javafx.scene.Node; -import one.jpro.platform.media.recorder.impl.WebMediaRecorder; +import one.jpro.platform.media.recorder.WebMediaRecorder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/jpro-media/src/main/java/one/jpro/platform/media/recorder/impl/BaseMediaRecorder.java b/jpro-media/src/main/java/one/jpro/platform/media/recorder/BaseMediaRecorder.java similarity index 97% rename from jpro-media/src/main/java/one/jpro/platform/media/recorder/impl/BaseMediaRecorder.java rename to jpro-media/src/main/java/one/jpro/platform/media/recorder/BaseMediaRecorder.java index c20319df..5969dee8 100644 --- a/jpro-media/src/main/java/one/jpro/platform/media/recorder/impl/BaseMediaRecorder.java +++ b/jpro-media/src/main/java/one/jpro/platform/media/recorder/BaseMediaRecorder.java @@ -1,4 +1,4 @@ -package one.jpro.platform.media.recorder.impl; +package one.jpro.platform.media.recorder; import com.sun.javafx.event.EventHandlerManager; import javafx.application.Platform; @@ -12,8 +12,6 @@ import javafx.util.Duration; import one.jpro.platform.media.MediaSource; import one.jpro.platform.media.event.MediaRecorderEvent; -import one.jpro.platform.media.recorder.MediaRecorder; -import one.jpro.platform.media.recorder.MediaRecorderException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -28,14 +26,14 @@ */ abstract class BaseMediaRecorder implements MediaRecorder { + private static final Logger logger = LoggerFactory.getLogger(BaseMediaRecorder.class); + private RecorderTimerTask recorderTimerTask; volatile boolean recorderReady; volatile boolean isUpdateDurationEnabled; long startRecordingTime = 0; private double pauseDurationTime = 0; - private final Logger log = LoggerFactory.getLogger(BaseMediaRecorder.class); - // media source property (read-only) ReadOnlyObjectWrapper mediaSource; @@ -119,12 +117,12 @@ public Duration getDuration() { return (duration == null) ? Duration.ZERO : duration.get(); } - void setDuration(Duration value) { + final void setDuration(Duration value) { durationPropertyImpl().set(value); } @Override - public ReadOnlyObjectProperty durationProperty() { + public final ReadOnlyObjectProperty durationProperty() { return durationPropertyImpl().getReadOnlyProperty(); } @@ -134,7 +132,7 @@ private ReadOnlyObjectWrapper durationPropertyImpl() { @Override protected void invalidated() { - log.trace("Recording duration: {} s", get().toSeconds()); + logger.trace("Recording duration: {} s", get().toSeconds()); } }; } diff --git a/jpro-media/src/main/java/one/jpro/platform/media/recorder/MediaRecorder.java b/jpro-media/src/main/java/one/jpro/platform/media/recorder/MediaRecorder.java index c77c5857..daae7ea8 100644 --- a/jpro-media/src/main/java/one/jpro/platform/media/recorder/MediaRecorder.java +++ b/jpro-media/src/main/java/one/jpro/platform/media/recorder/MediaRecorder.java @@ -11,8 +11,6 @@ import one.jpro.platform.media.MediaSource; import one.jpro.platform.media.MediaView; import one.jpro.platform.media.event.MediaRecorderEvent; -import one.jpro.platform.media.recorder.impl.FXMediaRecorder; -import one.jpro.platform.media.recorder.impl.WebMediaRecorder; import java.util.Optional; @@ -60,7 +58,7 @@ static MediaRecorder create(Stage stage) { final WebAPI webAPI = WebAPI.getWebAPI(stage); return new WebMediaRecorder(webAPI); } - return new FXMediaRecorder(); + return new NativeMediaRecorder(); } /** diff --git a/jpro-media/src/main/java/one/jpro/platform/media/recorder/impl/MediaRecorderOptions.java b/jpro-media/src/main/java/one/jpro/platform/media/recorder/MediaRecorderOptions.java similarity index 98% rename from jpro-media/src/main/java/one/jpro/platform/media/recorder/impl/MediaRecorderOptions.java rename to jpro-media/src/main/java/one/jpro/platform/media/recorder/MediaRecorderOptions.java index 5b2c0d5d..f91279f4 100644 --- a/jpro-media/src/main/java/one/jpro/platform/media/recorder/impl/MediaRecorderOptions.java +++ b/jpro-media/src/main/java/one/jpro/platform/media/recorder/MediaRecorderOptions.java @@ -1,4 +1,4 @@ -package one.jpro.platform.media.recorder.impl; +package one.jpro.platform.media.recorder; import org.json.JSONObject; diff --git a/jpro-media/src/main/java/one/jpro/platform/media/recorder/NativeMediaRecorder.java b/jpro-media/src/main/java/one/jpro/platform/media/recorder/NativeMediaRecorder.java new file mode 100644 index 00000000..b3354d0a --- /dev/null +++ b/jpro-media/src/main/java/one/jpro/platform/media/recorder/NativeMediaRecorder.java @@ -0,0 +1,665 @@ +package one.jpro.platform.media.recorder; + +import javafx.application.Platform; +import javafx.event.Event; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import one.jpro.platform.media.MediaSource; +import one.jpro.platform.media.event.MediaRecorderEvent; +import org.bytedeco.ffmpeg.global.avcodec; +import org.bytedeco.ffmpeg.global.avutil; +import org.bytedeco.javacv.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.sound.sampled.*; +import java.io.File; +import java.io.IOException; +import java.nio.CharBuffer; +import java.nio.ShortBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.*; +import java.util.stream.Stream; + +/** + * {@link MediaRecorder} implementation for the desktop. + * + * @author Besmir Beqiri + */ +public class NativeMediaRecorder extends BaseMediaRecorder { + + private static final Logger logger = LoggerFactory.getLogger(NativeMediaRecorder.class); + + private static final Path RECORDING_PATH = Path.of(System.getProperty("user.home"), + ".jpro", "video", "capture"); + private static final String DIVIDER_LINE = + CharBuffer.allocate(80).toString().replace('\0', '*'); + + // Video resources + private static final int CAMERA_DEVICE_INDEX = 0; // Use the default system camera + private static final int FRAME_RATE = 30; + private static final String MP4_FILE_EXTENSION = ".mp4"; + private FrameGrabber cameraGrabber; + private FFmpegFrameGrabber micGrabber; + private final JavaFXFrameConverter cameraFrameConverter; + private final ImageView cameraView; + private double frameRate; + + // Audio resources + private static final int DEFAULT_AUDIO_CHANNELS = 1; // Mono audio + private static final int DEFAULT_AUDIO_SAMPLE_RATE = 44100; // 44.1 KHz + + // Storage resources + private FFmpegFrameRecorder recorder; + private Path tempVideoFile; + + // Recording state + private volatile long startTime = 0; + private volatile boolean recording = false; + + // Concurrency and locking resources + private final ThreadGroup scheduledThreadGroup = new ThreadGroup("Media Recorder thread pool"); + private int threadCounter; + private final ExecutorService videoExecutorService; + private final ExecutorService audioExecutorService; + private final ExecutorService startStopRecordingExecutorService; + private final Semaphore semaphore = new Semaphore(2); + + /** + * Default constructor for FXMediaRecorder. + * Initializes the video and audio capture resources. + */ + public NativeMediaRecorder() { + // Set native log level to error + avutil.av_log_set_level(avutil.AV_LOG_ERROR); + + final ThreadFactory threadFactory = run -> { + final Thread thread = new Thread(scheduledThreadGroup, run); + thread.setName("Media Recorder Thread " + threadCounter++); + thread.setPriority(Thread.MIN_PRIORITY); + thread.setDaemon(true); + return thread; + }; + videoExecutorService = Executors.newSingleThreadExecutor(threadFactory); + audioExecutorService = Executors.newSingleThreadExecutor(threadFactory); + startStopRecordingExecutorService = Executors.newSingleThreadExecutor(threadFactory); + + // Initialize camera frame grabber + cameraGrabber = (isOsWindows()) ? new VideoInputFrameGrabber(CAMERA_DEVICE_INDEX) + : new OpenCVFrameGrabber(CAMERA_DEVICE_INDEX); + // Frame to JavaFX image converter + cameraFrameConverter = new JavaFXFrameConverter(); + // Use ImageView to show camera grabbed frames + cameraView = new ImageView(); + + // Initialize audio frame grabber + micGrabber = new FFmpegFrameGrabber(getDefaultAudioInputDevice()); + micGrabber.setFormat(getDefaultAudioInputFormat()); + micGrabber.setAudioChannels(DEFAULT_AUDIO_CHANNELS); + micGrabber.setSampleRate(DEFAULT_AUDIO_SAMPLE_RATE); + + // Stop and release native resources on exit + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + stopRecording(); + release(); + + // delete temporary video files + if (Files.exists(RECORDING_PATH)) { + try (Stream pathStream = Files.walk(RECORDING_PATH)) { + pathStream.sorted(Comparator.reverseOrder()) + .map(Path::toFile) + .forEach(File::delete); + } catch (IOException ex) { + logger.error(ex.getMessage(), ex); + } + } + })); + } + + /** + * Returns the {@link ImageView} instance used to display video frames captured from the camera. + * + * @return ImageView instance showing the captured video frames + */ + public final ImageView getCameraView() { + return cameraView; + } + + @Override + public final void enable() { + if (recorderReady) { + logger.info("Media recorder is already enabled."); + } else { + logger.debug("Enabling media recorder..."); + enableVideoCapture(); + enableAudioCapture(); + } + + try { + // Wait for the video and audio capture to be enabled + semaphore.acquire(); + } catch (InterruptedException ex) { + setError("Exception during the enabling of video and audio capture.", ex); + } + + // Set recorder ready + recorderReady = true; + + // Set status to ready + setStatus(Status.READY); + + // Fire ready event + Event.fireEvent(NativeMediaRecorder.this, + new MediaRecorderEvent(NativeMediaRecorder.this, + MediaRecorderEvent.MEDIA_RECORDER_READY)); + } + + /** + * Initializes and starts video capture from the camera. + */ + private void enableVideoCapture() { + logger.debug("Enabling video capture..."); + // Camera frame grabber runnable + final Runnable frameGrabber = () -> { + try { + // Acquire the semaphore + semaphore.acquire(); + + // start the video capture + cameraGrabber.start(); + frameRate = (cameraGrabber.getFrameRate() < FRAME_RATE) ? FRAME_RATE : cameraGrabber.getFrameRate(); + printCaptureDeviceDescription(); + } catch (FrameGrabber.Exception | InterruptedException ex) { + setError("Exception during the enabling of video stream from the camera.", ex); + release(); + } finally { + // Release the semaphore + semaphore.release(); + logger.debug("Video capture enabled."); + } + + // Start the camera frame grabbing, showing and recording task + try { + while (recorderReady) { + // effectively grab a single frame + final Frame videoFrame = cameraGrabber.grabFrame(); + if (videoFrame != null) { + // convert and show the frame + updateCameraView(cameraView, cameraFrameConverter.convert(videoFrame)); + // write the webcam frame if recording started + writeVideoFrame(videoFrame); + } + } + } catch (FrameGrabber.Exception ex) { + setError("Exception during camera stream frame grabbing.", ex); + release(); + } + }; + + videoExecutorService.execute(frameGrabber); + } + + /** + * Initializes and starts audio capture from the microphone. + */ + private void enableAudioCapture() { + logger.debug("Enabling audio capture..."); + final Runnable audioSampleGrabber = () -> { + try { + // Acquire the semaphore + semaphore.acquire(); + + // start the audio capture + micGrabber.start(); + } catch (FrameGrabber.Exception | InterruptedException ex) { + setError("Exception during the enabling of audio stream from the microphone.", ex); + release(); + } finally { + // Release the semaphore + semaphore.release(); + logger.debug("Audio capture enabled."); + } + + try { + while (recorderReady) { + // effectively grab a single frame + final Frame audioFrame = micGrabber.grabSamples(); + if (audioFrame != null) { + ShortBuffer audioData = (ShortBuffer) audioFrame.samples[0]; + short[] shorts = new short[audioData.limit()]; + audioData.get(shorts); + audioData.flip(); + + writeAudioSamples(audioData); + } + } + } catch (FrameGrabber.Exception ex) { + setError("Exception during microphone stream frame grabbing.", ex); + release(); + } + }; + + audioExecutorService.execute(audioSampleGrabber); + } + + /** + * Records a single video frame. + * + * @param frame the video frame to be recorded + */ + private void writeVideoFrame(Frame frame) { + if (recorder != null && recording) { + try { + recorder.record(frame); + } catch (FFmpegFrameRecorder.Exception ex) { + setError("Exception during video frame recording.", ex); + } + } + } + + /** + * Records audio samples. This method is called for each audio sample captured from the microphone. + * + * @param audioSamples the buffer containing audio samples to be recorded + */ + private void writeAudioSamples(ShortBuffer audioSamples) { + if (recorder != null && recording) { + try { + recorder.recordSamples(audioSamples); + } catch (FFmpegFrameRecorder.Exception ex) { + setError("Exception during audio samples recording.", ex); + } + } + } + + @Override + public final void start() { + if (getStatus().equals(Status.INACTIVE) || getStatus().equals(Status.READY)) { + final Runnable startRecordingRunnable = () -> { + // Start the recording if the camera is enabled + if (recorderReady) { + tempVideoFile = createTempFilename("video_", MP4_FILE_EXTENSION); + + recorder = new FFmpegFrameRecorder(tempVideoFile.toString(), cameraGrabber.getImageWidth(), + cameraGrabber.getImageHeight(), micGrabber.getAudioChannels()); + recorder.setInterleaved(true); + recorder.setVideoOption("tune", "zerolatency"); // low latency for webcam streaming + recorder.setVideoOption("preset", "ultrafast"); // low cpu usage for the encoder + recorder.setVideoOption("crf", "28"); // video quality + recorder.setVideoBitrate(8 * 1024 * 1024); // 8 Mbps for 1080p + recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264); + recorder.setFormat("mp4"); + recorder.setFrameRate(frameRate); + recorder.setGopSize((int) (frameRate * 2)); + recorder.setAudioOption("crf", "0"); // no variable bitrate audio + recorder.setAudioQuality(0); // highest quality + recorder.setAudioBitrate(192000); // 192 kbps + recorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC); + recorder.setSampleRate(micGrabber.getSampleRate()); + + try { + // Enable recording + recorder.start(); + startTime = System.currentTimeMillis(); + recording = true; + + Platform.runLater(() -> { + // Set status to recording + setStatus(Status.RECORDING); + + // Fire start event + Event.fireEvent(NativeMediaRecorder.this, + new MediaRecorderEvent(NativeMediaRecorder.this, + MediaRecorderEvent.MEDIA_RECORDER_START)); + }); + } catch (FFmpegFrameRecorder.Exception ex) { + setError("Exception on starting the audio/video recorder.", ex); + } + } else { + logger.info("Please, enable the camera first!"); + } + }; + + startStopRecordingExecutorService.execute(startRecordingRunnable); + } else if (getStatus().equals(Status.PAUSED)) { + if (recorderReady) { + // enable recording + recording = true; + + // Set status to recording + setStatus(Status.RECORDING); + + // Fire start event + Event.fireEvent(NativeMediaRecorder.this, + new MediaRecorderEvent(NativeMediaRecorder.this, + MediaRecorderEvent.MEDIA_RECORDER_RESUME)); + } + } + } + + @Override + public final void pause() { + // Disable recording + recording = false; + + // Set status to paused + setStatus(Status.PAUSED); + + // Fire start event + Event.fireEvent(NativeMediaRecorder.this, + new MediaRecorderEvent(NativeMediaRecorder.this, + MediaRecorderEvent.MEDIA_RECORDER_PAUSE)); + } + + @Override + public final void stop() { + final Runnable startRecordingRunnable = () -> { + stopRecording(); + + Platform.runLater(() -> { + // Set the media source + setMediaSource(new MediaSource(tempVideoFile.toUri().toString())); + + // Set status to inactive + setStatus(Status.INACTIVE); + + // Fire start event + Event.fireEvent(NativeMediaRecorder.this, + new MediaRecorderEvent(NativeMediaRecorder.this, + MediaRecorderEvent.MEDIA_RECORDER_STOP)); + }); + }; + startStopRecordingExecutorService.execute(startRecordingRunnable); + } + + /** + * Stop the recording and closes the file. + */ + private void stopRecording() { + // Stop recording + recording = false; + + // Release video recorder resources + if (recorder != null) { + try { + recorder.close(); // This call stops the recorder and releases all resources used by it. + } catch (FrameRecorder.Exception ex) { + setError("Exception on stopping the audio/video recorder", ex); + } + } + } + + /** + * Stop the acquisition from the camera and microphone and release all the resources. + */ + public final void release() { + recorderReady = false; + recording = false; + releaseVideoResources(); + releaseAudioResources(); + } + + /** + * Releases the resources associated with the video capture. + */ + private void releaseVideoResources() { + if (videoExecutorService != null && !videoExecutorService.isShutdown()) { + try { + // release video grabber + if (cameraGrabber != null) { + cameraGrabber.close(); + cameraGrabber = null; + } + + // stop the video recoding service + videoExecutorService.shutdown(); + //noinspection ResultOfMethodCallIgnored + videoExecutorService.awaitTermination(100, TimeUnit.MILLISECONDS); + } catch (FrameGrabber.Exception | InterruptedException ex) { + setError("Exception in stopping the video frame capture service.", ex); + } + } + } + + /** + * Releases the resources associated with the audio capture. + */ + private void releaseAudioResources() { + if (audioExecutorService != null && !audioExecutorService.isShutdown()) { + try { + // release audio grabber + if (micGrabber != null) { + micGrabber.close(); + micGrabber = null; + } + + // stop the audio recording service + audioExecutorService.shutdown(); + //noinspection ResultOfMethodCallIgnored + audioExecutorService.awaitTermination(100, TimeUnit.MILLISECONDS); + } catch (FrameGrabber.Exception | InterruptedException ex) { + setError("Exception in stopping the audio frame capture service.", ex); + } + } + } + + /** + * Update the {@link ImageView} in the JavaFX main thread + * + * @param view the {@link ImageView} to update + * @param image the {@link Image} to show + */ + private void updateCameraView(ImageView view, Image image) { + Platform.runLater(() -> view.setImage(image)); + } + + /** + * Create a random filename with the given prefix and postfix. + * + * @param prefix the filename prefix + * @param postfix the filename postfix + * @return a string containing the entire path with the created filename + */ + private Path createTempFilename(final String prefix, final String postfix) { + // Generate a random filename with the given prefix and postfix + final ThreadLocalRandom random = ThreadLocalRandom.current(); + String filename = "vid_" + random.nextInt(0, Integer.MAX_VALUE); + if (prefix != null) { + filename = prefix + random.nextInt(0, Integer.MAX_VALUE); + } + if (postfix != null) { + filename += postfix; + } + + final Path tempFile = RECORDING_PATH.resolve(filename); + final Path parentDir = tempFile.getParent(); + if (Files.notExists(parentDir)) { + try { + Files.createDirectories(parentDir); + } catch (IOException ex) { + logger.error(ex.getMessage(), ex); + } + } + return tempFile; + } + + /** + * Checks if the operating system is Windows. + * + * @return true if the OS is Windows, false otherwise + */ + private boolean isOsWindows() { + return System.getProperty("os.name").toLowerCase().contains("win"); + } + + /** + * Set the error message and exception. + * This method makes sure that {@link #errorProperty()} is updated in the JavaFX Application Thread. + * + * @param message the error message + * @param ex the exception + */ + private void setError(String message, Exception ex) { + if (Platform.isFxApplicationThread()) { + setError(new MediaRecorderException(message, ex)); + } else { + Platform.runLater(() -> setError(new MediaRecorderException(message, ex))); + } + logger.error(message, ex); + } + + /** + * Prints to terminal the capture device description. This includes details about the camera and + * microphone being used for recording. + */ + private void printCaptureDeviceDescription() { + logger.debug(DIVIDER_LINE); + + // Print camera device description + if (cameraGrabber != null) { + try { + if (isOsWindows()) { + logger.debug("Camera Device Description: " + + Arrays.toString(VideoInputFrameGrabber.getDeviceDescriptions())); + } + + logger.debug("Image Width: " + cameraGrabber.getImageWidth()); + logger.debug("Image Height: " + cameraGrabber.getImageHeight()); + logger.debug("Frame Rate: " + frameRate); + } catch (FrameGrabber.Exception ex) { + logger.error("Error getting camera device description: " + ex.getMessage(), ex); + } + } + + // Print microphone device description + if (micGrabber != null) { + String microphoneDescription = "format: " + micGrabber.getFormat() + + " - device: " + getDefaultAudioInputDevice(); + logger.debug("Microphone Device Description: " + microphoneDescription); + logger.debug("Audio Channels: " + micGrabber.getAudioChannels()); + logger.debug("Sample Rate: " + micGrabber.getSampleRate()); + } + } + + /** + * Adjusts the timestamp of the recording to match the system time. Ensures synchronization + * between video and audio streams. + */ + private void adjustTimestamp() { + long t = 1000 * (System.currentTimeMillis() - startTime); + if (t > recorder.getTimestamp()) { + logger.debug("Correct recorder timestamp: " + t); + recorder.setTimestamp(t); + } + } + + /** + * Gets the default audio input device name based on the operating system. + *

+ * This method determines the appropriate audio input device for use with {@link FFmpegFrameGrabber}, + * based on the operating system where the application is running: + *

+ *
    + *
  • Windows: Returns "null", which typically lets the system select the default device. + * Note: This might need to be adjusted based on the system configuration.
  • + *
  • macOS: Returns ":0" to refer to the default audio input device as per AVFoundation's standard.
  • + *
  • Linux: Returns "default", which typically refers to the default ALSA audio input device.
  • + *
  • Other: Returns "default" as a generic fallback. This should be validated or adjusted + * based on the specific requirements or environment.
  • + *
+ * + * @return the name of the default audio input device + */ + public static String getDefaultAudioInputDevice() { + String OS = System.getProperty("os.name").toLowerCase(); + + String audioDevice; + if (OS.contains("win")) { + // Windows - use device name + audioDevice = "audio=" + getAudioInputDevices().get(1).getName(); + } else if (OS.contains("mac")) { + // macOS - default device + audioDevice = ":0"; + } else if (OS.contains("nix") || OS.contains("nux") || OS.contains("aix")) { + // Linux - default device + audioDevice = "default"; + } else { + // Unknown OS - fallback + audioDevice = "default"; + } + + return audioDevice; + } + + /** + * Retries the list of available audio input devices used as microphones. + * + * @return the list of available audio input devices + */ + private static List getAudioInputDevices() { + final Mixer.Info[] mixerInfos = AudioSystem.getMixerInfo(); + List result = new ArrayList<>(); + for (Mixer.Info info : mixerInfos) { + final Mixer mixer = AudioSystem.getMixer(info); + final Line.Info[] lineInfos = mixer.getTargetLineInfo(); + // Only prints out info is it is a Microphone + if (lineInfos.length >= 1 && lineInfos[0].getLineClass().equals(TargetDataLine.class)) { + logger.debug(DIVIDER_LINE); + for (Line.Info lineInfo : lineInfos) { + logger.debug("Mic Line Name: " + info.getName()); // The audio device name + logger.debug("Mic Line Description: " + info.getDescription()); // The type of audio device + logger.debug("Supported Audio Formats:"); + if (lineInfo instanceof final DataLine.Info dataLineInfo) { + Arrays.stream(dataLineInfo.getFormats()).forEach(format -> logger.debug("{}", format)); + } + } + logger.debug(DIVIDER_LINE); + result.add(info); + } + } + return result; + } + + /** + * Gets the default audio input format based on the operating system. + *

+ * This method determines the appropriate audio input format for use with {@link FFmpegFrameGrabber}, + * based on the operating system where the application is running: + *

+ *
    + *
  • Windows: Returns "dshow" for DirectShow.
  • + *
  • macOS: Returns "avfoundation" for AVFoundation.
  • + *
  • Linux: Returns "alsa" for ALSA.
  • + *
  • Other: Returns "default" as a fallback, + * but this should be adjusted based on specific requirements or environment.
  • + *
+ * + * @return the format string for audio input format + */ + public static String getDefaultAudioInputFormat() { + String OS = System.getProperty("os.name").toLowerCase(); + String format; + + if (OS.contains("win")) { + // Windows - DirectShow + format = "dshow"; + } else if (OS.contains("mac")) { + // macOS - AVFoundation + format = "avfoundation"; + } else if (OS.contains("nix") || OS.contains("nux") || OS.contains("aix")) { + // Linux - ALSA + format = "alsa"; + } else { + // Unknown OS - fallback + format = "default"; + } + + return format; + } + +} diff --git a/jpro-media/src/main/java/one/jpro/platform/media/recorder/impl/FXMediaRecorderView.java b/jpro-media/src/main/java/one/jpro/platform/media/recorder/NativeMediaRecorderView.java similarity index 82% rename from jpro-media/src/main/java/one/jpro/platform/media/recorder/impl/FXMediaRecorderView.java rename to jpro-media/src/main/java/one/jpro/platform/media/recorder/NativeMediaRecorderView.java index e581e403..878ded28 100644 --- a/jpro-media/src/main/java/one/jpro/platform/media/recorder/impl/FXMediaRecorderView.java +++ b/jpro-media/src/main/java/one/jpro/platform/media/recorder/NativeMediaRecorderView.java @@ -1,4 +1,4 @@ -package one.jpro.platform.media.recorder.impl; +package one.jpro.platform.media.recorder; import javafx.beans.InvalidationListener; import javafx.beans.WeakInvalidationListener; @@ -6,7 +6,6 @@ import javafx.scene.image.ImageView; import one.jpro.platform.media.MediaEngine; import one.jpro.platform.media.MediaView; -import one.jpro.platform.media.recorder.MediaRecorder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -15,23 +14,23 @@ * * @author Besmir Beqiri */ -public class FXMediaRecorderView extends MediaView { +public class NativeMediaRecorderView extends MediaView { - private final Logger log = LoggerFactory.getLogger(FXMediaRecorderView.class); + private static final Logger logger = LoggerFactory.getLogger(NativeMediaRecorderView.class); private ImageView fxFrameView; - public FXMediaRecorderView() { + public NativeMediaRecorderView() { getStyleClass().add(DEFAULT_STYLE_CLASS); } - public FXMediaRecorderView(FXMediaRecorder mediaRecorder) { + public NativeMediaRecorderView(NativeMediaRecorder mediaRecorder) { this(); setMediaEngine(mediaRecorder); } @Override - public ObjectProperty mediaEngineProperty() { + public final ObjectProperty mediaEngineProperty() { if (mediaEngine == null) { mediaEngine = new SimpleObjectProperty<>(this, "mediaEngine") { @@ -61,7 +60,7 @@ protected void invalidated() { } @Override - public DoubleProperty fitHeightProperty() { + public final DoubleProperty fitHeightProperty() { if (fitHeight == null) { fitHeight = new SimpleDoubleProperty(this, "fitHeight") { @@ -84,7 +83,7 @@ protected void invalidated() { if (fxFrameView != null) { fxFrameView.setPreserveRatio(get()); } - log.trace("preserve ratio: {}", isPreserveRatio()); + logger.trace("preserve ratio: {}", isPreserveRatio()); } }; } @@ -99,7 +98,7 @@ private void setInternalFitWidth(double fitWidth) { getChildren().add(fxFrameView); } fxFrameView.setFitWidth(fitWidth); - log.trace("video width: {}", fitWidth); + logger.trace("video width: {}", fitWidth); } } @@ -111,7 +110,7 @@ private void setInternalFitHeight(double fitHeight) { getChildren().add(fxFrameView); } fxFrameView.setFitHeight(fitHeight); - log.trace("video height: " + fitHeight); + logger.trace("video height: " + fitHeight); } } @@ -120,7 +119,7 @@ private void setInternalFitHeight(double fitHeight) { new WeakInvalidationListener(updateViewContainerListener); private void updateViewContainer() { - if (getScene() != null && getMediaEngine() instanceof FXMediaRecorder fxMediaRecorder) { + if (getScene() != null && getMediaEngine() instanceof NativeMediaRecorder fxMediaRecorder) { fxFrameView = fxMediaRecorder.getCameraView(); fxFrameView.setPreserveRatio(isPreserveRatio()); setInternalFitWidth(getFitWidth()); diff --git a/jpro-media/src/main/java/one/jpro/platform/media/recorder/impl/WebMediaRecorder.java b/jpro-media/src/main/java/one/jpro/platform/media/recorder/WebMediaRecorder.java similarity index 94% rename from jpro-media/src/main/java/one/jpro/platform/media/recorder/impl/WebMediaRecorder.java rename to jpro-media/src/main/java/one/jpro/platform/media/recorder/WebMediaRecorder.java index 7ba794e6..519895a5 100644 --- a/jpro-media/src/main/java/one/jpro/platform/media/recorder/impl/WebMediaRecorder.java +++ b/jpro-media/src/main/java/one/jpro/platform/media/recorder/WebMediaRecorder.java @@ -1,4 +1,4 @@ -package one.jpro.platform.media.recorder.impl; +package one.jpro.platform.media.recorder; import com.jpro.webapi.JSVariable; import com.jpro.webapi.WebAPI; @@ -8,8 +8,6 @@ import one.jpro.platform.media.MediaSource; import one.jpro.platform.media.WebMediaEngine; import one.jpro.platform.media.event.MediaRecorderEvent; -import one.jpro.platform.media.recorder.MediaRecorder; -import one.jpro.platform.media.recorder.MediaRecorderException; import org.json.JSONObject; import java.util.Objects; @@ -19,7 +17,7 @@ * * @author Besmir Beqiri */ -public final class WebMediaRecorder extends BaseMediaRecorder implements WebMediaEngine { +public class WebMediaRecorder extends BaseMediaRecorder implements WebMediaEngine { private static final String DEFAULT_MIME_TYPE = "video/webm"; @@ -120,12 +118,12 @@ public WebMediaRecorder(WebAPI webAPI) { } @Override - public WebAPI getWebAPI() { + public final WebAPI getWebAPI() { return webAPI; } @Override - public JSVariable getVideoElement() { + public final JSVariable getVideoElement() { return recorderVideoElement; } @@ -140,7 +138,7 @@ public JSVariable getVideoElement() { * specified format. * @throws Exception */ - public boolean isTypeSupported(String mimeType) throws Exception { + public final boolean isTypeSupported(String mimeType) throws Exception { return Boolean.getBoolean(webAPI.executeScriptWithReturn(""" MediaRecorder.isTypeSupported("%s") """.formatted(mimeType))); @@ -149,7 +147,7 @@ public boolean isTypeSupported(String mimeType) throws Exception { // mimeType property (read-only) private ReadOnlyStringWrapper mimeType; - public String getMimeType() { + public final String getMimeType() { return (mimeType == null) ? DEFAULT_MIME_TYPE : mimeType.get(); } @@ -157,7 +155,7 @@ private void setMimeType(String value) { mimeTypePropertyImpl().set(value); } - public ReadOnlyStringProperty mimeTypeProperty() { + public final ReadOnlyStringProperty mimeTypeProperty() { return mimeTypePropertyImpl().getReadOnlyProperty(); } @@ -170,7 +168,7 @@ private ReadOnlyStringWrapper mimeTypePropertyImpl() { // Recorder controller methods @Override - public void enable() { + public final void enable() { final var mediaRecorderOptions = new MediaRecorderOptions().mimeType(getMimeType()); webAPI.executeScript(""" $blobsRecorded = []; // stream buffer @@ -220,7 +218,7 @@ public void enable() { } @Override - public void start() { + public final void start() { if (recorderReady) { if (getStatus().equals(Status.INACTIVE) || getStatus().equals(Status.READY)) { webAPI.executeScript(""" @@ -240,7 +238,7 @@ public void start() { } @Override - public void pause() { + public final void pause() { if (recorderReady) { webAPI.executeScript(""" if ($mediaRecorder.state === "recording") { @@ -251,7 +249,7 @@ public void pause() { } @Override - public void stop() { + public final void stop() { if (recorderReady) { webAPI.executeScript(""" $mediaRecorder.stop(); diff --git a/jpro-media/src/main/java/one/jpro/platform/media/recorder/impl/WebMediaRecorderView.java b/jpro-media/src/main/java/one/jpro/platform/media/recorder/WebMediaRecorderView.java similarity index 83% rename from jpro-media/src/main/java/one/jpro/platform/media/recorder/impl/WebMediaRecorderView.java rename to jpro-media/src/main/java/one/jpro/platform/media/recorder/WebMediaRecorderView.java index d2c9ae5f..21886bc6 100644 --- a/jpro-media/src/main/java/one/jpro/platform/media/recorder/impl/WebMediaRecorderView.java +++ b/jpro-media/src/main/java/one/jpro/platform/media/recorder/WebMediaRecorderView.java @@ -1,9 +1,8 @@ -package one.jpro.platform.media.recorder.impl; +package one.jpro.platform.media.recorder; import com.jpro.webapi.WebAPI; import one.jpro.platform.media.MediaView; import one.jpro.platform.media.WebMediaView; -import one.jpro.platform.media.recorder.MediaRecorder; /** * {@link MediaView} implementation for a web {@link MediaRecorder}. diff --git a/jpro-media/src/main/java/one/jpro/platform/media/recorder/impl/FXMediaRecorder.java b/jpro-media/src/main/java/one/jpro/platform/media/recorder/impl/FXMediaRecorder.java deleted file mode 100644 index 38792a55..00000000 --- a/jpro-media/src/main/java/one/jpro/platform/media/recorder/impl/FXMediaRecorder.java +++ /dev/null @@ -1,615 +0,0 @@ -package one.jpro.platform.media.recorder.impl; - -import javafx.application.Platform; -import javafx.event.Event; -import javafx.scene.image.Image; -import javafx.scene.image.ImageView; -import one.jpro.platform.media.MediaSource; -import one.jpro.platform.media.event.MediaRecorderEvent; -import one.jpro.platform.media.recorder.MediaRecorder; -import one.jpro.platform.media.recorder.MediaRecorderException; -import org.bytedeco.ffmpeg.global.avcodec; -import org.bytedeco.ffmpeg.global.avutil; -import org.bytedeco.javacv.*; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import javax.sound.sampled.*; -import java.io.File; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.CharBuffer; -import java.nio.ShortBuffer; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Arrays; -import java.util.Comparator; -import java.util.concurrent.*; -import java.util.concurrent.locks.ReentrantReadWriteLock; -import java.util.stream.Stream; - -/** - * {@link MediaRecorder} implementation for the desktop. - * - * @author Besmir Beqiri - */ -public final class FXMediaRecorder extends BaseMediaRecorder { - - private final Logger log = LoggerFactory.getLogger(FXMediaRecorder.class); - - private static final Path RECORDING_PATH = Path.of(System.getProperty("user.home"), - ".jpro", "video", "capture"); - private static final String DIVIDER_LINE = - CharBuffer.allocate(80).toString().replace( '\0', '*' ); - - // Video resources - private static final int WEBCAM_DEVICE_INDEX = 0; - private static final int FRAME_RATE = 30; - private static final String MP4_FILE_EXTENSION = ".mp4"; - private final FrameGrabber webcamGrabber; - private final JavaFXFrameConverter frameConverter; - private final ImageView frameView; - private double frameRate; - - // Audio resources - private static final int DEFAULT_AUDIO_SAMPLE_RATE = 44100; // 44.1 KHz - private static final int DEFAULT_AUDIO_CHANNELS = 0; // no audioHz - private static final int DEFAULT_AUDIO_FRAME_SIZE = 1; // 1 byte - private AudioFormat audioFormat; - private TargetDataLine micLine; - private int audioSampleRate; - private int audioNumChannels; - - // Storage resources - private FFmpegFrameRecorder recorder; - private Path tempVideoFile; - - // Recording state - private volatile boolean recordingStarted = false; - private volatile boolean recordingStopped = false; - - // Concurrency and locking resources - private final ThreadGroup scheduledThreadGroup = new ThreadGroup("Media Recorder thread pool"); - private int threadCounter; - private final ExecutorService videoExecutorService; - private final ScheduledExecutorService audioExecutorService; - private final ExecutorService startStopRecordingExecutorService; - private final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock(); - private final ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock(); - private final ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock(); - - public FXMediaRecorder() { - // Set native log level to error - avutil.av_log_set_level(avutil.AV_LOG_ERROR); - - final ThreadFactory threadFactory = run -> { - final Thread thread = new Thread(scheduledThreadGroup, run); - thread.setName("Media Recorder Thread " + threadCounter++); - thread.setPriority(Thread.MIN_PRIORITY); - thread.setDaemon(true); - return thread; - }; - videoExecutorService = Executors.newSingleThreadExecutor(threadFactory); - audioExecutorService = Executors.newSingleThreadScheduledExecutor(threadFactory); - startStopRecordingExecutorService = Executors.newSingleThreadExecutor(threadFactory); - - // Initialize webcam frame grabber - webcamGrabber = (isOsWindows()) ? new VideoInputFrameGrabber(WEBCAM_DEVICE_INDEX) - : new OpenCVFrameGrabber(WEBCAM_DEVICE_INDEX); - // Frame to JavaFX image converter - frameConverter = new JavaFXFrameConverter(); - // Use ImageView to show camera grabbed frames - frameView = new ImageView(); - - // Stop and release native resources on exit - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - stopRecording(); - release(); - - // delete temporary video files - if (Files.exists(RECORDING_PATH)) { - try (Stream pathStream = Files.walk(RECORDING_PATH)) { - pathStream.sorted(Comparator.reverseOrder()) - .map(Path::toFile) - .forEach(File::delete); - } catch (IOException ex) { - log.error(ex.getMessage(), ex); - } - } - })); - } - - ImageView getCameraView() { - return frameView; - } - - @Override - public void enable() { - if (recorderReady) { - log.info("Media recorder is already enabled."); - } else { - log.debug("Enabling media recorder..."); - // Camera frame grabber runnable - final Runnable frameGrabber = () -> { - try { - // start the video capture - webcamGrabber.start(); - frameRate = (webcamGrabber.getFrameRate() < FRAME_RATE) ? FRAME_RATE : webcamGrabber.getFrameRate(); - printCaptureDeviceDescription(); - } catch (FrameGrabber.Exception ex) { - setError("Exception during the enabling of video camera stream.", ex); - release(); - } - - try { - // start the audio capture - enableAudioCapture(); - } catch (LineUnavailableException ex) { - setError("Exception on creating audio input line from the microphone.", ex); - if (micLine != null) { - micLine.close(); - } - } - - // Set recorder ready - recorderReady = true; - Platform.runLater(() -> { - // Set status to ready - setStatus(Status.READY); - - // Fire ready event - Event.fireEvent(FXMediaRecorder.this, - new MediaRecorderEvent(FXMediaRecorder.this, - MediaRecorderEvent.MEDIA_RECORDER_READY)); - }); - - // Start the camera frame grabbing, showing and recording task - try { - while (recorderReady) { - // effectively grab a single frame - final Frame frame; - readLock.lock(); - try { - frame = webcamGrabber.grab(); - } finally { - readLock.unlock(); - } - - if (frame != null) { - // convert and show the frame - updateCameraView(frameView, frameConverter.convert(frame)); - - // write the webcam frame if recording started - writeVideoFrame(frame); - } - } - } catch (FrameGrabber.Exception ex) { - setError("Exception during camera stream frame grabbing.", ex); - release(); - } - }; - - videoExecutorService.execute(frameGrabber); - } - } - - /** - * Records a video frame. - * - * @param frame the frame to record - */ - private void writeVideoFrame(Frame frame) { - if (!recordingStopped && recorder != null) { - writeLock.lock(); - try { - recorder.record(frame); - } catch (FFmpegFrameRecorder.Exception ex) { - setError("Exception during video frame recording.", ex); - } finally { - writeLock.unlock(); - } - } - } - - /** - * Records an audio data. - * - * @throws LineUnavailableException if the mic line is unavailable - */ - private void enableAudioCapture() throws LineUnavailableException { - final Mixer.Info micDevice = getDefaultMicDevice(); - if (micDevice != null) { - final Mixer micMixer = AudioSystem.getMixer(micDevice); - // Audio format: 44.1k sample rate, 16 bits, mono, signed, little endian - audioFormat = new AudioFormat(DEFAULT_AUDIO_SAMPLE_RATE, 16, 1, true, false); - DataLine.Info dataLineInfo = new DataLine.Info(TargetDataLine.class, audioFormat); - - if (micMixer.isLineSupported(dataLineInfo)) { - micLine = (TargetDataLine) micMixer.getLine(dataLineInfo); - micLine.open(audioFormat); - micLine.start(); - - audioSampleRate = (int) audioFormat.getSampleRate(); - audioNumChannels = audioFormat.getChannels(); - } - - final Runnable audioSampleGrabber = () -> { - if (recordingStarted) { - // Initialize audio buffer - final int audioBufferSize = audioSampleRate * audioNumChannels; - final byte[] audioBytes = new byte[audioBufferSize]; - - // Read from the line - int nBytesRead = 0; - while (nBytesRead == 0) { - nBytesRead = micLine.read(audioBytes, 0, micLine.available()); - } - - final int nSamplesRead = nBytesRead / 2; - final short[] samples = new short[nSamplesRead]; - - final ByteOrder byteOrder = audioFormat.isBigEndian() ? - ByteOrder.BIG_ENDIAN : ByteOrder.LITTLE_ENDIAN; - - // Wrap our short[] into a ShortBuffer - ByteBuffer.wrap(audioBytes).order(byteOrder).asShortBuffer().get(samples); - ShortBuffer samplesBuff = ShortBuffer.wrap(samples, 0, nSamplesRead); - -// log.trace("Record audio samples: {}", nSamplesRead); - - // recorder audio data - if (!recordingStopped && recorder != null) { - writeLock.lock(); - try { - recorder.recordSamples(audioSampleRate, audioNumChannels, samplesBuff); - } catch (FFmpegFrameRecorder.Exception ex) { - setError("Exception on recording the audio samples.", ex); - } finally { - writeLock.unlock(); - } - } - } - }; - - final long period = (long) (1000.0 / frameRate); - audioExecutorService.scheduleAtFixedRate(audioSampleGrabber, 0, period, TimeUnit.MILLISECONDS); - } - } - - /** - * Retries the list of available audio input devices and returns the default microphone device. - * - * @return the default microphone device - */ - private Mixer.Info getDefaultMicDevice() { - final Mixer.Info[] mixerInfos = AudioSystem.getMixerInfo(); - for (Mixer.Info info : mixerInfos) { - final Mixer mixer = AudioSystem.getMixer(info); - final Line.Info[] lineInfos = mixer.getTargetLineInfo(); - // Only prints out info is it is a Microphone - if (lineInfos.length >= 1 && lineInfos[0].getLineClass().equals(TargetDataLine.class)) { - log.debug(DIVIDER_LINE); - for (Line.Info lineInfo : lineInfos) { - log.debug("Mic Line Name: " + info.getName()); // The audio device name - log.debug("Mic Line Description: " + info.getDescription()); // The type of audio device - printSupportedAudioFormats(lineInfo); - } - log.debug(DIVIDER_LINE); - return info; - } - } - return null; - } - - @Override - public void start() { - if (getStatus().equals(Status.INACTIVE) || getStatus().equals(Status.READY)) { - final Runnable startRecordingRunnable = () -> { - // Start the recording if the camera is enabled - if (recorderReady) { - tempVideoFile = createTempFilename("video_", MP4_FILE_EXTENSION); - - recorder = new FFmpegFrameRecorder(tempVideoFile.toString(), - webcamGrabber.getImageWidth(), webcamGrabber.getImageHeight()); - recorder.setInterleaved(true); - recorder.setVideoOption("tune", "zerolatency"); // low latency for webcam streaming - recorder.setVideoOption("preset", "ultrafast"); // low cpu usage for the encoder - recorder.setVideoOption("crf", "28"); // video quality -// recorder.setVideoBitrate(webcamGrabber.getVideoBitrate()); - recorder.setVideoBitrate(8 * 1024 * 1024); // 8 Mbps for 1080p - recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264); - recorder.setFormat("mp4"); - recorder.setFrameRate(frameRate); - recorder.setGopSize((int) (frameRate * 2)); - recorder.setAudioOption("crf", "0"); // no variable bitrate audio - recorder.setAudioQuality(0); // highest quality -// recorder.setAudioBitrate(getAudioSampleRate() * getFrameSize() * getAudioChannels()); - recorder.setAudioBitrate(192000); // 192 kbps - recorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC); - recorder.setSampleRate(getAudioSampleRate()); - recorder.setAudioChannels(getAudioChannels()); - - try { - // Enable recording - recorder.start(); - recordingStopped = false; - recordingStarted = true; - - Platform.runLater(() -> { - // Set status to recording - setStatus(Status.RECORDING); - - // Fire start event - Event.fireEvent(FXMediaRecorder.this, - new MediaRecorderEvent(FXMediaRecorder.this, - MediaRecorderEvent.MEDIA_RECORDER_START)); - }); - } catch (FFmpegFrameRecorder.Exception ex) { - setError("Exception on starting the audio/video recorder.", ex); - } - } else { - log.info("Please, enable the camera first!"); - } - }; - - startStopRecordingExecutorService.execute(startRecordingRunnable); - } else if (getStatus().equals(Status.PAUSED)) { - if (recorderReady) { - // enable recording - recordingStarted = true; - - // Set status to recording - setStatus(Status.RECORDING); - - // Fire start event - Event.fireEvent(FXMediaRecorder.this, - new MediaRecorderEvent(FXMediaRecorder.this, - MediaRecorderEvent.MEDIA_RECORDER_RESUME)); - } - } - } - - @Override - public void pause() { - // Disable recording - recordingStarted = false; - - // Set status to paused - setStatus(Status.PAUSED); - - // Fire start event - Event.fireEvent(FXMediaRecorder.this, - new MediaRecorderEvent(FXMediaRecorder.this, - MediaRecorderEvent.MEDIA_RECORDER_PAUSE)); - } - - @Override - public void stop() { - final Runnable startRecordingRunnable = () -> { - stopRecording(); - - Platform.runLater(() -> { - // Set the media source - setMediaSource(new MediaSource(tempVideoFile.toUri().toString())); - - // Set status to inactive - setStatus(Status.INACTIVE); - - // Fire start event - Event.fireEvent(FXMediaRecorder.this, - new MediaRecorderEvent(FXMediaRecorder.this, - MediaRecorderEvent.MEDIA_RECORDER_STOP)); - }); - }; - startStopRecordingExecutorService.execute(startRecordingRunnable); - } - - /** - * Stop the recording and closes the file. - */ - private void stopRecording() { - // Stop recording - recordingStarted = false; - recordingStopped = true; - - // Release video recorder resources - if (recorder != null) { - writeLock.lock(); - try { - recorder.close(); // This call stops the recorder and releases all resources used by it. - } catch (FrameRecorder.Exception ex) { - setError("Exception on stopping the audio/video recorder", ex); - } finally { - writeLock.unlock(); - } - } - } - - /** - * Stop the acquisition from the camera and microphone and release all the resources. - */ - public void release() { - recorderReady = false; - releaseVideoResources(); - releaseAudioResources(); - } - - /** - * Release the video resources. - */ - private void releaseVideoResources() { - if (videoExecutorService != null && !videoExecutorService.isShutdown()) { - try { - // release video grabber - if (webcamGrabber != null) { - webcamGrabber.release(); - } - - // stop the video recoding service - videoExecutorService.shutdown(); - //noinspection ResultOfMethodCallIgnored - videoExecutorService.awaitTermination(100, TimeUnit.MILLISECONDS); - } catch (FrameGrabber.Exception | InterruptedException ex) { - setError("Exception in stopping the video frame capture service.", ex); - } - } - } - - /** - * Release the audio resources. - */ - private void releaseAudioResources() { - if (audioExecutorService != null && !audioExecutorService.isShutdown()) { - try { - // release audio grabber - if (micLine != null) { - micLine.close(); - } - - // stop the audio recording service - audioExecutorService.shutdown(); - //noinspection ResultOfMethodCallIgnored - audioExecutorService.awaitTermination(100, TimeUnit.MILLISECONDS); - } catch (InterruptedException ex) { - setError("Exception in stopping the audio frame capture service.", ex); - } - } - } - - /** - * Obtains the audio sample rate from the specified audio format - * used for recoding. If not specified, then default value (44100) is returned. - * - * @return the audio sample rate - */ - private int getAudioSampleRate() { - if (audioFormat == null) return DEFAULT_AUDIO_SAMPLE_RATE; - - final int sampleRate = (int) audioFormat.getSampleRate(); - return (sampleRate == AudioSystem.NOT_SPECIFIED) ? DEFAULT_AUDIO_SAMPLE_RATE : sampleRate; - } - - /** - * Obtains the number of audio channels from the specified audio format - * used for recoding. If not specified, then 0 is returned, - * meaning there no audio input device available. - * - * @return the number of audio channels (1 for mono, 2 for stereo, etc.) - */ - private int getAudioChannels() { - if (audioFormat == null) return DEFAULT_AUDIO_CHANNELS; - - final int audioChannels = audioFormat.getChannels(); - return (audioChannels == AudioSystem.NOT_SPECIFIED) ? DEFAULT_AUDIO_CHANNELS : audioChannels; - } - - /** - * Obtains the frame size in bytes. For compressed formats, the return value is - * the frame size of the uncompressed audio data. {@code AudioSystem.NOT_SPECIFIED} - * is returned when the frame size is not defined for this audio format. - * - * @return the number of bytes per frame, or {@code AudioSystem.NOT_SPECIFIED} - */ - private int getFrameSize() { - if (audioFormat == null) return DEFAULT_AUDIO_FRAME_SIZE; - - final int frameSize = audioFormat.getFrameSize(); - return (frameSize == AudioSystem.NOT_SPECIFIED) ? DEFAULT_AUDIO_FRAME_SIZE : frameSize; - } - - /** - * Update the {@link ImageView} in the JavaFX main thread - * - * @param view the {@link ImageView} to update - * @param image the {@link Image} to show - */ - private void updateCameraView(ImageView view, Image image) { - Platform.runLater(() -> view.setImage(image)); - } - - /** - * Create a random filename with the given prefix and postfix. - * - * @param prefix the filename prefix - * @param postfix the filename postfix - * @return a string containing the entire path with the created filename - */ - private Path createTempFilename(final String prefix, final String postfix) { - // Generate a random filename with the given prefix and postfix - final ThreadLocalRandom random = ThreadLocalRandom.current(); - String filename = "vid_" + random.nextInt(0, Integer.MAX_VALUE); - if (prefix != null) { - filename = prefix + random.nextInt(0, Integer.MAX_VALUE); - } - if (postfix != null) { - filename += postfix; - } - - final Path tempFile = RECORDING_PATH.resolve(filename); - final Path parentDir = tempFile.getParent(); - if (Files.notExists(parentDir)) { - try { - Files.createDirectories(parentDir); - } catch (IOException ex) { - log.error(ex.getMessage(), ex); - } - } - return tempFile; - } - - /** - * Check if the OS is Windows. - * - * @return true if the OS is Windows, false otherwise - */ - private boolean isOsWindows() { - return System.getProperty("os.name").toLowerCase().contains("win"); - } - - /** - * Set the error message and exception. - * This method makes sure that {@link #errorProperty()} is updated in the JavaFX Application Thread. - * - * @param message the error message - * @param ex the exception - */ - private void setError(String message, Exception ex) { - if (Platform.isFxApplicationThread()) { - setError(new MediaRecorderException(message, ex)); - } else { - Platform.runLater(() -> setError(new MediaRecorderException(message, ex))); - } - log.error(message, ex); - } - - /** - * Print to terminal the capture device description. - */ - private void printCaptureDeviceDescription() { - log.debug(DIVIDER_LINE); - if (isOsWindows()) { - try { - log.debug("Capture devices: " + Arrays.toString(VideoInputFrameGrabber.getDeviceDescriptions())); - } catch (FrameGrabber.Exception ex) { - log.error(ex.getMessage(), ex); - } - } - log.debug("Capture Device Info:"); - log.debug("Image Width: " + webcamGrabber.getImageWidth()); - log.debug("Image Height: " + webcamGrabber.getImageHeight()); - log.debug("Frame Rate: " + frameRate); - } - - /** - * Print to terminal the supported audio formats. - * - * @param lineInfo the line info - */ - private void printSupportedAudioFormats(final Line.Info lineInfo) { - if (lineInfo instanceof final DataLine.Info dataLineInfo) { - log.debug("Supported Audio Formats:"); - Arrays.stream(dataLineInfo.getFormats()).forEach(format -> log.debug("{}", format)); - } - } -}