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

+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
}
}