Add automatic microphone mixing and single FFmpeg pipeline
This commit is contained in:
1 parent
2819d31490
commit
f11e636826
3 files changed
+436
-138
No files matched your search
+376
-112
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user