Fix RTSP clock pacing at 20 fps

This commit is contained in:
seo-amugae committed 2026-10-08 11:46:16 +09:00
1 parent 5241194b9a
commit 6c91d4f372
3 files changed
+130 -40

No files matched your search

+108 -35
View File
@@ -12,7 +12,10 @@ param(
[int]$ChannelCount = 2,
[ValidateRange(1, 240)]
[int]$FrameRate = 30,
[int]$FrameRate = 20,
[ValidateRange(1, 10)]
[int]$GopSeconds = 2,
[ValidatePattern("^[0-9]+[kKmM]$")]
[string]$VideoBitrate = "8000k",
@@ -536,9 +539,9 @@ 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 =
int maximumSilenceFrames = Math.Max(1, (int)(format.SamplesPerSec / 20));
long framesWritten = 0;
System.Diagnostics.Stopwatch streamClock =
System.Diagnostics.Stopwatch.StartNew();
while (pipe.IsConnected)
{
@@ -548,17 +551,23 @@ namespace ScreenRtsp.Audio
if (packetFrames == 0)
{
// 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)
// Sleep(2) can wake at roughly 15.6 ms on Windows. A
// fixed 10 ms block per wake therefore makes the audio
// clock run slow. Pace silence from elapsed wall time.
long targetFrames = (long)(
streamClock.Elapsed.TotalSeconds * format.SamplesPerSec);
long missingFrames = targetFrames - framesWritten;
if (missingFrames > 0)
{
int silenceFrames = (int)Math.Min(
missingFrames, maximumSilenceFrames);
int silenceBytes = checked(silenceFrames * format.BlockAlign);
if (buffer.Length < silenceBytes) buffer = new byte[silenceBytes];
Array.Clear(buffer, 0, silenceBytes);
pipe.Write(buffer, 0, silenceBytes);
packetClock.Restart();
framesWritten += silenceFrames;
}
Thread.Sleep(2);
Thread.Sleep(1);
continue;
}
@@ -582,7 +591,7 @@ namespace ScreenRtsp.Audio
try
{
pipe.Write(buffer, 0, bytes);
packetClock.Restart();
framesWritten += frames;
}
finally
{
@@ -710,7 +719,7 @@ function Assert-IniSettings {
param($Configuration)
$allowed = @{
General = @("ChannelCount", "StableTimeSec", "PollIntervalSec")
Video = @("FrameRate", "Bitrate", "EncoderPriority")
Video = @("FrameRate", "GopSeconds", "Bitrate", "EncoderPriority")
Audio = @("Enabled", "MicrophoneEnabled", "Bitrate", "SignalThreshold")
Network = @("NicDescription", "HealthTarget")
RTSP = @(
@@ -779,6 +788,10 @@ if ($ConfigWasLoaded) {
if ($setting.Found -and -not $ExplicitParameters.ContainsKey("FrameRate")) {
$FrameRate = [int]$setting.Value
}
$setting = Get-IniSetting $ini "Video" "GopSeconds"
if ($setting.Found -and -not $ExplicitParameters.ContainsKey("GopSeconds")) {
$GopSeconds = [int]$setting.Value
}
$setting = Get-IniSetting $ini "Video" "Bitrate"
if ($setting.Found -and -not $ExplicitParameters.ContainsKey("VideoBitrate")) {
$VideoBitrate = [string]$setting.Value
@@ -888,6 +901,7 @@ Assert-IntegerRange "General.ChannelCount" $ChannelCount 1 16
Assert-IntegerRange "General.StableTimeSec" $StableTimeSec 1 60
Assert-IntegerRange "General.PollIntervalSec" $PollIntervalSec 1 60
Assert-IntegerRange "Video.FrameRate" $FrameRate 1 240
Assert-IntegerRange "Video.GopSeconds" $GopSeconds 1 10
Assert-IntegerRange "RTSP.Port" $RtspPort 1 65535
Assert-IntegerRange "RTSP.HealthCheckIntervalSec" $RtspHealthCheckIntervalSec 1 3600
Assert-IntegerRange "RTSP.HealthTimeoutSec" $RtspHealthTimeoutSec 1 60
@@ -902,6 +916,7 @@ if ($AudioBitrate -notmatch '^[0-9]+[kKmM]$') {
if ($AudioSignalThreshold -lt 0.0 -or $AudioSignalThreshold -gt 1.0) {
throw "Audio.SignalThreshold 값은 0.0 이상 1.0 이하여야 합니다: $AudioSignalThreshold"
}
$GopSize = $FrameRate * $GopSeconds
if (-not [string]::IsNullOrWhiteSpace($EncoderPriorityCsv)) {
$EncoderPriority = @($EncoderPriorityCsv -split ',' | ForEach-Object { $_.Trim() })
@@ -1059,6 +1074,7 @@ function Get-HiddenWorkerArgumentList {
"-EncoderPriorityCsv", ($EncoderPriority -join ","),
"-ChannelCount", [string]$ChannelCount,
"-FrameRate", [string]$FrameRate,
"-GopSeconds", [string]$GopSeconds,
"-VideoBitrate", $VideoBitrate,
"-AudioBitrate", $AudioBitrate,
"-AudioEnabled", [string]$AudioEnabled,
@@ -1153,6 +1169,8 @@ if ($ShowConfiguration) {
StableTimeSec = $StableTimeSec
PollIntervalSec = $PollIntervalSec
FrameRate = $FrameRate
GopSeconds = $GopSeconds
GopSize = $GopSize
VideoBitrate = $VideoBitrate
EncoderPriority = ($EncoderPriority -join " > ")
AudioEnabled = $AudioEnabled
@@ -1662,7 +1680,7 @@ function Get-EncoderDefinition {
Vendor = "AMD"; Name = "h264_amf"
Arguments = @(
"-usage","lowlatency","-quality","speed","-rc","cbr","-latency","1",
"-forced_idr","1","-header_spacing",[string]$FrameRate
"-forced_idr","1","-header_spacing",[string]$GopSize
)
}
}
@@ -1747,8 +1765,10 @@ function Test-HardwareEncoder {
$testDirectory = New-DependencyTempDirectory
$bitstreamPath = Join-Path $testDirectory ("{0}.h264" -f $Definition.Name)
$testFrameRate = [Math]::Max(1, [int]$FrameRate)
$frameCount = $testFrameRate * [Math]::Max(3, $TestSeconds)
$expectedIdrFrames = [Math]::Max(3, [int][Math]::Floor(($frameCount - 1) / $testFrameRate) + 1)
$testGopSize = [Math]::Max(1, [int]$GopSize)
$effectiveTestSeconds = [Math]::Max([Math]::Max(3, $TestSeconds), (($GopSeconds * 2) + 1))
$frameCount = $testFrameRate * $effectiveTestSeconds
$expectedIdrFrames = [Math]::Max(3, [int][Math]::Floor(($frameCount - 1) / $testGopSize) + 1)
$arguments = @(
"-hide_banner", "-loglevel", "verbose", "-nostdin",
"-f", "lavfi", "-i", "testsrc2=size=1280x720:rate=$testFrameRate",
@@ -1756,8 +1776,8 @@ function Test-HardwareEncoder {
"-c:v", $Definition.Name
) + $Definition.Arguments + @(
"-b:v", "4000k", "-maxrate", "4000k", "-bufsize", "4000k",
"-g", [string]$testFrameRate, "-bf", "0",
"-force_key_frames", "expr:gte(t,n_forced*1)",
"-g", [string]$testGopSize, "-bf", "0",
"-force_key_frames", "expr:gte(t,n_forced*$GopSeconds)",
"-f", "h264", "-y", $bitstreamPath
)
try {
@@ -1799,7 +1819,7 @@ function Test-HardwareEncoder {
Encoder = $Definition.Name
Passed = $passed
ExitCode = $result.ExitCode
TestSeconds = [Math]::Max(3, $TestSeconds)
TestSeconds = $effectiveTestSeconds
Frames = if ($metrics) { $metrics.DecodedFrames } else { 0 }
IdrFrames = if ($metrics) { $metrics.IdrFrameCount } else { 0 }
NalIdrCount = if ($metrics) { $metrics.NalIdrCount } else { 0 }
@@ -2147,7 +2167,7 @@ function Get-EnvironmentFingerprint {
}
else { "" }
$source = (Get-GpuSignature) + "|" + $ffmpegHash + "|" + $mediaHash + "|" + $scriptHash + "|" +
($EncoderPriority -join ",") + "|$FrameRate|$VideoBitrate"
($EncoderPriority -join ",") + "|$FrameRate|$GopSeconds|$VideoBitrate"
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($source)
@@ -2948,7 +2968,14 @@ function Get-VideoFilter {
$filters = @("hwdownload", "format=bgra")
$rotationFilter = Get-RotationFilter $Screen.Orientation
if ($rotationFilter) { $filters += $rotationFilter.Split(',') }
$filters += @("setpts=N/(${FrameRate}*TB)", "format=nv12")
# ddagrab emits one probe frame before slower inputs (notably DirectShow)
# finish opening. Drop that stale frame, then preserve DDA elapsed-time PTS.
# Do not synthesize missed frames: doing so creates an encode backlog and
# makes media time lag wall time on an overloaded GPU.
$filters += @(
"trim=start_frame=1", "setpts=PTS-STARTPTS", "settb=AVTB",
"format=nv12"
)
return ($filters -join ",")
}
@@ -2966,37 +2993,38 @@ function New-FFmpegArguments {
"-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]"
}
$videoInputOffset = 0
if ($AudioEnabled -eq 1) {
$playbackInputIndex = $SelectedScreens.Count
# Open slower audio devices first. ddagrab creates a probe frame as
# soon as its input is opened; opening it before DirectShow gives the
# first two DDA frames timestamps that are several seconds apart.
$playbackInputIndex = 0
$arguments += @(
"-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," +
$filterParts += "[$playbackInputIndex`:a]asetpts=PTS-STARTPTS," +
"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",
"-thread_queue_size", "1024", "-rtbufsize", "32M", "-f", "dshow",
"-i", "audio=$MicrophoneInputName"
)
$filterParts += "[$microphoneInputIndex`:a]aresample=48000:async=1:first_pts=0," +
$filterParts += "[$microphoneInputIndex`:a]asetpts=PTS-STARTPTS," +
"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]"
$videoInputOffset = 2
}
else {
$filterParts += "[sys]anull[mixed]"
$videoInputOffset = 1
}
if ($SelectedScreens.Count -eq 1) {
@@ -3008,6 +3036,14 @@ function New-FFmpegArguments {
}
}
for ($index = 0; $index -lt $SelectedScreens.Count; $index++) {
$screen = $SelectedScreens[$index]
$videoInputIndex = $videoInputOffset + $index
$ddaInput = "ddagrab=output_idx=$($screen.OutputIndex):draw_mouse=1:framerate=${FrameRate}:dup_frames=1"
$arguments += @("-f", "lavfi", "-i", $ddaInput)
$filterParts += "[$videoInputIndex`:v]$(Get-VideoFilter $screen)[v$index]"
}
$arguments += @("-filter_complex", ($filterParts -join ";"))
for ($index = 0; $index -lt $SelectedScreens.Count; $index++) {
@@ -3018,8 +3054,10 @@ function New-FFmpegArguments {
$arguments += @($Encoder.Arguments)
$arguments += @(
"-b:v", $VideoBitrate, "-maxrate", $VideoBitrate, "-bufsize", $VideoBitrate,
"-g", $FrameRate, "-bf", "0", "-pix_fmt", "nv12",
"-force_key_frames", "expr:gte(t,n_forced*1)"
"-g", $GopSize, "-bf", "0", "-pix_fmt", "nv12",
"-force_key_frames", "expr:gte(t,n_forced*$GopSeconds)",
"-enc_time_base", "filter",
"-fps_mode", "passthrough"
)
if ($AudioEnabled -eq 1) {
$arguments += @(
@@ -3027,7 +3065,8 @@ function New-FFmpegArguments {
)
}
$arguments += @(
"-f", "rtsp", "-rtsp_transport", "tcp", "$RTSPPublishBase/monitor$channel"
"-f", "rtsp", "-max_interleave_delta", "100000", "-flush_packets", "1",
"-rtsp_transport", "tcp", "$RTSPPublishBase/monitor$channel"
)
}
@@ -3652,6 +3691,13 @@ function Invoke-SimulationTests {
$qsv -contains "-forced_idr" -and $qsv -contains "-idr_interval")
}
Invoke-SimulationCase "무음 WASAPI loopback은 경과시간 기준으로 PCM 시계 유지" {
return ($AudioInteropSource -match 'streamClock\.Elapsed\.TotalSeconds' -and
$AudioInteropSource -match 'targetFrames\s*-\s*framesWritten' -and
$AudioInteropSource -match 'framesWritten\s*\+=\s*silenceFrames' -and
$AudioInteropSource -notmatch 'packetClock\.Restart')
}
Invoke-SimulationCase "자동 실행은 숨김 worker와 config.ini만 전달" {
$arguments = Get-StartupArgumentList
return ($arguments -match '-WindowStyle\s+Hidden' -and
@@ -3715,6 +3761,7 @@ function Invoke-SimulationTests {
$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
@([regex]::Matches($filter, 'asetpts=PTS-STARTPTS')).Count -eq 2 -and
$arguments -contains 'dshow' -and
$arguments -contains '-force_key_frames' -and
@($arguments | Where-Object { $_ -eq 'h264_nvenc' }).Count -eq 2 -and
@@ -3722,6 +3769,32 @@ function Invoke-SimulationTests {
@($arguments | Where-Object { $_ -eq "$RTSPPublishBase/monitor2" }).Count -eq 1)
}
Invoke-SimulationCase "DDA와 인코더가 20fps/GOP40 및 원본 PTS를 일관되게 사용" {
$script:AudioEnabled = 1
$script:MicrophoneEnabled = 1
$arguments = @(New-FFmpegArguments `
-SelectedScreens @($screenPrimary,$screenSecond) `
-PlaybackEndpoint $defaultAudio -MicrophoneEndpoint $defaultMicrophone `
-MicrophoneInputName $defaultMicrophone.Name `
-Encoder (Get-EncoderDefinition "Intel") -AudioPipePath "\\.\pipe\simulation")
$filterIndex = [Array]::IndexOf($arguments, "-filter_complex")
$filter = if ($filterIndex -ge 0) { $arguments[$filterIndex + 1] } else { "" }
$joined = $arguments -join " "
$pipePosition = $joined.IndexOf('\\.\pipe\simulation')
$ddaPosition = $joined.IndexOf('ddagrab=')
return ($FrameRate -eq 20 -and $GopSize -eq 40 -and
$pipePosition -ge 0 -and $ddaPosition -gt $pipePosition -and
$joined -match 'framerate=20' -and
$filter -match 'trim=start_frame=1' -and
$filter -match 'setpts=PTS-STARTPTS' -and $filter -match 'settb=AVTB' -and
$filter -notmatch 'setpts=N/' -and
@($arguments | Where-Object { $_ -eq "40" }).Count -ge 2 -and
@($arguments | Where-Object { $_ -eq "filter" }).Count -eq 2 -and
@($arguments | Where-Object { $_ -eq "passthrough" }).Count -eq 2 -and
@($arguments | Where-Object { $_ -eq "100000" }).Count -eq 2 -and
$joined -match 'n_forced\*2')
}
Invoke-SimulationCase "마이크 OFF이면 시스템 재생음만 두 채널에 분배" {
$script:AudioEnabled = 1
$script:MicrophoneEnabled = 0
@@ -3966,7 +4039,7 @@ try {
"${RtspHealthCheckIntervalSec}초 주기 / ${RtspHealthTimeoutSec}초 제한 / " +
"${RtspHealthFailureThreshold}회 연속 실패 시 재시작"
)
Write-Log "영상 설정: ${FrameRate}fps / 채널당 $VideoBitrate"
Write-Log "영상 설정: ${FrameRate}fps / GOP ${GopSize}프레임(${GopSeconds}초) / DDA PTS 유지 / 채널당 $VideoBitrate"
$lastSystemSignature = ""
$healthySince = $null