From f11e63682670528e481c0c40d59678d9cc329877 Mon Sep 17 00:00:00 2001 From: seo-amugae Date: Thu, 8 Oct 2026 01:41:49 +0900 Subject: [PATCH] Add automatic microphone mixing and single FFmpeg pipeline --- README.md | 77 +++++--- config.ini | 9 +- screen-rtsp.ps1 | 488 +++++++++++++++++++++++++++++++++++++----------- 3 files changed, 436 insertions(+), 138 deletions(-) diff --git a/README.md b/README.md index 9789b0c..e4e6cea 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # RTSP -Windows의 활성 모니터를 독립 RTSP 채널로 송출하고, 실제 재생 신호가 있는 오디오 출력 장치를 자동 선택해 시스템 소리를 함께 전송하는 PowerShell 기반 watchdog입니다. +Windows의 활성 모니터를 독립 RTSP 채널로 송출하고, 실제 신호가 있는 시스템 재생 장치와 마이크를 자동 선택해 혼합 오디오를 함께 전송하는 PowerShell 기반 watchdog입니다. ## 프로젝트 성격 @@ -20,13 +20,16 @@ RTSP는 `ddagrab` 기반 Desktop Duplication API로 화면과 마우스 커서 - 기본 우선순위 `NVIDIA > AMD > Intel`, 실행 인자로 변경 가능 - Windows Core Audio WASAPI loopback 기반 시스템 재생음 캡처 - 실제 오디오 peak가 있는 출력 장치 우선 선택 -- 무음 상태에서는 기본 출력 장치를 유지해 송출이 불필요하게 끊기지 않음 -- 기본 출력 장치, 재생 장치, 오디오 포맷 변경 시 전체 채널 재시작 +- 활성 마이크 입력 endpoint 자동 검색 및 실제 입력 peak 기반 선택 +- 무음 상태에서는 Windows 기본 출력·입력 장치를 선택해 송출이 불필요하게 끊기지 않음 +- DirectShow 마이크와 시스템 재생음을 `amix`로 혼합하고 `asplit`으로 모든 채널에 분배 +- 하나의 FFmpeg가 모든 DDA 입력과 RTSP 출력을 함께 처리 +- 기본 출력·입력 장치, 선택 장치, 오디오 포맷 변경 시 전체 채널 재시작 - 기본 30fps, 영상 8Mbps, AAC 오디오 192kbps - NIC Link, IPv4, `ifIndex`, IP 변경 감지 - 선택적 Source IP 고정 ping 상태 검사 - 3초간 정상 상태가 유지된 뒤 전체 채널 시작 -- FFmpeg 또는 오디오 worker 하나가 종료되면 전체 채널 재시작 +- 단일 FFmpeg 또는 WASAPI loopback worker가 종료되면 전체 채널 재시작 - MediaMTX 종료 시 자동 재시작 - 기존 MediaMTX가 RTSP 포트를 사용 중이면 해당 프로세스 재사용 - 자동 생성 MediaMTX 설정은 RTSP over TCP만 사용해 UDP RTP 포트 충돌 방지 @@ -35,9 +38,9 @@ RTSP는 `ddagrab` 기반 Desktop Duplication API로 화면과 마우스 커서 - 절전 해제나 장시간 polling 중단 감지 시 전체 채널 강제 재구성 - 로그인 트리거와 1분 간격 무기한 복구 트리거를 이용한 Watchdog 지속 복구 - `config.ini` 기반 기능별 설정과 명령줄 override -- 오디오 캡처/AAC 트랙을 `1` 또는 `0`으로 활성화·비활성화 +- 전체 오디오와 마이크 혼합을 각각 `1` 또는 `0`으로 활성화·비활성화 - FFmpeg와 MediaMTX 부재 시 HTTPS 다운로드, SHA-256 검증, 자동 설치 -- 모니터·오디오·NIC·프로세스·절전 복구·설정·안전장치용 내장 시뮬레이션 35건 +- 모니터·오디오·NIC·프로세스·절전 복구·설정·안전장치용 내장 시뮬레이션 42건 ## RTSP URL @@ -48,17 +51,33 @@ rtsp://<현재_NIC_IP>:8554/monitor1 rtsp://<현재_NIC_IP>:8554/monitor2 ``` +## 프로세스 구조 + +기본 2채널에서는 화면·오디오 입력을 하나의 FFmpeg가 열고 두 RTSP 출력을 동시에 publish합니다. + +```text +ddagrab monitor 1 ───────────────────────────────┐ +ddagrab monitor 2 ───────────────────────────────┤ + │ +WASAPI 출력 loopback worker → named pipe ─┐ ├─ FFmpeg 1개 + ├─amix│ ├─ /monitor1 +자동 선택 마이크 → DirectShow ────────────┘ │ └─ /monitor2 + │ + amix → asplit +``` + +`ChannelCount`를 늘리면 동일한 단일 FFmpeg 안에서 선택된 화면 수만큼 DDA 입력과 RTSP 출력을 만들고, 혼합 오디오도 같은 수만큼 분배합니다. + ## 구성 - `screen-rtsp.ps1`: 캡처, 오디오 loopback, GPU 선택, MediaMTX/FFmpeg watchdog - `config.ini`: 영상, 오디오, 네트워크, RTSP 설정과 진단 명령 예시 -- `ffmpeg.exe`: `ddagrab`, `h264_nvenc`, `h264_amf`, `h264_qsv`, `aac` 지원 FFmpeg +- `ffmpeg.exe`: `ddagrab`, `dshow`, `h264_nvenc`, `h264_amf`, `h264_qsv`, `aac` 지원 FFmpeg - `mediamtx.exe`: RTSP 서버. 현재 검증 기준 버전은 v1.21.1 - `mediamtx.yml`: 없으면 스크립트가 기본 설정을 자동 생성 - `logs/watchdog.log`: watchdog 상태 로그 -- `logs/monitor1-ffmpeg.log`: monitor1 FFmpeg 로그 -- `logs/monitor2-ffmpeg.log`: monitor2 FFmpeg 로그 -- `logs/monitor1-audio.log`, `logs/monitor2-audio.log`: WASAPI loopback worker 오류 로그 +- `logs/ffmpeg.log`: 모든 monitor 출력을 담당하는 단일 FFmpeg 로그 +- `logs/audio-loopback.log`: 단일 WASAPI loopback worker 오류 로그 실행 파일과 생성 파일은 저장소에 포함하지 않습니다. `Dependencies.AutoInstall=1`이면 실행할 때 없는 `ffmpeg.exe`와 `mediamtx.exe`를 스크립트 옆에 자동 설치합니다. @@ -74,11 +93,12 @@ EncoderPriority=NVIDIA,AMD,Intel [Audio] Enabled=1 +MicrophoneEnabled=1 Bitrate=192k SignalThreshold=0.0005 ``` -`Audio.Enabled=1`이면 현재 재생 장치의 시스템 소리를 WASAPI loopback으로 캡처해 AAC 트랙을 추가합니다. `0`이면 오디오 장치 감시, loopback worker, AAC 트랙을 모두 사용하지 않고 영상만 송출합니다. +`Audio.Enabled=1`이면 선택한 출력 장치의 시스템 재생음을 WASAPI loopback으로 캡처합니다. `MicrophoneEnabled=1`이면 자동 선택한 마이크를 DirectShow로 열고 시스템 재생음과 혼합합니다. `Audio.Enabled=0`이면 마이크 설정과 관계없이 모든 오디오 감시와 AAC 트랙을 끄고 영상만 송출합니다. 줄 앞의 공백을 제외한 첫 문자가 `#`, `;`, `//`이면 설명이나 참고 명령으로 인식해 건너뜁니다. 값 오른쪽의 inline 주석은 지원하지 않으므로 설명은 별도 줄에 작성합니다. 명령줄에서 직접 지정한 값은 INI 값보다 우선합니다. @@ -93,8 +113,9 @@ SignalThreshold=0.0005 | `Video` | `Bitrate` | `8000k` | 채널별 영상 비트레이트 | | `Video` | `EncoderPriority` | `NVIDIA,AMD,Intel` | GPU 선택 순서 | | `Audio` | `Enabled` | `1` | 시스템 재생음과 AAC 트랙 사용 여부 | +| `Audio` | `MicrophoneEnabled` | `1` | 마이크 자동 선택 및 시스템 재생음 혼합 여부 | | `Audio` | `Bitrate` | `192k` | AAC 비트레이트 | -| `Audio` | `SignalThreshold` | `0.0005` | 실제 재생 장치 판별 peak 기준 | +| `Audio` | `SignalThreshold` | `0.0005` | 실제 출력·입력 신호 장치 판별 peak 기준 | | `Network` | `NicDescription` | 빈 값 | 비우면 기본 경로 NIC 자동 선택 | | `Network` | `HealthTarget` | 빈 값 | Source IP ping 대상 | | `RTSP` | `Port` | `8554` | MediaMTX RTSP 포트 | @@ -135,7 +156,7 @@ INI를 해석한 최종 설정값만 확인하고 종료할 수도 있습니다. ## 내장 시뮬레이션 -실제 송출 프로세스나 예약 작업을 만들지 않고 주요 판단 로직 35건을 검사합니다. +실제 송출 프로세스나 예약 작업을 만들지 않고 주요 판단 로직 42건을 검사합니다. ```powershell .\screen-rtsp.ps1 -RunSimulationTests @@ -147,11 +168,11 @@ INI를 해석한 최종 설정값만 확인하고 종료할 수도 있습니다. 1. FFmpeg와 MediaMTX가 없으면 설정에 따라 다운로드·검증·설치한 뒤, FFmpeg의 `ddagrab`과 H.264 하드웨어 인코더 포함 여부를 확인합니다. 2. 설치된 GPU를 `NVIDIA > AMD > Intel` 순서로 찾고 각 인코더를 실제 1프레임으로 초기화합니다. -3. 활성 모니터, IPv4 기본 경로 NIC, 활성 오디오 출력 장치를 읽습니다. -4. Core Audio peak가 감지된 출력 장치를 우선하고, 신호가 없으면 Windows 기본 출력 장치를 사용합니다. -5. 모든 조건이 3초 동안 유지되면 채널마다 WASAPI loopback worker와 FFmpeg를 시작합니다. +3. 활성 모니터, IPv4 기본 경로 NIC, 활성 오디오 출력·입력 endpoint를 읽습니다. +4. Core Audio peak가 감지된 출력 장치와 마이크를 우선하고, 신호가 없으면 Windows 기본 Multimedia 장치를 사용합니다. +5. 모든 조건이 3초 동안 유지되면 WASAPI loopback worker 하나와 FFmpeg 하나를 시작합니다. 6. 화면은 `ddagrab -> hwdownload -> BGRA -> 회전 보정 -> NV12 -> 하드웨어 H.264` 순서로 처리합니다. -7. 시스템 소리는 named pipe를 통해 FFmpeg로 전달하고 AAC로 인코딩합니다. +7. 시스템 소리는 named pipe, 마이크는 DirectShow로 FFmpeg에 입력됩니다. 두 소리를 `amix`한 뒤 `asplit`으로 각 RTSP 출력에 넣고 AAC로 인코딩합니다. 8. 모니터, NIC, GPU, 오디오 장치 또는 자식 프로세스 상태가 바뀌면 전체 채널을 종료한 뒤 새 구성을 읽어 재시작합니다. ## 설치 및 실행 @@ -185,7 +206,7 @@ Start-ScheduledTask -TaskName "Screen RTSP Watchdog" - 절전 중 놓친 실행은 `StartWhenAvailable`로 절전 해제 후 시작 - 배터리 전환 시에도 중지하지 않으며 실행 시간 제한 없음 -Watchdog 내부에서는 Windows `PowerModeChanged/Resume` 이벤트를 감시하고, 이벤트 누락에 대비해 polling 시간 공백도 함께 확인합니다. 절전 해제가 감지되면 FFmpeg 2채널을 모두 종료하고 모니터·NIC·오디오·GPU 구성을 다시 읽은 뒤 안정화 시간 이후 재시작합니다. 모니터 분리 시에는 채널을 종료하고 계속 대기하며, 필요한 수만큼 다시 연결되면 동일하게 자동 복구합니다. +Watchdog 내부에서는 Windows `PowerModeChanged/Resume` 이벤트를 감시하고, 이벤트 누락에 대비해 polling 시간 공백도 함께 확인합니다. 절전 해제가 감지되면 단일 FFmpeg AV 파이프라인을 종료하고 모니터·NIC·오디오·GPU 구성을 다시 읽은 뒤 안정화 시간 이후 재시작합니다. 모니터 분리 시에는 채널을 종료하고 계속 대기하며, 필요한 수만큼 다시 연결되면 동일하게 자동 복구합니다. 등록된 작업은 `config.ini` 경로만 전달하므로 설정 파일을 수정한 뒤 작업을 다시 시작하면 변경값이 적용됩니다. 기존 작업이 등록돼 있다면 업데이트된 복구 설정을 적용하기 위해 `-InstallStartup`을 다시 실행해야 합니다. @@ -219,6 +240,12 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\screen-rtsp.ps1 -Unins .\screen-rtsp.ps1 -AudioEnabled 0 ``` +시스템 재생음은 유지하고 마이크만 끄는 예시: + +```powershell +.\screen-rtsp.ps1 -MicrophoneEnabled 0 +``` + GPU 우선순위를 바꾸는 예시: ```powershell @@ -243,14 +270,16 @@ GPU 우선순위를 바꾸는 예시: ## 오디오 장치 선택 정책 -스크립트는 활성 상태의 Windows Render endpoint를 모두 읽고 각 장치의 peak를 확인합니다. +스크립트는 활성 상태의 Windows Render와 Capture endpoint를 모두 읽고 각 장치의 peak를 확인합니다. -- 현재 선택 장치에 실제 신호가 계속 있으면 해당 장치를 유지합니다. -- 다른 활성 장치에서만 신호가 감지되면 해당 장치로 전환합니다. -- 모든 장치가 무음이면 Windows 기본 Multimedia 출력 장치를 선택합니다. -- 선택 장치가 제거되거나 기본 장치와 mix format이 바뀌면 전체 RTSP 채널을 재시작합니다. +- 현재 선택한 출력 또는 입력 장치에 실제 신호가 계속 있으면 해당 장치를 유지합니다. +- 다른 활성 장치에서만 신호가 감지되면 해당 출력 장치 또는 마이크로 전환합니다. +- 모든 장치가 무음이면 Windows 기본 Multimedia 출력·입력 장치를 선택합니다. +- 선택 endpoint ID, 기본 장치 여부, sample rate, 채널 수 또는 sample format이 바뀌면 전체 RTSP 채널을 재시작합니다. +- Core Audio와 DirectShow가 장치명 공백을 다르게 표시해도 정규화해 같은 마이크로 연결합니다. +- 마이크가 활성화된 상태에서 입력 장치가 없으면 영상만 보내지 않고 정상 입력 장치가 생길 때까지 전체 파이프라인을 대기합니다. -이 정책은 음악이나 알림 사이의 무음 구간마다 송출이 중단되는 것을 막으면서, Bluetooth/HDMI/USB 오디오처럼 실제 재생 장치가 바뀌는 상황을 자동 반영합니다. +이 정책은 음악이나 음성 사이의 무음 구간마다 송출이 중단되는 것을 막으면서 Bluetooth/HDMI/USB 출력과 USB/Bluetooth 마이크 변경을 자동 반영합니다. ## 운영 체크포인트 diff --git a/config.ini b/config.ini index 560ea08..f6ecd4d 100644 --- a/config.ini +++ b/config.ini @@ -43,6 +43,7 @@ # 오디오 장치 확인 # Get-PnpDevice -Class AudioEndpoint | Select-Object Status,FriendlyName,InstanceId # Get-CimInstance Win32_SoundDevice | Select-Object Name,Status,PNPDeviceID +# .\ffmpeg.exe -hide_banner -list_devices true -f dshow -i dummy # # 네트워크 확인 # Get-NetAdapter | Select-Object Name,InterfaceDescription,ifIndex,Status,LinkSpeed @@ -72,11 +73,15 @@ Bitrate=8000k EncoderPriority=NVIDIA,AMD,Intel [Audio] -# 시스템 재생음 녹음 및 RTSP AAC 트랙: 1=사용, 0=사용 안 함 +# 시스템 재생음 및 RTSP AAC 트랙: 1=사용, 0=영상만 송출 Enabled=1 + +# 마이크를 자동 선택해 시스템 재생음과 혼합: 1=사용, 0=시스템 재생음만 사용 +# Enabled=0이면 이 설정과 관계없이 모든 오디오 입력을 사용하지 않습니다. +MicrophoneEnabled=1 Bitrate=192k -# 실제 소리가 출력되는 장치를 판별할 peak 최소값입니다. 범위는 0.0~1.0입니다. +# 실제 신호가 있는 출력 장치와 마이크를 판별할 peak 최소값입니다. 범위는 0.0~1.0입니다. SignalThreshold=0.0005 [Network] diff --git a/screen-rtsp.ps1 b/screen-rtsp.ps1 index 8eca0c8..cab351d 100644 --- a/screen-rtsp.ps1 +++ b/screen-rtsp.ps1 @@ -23,6 +23,9 @@ param( [ValidateSet(0, 1)] [int]$AudioEnabled = 1, + [ValidateSet(0, 1)] + [int]$MicrophoneEnabled = 1, + [ValidateRange(0.0, 1.0)] [double]$AudioSignalThreshold = 0.0005, @@ -79,7 +82,8 @@ foreach ($parameterName in $PSBoundParameters.Keys) { # Windows multi-monitor RTSP watchdog # - Desktop Duplication API (ddagrab) # - NVIDIA NVENC / AMD AMF / Intel QSV automatic selection -# - Core Audio WASAPI loopback capture +# - Core Audio WASAPI loopback + automatic microphone capture +# - One FFmpeg process publishes every monitor channel # - MediaMTX publisher watchdog # ============================================================ @@ -412,7 +416,7 @@ namespace ScreenRtsp.Audio } } - public static EndpointInfo[] GetRenderEndpoints() + private static EndpointInfo[] GetEndpoints(EDataFlow flow) { IMMDeviceEnumerator enumerator = null; IMMDeviceCollection collection = null; @@ -423,11 +427,11 @@ namespace ScreenRtsp.Audio try { enumerator = (IMMDeviceEnumerator)new MMDeviceEnumeratorComObject(); - if (enumerator.GetDefaultAudioEndpoint(EDataFlow.Render, ERole.Multimedia, + if (enumerator.GetDefaultAudioEndpoint(flow, ERole.Multimedia, out defaultDevice) >= 0 && defaultDevice != null) defaultId = GetId(defaultDevice); - ThrowIfFailed(enumerator.EnumAudioEndpoints(EDataFlow.Render, DeviceStateActive, + ThrowIfFailed(enumerator.EnumAudioEndpoints(flow, DeviceStateActive, out collection), "IMMDeviceEnumerator.EnumAudioEndpoints"); uint count; @@ -464,6 +468,16 @@ namespace ScreenRtsp.Audio } } + public static EndpointInfo[] GetRenderEndpoints() + { + return GetEndpoints(EDataFlow.Render); + } + + public static EndpointInfo[] GetCaptureEndpoints() + { + return GetEndpoints(EDataFlow.Capture); + } + public static void CaptureLoopback(string endpointId, string pipeName) { IMMDeviceEnumerator enumerator = null; @@ -680,7 +694,7 @@ function Assert-IniSettings { $allowed = @{ General = @("ChannelCount", "StableTimeSec", "PollIntervalSec") Video = @("FrameRate", "Bitrate", "EncoderPriority") - Audio = @("Enabled", "Bitrate", "SignalThreshold") + Audio = @("Enabled", "MicrophoneEnabled", "Bitrate", "SignalThreshold") Network = @("NicDescription", "HealthTarget") RTSP = @("Port") Dependencies = @( @@ -755,6 +769,10 @@ if ($ConfigWasLoaded) { if ($setting.Found -and -not $ExplicitParameters.ContainsKey("AudioEnabled")) { $AudioEnabled = Convert-IniSwitch $setting.Value "Audio.Enabled" } + $setting = Get-IniSetting $ini "Audio" "MicrophoneEnabled" + if ($setting.Found -and -not $ExplicitParameters.ContainsKey("MicrophoneEnabled")) { + $MicrophoneEnabled = Convert-IniSwitch $setting.Value "Audio.MicrophoneEnabled" + } $setting = Get-IniSetting $ini "Audio" "Bitrate" if ($setting.Found -and -not $ExplicitParameters.ContainsKey("AudioBitrate")) { $AudioBitrate = [string]$setting.Value @@ -853,6 +871,7 @@ function Get-HiddenWorkerArgumentList { "-VideoBitrate", $VideoBitrate, "-AudioBitrate", $AudioBitrate, "-AudioEnabled", [string]$AudioEnabled, + "-MicrophoneEnabled", [string]$MicrophoneEnabled, "-AudioSignalThreshold", $AudioSignalThreshold.ToString( [System.Globalization.CultureInfo]::InvariantCulture ), @@ -919,6 +938,7 @@ if ($ShowConfiguration) { VideoBitrate = $VideoBitrate EncoderPriority = ($EncoderPriority -join " > ") AudioEnabled = $AudioEnabled + MicrophoneEnabled = $MicrophoneEnabled AudioBitrate = $AudioBitrate AudioSignalThreshold = $AudioSignalThreshold NicDescription = $NicDescription @@ -1180,6 +1200,12 @@ function Test-FFmpegCapabilities { if ($AudioEnabled -eq 1 -and $encoders -notmatch '(?m)\baac\b') { throw "오디오 캡처가 켜져 있지만 현재 FFmpeg에 AAC 인코더가 없습니다." } + if ($AudioEnabled -eq 1 -and $MicrophoneEnabled -eq 1) { + $devices = (& $FFmpegPath -hide_banner -devices 2>&1 | Out-String) + if ($devices -notmatch '(?m)\bdshow\b') { + throw "마이크 캡처가 켜져 있지만 현재 FFmpeg에 DirectShow(dshow) 입력이 없습니다." + } + } return $encoders } @@ -1522,43 +1548,99 @@ function Get-AudioEndpoints { return @([ScreenRtsp.Audio.CoreAudio]::GetRenderEndpoints()) } +function Get-MicrophoneEndpoints { + return @([ScreenRtsp.Audio.CoreAudio]::GetCaptureEndpoints()) +} + $script:SelectedAudioEndpointId = $null +$script:SelectedMicrophoneEndpointId = $null + +function Find-PreferredAudioEndpoint { + param($Endpoints, [string]$CurrentEndpointId) + $available = @($Endpoints) + if ($available.Count -eq 0) { return $null } + + $current = $null + if ($CurrentEndpointId) { + $current = $available | Where-Object Id -eq $CurrentEndpointId | Select-Object -First 1 + } + $signaling = @($available | Where-Object { $_.Peak -ge $AudioSignalThreshold } | + Sort-Object Peak -Descending) + + if ($signaling.Count -gt 0) { + if ($current -and $current.Peak -ge $AudioSignalThreshold) { return $current } + return $signaling[0] + } + + $default = $available | Where-Object IsDefault | Select-Object -First 1 + if ($default) { return $default } + if ($current) { return $current } + return $available[0] +} function Select-AudioEndpoint { param($Endpoints) - if ($Endpoints.Count -eq 0) { - $script:SelectedAudioEndpointId = $null - return $null - } + $selected = Find-PreferredAudioEndpoint -Endpoints $Endpoints ` + -CurrentEndpointId $script:SelectedAudioEndpointId + $script:SelectedAudioEndpointId = if ($selected) { $selected.Id } else { $null } + return $selected +} - $current = $null - if ($script:SelectedAudioEndpointId) { - $current = $Endpoints | Where-Object Id -eq $script:SelectedAudioEndpointId | Select-Object -First 1 - } - $audible = @($Endpoints | Where-Object { $_.Peak -ge $AudioSignalThreshold } | - Sort-Object Peak -Descending) +function Select-MicrophoneEndpoint { + param($Endpoints) + $selected = Find-PreferredAudioEndpoint -Endpoints $Endpoints ` + -CurrentEndpointId $script:SelectedMicrophoneEndpointId + $script:SelectedMicrophoneEndpointId = if ($selected) { $selected.Id } else { $null } + return $selected +} - if ($audible.Count -gt 0) { - if ($current -and $current.Peak -ge $AudioSignalThreshold) { return $current } - $script:SelectedAudioEndpointId = $audible[0].Id - return $audible[0] - } +function ConvertTo-AudioDeviceMatchKey { + param([string]$Name) + if ([string]::IsNullOrWhiteSpace($Name)) { return "" } + $normalized = $Name.Normalize([System.Text.NormalizationForm]::FormKC).ToUpperInvariant() + return [System.Text.RegularExpressions.Regex]::Replace($normalized, '[\W_]', '') +} - $default = $Endpoints | Where-Object IsDefault | Select-Object -First 1 - if ($default) { - $script:SelectedAudioEndpointId = $default.Id - return $default +function Get-DirectShowAudioDeviceNames { + $result = Invoke-ProcessWithTimeout -FilePath $FFmpegPath -ArgumentList @( + "-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "dummy" + ) -TimeoutMilliseconds 10000 + if ($result.ExitCode -eq -1) { + throw "DirectShow 오디오 장치 목록 확인 시간이 초과되었습니다." } - if ($current) { return $current } - $script:SelectedAudioEndpointId = $Endpoints[0].Id - return $Endpoints[0] + return @([regex]::Matches($result.Output, '(?m)"(?[^"\r\n]+)"\s+\(audio\)') | + ForEach-Object { $_.Groups['name'].Value } | Select-Object -Unique) +} + +function Resolve-DirectShowMicrophoneName { + param($Endpoint, [string[]]$DeviceNames) + if (-not $Endpoint) { return $null } + $exact = @($DeviceNames | Where-Object { $_ -ceq $Endpoint.Name }) + if ($exact.Count -gt 0) { return $exact[0] } + + $endpointKey = ConvertTo-AudioDeviceMatchKey $Endpoint.Name + $normalized = @($DeviceNames | Where-Object { + (ConvertTo-AudioDeviceMatchKey $_) -eq $endpointKey + }) + if ($normalized.Count -eq 1) { return $normalized[0] } + return $null +} + +function Get-AudioEndpointSignature { + param($Endpoint) + if (-not $Endpoint) { return "MISSING" } + return "{0}:{1}:{2}:{3}:{4}:{5}" -f $Endpoint.Id,$Endpoint.IsDefault, + $Endpoint.SampleRate,$Endpoint.Channels,$Endpoint.BitsPerSample,$Endpoint.SampleFormat } function Get-AudioSignature { - param($Endpoint) - if (-not $Endpoint) { return "AUDIO_MISSING" } - return "{0}:{1}:{2}:{3}:{4}" -f $Endpoint.Id,$Endpoint.SampleRate,$Endpoint.Channels, - $Endpoint.BitsPerSample,$Endpoint.SampleFormat + param($PlaybackEndpoint, $MicrophoneEndpoint) + $playback = Get-AudioEndpointSignature $PlaybackEndpoint + $microphone = if ($MicrophoneEnabled -eq 1) { + Get-AudioEndpointSignature $MicrophoneEndpoint + } + else { "DISABLED" } + return "PLAYBACK=$playback|MICROPHONE=$microphone" } function Get-NetworkState { @@ -1679,12 +1761,12 @@ function Get-RotationFilter { } function Start-AudioCaptureWorker { - param([int]$Channel, $Endpoint, [string]$PipeName) + param($Endpoint, [string]$PipeName) $arguments = @( "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $PSCommandPath, "-AudioWorker", "-AudioEndpointId", $Endpoint.Id, "-AudioPipeName", $PipeName ) - $errorLog = Join-Path $LogDir "monitor$Channel-audio.log" + $errorLog = Join-Path $LogDir "audio-loopback.log" return Start-Process -FilePath $PowerShellPath -ArgumentList (Join-ProcessArguments $arguments) ` -WorkingDirectory $BaseDir -WindowStyle Hidden -RedirectStandardError $errorLog -PassThru } @@ -1706,94 +1788,169 @@ function Wait-AudioPipe { throw "오디오 named pipe 생성 시간 초과: $PipeName" } -function Start-Stream { - param( - [int]$Channel, - $Screen, - $Endpoint, - $Encoder - ) - $ddaInput = "ddagrab=output_idx=$($Screen.OutputIndex):draw_mouse=1:framerate=${FrameRate}:dup_frames=1" +function Get-VideoFilter { + param($Screen) $filters = @("hwdownload", "format=bgra") $rotationFilter = Get-RotationFilter $Screen.Orientation if ($rotationFilter) { $filters += $rotationFilter.Split(',') } $filters += @("setpts=N/(${FrameRate}*TB)", "format=nv12") - $videoFilter = $filters -join "," - $rtspUrl = "$RTSPPublishBase/monitor$Channel" - $logFile = Join-Path $LogDir "monitor$Channel-ffmpeg.log" + return ($filters -join ",") +} + +function New-FFmpegArguments { + param( + $SelectedScreens, + $PlaybackEndpoint, + $MicrophoneEndpoint, + [string]$MicrophoneInputName, + $Encoder, + [string]$AudioPipePath + ) $arguments = @( - "-hide_banner", "-loglevel", "warning", "-nostdin", - "-f", "lavfi", "-i", $ddaInput + "-hide_banner", "-loglevel", "warning", "-nostdin" ) + $filterParts = @() + + for ($index = 0; $index -lt $SelectedScreens.Count; $index++) { + $screen = $SelectedScreens[$index] + $ddaInput = "ddagrab=output_idx=$($screen.OutputIndex):draw_mouse=1:framerate=${FrameRate}:dup_frames=1" + $arguments += @("-f", "lavfi", "-i", $ddaInput) + $filterParts += "[$index`:v]$(Get-VideoFilter $screen)[v$index]" + } if ($AudioEnabled -eq 1) { - $pipeName = "screen-rtsp-{0}-{1}-{2}" -f $PID,$Channel,([Guid]::NewGuid().ToString("N")) - $audioProcess = Start-AudioCaptureWorker -Channel $Channel -Endpoint $Endpoint -PipeName $pipeName - $script:AudioProcesses["monitor$Channel"] = $audioProcess - Wait-AudioPipe -Process $audioProcess -PipeName $pipeName - $pipePath = "\\.\pipe\$pipeName" + $playbackInputIndex = $SelectedScreens.Count $arguments += @( - "-thread_queue_size", "1024", "-f", $Endpoint.SampleFormat, - "-ar", $Endpoint.SampleRate, "-ac", $Endpoint.Channels, "-i", $pipePath, - "-map", "0:v:0", "-map", "1:a:0" + "-thread_queue_size", "1024", "-f", $PlaybackEndpoint.SampleFormat, + "-ar", $PlaybackEndpoint.SampleRate, "-ac", $PlaybackEndpoint.Channels, + "-i", $AudioPipePath + ) + $filterParts += "[$playbackInputIndex`:a]aresample=48000:async=1:first_pts=0," + + "aformat=sample_fmts=fltp:channel_layouts=stereo[sys]" + + if ($MicrophoneEnabled -eq 1) { + $microphoneInputIndex = $playbackInputIndex + 1 + $arguments += @( + "-thread_queue_size", "1024", "-f", "dshow", + "-i", "audio=$MicrophoneInputName" + ) + $filterParts += "[$microphoneInputIndex`:a]aresample=48000:async=1:first_pts=0," + + "aformat=sample_fmts=fltp:channel_layouts=stereo[mic]" + $filterParts += "[sys][mic]amix=inputs=2:duration=longest:" + + "dropout_transition=2:normalize=1[mixed]" + } + else { + $filterParts += "[sys]anull[mixed]" + } + + if ($SelectedScreens.Count -eq 1) { + $filterParts += "[mixed]anull[a0]" + } + else { + $audioLabels = (0..($SelectedScreens.Count - 1) | ForEach-Object { "[a$_]" }) -join "" + $filterParts += "[mixed]asplit=$($SelectedScreens.Count)$audioLabels" + } + } + + $arguments += @("-filter_complex", ($filterParts -join ";")) + + for ($index = 0; $index -lt $SelectedScreens.Count; $index++) { + $channel = $index + 1 + $arguments += @("-map", "[v$index]") + if ($AudioEnabled -eq 1) { $arguments += @("-map", "[a$index]") } + $arguments += @("-c:v", $Encoder.Name) + $arguments += @($Encoder.Arguments) + $arguments += @( + "-b:v", $VideoBitrate, "-maxrate", $VideoBitrate, "-bufsize", $VideoBitrate, + "-g", $FrameRate, "-bf", "0", "-pix_fmt", "nv12" + ) + if ($AudioEnabled -eq 1) { + $arguments += @( + "-c:a", "aac", "-b:a", $AudioBitrate, "-ar", "48000", "-ac", "2" + ) + } + $arguments += @( + "-f", "rtsp", "-rtsp_transport", "tcp", "$RTSPPublishBase/monitor$channel" ) } - else { - $arguments += @("-map", "0:v:0") - } - $arguments += @("-vf", $videoFilter, "-c:v", $Encoder.Name) - $arguments += $Encoder.Arguments - $arguments += @( - "-b:v", $VideoBitrate, "-maxrate", $VideoBitrate, "-bufsize", $VideoBitrate, - "-g", $FrameRate, "-bf", "0", "-pix_fmt", "nv12" - ) - if ($AudioEnabled -eq 1) { - $arguments += @( - "-c:a", "aac", "-b:a", $AudioBitrate, "-ar", "48000", "-ac", "2", - "-af", "aresample=async=1000:first_pts=0" - ) - } - $arguments += @( - "-f", "rtsp", "-rtsp_transport", "tcp", $rtspUrl - ) - - $audioDescription = if ($AudioEnabled -eq 1) { $Endpoint.Name } else { "사용 안 함" } - Write-Log ("monitor{0} 시작: DDA output_idx={1}, {2}x{3}, 회전={4}, {5}, 오디오={6}" -f ` - $Channel,$Screen.OutputIndex,$Screen.Width,$Screen.Height,$Screen.Orientation, - $Encoder.Name,$audioDescription) - - return Start-Process -FilePath $FFmpegPath -ArgumentList (Join-ProcessArguments $arguments) ` - -WorkingDirectory $BaseDir -WindowStyle Hidden -RedirectStandardError $logFile -PassThru + return $arguments } function Start-Streams { - param($Screens, $Network, $Endpoint, $Encoder) + param($Screens, $Network, $PlaybackEndpoint, $MicrophoneEndpoint, $Encoder) $selected = @(Select-StreamingScreens $Screens) if ($selected.Count -lt $ChannelCount) { return } - Write-Log "RTSP $ChannelCount 채널 시작" + + $audioPipePath = "" if ($AudioEnabled -eq 1) { - Write-Log "오디오 출력 장치: $($Endpoint.Name) (peak=$([Math]::Round($Endpoint.Peak, 4)))" + $pipeName = "screen-rtsp-{0}-{1}" -f $PID,([Guid]::NewGuid().ToString("N")) + $audioProcess = Start-AudioCaptureWorker -Endpoint $PlaybackEndpoint -PipeName $pipeName + $script:AudioProcesses["playback"] = $audioProcess + Wait-AudioPipe -Process $audioProcess -PipeName $pipeName + $audioPipePath = "\\.\pipe\$pipeName" + } + + $microphoneInputName = "" + if ($AudioEnabled -eq 1 -and $MicrophoneEnabled -eq 1) { + $directShowDeviceNames = @(Get-DirectShowAudioDeviceNames) + $microphoneInputName = Resolve-DirectShowMicrophoneName ` + -Endpoint $MicrophoneEndpoint -DeviceNames $directShowDeviceNames + if (-not $microphoneInputName) { + throw "선택된 마이크를 FFmpeg DirectShow 장치와 연결할 수 없습니다: $($MicrophoneEndpoint.Name)" + } + } + + $arguments = @(New-FFmpegArguments -SelectedScreens $selected ` + -PlaybackEndpoint $PlaybackEndpoint -MicrophoneEndpoint $MicrophoneEndpoint ` + -MicrophoneInputName $microphoneInputName ` + -Encoder $Encoder -AudioPipePath $audioPipePath) + $ffmpegLog = Join-Path $LogDir "ffmpeg.log" + $ffmpegProcess = Start-Process -FilePath $FFmpegPath ` + -ArgumentList (Join-ProcessArguments $arguments) -WorkingDirectory $BaseDir ` + -WindowStyle Hidden -RedirectStandardError $ffmpegLog -PassThru + $script:StreamProcesses["pipeline"] = $ffmpegProcess + + Write-Log "RTSP $ChannelCount 채널 시작: 단일 FFmpeg PID=$($ffmpegProcess.Id)" + for ($index = 0; $index -lt $selected.Count; $index++) { + $channel = $index + 1 + $screen = $selected[$index] + Write-Log ("monitor{0}: DDA output_idx={1}, {2}x{3}, 회전={4}, {5}" -f ` + $channel,$screen.OutputIndex,$screen.Width,$screen.Height,$screen.Orientation,$Encoder.Name) + } + if ($AudioEnabled -eq 1) { + Write-Log "시스템 재생음: $($PlaybackEndpoint.Name) (peak=$([Math]::Round($PlaybackEndpoint.Peak, 4)))" + if ($MicrophoneEnabled -eq 1) { + Write-Log "마이크: $($MicrophoneEndpoint.Name) (peak=$([Math]::Round($MicrophoneEndpoint.Peak, 4)))" + Write-Log "오디오 혼합: 시스템 재생음 + 마이크 -> AAC $AudioBitrate" + } + else { + Write-Log "마이크 캡처: 사용 안 함" + } } else { Write-Log "오디오 캡처: 사용 안 함" } - for ($index = 0; $index -lt $ChannelCount; $index++) { - $channel = $index + 1 - $process = Start-Stream -Channel $channel -Screen $selected[$index] ` - -Endpoint $Endpoint -Encoder $Encoder - $script:StreamProcesses["monitor$channel"] = $process - Start-Sleep -Milliseconds 250 - } + for ($channel = 1; $channel -le $ChannelCount; $channel++) { Write-Log "수신 URL: rtsp://$($Network.IPv4.IPAddress):$RtspPort/monitor$channel" } + + Start-Sleep -Seconds 1 + if (-not (Test-StreamsRunning)) { + if (Test-Path -LiteralPath $ffmpegLog) { + Get-Content -LiteralPath $ffmpegLog -Tail 30 | ForEach-Object { + Write-Log "FFmpeg: $_" + } + } + throw "FFmpeg 또는 오디오 loopback worker가 실행 직후 종료되었습니다." + } } function Test-StreamsRunning { - $expectedAudioProcessCount = if ($AudioEnabled -eq 1) { $ChannelCount } else { 0 } - if ($StreamProcesses.Count -ne $ChannelCount -or + $expectedAudioProcessCount = if ($AudioEnabled -eq 1) { 1 } else { 0 } + if ($StreamProcesses.Count -ne 1 -or $AudioProcesses.Count -ne $expectedAudioProcessCount) { return $false } @@ -1804,7 +1961,7 @@ function Test-StreamsRunning { } function Get-StreamingReadinessReason { - param($Screens, $Network, $Endpoint) + param($Screens, $Network, $PlaybackEndpoint, $MicrophoneEndpoint) $capturableScreenCount = @($Screens | Where-Object { $_.OutputIndex -ge 0 }).Count if ($Screens.Count -lt $ChannelCount) { @@ -1816,9 +1973,12 @@ function Get-StreamingReadinessReason { $networkReason = Test-NetworkHealth $Network if ($networkReason) { return $networkReason } - if ($AudioEnabled -eq 1 -and -not $Endpoint) { + if ($AudioEnabled -eq 1 -and -not $PlaybackEndpoint) { return "활성 오디오 출력 장치 없음" } + if ($AudioEnabled -eq 1 -and $MicrophoneEnabled -eq 1 -and -not $MicrophoneEndpoint) { + return "활성 마이크 입력 장치 없음" + } return $null } @@ -1876,8 +2036,10 @@ function Invoke-SimulationTests { $originalChannelCount = $script:ChannelCount $originalSelectedAudioEndpointId = $script:SelectedAudioEndpointId + $originalSelectedMicrophoneEndpointId = $script:SelectedMicrophoneEndpointId $originalHealthTarget = $script:HealthTarget $originalAudioEnabled = $script:AudioEnabled + $originalMicrophoneEnabled = $script:MicrophoneEnabled $originalStreamProcesses = $script:StreamProcesses $originalAudioProcesses = $script:AudioProcesses try { @@ -1885,10 +2047,12 @@ function Invoke-SimulationTests { $script:HealthTarget = "" Invoke-SimulationCase "INI 주석(#, ;, //)과 따옴표 값 파싱" { - $path = New-SimulationIni "# one`r`n; two`r`n// three`r`n[Audio]`r`nEnabled=0`r`nBitrate=`"128k`"`r`nSignalThreshold=0.1" + $path = New-SimulationIni "# one`r`n; two`r`n// three`r`n[Audio]`r`nEnabled=1`r`nMicrophoneEnabled=0`r`nBitrate=`"128k`"`r`nSignalThreshold=0.1" try { $parsed = Read-IniConfiguration $path - return ($parsed.Audio.Enabled -eq "0" -and $parsed.Audio.Bitrate -eq "128k") + return ($parsed.Audio.Enabled -eq "1" -and + $parsed.Audio.MicrophoneEnabled -eq "0" -and + $parsed.Audio.Bitrate -eq "128k") } finally { [System.IO.File]::Delete($path) } } @@ -1980,6 +2144,14 @@ function Invoke-SimulationTests { Id="audible"; Name="HDMI"; IsDefault=$false; Peak=0.2 SampleRate=48000; Channels=2; BitsPerSample=32; SampleFormat="f32le" } + $defaultMicrophone = [PSCustomObject]@{ + Id="mic-default"; Name="Default Microphone"; IsDefault=$true; Peak=0.0 + SampleRate=48000; Channels=1; BitsPerSample=16; SampleFormat="s16le" + } + $activeMicrophone = [PSCustomObject]@{ + Id="mic-active"; Name="USB Microphone"; IsDefault=$false; Peak=0.2 + SampleRate=48000; Channels=1; BitsPerSample=16; SampleFormat="s16le" + } Invoke-SimulationCase "실제 peak가 있는 오디오 장치 우선" { $script:SelectedAudioEndpointId = $null @@ -1999,10 +2171,38 @@ function Invoke-SimulationTests { return ((Select-AudioEndpoint @($defaultAudio,$audibleAudio)).Id -eq "audible") } + Invoke-SimulationCase "실제 입력 신호가 있는 마이크 자동 선택" { + $script:SelectedMicrophoneEndpointId = $null + return ((Select-MicrophoneEndpoint @($defaultMicrophone,$activeMicrophone)).Id -eq + "mic-active") + } + + Invoke-SimulationCase "마이크가 모두 무음이면 기본 입력 선택" { + $script:SelectedMicrophoneEndpointId = $null + $activeMicrophone.Peak = 0.0 + return ((Select-MicrophoneEndpoint @($activeMicrophone,$defaultMicrophone)).Id -eq + "mic-default") + } + + Invoke-SimulationCase "Core Audio와 DirectShow 마이크 이름 공백 차이 매핑" { + $endpoint = $defaultMicrophone.PSObject.Copy() + $endpoint.Name = "마이크 (Divoom Audio)" + return ((Resolve-DirectShowMicrophoneName $endpoint ` + @("마이크(Divoom Audio)", "다른 마이크")) -eq "마이크(Divoom Audio)") + } + Invoke-SimulationCase "오디오 mix format 변경 signature 감지" { $changedAudio = $defaultAudio.PSObject.Copy() $changedAudio.SampleRate = 44100 - return ((Get-AudioSignature $defaultAudio) -ne (Get-AudioSignature $changedAudio)) + return ((Get-AudioSignature $defaultAudio $defaultMicrophone) -ne + (Get-AudioSignature $changedAudio $defaultMicrophone)) + } + + Invoke-SimulationCase "기본 입력 장치 변경 signature 감지" { + $changedMicrophone = $defaultMicrophone.PSObject.Copy() + $changedMicrophone.Id = "mic-reconnected" + return ((Get-AudioSignature $defaultAudio $defaultMicrophone) -ne + (Get-AudioSignature $defaultAudio $changedMicrophone)) } Invoke-SimulationCase "NIC 부재 signature" { @@ -2049,6 +2249,7 @@ function Invoke-SimulationTests { Invoke-SimulationCase "오디오 사용 중 endpoint가 없으면 시작 보류" { $script:AudioEnabled = 1 + $script:MicrophoneEnabled = 1 $network = [PSCustomObject]@{ Adapter=[PSCustomObject]@{Status="Up"}; IPv4=[PSCustomObject]@{IPAddress="192.168.1.10"} @@ -2057,14 +2258,26 @@ function Invoke-SimulationTests { "활성 오디오 출력 장치 없음") } + Invoke-SimulationCase "마이크 사용 중 입력 endpoint가 없으면 시작 보류" { + $script:AudioEnabled = 1 + $script:MicrophoneEnabled = 1 + $network = [PSCustomObject]@{ + Adapter=[PSCustomObject]@{Status="Up"}; + IPv4=[PSCustomObject]@{IPAddress="192.168.1.10"} + } + return ((Get-StreamingReadinessReason @($screenPrimary,$screenSecond) ` + $network $defaultAudio $null) -eq "활성 마이크 입력 장치 없음") + } + Invoke-SimulationCase "모니터/NIC/오디오가 정상이면 시작 가능" { $script:AudioEnabled = 1 + $script:MicrophoneEnabled = 1 $network = [PSCustomObject]@{ Adapter=[PSCustomObject]@{Status="Up"}; IPv4=[PSCustomObject]@{IPAddress="192.168.1.10"} } return ($null -eq (Get-StreamingReadinessReason ` - @($screenPrimary,$screenSecond) $network $defaultAudio)) + @($screenPrimary,$screenSecond) $network $defaultAudio $defaultMicrophone)) } Invoke-SimulationCase "GPU별 H.264 인코더 매핑" { @@ -2086,6 +2299,7 @@ function Invoke-SimulationTests { return ($arguments -match '-EncoderPriorityCsv' -and $arguments -match '-ChannelCount\s+2' -and $arguments -match '-AudioEnabled\s+[01]' -and + $arguments -match '-MicrophoneEnabled\s+[01]' -and $arguments -match '-RtspPort\s+8554' -and $arguments -match '-WatchdogWorker') } @@ -2119,27 +2333,57 @@ function Invoke-SimulationTests { return (Test-WatchdogResumeGap $previous $previous.AddMinutes(5)) } - Invoke-SimulationCase "2채널 FFmpeg/오디오 worker가 모두 생존" { + Invoke-SimulationCase "단일 FFmpeg가 2화면과 혼합 오디오를 2개 RTSP로 구성" { + $script:AudioEnabled = 1 + $script:MicrophoneEnabled = 1 + $encoderDefinition = Get-EncoderDefinition "NVIDIA" + $arguments = @(New-FFmpegArguments ` + -SelectedScreens @($screenPrimary,$screenSecond) ` + -PlaybackEndpoint $defaultAudio -MicrophoneEndpoint $defaultMicrophone ` + -MicrophoneInputName $defaultMicrophone.Name ` + -Encoder $encoderDefinition -AudioPipePath "\\.\pipe\simulation") + $filterIndex = [Array]::IndexOf($arguments, "-filter_complex") + $filter = if ($filterIndex -ge 0) { $arguments[$filterIndex + 1] } else { "" } + return ($filter -match 'amix=inputs=2' -and $filter -match 'asplit=2' -and + $arguments -contains 'dshow' -and + @($arguments | Where-Object { $_ -eq 'h264_nvenc' }).Count -eq 2 -and + @($arguments | Where-Object { $_ -eq "$RTSPPublishBase/monitor1" }).Count -eq 1 -and + @($arguments | Where-Object { $_ -eq "$RTSPPublishBase/monitor2" }).Count -eq 1) + } + + Invoke-SimulationCase "마이크 OFF이면 시스템 재생음만 두 채널에 분배" { + $script:AudioEnabled = 1 + $script:MicrophoneEnabled = 0 + $arguments = @(New-FFmpegArguments ` + -SelectedScreens @($screenPrimary,$screenSecond) ` + -PlaybackEndpoint $defaultAudio -MicrophoneEndpoint $null ` + -Encoder (Get-EncoderDefinition "Intel") -AudioPipePath "\\.\pipe\simulation") + $filterIndex = [Array]::IndexOf($arguments, "-filter_complex") + $filter = $arguments[$filterIndex + 1] + return ($filter -notmatch 'amix=' -and $filter -match 'asplit=2' -and + $arguments -notcontains 'dshow') + } + + Invoke-SimulationCase "단일 FFmpeg/단일 loopback worker가 모두 생존" { $alive = [PSCustomObject]@{HasExited=$false} $script:AudioEnabled = 1 - $script:StreamProcesses = @{monitor1=$alive; monitor2=$alive} - $script:AudioProcesses = @{monitor1=$alive; monitor2=$alive} + $script:StreamProcesses = @{pipeline=$alive} + $script:AudioProcesses = @{playback=$alive} return (Test-StreamsRunning) } - Invoke-SimulationCase "FFmpeg 하나가 종료되면 전체 재시작 필요" { - $alive = [PSCustomObject]@{HasExited=$false} + Invoke-SimulationCase "단일 FFmpeg가 종료되면 전체 재시작 필요" { $dead = [PSCustomObject]@{HasExited=$true} $script:AudioEnabled = 1 - $script:StreamProcesses = @{monitor1=$alive; monitor2=$dead} - $script:AudioProcesses = @{monitor1=$alive; monitor2=$alive} + $script:StreamProcesses = @{pipeline=$dead} + $script:AudioProcesses = @{playback=[PSCustomObject]@{HasExited=$false}} return (-not (Test-StreamsRunning)) } - Invoke-SimulationCase "오디오 OFF이면 worker 없이 2채널 정상" { + Invoke-SimulationCase "오디오 OFF이면 worker 없이 단일 FFmpeg 정상" { $alive = [PSCustomObject]@{HasExited=$false} $script:AudioEnabled = 0 - $script:StreamProcesses = @{monitor1=$alive; monitor2=$alive} + $script:StreamProcesses = @{pipeline=$alive} $script:AudioProcesses = @{} return (Test-StreamsRunning) } @@ -2174,8 +2418,10 @@ function Invoke-SimulationTests { finally { $script:ChannelCount = $originalChannelCount $script:SelectedAudioEndpointId = $originalSelectedAudioEndpointId + $script:SelectedMicrophoneEndpointId = $originalSelectedMicrophoneEndpointId $script:HealthTarget = $originalHealthTarget $script:AudioEnabled = $originalAudioEnabled + $script:MicrophoneEnabled = $originalMicrophoneEnabled $script:StreamProcesses = $originalStreamProcesses $script:AudioProcesses = $originalAudioProcesses } @@ -2222,6 +2468,9 @@ try { else { Write-Log "설정 파일 없음 - 내장 기본값 사용: $ConfigPath" } Write-Log "인코더 우선순위: $($EncoderPriority -join ' > ')" Write-Log "오디오 캡처: $(if ($AudioEnabled -eq 1) { '사용' } else { '사용 안 함' })" + if ($AudioEnabled -eq 1) { + Write-Log "마이크 혼합: $(if ($MicrophoneEnabled -eq 1) { '사용' } else { '사용 안 함' })" + } if ($NicDescription) { Write-Log "고정 NIC: $NicDescription" } else { Write-Log "NIC 선택: IPv4 기본 경로 자동 감지" } if ($HealthTarget) { Write-Log "통신 확인 대상: $HealthTarget" } @@ -2256,6 +2505,7 @@ try { if (-not $mediaReady) { Stop-Streams $healthySince = $null + $lastPollAt = Get-Date Start-Sleep -Seconds $PollIntervalSec continue } @@ -2263,12 +2513,22 @@ try { $screens = @(Get-ActiveScreens) if ($AudioEnabled -eq 1) { $endpoints = @(Get-AudioEndpoints) - $endpoint = Select-AudioEndpoint $endpoints - $audioSignature = Get-AudioSignature $endpoint + $playbackEndpoint = Select-AudioEndpoint $endpoints + if ($MicrophoneEnabled -eq 1) { + $microphoneEndpoints = @(Get-MicrophoneEndpoints) + $microphoneEndpoint = Select-MicrophoneEndpoint $microphoneEndpoints + } + else { + $microphoneEndpoints = @() + $microphoneEndpoint = $null + } + $audioSignature = Get-AudioSignature $playbackEndpoint $microphoneEndpoint } else { $endpoints = @() - $endpoint = $null + $microphoneEndpoints = @() + $playbackEndpoint = $null + $microphoneEndpoint = $null $audioSignature = "AUDIO_DISABLED" } $network = Get-NetworkState @@ -2299,13 +2559,14 @@ try { } $reason = Get-StreamingReadinessReason -Screens $screens -Network $network ` - -Endpoint $endpoint + -PlaybackEndpoint $playbackEndpoint -MicrophoneEndpoint $microphoneEndpoint if ($reason) { if ($reason -ne $lastReason) { Write-Log "송출 조건 불충족: $reason" } $lastReason = $reason Stop-Streams $healthySince = $null + $lastPollAt = Get-Date Start-Sleep -Seconds $PollIntervalSec continue } @@ -2327,7 +2588,9 @@ try { Write-Log "송출 조건 정상 - $StableTimeSec 초 안정화 확인 시작" } if (((Get-Date) - $healthySince).TotalSeconds -ge $StableTimeSec) { - Start-Streams -Screens $screens -Network $network -Endpoint $endpoint -Encoder $encoder + Start-Streams -Screens $screens -Network $network ` + -PlaybackEndpoint $playbackEndpoint -MicrophoneEndpoint $microphoneEndpoint ` + -Encoder $encoder $healthySince = $null } } @@ -2337,6 +2600,7 @@ try { Stop-Streams $healthySince = $null } + $lastPollAt = Get-Date Start-Sleep -Seconds $PollIntervalSec } }