Add automatic microphone mixing and single FFmpeg pipeline

This commit is contained in:
seo-amugae committed 2026-10-08 01:41:49 +09:00
1 parent 2819d31490
commit f11e636826
3 files changed
+436 -138

No files matched your search

+53 -24
View File
@@ -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 마이크 변경을 자동 반영합니다.
## 운영 체크포인트
+7 -2
View File
@@ -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]
+376 -112
View File
@@ -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)"(?<name>[^"\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
}
}