diff --git a/Classes/Recorders/LibObsRecorder.cs b/Classes/Recorders/LibObsRecorder.cs index 33b74427..8cbe9eba 100644 --- a/Classes/Recorders/LibObsRecorder.cs +++ b/Classes/Recorders/LibObsRecorder.cs @@ -18,13 +18,12 @@ public class LibObsRecorder : BaseRecorder { public bool Connected { get; private set; } public bool DisplayCapture; public bool isStopping; - static string videoSavePath { get; set; } = ""; - static string videoNameTimeStamp = ""; + static IntPtr windowHandle = IntPtr.Zero; static IntPtr output = IntPtr.Zero; - static Rect windowSize; + private static CaptureSettings captureSettings => SettingsService.Settings.captureSettings; Dictionary audioSources = new(), videoSources = new(); Dictionary audioEncoders = new(), videoEncoders = new(); @@ -82,6 +81,19 @@ public class LibObsRecorder : BaseRecorder { new FileFormat("mov", "QuickTime (.mov)", true) }; +#if WINDOWS + const string audioOutSourceId = "wasapi_output_capture"; + const string audioInSourceId = "wasapi_input_capture"; + const string audioProcessSourceId = "wasapi_process_output_capture"; + const string audioEncoderId = "ffmpeg_aac"; + const string videoSourceId = "game_capture"; +#else + const string audioOutSourceId = "pulse_output_capture"; + const string audioInSourceId = "pulse_input_capture"; + const string audioEncoderId = "ffmpeg_aac"; + const string videoSourceId = "xcomposite_input"; +#endif + static bool signalOutputStop = false; static bool signalGCHookSuccess = false; static int signalGCHookAttempt = 0; @@ -118,7 +130,7 @@ public override void Start() { base_set_log_handler(new log_handler_t((lvl, msg, args, p) => { try { string formattedMsg = MarshalUtils.GetLogMessage(msg, args); - Logger.WriteLine(((LogErrorLevel)lvl).ToString() + ": " + formattedMsg); + Logger.WriteLine(((LogErrorLevel)lvl) + ": " + formattedMsg); // a very crude way to see if game_capture source has successfully hooked/capture application.... // does game_capture source provide any signals that we can alternatively use? @@ -188,177 +200,132 @@ public override void Start() { HasNvidiaAudioSDK(); GetAvailableFileFormats(); - // update user settings - WebMessage.SendMessage(GetUserSettings()); } - const int retryInterval = 2000; // 2 second - const int maxRetryAttempts = 20; // 30 retries + const int RetryInterval = 2000; // 2 seconds + const int MaxRetryAttempts = 20; // 20 retries public override async Task StartRecording() { if (output != IntPtr.Zero) return false; signalOutputStop = false; - int retryAttempt = 0; var session = RecordingService.GetCurrentSession(); - // If session is empty, this is a manual record attempt. Lets try to yolo record the foregroundwindow - if (session.Pid == 0 && WindowService.GetForegroundWindow(out int processId, out nint hwid)) { - if (processId != 0 || hwid != 0) { - WindowService.GetExecutablePathFromProcessId(processId, out string executablePath); - DetectionService.AutoDetectGame(processId, executablePath, hwid, autoRecord: false); - session = RecordingService.GetCurrentSession(); + windowHandle = session.WindowHandle; +#if WINDOWS + var recordWindow = WindowService.GetWindowTitle(windowHandle) + ":" + WindowService.GetClassName(windowHandle) + ":" + Path.GetFileName(session.Exe); +#else + var recordWindow = windowHandle + "\r\n" + WindowService.GetWindowTitle(windowHandle) + "\r\n" + WindowService.GetClassName(windowHandle); +#endif + await GetValidWindowSize(); + + Logger.WriteLine($"Preparing to create libobs output [{bnum_allocs()}]..."); + + audioEncoders.TryAdd("combined", obs_audio_encoder_create(audioEncoderId, "combined", IntPtr.Zero, 0, IntPtr.Zero)); + obs_encoder_set_audio(audioEncoders["combined"], obs_get_audio()); + if (captureSettings.captureGameAudio) CreateAudioApplicationSource(new AudioApplication(session.GameTitle, recordWindow)); + else SetupAudioSources(); + if (!await SetupVideoSources(recordWindow, session.Pid, session.ForceDisplayCapture)) return false; + SetupOutput(session.videoSavePath); + + if (!await CheckIfReady()) return false; + + // preparations complete, launch the rocket + Logger.WriteLine($"LibObs output is starting [{bnum_allocs()}]..."); + bool outputStartSuccess = obs_output_start(output); + if (outputStartSuccess != true) { + string error = obs_output_get_last_error(output).Trim(); + Logger.WriteLine("LibObs output recording error: '" + error + "'"); + if (error.Length <= 0) { + WebMessage.DisplayModal("An unexpected error occured. Detailed information written in logs.", "Recording Error", "warning"); } else { - return false; + WebMessage.DisplayModal(error, "Recording Error", "warning"); } + ReleaseAll(); + return false; } - // attempt to retrieve process's window handle to retrieve class name and window title - windowHandle = session.WindowHandle; - while ((DetectionService.HasBadWordInClassName(windowHandle) || windowHandle == IntPtr.Zero) && retryAttempt < maxRetryAttempts) { - Logger.WriteLine($"Waiting to retrieve process handle... retry attempt #{retryAttempt}"); - await Task.Delay(retryInterval); - retryAttempt++; - // alternate on retry attempts, one or the other might get us a better handle - windowHandle = WindowService.GetWindowHandleByProcessId(session.Pid, retryAttempt % 2 == 1); - } - if (retryAttempt >= maxRetryAttempts) { - return false; + Logger.WriteLine($"LibObs started recording [{session.Pid}] [{session.GameTitle}] [{recordWindow}]"); + + IntegrationService.Start(session.GameTitle); + return true; + } + + private async Task CheckIfReady() { + int retryAttempt = 0; + bool canStartCapture = obs_output_can_begin_data_capture(output, 0); + if (!canStartCapture) { + while (!obs_output_initialize_encoders(output, 0) && retryAttempt < MaxRetryAttempts) { + Logger.WriteLine($"Waiting for encoders to finish initializing... retry attempt #{retryAttempt}"); + await Task.Delay(RetryInterval); + retryAttempt++; + } + if (retryAttempt >= MaxRetryAttempts) { + Logger.WriteLine("Unable to get encoders to initialize"); + ReleaseAll(); + return false; + } } retryAttempt = 0; - string dir = Path.Join(GetPlaysFolder(), "/" + MakeValidFolderNameSimple(session.GameTitle) + "/"); - try { - Directory.CreateDirectory(dir); - } - catch (Exception e) { - WebMessage.DisplayModal(string.Format("Unable to create folder {0}. Do you have permission to create it?", dir), "Recording Error", "warning"); - Logger.WriteLine(e.ToString()); + // another null check just incase + if (output == IntPtr.Zero) { + Logger.WriteLine("LibObs output returned null, something really went wrong (this isn't suppose to happen)..."); + WebMessage.DisplayModal("An unexpected error occured. Detailed information written in logs.", "Recording Error", "warning"); + ReleaseAll(); return false; } - videoNameTimeStamp = DateTime.Now.ToString("yyyy_MM_dd_HH_mm_ss"); - FileFormat currentFileFormat = SettingsService.Settings.captureSettings.fileFormat ?? (new FileFormat("mp4", "MP4 (.mp4)", true)); - Logger.WriteLine($"Output file format: " + currentFileFormat.ToString()); - videoSavePath = Path.Join(dir, videoNameTimeStamp + "-ses." + currentFileFormat.GetFileExtension()); + return true; + } - // Get the window class name -#if WINDOWS - var windowClassNameId = WindowService.GetWindowTitle(windowHandle) + ":" + WindowService.GetClassName(windowHandle) + ":" + Path.GetFileName(session.Exe); -#else - var windowClassNameId = windowHandle + "\r\n" + WindowService.GetWindowTitle(windowHandle) + "\r\n" + WindowService.GetClassName(windowHandle); -#endif - // get game's window size and change output to match - windowSize = WindowService.GetWindowSize(windowHandle); - // sometimes, the inital window size might be in a middle of a transition, and gives us a weird dimension - // this is a quick a dirty check: if there aren't more than 1120 pixels, we can assume it needs a retry - while (windowSize.GetWidth() + windowSize.GetHeight() < 1120 && retryAttempt < maxRetryAttempts) { - Logger.WriteLine($"Waiting to retrieve correct window size (currently {windowSize.GetWidth()}x{windowSize.GetHeight()})... retry attempt #{retryAttempt}"); - await Task.Delay(retryInterval); - retryAttempt++; - windowSize = WindowService.GetWindowSize(windowHandle); - } - if (windowSize.GetWidth() + windowSize.GetHeight() < 1120 && retryAttempt >= maxRetryAttempts) { - Logger.WriteLine($"Possible issue in getting correct window size {windowSize.GetWidth()}x{windowSize.GetHeight()}"); - ResetVideo(); + private void SetupOutput(string outputPath) { + + // SETUP NEW OUTPUT + if (captureSettings.useReplayBuffer) { + IntPtr bufferOutputSettings = obs_data_create(); + obs_data_set_string(bufferOutputSettings, "directory", Path.GetDirectoryName(outputPath)); + obs_data_set_string(bufferOutputSettings, "format", "%CCYY-%MM-%DD %hh-%mm-%ss-ses"); + obs_data_set_string(bufferOutputSettings, "extension", captureSettings.fileFormat.format); + obs_data_set_int(bufferOutputSettings, "max_time_sec", captureSettings.replayBufferDuration); + obs_data_set_int(bufferOutputSettings, "max_size_mb", captureSettings.replayBufferSize); + output = obs_output_create("replay_buffer", "replay_buffer_output", bufferOutputSettings, IntPtr.Zero); + + obs_data_release(bufferOutputSettings); } else { - Logger.WriteLine($"Game capture window size: {windowSize.GetWidth()}x{windowSize.GetHeight()}"); - ResetVideo(windowHandle, windowSize.GetWidth(), windowSize.GetHeight()); - } + output = obs_output_create("ffmpeg_muxer", "simple_ffmpeg_output", IntPtr.Zero, IntPtr.Zero); - Logger.WriteLine($"Preparing to create libobs output [{bnum_allocs()}]..."); -#if WINDOWS - string audioOutSourceId = "wasapi_output_capture"; - string audioInSourceId = "wasapi_input_capture"; - string audioProcessSourceId = "wasapi_process_output_capture"; - string audioEncoderId = "ffmpeg_aac"; - string videoSourceId = "game_capture"; -#else - string audioOutSourceId = "pulse_output_capture"; - string audioInSourceId = "pulse_input_capture"; - string audioEncoderId = "ffmpeg_aac"; - string videoSourceId = "xcomposite_input"; -#endif - // SETUP NEW AUDIO SOURCES & ENCODERS - // - Create sources for output and input devices - // TODO: isolate game audio and discord app audio - // TODO: have user adjustable audio tracks, especially if the user is trying to use more than 6 tracks (6 is the limit) - // as of now, if the audio sources exceed 6 tracks, then those tracks will be defaulted to track 6 (index = 5) - audioEncoders.TryAdd("combined", obs_audio_encoder_create(audioEncoderId, "combined", IntPtr.Zero, 0, IntPtr.Zero)); - obs_encoder_set_audio(audioEncoders["combined"], obs_get_audio()); - if (SettingsService.Settings.captureSettings.captureGameAudio) { - IntPtr settings = obs_data_create(); - obs_data_set_string(settings, "window", $"{WindowService.GetWindowTitle(session.WindowHandle)}:{WindowService.GetClassName(session.WindowHandle)}:{session.Exe}"); - audioSources.TryAdd("Game Audio", obs_audio_source_create(audioProcessSourceId, "Game Audio", settings: settings, mono: false)); - obs_set_output_source(1, audioSources["Game Audio"]); - obs_source_set_audio_mixers(audioSources["Game Audio"], 1 | (uint)(1 << Math.Min(1, 5))); + + // SETUP OUTPUT SETTINGS + IntPtr outputSettings = obs_data_create(); + obs_data_set_string(outputSettings, "path", outputPath); + obs_output_update(output, outputSettings); + obs_data_release(outputSettings); } - else { - int totalDevices = 0; - foreach (var (outputDevice, index) in SettingsService.Settings.captureSettings.outputDevices.WithIndex()) { - audioSources.TryAdd("(output) " + outputDevice.deviceId, obs_audio_source_create(audioOutSourceId, "(output) " + outputDevice.deviceLabel, deviceId: outputDevice.deviceId)); - obs_set_output_source((uint)(index + 1), audioSources["(output) " + outputDevice.deviceId]); - obs_source_set_audio_mixers(audioSources["(output) " + outputDevice.deviceId], 1 | (uint)(1 << Math.Min(index + 1, 5))); - obs_source_set_volume(audioSources["(output) " + outputDevice.deviceId], outputDevice.deviceVolume / (float)100); - if (index + 1 < 6) { - audioEncoders.TryAdd("(output) " + outputDevice.deviceId, obs_audio_encoder_create(audioEncoderId, "(output) " + outputDevice.deviceLabel, IntPtr.Zero, (UIntPtr)index + 1, IntPtr.Zero)); - obs_encoder_set_audio(audioEncoders["(output) " + outputDevice.deviceId], obs_get_audio()); - } - else - Logger.WriteLine($"[Warning] Exceeding 6 audio sources ({index + 1}), cannot add another track (max = 6)"); - totalDevices++; - } - foreach (var (inputDevice, index) in SettingsService.Settings.captureSettings.inputDevices.WithIndex()) { - audioSources.TryAdd("(input) " + inputDevice.deviceId, obs_audio_source_create(audioInSourceId, "(input) " + inputDevice.deviceLabel, deviceId: inputDevice.deviceId, mono: true)); - obs_set_output_source((uint)(index + totalDevices + 1), audioSources["(input) " + inputDevice.deviceId]); - obs_source_set_audio_mixers(audioSources["(input) " + inputDevice.deviceId], 1 | (uint)(1 << Math.Min(index + totalDevices + 1, 5))); - obs_source_set_volume(audioSources["(input) " + inputDevice.deviceId], inputDevice.deviceVolume / (float)100); - if (index + totalDevices + 1 < 6) { - audioEncoders.TryAdd("(input) " + inputDevice.deviceId, obs_audio_encoder_create(audioEncoderId, "(input) " + inputDevice.deviceLabel, IntPtr.Zero, (UIntPtr)(index + totalDevices + 1), IntPtr.Zero)); - obs_encoder_set_audio(audioEncoders["(input) " + inputDevice.deviceId], obs_get_audio()); - } - else - Logger.WriteLine($"[Warning] Exceeding 6 audio sources ({index + totalDevices + 1}), cannot add another track (max = 6)"); - - if (inputDevice.denoiser) { - nint settings = obs_data_create(); - obs_data_set_string(settings, "method", "denoiser"); - obs_data_set_string(settings, "versioned_id", "noise_suppress_filter_v2"); - nint noiseSuppressFilter = obs_source_create("noise_suppress_filter", "Noise Suppression", settings, IntPtr.Zero); - obs_source_filter_add(audioSources["(input) " + inputDevice.deviceId], noiseSuppressFilter); - obs_data_release(settings); - } - } + signal_handler_connect(obs_output_get_signal_handler(output), "stop", outputStopCb, IntPtr.Zero); - // TODO: Implement frontend for selecting applications - foreach (var (audioApplication, index) in SettingsService.Settings.captureSettings.audioApplications.WithIndex()) { - IntPtr settings = obs_data_create(); - obs_data_set_string(settings, "window", audioApplication.application); - audioSources.TryAdd("(input) " + audioApplication, obs_audio_source_create(audioProcessSourceId, "(input) " + audioApplication, deviceId: audioApplication.application, mono: false)); - obs_set_output_source((uint)(index + totalDevices + 1), audioSources["(input) " + audioApplication.application]); - obs_source_set_audio_mixers(audioSources["(input) " + audioApplication], 1 | (uint)(1 << Math.Min(index + totalDevices + 1, 5))); - obs_source_set_volume(audioSources["(input) " + audioApplication], audioApplication.applicationVolume / (float)100); - if (index + totalDevices + 1 < 6) { - audioEncoders.TryAdd("(input) " + audioApplication.application, obs_audio_encoder_create(audioEncoderId, "(input) " + audioApplication.application, IntPtr.Zero, (UIntPtr)(index + totalDevices + 1), IntPtr.Zero)); - obs_encoder_set_audio(audioEncoders["(input) " + audioApplication.application], obs_get_audio()); - } - else - Logger.WriteLine($"[Warning] Exceeding 6 audio sources ({index + totalDevices + 1}), cannot add another track (max = 6)"); - } + obs_output_set_video_encoder(output, videoEncoders[captureSettings.encoder]); + nuint idx = 0; + foreach (var audioEncoder in audioEncoders) { + obs_output_set_audio_encoder(output, audioEncoder.Value, idx); + idx++; } + } - string encoder = SettingsService.Settings.captureSettings.encoder; - string rateControl = SettingsService.Settings.captureSettings.rateControl; - string fileFormat = SettingsService.Settings.captureSettings.fileFormat.format; + private async Task SetupVideoSources(string recordWindow, int pid, bool forceDisplayCapture) { + string encoder = captureSettings.encoder; + string rateControl = captureSettings.rateControl; + string fileFormat = captureSettings.fileFormat.format; - if (session.ForceDisplayCapture == false) { + int retryAttempt = 0; + if (forceDisplayCapture == false) { // SETUP NEW VIDEO SOURCE // - Create a source for the game_capture in channel 0 IntPtr videoSourceSettings = obs_data_create(); obs_data_set_string(videoSourceSettings, "capture_mode", WindowService.IsFullscreen(windowHandle) ? "any_fullscreen" : "window"); - obs_data_set_string(videoSourceSettings, "capture_window", windowClassNameId); - obs_data_set_string(videoSourceSettings, "window", windowClassNameId); + obs_data_set_string(videoSourceSettings, "capture_window", recordWindow); + obs_data_set_string(videoSourceSettings, "window", recordWindow); videoSources.TryAdd("gameplay", obs_source_create(videoSourceId, "gameplay", videoSourceSettings, IntPtr.Zero)); obs_data_release(videoSourceSettings); @@ -370,7 +337,7 @@ public override async Task StartRecording() { // attempt to wait for game_capture source to hook first if (videoSourceId == "game_capture") { retryAttempt = 0; - Logger.WriteLine($"Waiting for successful graphics hook for [{windowClassNameId}]..."); + Logger.WriteLine($"Waiting for successful graphics hook for [{recordWindow}]..."); // SETUP HOOK SIGNAL HANDLERS signal_handler_connect(obs_output_get_signal_handler(videoSources["gameplay"]), "hooked", (data, cd) => { @@ -380,8 +347,8 @@ public override async Task StartRecording() { Logger.WriteLine("unhooked"); }, IntPtr.Zero); - while (signalGCHookSuccess == false && retryAttempt < Math.Min(maxRetryAttempts + signalGCHookAttempt, 30)) { - await Task.Delay(retryInterval); + while (signalGCHookSuccess == false && retryAttempt < Math.Min(MaxRetryAttempts + signalGCHookAttempt, 30)) { + await Task.Delay(RetryInterval); retryAttempt++; } } @@ -393,19 +360,16 @@ public override async Task StartRecording() { signalGCHookAttempt = 0; if (videoSourceId == "game_capture" && signalGCHookSuccess == false) { - if (session.ForceDisplayCapture == false) { - Logger.WriteLine($"Unable to get graphics hook for [{windowClassNameId}] after {retryAttempt} attempts"); + if (forceDisplayCapture == false) { + Logger.WriteLine($"Unable to get graphics hook for [{recordWindow}] after {retryAttempt} attempts"); } - Process process; try { - process = Process.GetProcessById(session.Pid); + process = Process.GetProcessById(pid); } catch { - ReleaseOutput(); - ReleaseSources(); - ReleaseEncoders(); + ReleaseAll(); return false; } @@ -418,103 +382,101 @@ public override async Task StartRecording() { Logger.WriteLine("Could not get process exit status: " + ex.ToString()); } - if (SettingsService.Settings.captureSettings.useDisplayCapture && !processHasExited) { + if (captureSettings.useDisplayCapture && !processHasExited) { Logger.WriteLine("Attempting to use display capture instead"); StartDisplayCapture(); } else { - ReleaseOutput(); - ReleaseSources(); - ReleaseEncoders(); + ReleaseAll(); return false; } } - retryAttempt = 0; - // SETUP NEW OUTPUT - if (SettingsService.Settings.captureSettings.useReplayBuffer) { - IntPtr bufferOutputSettings = obs_data_create(); - obs_data_set_string(bufferOutputSettings, "directory", dir); - obs_data_set_string(bufferOutputSettings, "format", "%CCYY-%MM-%DD %hh-%mm-%ss-ses"); - obs_data_set_string(bufferOutputSettings, "extension", fileFormat); - obs_data_set_int(bufferOutputSettings, "max_time_sec", SettingsService.Settings.captureSettings.replayBufferDuration); - obs_data_set_int(bufferOutputSettings, "max_size_mb", SettingsService.Settings.captureSettings.replayBufferSize); - output = obs_output_create("replay_buffer", "replay_buffer_output", bufferOutputSettings, IntPtr.Zero); + return true; + } - obs_data_release(bufferOutputSettings); + private void SetupAudioSources() { + // SETUP NEW AUDIO SOURCES & ENCODERS + // - Create sources for output and input devices + // TODO: isolate game audio and discord app audio + // TODO: have user adjustable audio tracks, especially if the user is trying to use more than 6 tracks (6 is the limit) + // as of now, if the audio sources exceed 6 tracks, then those tracks will be defaulted to track 6 (index = 5) + foreach (var outputDevice in captureSettings.outputDevices) { + CreateAudioDeviceSource(outputDevice); } - else { - output = obs_output_create("ffmpeg_muxer", "simple_ffmpeg_output", IntPtr.Zero, IntPtr.Zero); - - - // SETUP OUTPUT SETTINGS - IntPtr outputSettings = obs_data_create(); - obs_data_set_string(outputSettings, "path", videoSavePath); - obs_output_update(output, outputSettings); - obs_data_release(outputSettings); + foreach (var inputDevice in captureSettings.inputDevices) { + CreateAudioDeviceSource(inputDevice); } - signal_handler_connect(obs_output_get_signal_handler(output), "stop", outputStopCb, IntPtr.Zero); - obs_output_set_video_encoder(output, videoEncoders[encoder]); - nuint idx = 0; - foreach (var audioEncoder in audioEncoders) { - obs_output_set_audio_encoder(output, audioEncoder.Value, idx); - idx++; + // TODO: Implement frontend for selecting applications + foreach (var audioApplication in captureSettings.audioApplications) { + CreateAudioApplicationSource(audioApplication); } + } - // some quick checks on initializations before starting output - bool canStartCapture = obs_output_can_begin_data_capture(output, 0); - if (!canStartCapture) { - while (!obs_output_initialize_encoders(output, 0) && retryAttempt < maxRetryAttempts) { - Logger.WriteLine($"Waiting for encoders to finish initializing... retry attempt #{retryAttempt}"); - await Task.Delay(retryInterval); - retryAttempt++; - } - if (retryAttempt >= maxRetryAttempts) { - Logger.WriteLine("Unable to get encoders to initialize"); - ReleaseOutput(); - ReleaseSources(); - ReleaseEncoders(); - return false; - } - } - retryAttempt = 0; + private void CreateAudioApplicationSource(AudioApplication application) { + string id = "(application) " + application.name; + IntPtr settings = obs_data_create(); + obs_data_set_string(settings, "window", application.windowClassNameId); + audioSources.TryAdd(id, obs_audio_source_create(audioProcessSourceId, id, settings: settings, mono: false)); + obs_set_output_source((uint)audioSources.Count, audioSources[id]); + obs_source_set_audio_mixers(audioSources[id], 1 | (uint)(1 << Math.Min(audioSources.Count, 5))); + obs_source_set_volume(audioSources[id], application.applicationVolume / (float)100); + if (audioSources.Count <= 6) { + audioEncoders.TryAdd(id, obs_audio_encoder_create(audioEncoderId, id, IntPtr.Zero, (UIntPtr)(audioSources.Count), IntPtr.Zero)); + obs_encoder_set_audio(audioEncoders[id], obs_get_audio()); + } + else + Logger.WriteLine($"[Warning] Exceeding 6 audio sources ({audioSources.Count}), cannot add another track (max = 6)"); + } - // another null check just incase - if (output == IntPtr.Zero) { - Logger.WriteLine("LibObs output returned null, something really went wrong (this isn't suppose to happen)..."); - WebMessage.DisplayModal("An unexpected error occured. Detailed information written in logs.", "Recording Error", "warning"); - ReleaseOutput(); - ReleaseSources(); - ReleaseEncoders(); - return false; + private void CreateAudioDeviceSource(AudioDevice device) { + string deviceType = device.isInput ? "(input) " : "(output) "; + string id = deviceType + device.deviceId; + string label = deviceType + device.deviceLabel; + audioSources.TryAdd(id, obs_audio_source_create(audioInSourceId, label, deviceId: device.deviceId, mono: device.isInput)); + obs_set_output_source((uint)audioSources.Count, audioSources[id]); + obs_source_set_audio_mixers(audioSources[id], 1 | (uint)(1 << Math.Min(audioSources.Count, 5))); + obs_source_set_volume(audioSources[id], device.deviceVolume / (float)100); + if (audioSources.Count <= 6) { + audioEncoders.TryAdd(id, obs_audio_encoder_create(audioEncoderId, label, IntPtr.Zero, (UIntPtr)(audioSources.Count), IntPtr.Zero)); + obs_encoder_set_audio(audioEncoders[id], obs_get_audio()); + } + else + Logger.WriteLine($"[Warning] Exceeding 6 audio sources ({audioSources.Count}), cannot add another track (max = 6)"); + + if (device.denoiser) { + nint settings = obs_data_create(); + obs_data_set_string(settings, "method", "denoiser"); + obs_data_set_string(settings, "versioned_id", "noise_suppress_filter_v2"); + nint noiseSuppressFilter = obs_source_create("noise_suppress_filter", "Noise Suppression", settings, IntPtr.Zero); + obs_source_filter_add(audioSources[id], noiseSuppressFilter); + obs_data_release(settings); } + } - // preparations complete, launch the rocket - Logger.WriteLine($"LibObs output is starting [{bnum_allocs()}]..."); - bool outputStartSuccess = obs_output_start(output); - if (outputStartSuccess != true) { - string error = obs_output_get_last_error(output).Trim(); - Logger.WriteLine("LibObs output recording error: '" + error + "'"); - if (error.Length <= 0) { - WebMessage.DisplayModal("An unexpected error occured. Detailed information written in logs.", "Recording Error", "warning"); - } - else { - WebMessage.DisplayModal(error, "Recording Error", "warning"); - } - ReleaseOutput(); - ReleaseSources(); - ReleaseEncoders(); - return false; + private async Task GetValidWindowSize() { + int retryAttempt = 0; + windowSize = WindowService.GetWindowSize(windowHandle); + // sometimes, the inital window size might be in a middle of a transition, and gives us a weird dimension + // this is a quick a dirty check: if there aren't more than 1120 pixels, we can assume it needs a retry + while (windowSize.GetWidth() + windowSize.GetHeight() < 1120 && retryAttempt < MaxRetryAttempts) { + Logger.WriteLine($"Waiting to retrieve correct window size (currently {windowSize.GetWidth()}x{windowSize.GetHeight()})... retry attempt #{retryAttempt}"); + await Task.Delay(RetryInterval); + retryAttempt++; + windowSize = WindowService.GetWindowSize(windowHandle); + } + if (windowSize.GetWidth() + windowSize.GetHeight() < 1120 && retryAttempt >= MaxRetryAttempts) { + Logger.WriteLine($"Possible issue in getting correct window size {windowSize.GetWidth()}x{windowSize.GetHeight()}"); + ResetVideo(); } else { - Logger.WriteLine($"LibObs started recording [{session.Pid}] [{session.GameTitle}] [{windowClassNameId}]"); + Logger.WriteLine($"Game capture window size: {windowSize.GetWidth()}x{windowSize.GetHeight()}"); + ResetVideo(windowHandle, windowSize.GetWidth(), windowSize.GetHeight()); } - - IntegrationService.Start(session.GameTitle); - return true; } + private void StartDisplayCapture() { ReleaseVideoSources(); ResumeDisplayOutput(); @@ -557,18 +519,18 @@ private IntPtr GetVideoEncoder(string encoder, string rateControl, string format } obs_data_set_string(videoEncoderSettings, "rate_control", rate_controls[rateControl]); - obs_data_set_int(videoEncoderSettings, "bitrate", (uint)SettingsService.Settings.captureSettings.bitRate * 1000); - - if (SettingsService.Settings.captureSettings.rateControl == "VBR") { - obs_data_set_int(videoEncoderSettings, "max_bitrate", (uint)SettingsService.Settings.captureSettings.maxBitRate * 1000); - } - - if (SettingsService.Settings.captureSettings.rateControl == "CQP") { - obs_data_set_int(videoEncoderSettings, "cqp", (uint)SettingsService.Settings.captureSettings.cqLevel); - } + obs_data_set_int(videoEncoderSettings, "bitrate", (uint)captureSettings.bitRate * 1000); - if (SettingsService.Settings.captureSettings.rateControl == "CRF") { - obs_data_set_int(videoEncoderSettings, "crf", (uint)SettingsService.Settings.captureSettings.cqLevel); + switch (rateControl) { + case "VBR": + obs_data_set_int(videoEncoderSettings, "max_bitrate", (uint)captureSettings.maxBitRate * 1000); + break; + case "CQP": + obs_data_set_int(videoEncoderSettings, "cqp", (uint)captureSettings.cqLevel); + break; + case "CRF": + obs_data_set_int(videoEncoderSettings, "crf", (uint)captureSettings.cqLevel); + break; } // See https://github.com/obsproject/obs-studio/blob/9d2715fe72849bb8c1793bb964ba3d9dc2f189fe/UI/window-basic-main-outputs.cpp#L1310C1-L1310C1 @@ -706,50 +668,50 @@ public void GetAvailableEncoders() { //As x264 is a software encoder, it must be supported on all platforms availableEncoders.Add("Software (x264)"); Logger.WriteLine("Encoder options: " + string.Join(", ", availableEncoders)); - SettingsService.Settings.captureSettings.encodersCache = availableEncoders; - if (!availableEncoders.Contains(SettingsService.Settings.captureSettings.encoder)) { - if (!string.IsNullOrWhiteSpace(SettingsService.Settings.captureSettings.encoder)) + captureSettings.encodersCache = availableEncoders; + if (!availableEncoders.Contains(captureSettings.encoder)) { + if (!string.IsNullOrWhiteSpace(captureSettings.encoder)) WebMessage.DisplayModal($"The previously selected encoder is no longer available. The encoder has been reset to the default option: {availableEncoders[0]}.", "Encoder warning", "warning"); - SettingsService.Settings.captureSettings.encoder = availableEncoders[0]; + captureSettings.encoder = availableEncoders[0]; } SettingsService.SaveSettings(); } public bool HasNvidiaAudioSDK() { bool exists = Path.Exists("C:\\Program Files\\NVIDIA Corporation\\NVIDIA Audio Effects SDK"); - if (SettingsService.Settings.captureSettings.hasNvidiaAudioSDK != exists) { - SettingsService.Settings.captureSettings.hasNvidiaAudioSDK = exists; + if (captureSettings.hasNvidiaAudioSDK != exists) { + captureSettings.hasNvidiaAudioSDK = exists; SettingsService.SaveSettings(); } return exists; } public void GetAvailableRateControls() { - Logger.WriteLine("Selected encoder: " + SettingsService.Settings.captureSettings.encoder); - if (videoEncoderLink.TryGetValue(SettingsService.Settings.captureSettings.encoder, out List availableRateControls)) { + Logger.WriteLine("Selected encoder: " + captureSettings.encoder); + if (videoEncoderLink.TryGetValue(captureSettings.encoder, out List availableRateControls)) { Logger.WriteLine("Rate Control options: " + string.Join(", ", availableRateControls)); - SettingsService.Settings.captureSettings.rateControlCache = availableRateControls; - if (!availableRateControls.Contains(SettingsService.Settings.captureSettings.rateControl)) - SettingsService.Settings.captureSettings.rateControl = availableRateControls[0]; + captureSettings.rateControlCache = availableRateControls; + if (!availableRateControls.Contains(captureSettings.rateControl)) + captureSettings.rateControl = availableRateControls[0]; SettingsService.SaveSettings(); } } public void GetAvailableFileFormats() { - Logger.WriteLine("File format: " + SettingsService.Settings.captureSettings.fileFormat); + Logger.WriteLine("File format: " + captureSettings.fileFormat); - var selectedFormat = SettingsService.Settings.captureSettings.fileFormat; + var selectedFormat = captureSettings.fileFormat; // Check if we have an invalid file format selected if (selectedFormat == null || file_formats.Where(x => x.format == selectedFormat.format).Any() == false) { // Invalid file format, default to file_format_default. selectedFormat = file_format_default; - SettingsService.Settings.captureSettings.fileFormat = selectedFormat; + captureSettings.fileFormat = selectedFormat; } - SettingsService.Settings.captureSettings.fileFormatsCache = file_formats; + captureSettings.fileFormatsCache = file_formats; SettingsService.SaveSettings(); } @@ -763,13 +725,13 @@ public override async Task StopRecording() { obs_output_stop(output); // attempt to check if output signalled stop int retryAttempt = 0; - while (signalOutputStop == false && retryAttempt < maxRetryAttempts / 2) { + while (signalOutputStop == false && retryAttempt < MaxRetryAttempts / 2) { Logger.WriteLine($"Waiting for obs_output to stop... retry attempt #{retryAttempt}"); - await Task.Delay(retryInterval); + await Task.Delay(RetryInterval); retryAttempt++; } isStopping = false; - if (retryAttempt >= maxRetryAttempts / 2) { + if (retryAttempt >= MaxRetryAttempts / 2) { Logger.WriteLine($"Failed to get obs_output_stop signal, forcing output to stop."); obs_output_force_stop(output); } @@ -777,20 +739,18 @@ public override async Task StopRecording() { bool isReplayBuffer = IsUsingReplayBuffer(); // CLEANUP - ReleaseOutput(); - ReleaseSources(); - ReleaseEncoders(); + ReleaseAll(); DisplayCapture = false; if (!isReplayBuffer) { - Logger.WriteLine($"Session recording saved to {videoSavePath}"); - RecordingService.lastVideoDuration = GetVideoDuration(videoSavePath); + Logger.WriteLine($"Session recording saved to {session.videoSavePath}"); + RecordingService.lastVideoDuration = GetVideoDuration(session.videoSavePath); } if (IntegrationService.ActiveGameIntegration is LeagueOfLegendsIntegration lol) { - GetOrCreateMetadata(videoSavePath); - lol.UpdateMetadataWithStats(videoSavePath); + GetOrCreateMetadata(session.videoSavePath); + lol.UpdateMetadataWithStats(session.videoSavePath); } #if RELEASE && WINDOWS @@ -808,7 +768,7 @@ public override async Task StopRecording() { #endif IntegrationService.Shutdown(); if (!isReplayBuffer) - BookmarkService.ApplyBookmarkToSavedVideo("/" + Path.GetFileName(videoSavePath)); + BookmarkService.ApplyBookmarkToSavedVideo("/" + Path.GetFileName(session.videoSavePath)); Logger.WriteLine($"LibObs stopped recording {session.Pid} {session.GameTitle} [{bnum_allocs()}]"); return !signalOutputStop; @@ -864,12 +824,12 @@ public static void ResetVideo(nint windowHandle = 0, int outputWidth = 1, int ou #else graphics_module = "libobs-opengl", #endif - fps_num = (uint)SettingsService.Settings.captureSettings.frameRate, + fps_num = (uint)captureSettings.frameRate, fps_den = 1, base_width = (uint)(outputWidth > 1 ? outputWidth : screenWidth), base_height = (uint)(outputHeight > 1 ? outputHeight : screenHeight), - output_width = (uint)(outputWidth > 1 ? Convert.ToInt32(SettingsService.Settings.captureSettings.resolution * screenRatio) : screenWidth), - output_height = (uint)(outputHeight > 1 ? SettingsService.Settings.captureSettings.resolution : screenHeight), + output_width = (uint)(outputWidth > 1 ? Convert.ToInt32(captureSettings.resolution * screenRatio) : screenWidth), + output_height = (uint)(outputHeight > 1 ? captureSettings.resolution : screenHeight), output_format = video_format.VIDEO_FORMAT_NV12, gpu_conversion = true, colorspace = video_colorspace.VIDEO_CS_DEFAULT, @@ -882,12 +842,18 @@ public static void ResetVideo(nint windowHandle = 0, int outputWidth = 1, int ou } } - public void ReleaseSources() { + private void ReleaseAll() { + ReleaseOutput(); + ReleaseSources(); + ReleaseEncoders(); + } + + private void ReleaseSources() { ReleaseVideoSources(); ReleaseAudioSources(); } - public void ReleaseVideoSources() { + private void ReleaseVideoSources() { foreach (var videoSource in videoSources.Values) { obs_source_remove(videoSource); obs_source_release(videoSource); @@ -896,7 +862,7 @@ public void ReleaseVideoSources() { Logger.WriteLine("Released Video Sources."); } - public void ReleaseAudioSources() { + private void ReleaseAudioSources() { foreach (var audioSource in audioSources.Values) { obs_source_remove(audioSource); obs_source_release(audioSource); @@ -931,7 +897,7 @@ public void ReleaseEncoders() { Logger.WriteLine("Released Encoders."); } - public void ReleaseOutput() { + private void ReleaseOutput() { Logger.WriteLine("Releasing Output."); var reference = obs_output_get_ref(output); if (reference == IntPtr.Zero) { diff --git a/Classes/Recorders/PlaysLTCRecorder.cs b/Classes/Recorders/PlaysLTCRecorder.cs index 9ce00dc1..b4b372bf 100644 --- a/Classes/Recorders/PlaysLTCRecorder.cs +++ b/Classes/Recorders/PlaysLTCRecorder.cs @@ -83,7 +83,7 @@ public override void Start() { ltc.VideoCaptureReady += (sender, msg) => { RecordingService.GetCurrentSession().Pid = msg.Pid; if (SettingsService.Settings.captureSettings.recordingMode == "automatic") - RecordingService.StartRecording(); + RecordingService.StartRecording(false); }; ltc.ProcessTerminated += (sender, msg) => { diff --git a/Classes/Services/DetectionService.cs b/Classes/Services/DetectionService.cs index dc118cb1..c2d5a663 100644 --- a/Classes/Services/DetectionService.cs +++ b/Classes/Services/DetectionService.cs @@ -48,7 +48,7 @@ public static void WindowCreation(IntPtr hwnd, int processId = 0, [CallerMemberN WindowService.GetExecutablePathFromProcessId(processId, out string executablePath); if (executablePath != null) { - if (executablePath.ToString().ToLower().StartsWith(@"c:\windows\")) { // if this program is starting from here, + if (executablePath.ToLower().StartsWith(@"c:\windows\")) { // if this program is starting from here, return; // we can assume it is not a game } } @@ -252,7 +252,7 @@ public static bool AutoDetectGame(int processId, string executablePath, nint win bool allowed = SettingsService.Settings.captureSettings.recordingMode is "automatic" or "whitelist"; Logger.WriteLine($"{(allowed ? "Starting capture for" : "Ready to capture")} application: {detailedWindowStr}"); RecordingService.SetCurrentSession(processId, windowHandle, gameTitle, executablePath, gameDetection.forceDisplayCapture); - if (allowed) RecordingService.StartRecording(); + if (allowed) RecordingService.StartRecording(false); } return isGame; } diff --git a/Classes/Services/Keybinds/RecordingKeybind.cs b/Classes/Services/Keybinds/RecordingKeybind.cs index 52e8be37..b0ac0679 100644 --- a/Classes/Services/Keybinds/RecordingKeybind.cs +++ b/Classes/Services/Keybinds/RecordingKeybind.cs @@ -9,7 +9,7 @@ public RecordingKeybind() { } public override void Action() { if (RecordingService.IsRecording) RecordingService.StopRecording(true); - else RecordingService.StartRecording(); + else RecordingService.StartRecording(true); } } } \ No newline at end of file diff --git a/Classes/Services/RecordingService.cs b/Classes/Services/RecordingService.cs index d06e23cc..afcd1f22 100644 --- a/Classes/Services/RecordingService.cs +++ b/Classes/Services/RecordingService.cs @@ -1,8 +1,10 @@ using RePlays.Recorders; using RePlays.Utils; using System; +using System.IO; using System.Threading.Tasks; using System.Timers; +using static RePlays.Utils.Functions; using Timer = System.Timers.Timer; @@ -21,12 +23,17 @@ public static class RecordingService { private static bool IsRestarting { get; set; } public static bool GameInFocus { get; set; } + const int retryInterval = 2000; // 2 second + const int maxRetryAttempts = 20; // 30 retries + public class Session { public int Pid { get; internal set; } public nint WindowHandle { get; internal set; } public string GameTitle { get; internal set; } public string Exe { get; internal set; } public bool ForceDisplayCapture { get; internal set; } + public string videoSavePath { get; internal set; } + public Session(int _Pid, nint _WindowHandle, string _GameTitle, string _Exe = null, bool _ForceDisplayCapture = false) { Pid = _Pid; WindowHandle = _WindowHandle; @@ -48,6 +55,8 @@ public static async void Start(Type type) { ActiveRecorder = new LibObsRecorder(); Logger.WriteLine("Creating a new ActiveRecorder"); await Task.Run(() => ActiveRecorder.Start()); + //Update user settings + WebMessage.SendMessage(GetUserSettings()); await Task.Run(() => DetectionService.CheckTopLevelWindows()); } @@ -59,19 +68,69 @@ public static Session GetCurrentSession() { return currentSession; } + private static async Task GetGoodWindowHandle() { + int retryAttempt = 0; + while ((DetectionService.HasBadWordInClassName(currentSession.WindowHandle) || currentSession.WindowHandle == IntPtr.Zero) && retryAttempt < maxRetryAttempts) { + Logger.WriteLine($"Waiting to retrieve process handle... retry attempt #{retryAttempt}"); + await Task.Delay(retryInterval); + retryAttempt++; + // alternate on retry attempts, one or the other might get us a better handle + currentSession.WindowHandle = WindowService.GetWindowHandleByProcessId(currentSession.Pid, retryAttempt % 2 == 1); + } + if (retryAttempt >= maxRetryAttempts) { + return false; + } + return true; + } + + private static bool HandleManualRecord() { + if (WindowService.GetForegroundWindow(out int processId, out nint hwid)) { + if (processId != 0 || hwid != 0) { + WindowService.GetExecutablePathFromProcessId(processId, out string executablePath); + DetectionService.AutoDetectGame(processId, executablePath, hwid, autoRecord: false); + return true; + } + return false; + } + return false; + } + + private static bool SetSessionDetails() { + string dir = Path.Join(GetPlaysFolder(), "/" + MakeValidFolderNameSimple(currentSession.GameTitle) + "/"); + try { + Directory.CreateDirectory(dir); + } + catch (Exception e) { + WebMessage.DisplayModal(string.Format("Unable to create folder {0}. Do you have permission to create it?", dir), "Recording Error", "warning"); + Logger.WriteLine(e.ToString()); + return false; + } + string videoNameTimeStamp = DateTime.Now.ToString("yyyy_MM_dd_HH_mm_ss"); + + FileFormat currentFileFormat = SettingsService.Settings.captureSettings.fileFormat ?? (new FileFormat("mp4", "MP4 (.mp4)", true)); + Logger.WriteLine($"Output file format: " + currentFileFormat.ToString()); + currentSession.videoSavePath = Path.Join(dir, videoNameTimeStamp + "-ses." + currentFileFormat.GetFileExtension()); + return true; + } + //[STAThread] - public static async void StartRecording() { + public static async void StartRecording(bool manual) { if (IsRecording || IsPreRecording) { Logger.WriteLine($"Cannot start recording, already recording [{currentSession.Pid}][{currentSession.GameTitle}]"); return; } IsPreRecording = true; - bool result = await ActiveRecorder.StartRecording(); - Logger.WriteLine("Start Success: " + result.ToString()); - Logger.WriteLine("Still allowed to record: " + (!IsRecording && result).ToString()); + bool result = true; + + if (manual) result = HandleManualRecord(); + if (result) result = await GetGoodWindowHandle(); + if (result) result = SetSessionDetails(); + if (result) result = await ActiveRecorder.StartRecording(); + Logger.WriteLine("Start Success: " + result); + Logger.WriteLine("Still allowed to record: " + (!IsRecording && result)); if (!IsRecording && result) { - Logger.WriteLine("Current Session PID: " + currentSession.Pid.ToString()); + Logger.WriteLine("Current Session PID: " + currentSession.Pid); startTime = DateTime.Now; recordingTimer.Elapsed += OnTimedEvent; @@ -133,7 +192,9 @@ public static async void RestartRecording() { IsRestarting = true; bool stopResult = await ActiveRecorder.StopRecording(); - bool startResult = await ActiveRecorder.StartRecording(); + bool newSession = SetSessionDetails(); + bool startResult = false; + if (newSession) startResult = await ActiveRecorder.StartRecording(); if (stopResult && startResult) { Logger.WriteLine("Recording restart successful"); diff --git a/Classes/Utils/Helpers.cs b/Classes/Utils/Helpers.cs index 15356845..f1e94cdc 100644 --- a/Classes/Utils/Helpers.cs +++ b/Classes/Utils/Helpers.cs @@ -175,11 +175,11 @@ public static void GetAudioDevices() { var inputCache = SettingsService.Settings.captureSettings.inputDevicesCache; outputCache.Clear(); inputCache.Clear(); - outputCache.Add(new("default", "Default Device")); - inputCache.Add(new("default", "Default Device", false)); + outputCache.Add(new("default", "Default Device", false)); + inputCache.Add(new("default", "Default Device", true, false)); #if WINDOWS - outputCache.Add(new("communications", "Default Communication Device")); - inputCache.Add(new("communications", "Default Communication Device", false)); + outputCache.Add(new("communications", "Default Communication Device", false)); + inputCache.Add(new("communications", "Default Communication Device", true, false)); ManagementObjectSearcher searcher = new("Select * From Win32_PnPEntity"); ManagementObjectCollection deviceCollection = searcher.Get(); foreach (ManagementObject obj in deviceCollection) { @@ -188,12 +188,13 @@ public static void GetAudioDevices() { if (obj.Properties["PNPClass"].Value.ToString() == "AudioEndpoint") { string id = obj.Properties["PNPDeviceID"].Value.ToString().Split('\\').Last(); - AudioDevice dev = new(id, obj.Properties["Name"].Value.ToString()) { + bool isOutput = id.StartsWith("{0.0.0.00000000}"); + AudioDevice dev = new(id, obj.Properties["Name"].Value.ToString(), !isOutput) { deviceId = id.ToLower(), deviceLabel = obj.Properties["Name"].Value.ToString(), deviceVolume = 100 }; - if (id.StartsWith("{0.0.0.00000000}")) outputCache.Add(dev); + if (isOutput) outputCache.Add(dev); else inputCache.Add(dev); Logger.WriteLine(dev.deviceId + " | " + dev.deviceLabel); diff --git a/Classes/Utils/JSONObjects.cs b/Classes/Utils/JSONObjects.cs index 8fb1a526..46ce2fcf 100644 --- a/Classes/Utils/JSONObjects.cs +++ b/Classes/Utils/JSONObjects.cs @@ -89,9 +89,10 @@ public class Device { public class AudioDevice { public AudioDevice() { } - public AudioDevice(string deviceId, string deviceLabel, bool denoiser = false) { + public AudioDevice(string deviceId, string deviceLabel, bool isInput, bool denoiser = false) { _deviceId = deviceId; _deviceLabel = deviceLabel; + _isInput = isInput; _denoiser = denoiser; } private string _deviceId = ""; @@ -100,18 +101,23 @@ public AudioDevice(string deviceId, string deviceLabel, bool denoiser = false) { public string deviceLabel { get { return _deviceLabel; } set { _deviceLabel = value; } } private int _deviceVolume = 100; public int deviceVolume { get { return _deviceVolume; } set { _deviceVolume = value; } } + private bool _isInput; + public bool isInput { get { return _isInput; } set { _isInput = value; } } private bool _denoiser; public bool denoiser { get { return _denoiser; } set { _denoiser = value; } } } public class AudioApplication { public AudioApplication() { } - public AudioApplication(string application) { - _application = application; + public AudioApplication(string name, string recordWindow) { + _name = name; + _recordWindow = recordWindow; } - private string _application; - public string application { get { return _application; } set { _application = value; } } + private string _name; + public string name { get { return _name; } set { _name = value; } } + private string _recordWindow; + public string windowClassNameId { get { return _recordWindow; } set { _recordWindow = value; } } private int _applicationVolume = 100; public int applicationVolume { get { return _applicationVolume; } set { _applicationVolume = value; } } } @@ -181,7 +187,7 @@ public class CaptureSettings { private List _outputDevices = new(); public List outputDevices { get { return _outputDevices; } set { _outputDevices = value; } } - public List _audioApplications = new(); + private List _audioApplications = new(); public List audioApplications { get { return _audioApplications; } set { _audioApplications = value; } } private bool _hasNvidiaAudioSDK; diff --git a/ClientApp/src/index.tsx b/ClientApp/src/index.tsx index 53a35954..2048ae00 100644 --- a/ClientApp/src/index.tsx +++ b/ClientApp/src/index.tsx @@ -128,8 +128,8 @@ declare global { deviceId: string; deviceLabel: string; deviceVolume: number; + isInput: boolean; denoiser?: boolean; - isInput?: boolean; } interface FileFormat { title: string;