Add automatic microphone mixing and single FFmpeg pipeline
This commit is contained in:
1 parent
2819d31490
commit
f11e636826
3 files changed
+424
-126
No files matched your search
@@ -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
@@ -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]
|
||||
|
||||
+364
-100
@@ -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
|
||||
$selected = Find-PreferredAudioEndpoint -Endpoints $Endpoints `
|
||||
-CurrentEndpointId $script:SelectedAudioEndpointId
|
||||
$script:SelectedAudioEndpointId = if ($selected) { $selected.Id } else { $null }
|
||||
return $selected
|
||||
}
|
||||
|
||||
function Select-MicrophoneEndpoint {
|
||||
param($Endpoints)
|
||||
$selected = Find-PreferredAudioEndpoint -Endpoints $Endpoints `
|
||||
-CurrentEndpointId $script:SelectedMicrophoneEndpointId
|
||||
$script:SelectedMicrophoneEndpointId = if ($selected) { $selected.Id } else { $null }
|
||||
return $selected
|
||||
}
|
||||
|
||||
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_]', '')
|
||||
}
|
||||
|
||||
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 오디오 장치 목록 확인 시간이 초과되었습니다."
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
$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)
|
||||
|
||||
if ($audible.Count -gt 0) {
|
||||
if ($current -and $current.Peak -ge $AudioSignalThreshold) { return $current }
|
||||
$script:SelectedAudioEndpointId = $audible[0].Id
|
||||
return $audible[0]
|
||||
}
|
||||
|
||||
$default = $Endpoints | Where-Object IsDefault | Select-Object -First 1
|
||||
if ($default) {
|
||||
$script:SelectedAudioEndpointId = $default.Id
|
||||
return $default
|
||||
}
|
||||
if ($current) { return $current }
|
||||
$script:SelectedAudioEndpointId = $Endpoints[0].Id
|
||||
return $Endpoints[0]
|
||||
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 {
|
||||
$arguments += @("-map", "0:v:0")
|
||||
$filterParts += "[sys]anull[mixed]"
|
||||
}
|
||||
|
||||
$arguments += @("-vf", $videoFilter, "-c:v", $Encoder.Name)
|
||||
$arguments += $Encoder.Arguments
|
||||
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",
|
||||
"-af", "aresample=async=1000:first_pts=0"
|
||||
"-c:a", "aac", "-b:a", $AudioBitrate, "-ar", "48000", "-ac", "2"
|
||||
)
|
||||
}
|
||||
$arguments += @(
|
||||
"-f", "rtsp", "-rtsp_transport", "tcp", $rtspUrl
|
||||
"-f", "rtsp", "-rtsp_transport", "tcp", "$RTSPPublishBase/monitor$channel"
|
||||
)
|
||||
}
|
||||
|
||||
$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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user