Add INI configuration and optional audio capture

This commit is contained in:
seo-amugae committed 2026-10-08 00:30:23 +09:00
1 parent 5b15b06570
commit 089c2421c5
4 files changed
+464 -47

No files matched your search

+309 -47
View File
@@ -20,6 +20,12 @@ param(
[ValidatePattern("^[0-9]+[kKmM]$")]
[string]$AudioBitrate = "192k",
[ValidateSet(0, 1)]
[int]$AudioEnabled = 1,
[ValidateRange(0.0, 1.0)]
[double]$AudioSignalThreshold = 0.0005,
[string]$NicDescription = "",
[string]$HealthTarget = "",
@@ -32,8 +38,12 @@ param(
[ValidateRange(1, 65535)]
[int]$RtspPort = 8554,
[string]$ConfigPath = "",
[switch]$InstallStartup,
[switch]$UninstallStartup,
[switch]$StartupStatus,
[switch]$ShowConfiguration,
[Parameter(DontShow = $true)]
[switch]$AudioWorker,
@@ -46,13 +56,9 @@ param(
)
$ErrorActionPreference = "Stop"
if (-not [string]::IsNullOrWhiteSpace($EncoderPriorityCsv)) {
$EncoderPriority = @($EncoderPriorityCsv -split ',' | ForEach-Object { $_.Trim() })
$invalidEncoderVendor = $EncoderPriority | Where-Object { $_ -notin @("NVIDIA", "AMD", "Intel") }
if ($invalidEncoderVendor) {
throw "잘못된 EncoderPriorityCsv 값: $($invalidEncoderVendor -join ', ')"
}
$ExplicitParameters = @{}
foreach ($parameterName in $PSBoundParameters.Keys) {
$ExplicitParameters[$parameterName] = $true
}
# ============================================================
@@ -485,6 +491,10 @@ namespace ScreenRtsp.Audio
ThrowIfFailed(client.Start(), "IAudioClient.Start");
byte[] buffer = new byte[1024 * 1024];
int silenceFrames = Math.Max(1, (int)(format.SamplesPerSec / 100));
int silenceBytes = checked(silenceFrames * format.BlockAlign);
System.Diagnostics.Stopwatch packetClock =
System.Diagnostics.Stopwatch.StartNew();
while (pipe.IsConnected)
{
uint packetFrames;
@@ -493,7 +503,17 @@ namespace ScreenRtsp.Audio
if (packetFrames == 0)
{
Thread.Sleep(3);
// Some endpoints stop producing loopback packets while
// completely silent or power-saving. Keep FFmpeg and
// RTSP clocks moving with one 10 ms silent PCM block.
if (packetClock.ElapsedMilliseconds >= 10)
{
if (buffer.Length < silenceBytes) buffer = new byte[silenceBytes];
Array.Clear(buffer, 0, silenceBytes);
pipe.Write(buffer, 0, silenceBytes);
packetClock.Restart();
}
Thread.Sleep(2);
continue;
}
@@ -517,6 +537,7 @@ namespace ScreenRtsp.Audio
try
{
pipe.Write(buffer, 0, bytes);
packetClock.Restart();
}
finally
{
@@ -577,6 +598,180 @@ if ([string]::IsNullOrWhiteSpace($BaseDir)) {
$BaseDir = Split-Path -Parent $MyInvocation.MyCommand.Path
}
function Read-IniConfiguration {
param([string]$Path)
$result = @{}
$sectionName = "General"
$result[$sectionName] = @{}
$lineNumber = 0
foreach ($rawLine in (Get-Content -LiteralPath $Path)) {
$lineNumber++
$line = $rawLine.Trim()
if ([string]::IsNullOrWhiteSpace($line) -or
$line.StartsWith("#") -or
$line.StartsWith(";") -or
$line.StartsWith("//")) {
continue
}
if ($line -match '^\[([^\]]+)\]$') {
$sectionName = $Matches[1].Trim()
if (-not $result.ContainsKey($sectionName)) {
$result[$sectionName] = @{}
}
continue
}
if ($line -notmatch '^([^=]+)=(.*)$') {
throw "config.ini $lineNumber 번째 줄을 해석할 수 없습니다: $rawLine"
}
$key = $Matches[1].Trim()
$value = $Matches[2].Trim()
if ([string]::IsNullOrWhiteSpace($key)) {
throw "config.ini $lineNumber 번째 줄의 설정 이름이 비어 있습니다."
}
$hasDoubleQuotes = $value.StartsWith('"') -and $value.EndsWith('"')
$hasSingleQuotes = $value.StartsWith("'") -and $value.EndsWith("'")
if ($value.Length -ge 2 -and ($hasDoubleQuotes -or $hasSingleQuotes)) {
$value = $value.Substring(1, $value.Length - 2)
}
$result[$sectionName][$key] = $value
}
return $result
}
function Get-IniSetting {
param($Configuration, [string]$Section, [string]$Key)
if ($Configuration.ContainsKey($Section) -and $Configuration[$Section].ContainsKey($Key)) {
return [PSCustomObject]@{ Found = $true; Value = $Configuration[$Section][$Key] }
}
return [PSCustomObject]@{ Found = $false; Value = $null }
}
function Convert-IniSwitch {
param([string]$Value, [string]$Name)
if ($Value -notin @("0", "1")) {
throw "$Name 값은 1 또는 0이어야 합니다: $Value"
}
return [int]$Value
}
function Assert-IniSettings {
param($Configuration)
$allowed = @{
General = @("ChannelCount", "StableTimeSec", "PollIntervalSec")
Video = @("FrameRate", "Bitrate", "EncoderPriority")
Audio = @("Enabled", "Bitrate", "SignalThreshold")
Network = @("NicDescription", "HealthTarget")
RTSP = @("Port")
}
foreach ($sectionName in $Configuration.Keys) {
if (-not $allowed.ContainsKey($sectionName)) {
throw "config.ini에 알 수 없는 섹션이 있습니다: [$sectionName]"
}
foreach ($key in $Configuration[$sectionName].Keys) {
if ($allowed[$sectionName] -notcontains $key) {
throw "config.ini에 알 수 없는 설정이 있습니다: [$sectionName] $key"
}
}
}
}
if ([string]::IsNullOrWhiteSpace($ConfigPath)) {
$ConfigPath = Join-Path $BaseDir "config.ini"
}
elseif (-not [System.IO.Path]::IsPathRooted($ConfigPath)) {
$ConfigPath = Join-Path $BaseDir $ConfigPath
}
$ConfigPath = [System.IO.Path]::GetFullPath($ConfigPath)
$ConfigWasLoaded = $false
$ini = @{}
if (Test-Path -LiteralPath $ConfigPath -PathType Leaf) {
$ini = Read-IniConfiguration -Path $ConfigPath
Assert-IniSettings $ini
$ConfigWasLoaded = $true
}
elseif ($ExplicitParameters.ContainsKey("ConfigPath")) {
throw "지정한 설정 파일을 찾을 수 없습니다: $ConfigPath"
}
if ($ConfigWasLoaded) {
$setting = Get-IniSetting $ini "General" "ChannelCount"
if ($setting.Found -and -not $ExplicitParameters.ContainsKey("ChannelCount")) {
$ChannelCount = [int]$setting.Value
}
$setting = Get-IniSetting $ini "General" "StableTimeSec"
if ($setting.Found -and -not $ExplicitParameters.ContainsKey("StableTimeSec")) {
$StableTimeSec = [int]$setting.Value
}
$setting = Get-IniSetting $ini "General" "PollIntervalSec"
if ($setting.Found -and -not $ExplicitParameters.ContainsKey("PollIntervalSec")) {
$PollIntervalSec = [int]$setting.Value
}
$setting = Get-IniSetting $ini "Video" "FrameRate"
if ($setting.Found -and -not $ExplicitParameters.ContainsKey("FrameRate")) {
$FrameRate = [int]$setting.Value
}
$setting = Get-IniSetting $ini "Video" "Bitrate"
if ($setting.Found -and -not $ExplicitParameters.ContainsKey("VideoBitrate")) {
$VideoBitrate = [string]$setting.Value
}
$setting = Get-IniSetting $ini "Video" "EncoderPriority"
if ($setting.Found -and
-not $ExplicitParameters.ContainsKey("EncoderPriority") -and
-not $ExplicitParameters.ContainsKey("EncoderPriorityCsv")) {
$EncoderPriority = @($setting.Value -split ',' | ForEach-Object { $_.Trim() })
}
$setting = Get-IniSetting $ini "Audio" "Enabled"
if ($setting.Found -and -not $ExplicitParameters.ContainsKey("AudioEnabled")) {
$AudioEnabled = Convert-IniSwitch $setting.Value "Audio.Enabled"
}
$setting = Get-IniSetting $ini "Audio" "Bitrate"
if ($setting.Found -and -not $ExplicitParameters.ContainsKey("AudioBitrate")) {
$AudioBitrate = [string]$setting.Value
}
$setting = Get-IniSetting $ini "Audio" "SignalThreshold"
if ($setting.Found -and -not $ExplicitParameters.ContainsKey("AudioSignalThreshold")) {
$AudioSignalThreshold = [double]::Parse(
$setting.Value,
[System.Globalization.CultureInfo]::InvariantCulture
)
}
$setting = Get-IniSetting $ini "Network" "NicDescription"
if ($setting.Found -and -not $ExplicitParameters.ContainsKey("NicDescription")) {
$NicDescription = [string]$setting.Value
}
$setting = Get-IniSetting $ini "Network" "HealthTarget"
if ($setting.Found -and -not $ExplicitParameters.ContainsKey("HealthTarget")) {
$HealthTarget = [string]$setting.Value
}
$setting = Get-IniSetting $ini "RTSP" "Port"
if ($setting.Found -and -not $ExplicitParameters.ContainsKey("RtspPort")) {
$RtspPort = [int]$setting.Value
}
}
if (-not [string]::IsNullOrWhiteSpace($EncoderPriorityCsv)) {
$EncoderPriority = @($EncoderPriorityCsv -split ',' | ForEach-Object { $_.Trim() })
}
$invalidEncoderVendor = $EncoderPriority | Where-Object { $_ -notin @("NVIDIA", "AMD", "Intel") }
if ($EncoderPriority.Count -eq 0) {
throw "EncoderPriority에는 하나 이상의 GPU 제조사가 필요합니다."
}
if ($invalidEncoderVendor) {
throw "잘못된 EncoderPriority 값: $($invalidEncoderVendor -join ', ')"
}
$FFmpegPath = Join-Path $BaseDir "ffmpeg.exe"
$MediaMTXPath = Join-Path $BaseDir "mediamtx.exe"
$MediaMTXConfig = Join-Path $BaseDir "mediamtx.yml"
@@ -585,7 +780,6 @@ $WatchdogLog = Join-Path $LogDir "watchdog.log"
$TaskName = "Screen RTSP Watchdog"
$PowerShellPath = Join-Path $PSHOME "powershell.exe"
$RTSPPublishBase = "rtsp://127.0.0.1:$RtspPort"
$AudioSignalThreshold = 0.0005
function Quote-ProcessArgument {
param([string]$Value)
@@ -602,21 +796,8 @@ function Join-ProcessArguments {
function Get-StartupArgumentList {
$values = @(
"-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $PSCommandPath,
"-ChannelCount", $ChannelCount,
"-FrameRate", $FrameRate,
"-VideoBitrate", $VideoBitrate,
"-AudioBitrate", $AudioBitrate,
"-StableTimeSec", $StableTimeSec,
"-PollIntervalSec", $PollIntervalSec,
"-RtspPort", $RtspPort,
"-EncoderPriorityCsv", ($EncoderPriority -join ",")
"-ConfigPath", $ConfigPath
)
if (-not [string]::IsNullOrWhiteSpace($NicDescription)) {
$values += @("-NicDescription", $NicDescription)
}
if (-not [string]::IsNullOrWhiteSpace($HealthTarget)) {
$values += @("-HealthTarget", $HealthTarget)
}
return Join-ProcessArguments $values
}
@@ -632,6 +813,27 @@ function Install-StartupTask {
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger `
-Principal $principal -Settings $settings -Force | Out-Null
Write-Host "자동 실행 작업을 등록했습니다: $TaskName"
Write-Host "설정 파일: $ConfigPath"
}
if ($ShowConfiguration) {
[PSCustomObject]@{
ConfigPath = $ConfigPath
ConfigLoaded = $ConfigWasLoaded
ChannelCount = $ChannelCount
StableTimeSec = $StableTimeSec
PollIntervalSec = $PollIntervalSec
FrameRate = $FrameRate
VideoBitrate = $VideoBitrate
EncoderPriority = ($EncoderPriority -join " > ")
AudioEnabled = $AudioEnabled
AudioBitrate = $AudioBitrate
AudioSignalThreshold = $AudioSignalThreshold
NicDescription = $NicDescription
HealthTarget = $HealthTarget
RtspPort = $RtspPort
} | Format-List
exit 0
}
if ($InstallStartup) {
@@ -645,6 +847,25 @@ if ($UninstallStartup) {
exit 0
}
if ($StartupStatus) {
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if (-not $task) {
Write-Host "자동 실행 작업이 등록되어 있지 않습니다: $TaskName"
exit 1
}
$taskInfo = Get-ScheduledTaskInfo -TaskName $TaskName
[PSCustomObject]@{
TaskName = $TaskName
State = $task.State
LastRunTime = $taskInfo.LastRunTime
LastTaskResult = $taskInfo.LastTaskResult
NextRunTime = $taskInfo.NextRunTime
Execute = $task.Actions.Execute
Arguments = $task.Actions.Arguments
} | Format-List
exit 0
}
if (-not (Test-Path -LiteralPath $LogDir)) {
New-Item -ItemType Directory -Path $LogDir -Force | Out-Null
}
@@ -710,6 +931,9 @@ function Test-FFmpegCapabilities {
if ($encoders -notmatch '(?m)\bh264_(nvenc|amf|qsv)\b') {
throw "현재 FFmpeg에 지원 가능한 H.264 하드웨어 인코더가 없습니다."
}
if ($AudioEnabled -eq 1 -and $encoders -notmatch '(?m)\baac\b') {
throw "오디오 캡처가 켜져 있지만 현재 FFmpeg에 AAC 인코더가 없습니다."
}
return $encoders
}
@@ -824,7 +1048,9 @@ function Select-HardwareEncoder {
}
Add-Type -AssemblyName System.Windows.Forms
Initialize-AudioInterop
if ($AudioEnabled -eq 1) {
Initialize-AudioInterop
}
$DisplayInteropSource = @'
using System;
@@ -1227,11 +1453,6 @@ function Start-Stream {
$Endpoint,
$Encoder
)
$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
$ddaInput = "ddagrab=output_idx=$($Screen.OutputIndex):draw_mouse=1:framerate=${FrameRate}:dup_frames=1"
$filters = @("hwdownload", "format=bgra")
$rotationFilter = Get-RotationFilter $Screen.Orientation
@@ -1240,27 +1461,48 @@ function Start-Stream {
$videoFilter = $filters -join ","
$rtspUrl = "$RTSPPublishBase/monitor$Channel"
$logFile = Join-Path $LogDir "monitor$Channel-ffmpeg.log"
$pipePath = "\\.\pipe\$pipeName"
$arguments = @(
"-hide_banner", "-loglevel", "warning", "-nostdin",
"-f", "lavfi", "-i", $ddaInput,
"-thread_queue_size", "1024", "-f", $Endpoint.SampleFormat,
"-ar", $Endpoint.SampleRate, "-ac", $Endpoint.Channels, "-i", $pipePath,
"-map", "0:v:0", "-map", "1:a:0",
"-vf", $videoFilter,
"-c:v", $Encoder.Name
) + $Encoder.Arguments + @(
"-f", "lavfi", "-i", $ddaInput
)
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"
$arguments += @(
"-thread_queue_size", "1024", "-f", $Endpoint.SampleFormat,
"-ar", $Endpoint.SampleRate, "-ac", $Endpoint.Channels, "-i", $pipePath,
"-map", "0:v:0", "-map", "1:a:0"
)
}
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",
"-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",
"-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,$Endpoint.Name)
$Encoder.Name,$audioDescription)
return Start-Process -FilePath $FFmpegPath -ArgumentList (Join-ProcessArguments $arguments) `
-WorkingDirectory $BaseDir -WindowStyle Hidden -RedirectStandardError $logFile -PassThru
@@ -1271,7 +1513,12 @@ function Start-Streams {
$selected = @(Select-StreamingScreens $Screens)
if ($selected.Count -lt $ChannelCount) { return }
Write-Log "RTSP $ChannelCount 채널 시작"
Write-Log "오디오 출력 장치: $($Endpoint.Name) (peak=$([Math]::Round($Endpoint.Peak, 4)))"
if ($AudioEnabled -eq 1) {
Write-Log "오디오 출력 장치: $($Endpoint.Name) (peak=$([Math]::Round($Endpoint.Peak, 4)))"
}
else {
Write-Log "오디오 캡처: 사용 안 함"
}
for ($index = 0; $index -lt $ChannelCount; $index++) {
$channel = $index + 1
$process = Start-Stream -Channel $channel -Screen $selected[$index] `
@@ -1285,7 +1532,9 @@ function Start-Streams {
}
function Test-StreamsRunning {
if ($StreamProcesses.Count -ne $ChannelCount -or $AudioProcesses.Count -ne $ChannelCount) {
$expectedAudioProcessCount = if ($AudioEnabled -eq 1) { $ChannelCount } else { 0 }
if ($StreamProcesses.Count -ne $ChannelCount -or
$AudioProcesses.Count -ne $expectedAudioProcessCount) {
return $false
}
foreach ($process in @($StreamProcesses.Values) + @($AudioProcesses.Values)) {
@@ -1308,7 +1557,10 @@ try {
$gpuSignature = Get-GpuSignature
Write-Log "Screen RTSP Watchdog 시작"
if ($ConfigWasLoaded) { Write-Log "설정 파일: $ConfigPath" }
else { Write-Log "설정 파일 없음 - 내장 기본값 사용: $ConfigPath" }
Write-Log "인코더 우선순위: $($EncoderPriority -join ' > ')"
Write-Log "오디오 캡처: $(if ($AudioEnabled -eq 1) { '사용' } else { '사용 안 함' })"
if ($NicDescription) { Write-Log "고정 NIC: $NicDescription" }
else { Write-Log "NIC 선택: IPv4 기본 경로 자동 감지" }
if ($HealthTarget) { Write-Log "통신 확인 대상: $HealthTarget" }
@@ -1329,8 +1581,16 @@ try {
}
$screens = @(Get-ActiveScreens)
$endpoints = @(Get-AudioEndpoints)
$endpoint = Select-AudioEndpoint $endpoints
if ($AudioEnabled -eq 1) {
$endpoints = @(Get-AudioEndpoints)
$endpoint = Select-AudioEndpoint $endpoints
$audioSignature = Get-AudioSignature $endpoint
}
else {
$endpoints = @()
$endpoint = $null
$audioSignature = "AUDIO_DISABLED"
}
$network = Get-NetworkState
if (((Get-Date) - $lastGpuCheck).TotalSeconds -ge 10) {
@@ -1346,7 +1606,7 @@ try {
}
$systemSignature = (Get-ScreenSignature $screens) + "||" +
(Get-NetworkSignature $network) + "||" + (Get-AudioSignature $endpoint) +
(Get-NetworkSignature $network) + "||" + $audioSignature +
"||" + $encoder.Name
if ($systemSignature -ne $lastSystemSignature) {
@@ -1367,7 +1627,9 @@ try {
$reason = "기본 D3D11 adapter에서 캡처 가능한 모니터 $ChannelCount 개 미만"
}
if (-not $reason) { $reason = Test-NetworkHealth $network }
if (-not $reason -and -not $endpoint) { $reason = "활성 오디오 출력 장치 없음" }
if (-not $reason -and $AudioEnabled -eq 1 -and -not $endpoint) {
$reason = "활성 오디오 출력 장치 없음"
}
if ($reason) {
if ($reason -ne $lastReason) { Write-Log "송출 조건 불충족: $reason" }
@@ -1384,7 +1646,7 @@ try {
if (($StreamProcesses.Count -gt 0 -or $AudioProcesses.Count -gt 0) -and
-not (Test-StreamsRunning)) {
Write-Log "FFmpeg 또는 오디오 loopback worker 비정상 종료 감지"
Write-Log "FFmpeg 또는 보조 프로세스 비정상 종료 감지"
Stop-Streams
$healthySince = $null
}