From 6c91d4f3720d2ed5888925ef3c365f6cc90549ee Mon Sep 17 00:00:00 2001 From: seo-amugae Date: Thu, 8 Oct 2026 11:46:16 +0900 Subject: [PATCH] Fix RTSP clock pacing at 20 fps --- README.md | 22 ++++++-- config.ini | 5 +- screen-rtsp.ps1 | 143 ++++++++++++++++++++++++++++++++++++------------ 3 files changed, 130 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 60dfefc..3f10fd1 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ RTSP는 `ddagrab` 기반 Desktop Duplication API로 화면과 마우스 커서 - DirectShow 마이크와 시스템 재생음을 `amix`로 혼합하고 `asplit`으로 모든 채널에 분배 - 하나의 FFmpeg가 모든 DDA 입력과 RTSP 출력을 함께 처리 - 기본 출력·입력 장치, 선택 장치, 오디오 포맷 변경 시 전체 채널 재시작 -- 기본 30fps, 영상 8Mbps, AAC 오디오 192kbps +- 기본 20fps, GOP 2초(40프레임), 영상 8Mbps, AAC 오디오 192kbps - NIC Link, IPv4, `ifIndex`, IP 변경 감지 - 선택적 Source IP 고정 ping 상태 검사 - 3초간 정상 상태가 유지된 뒤 전체 채널 시작 @@ -108,7 +108,8 @@ GPU 이름과 드라이버, NVENC API, AMF/QSV 시험 결과, 선택 인코더, ```ini [Video] -FrameRate=30 +FrameRate=20 +GopSeconds=2 Bitrate=8000k EncoderPriority=NVIDIA,AMD,Intel @@ -130,7 +131,8 @@ SignalThreshold=0.0005 | `General` | `ChannelCount` | `2` | 송출 채널과 필요한 모니터 수 | | `General` | `StableTimeSec` | `3` | 정상 상태 안정화 시간 | | `General` | `PollIntervalSec` | `1` | 상태 확인 주기 | -| `Video` | `FrameRate` | `30` | 화면 프레임률 | +| `Video` | `FrameRate` | `20` | 화면 캡처 및 RTSP 영상 프레임률 | +| `Video` | `GopSeconds` | `2` | IDR/GOP 간격. 기본값은 20fps에서 40프레임 | | `Video` | `Bitrate` | `8000k` | 채널별 영상 비트레이트 | | `Video` | `EncoderPriority` | `NVIDIA,AMD,Intel` | GPU 선택 순서 | | `Audio` | `Enabled` | `1` | 시스템 재생음과 AAC 트랙 사용 여부 | @@ -152,6 +154,18 @@ SignalThreshold=0.0005 | `Dependencies` | `MediaMTXUrl` | v1.21.1 공식 ZIP | MediaMTX Windows amd64 ZIP 주소 | | `Dependencies` | `MediaMTXSha256` | v1.21.1 공식 해시 | MediaMTX ZIP 고정 SHA-256 | +영상 타임스탬프는 프레임 번호로 다시 만들지 않습니다. DDA가 제공한 경과시간 PTS를 0부터 시작하도록 정규화하고 마이크로초 time base로 인코더와 RTSP muxer까지 전달합니다. 따라서 순간 처리량이 목표 FPS 아래로 내려가더라도 영상 시간이 실제 경과시간보다 느려져 MediaMTX 녹화가 drift로 재설정되는 현상을 방지합니다. + +FFmpeg가 여러 실시간 입력을 여는 동안 `ddagrab`이 미리 만든 탐사용 첫 프레임은 버리고, 모든 입력이 준비된 뒤 들어온 첫 실제 프레임을 PTS 0으로 사용합니다. 처리하지 못한 프레임을 뒤늦게 복제하지 않으므로 과부하 때 실제 프레임 수는 줄 수 있지만, RTP 영상 시간은 실제 경과시간에서 뒤처지지 않습니다. + +입력은 초기화가 느린 WASAPI/DirectShow 오디오 장치를 먼저 열고 DDA 화면을 마지막에 엽니다. 따라서 마이크 초기화를 기다리는 동안 DDA의 첫 프레임 시각만 수 초 앞서가는 현상을 피합니다. 기본 목표는 채널별 20fps이며, 과부하로 프레임이 줄어도 타임스탬프를 느리게 늘려 밀린 프레임을 뒤늦게 보내지 않습니다. + +WASAPI loopback과 DirectShow 마이크는 서로 다른 절대 시작 PTS를 사용할 수 있으므로 각각 `asetpts=PTS-STARTPTS`로 정규화한 뒤 리샘플링하고 혼합합니다. 큰 DirectShow 시작 PTS를 `first_pts=0`으로 직접 보정하면서 장시간 무음을 삽입해 영상 처리를 막는 상황을 방지합니다. + +기본 출력 장치가 무음이라 WASAPI loopback 패킷이 멈출 때는 Windows `Sleep` 호출 횟수로 고정 길이 무음을 넣지 않습니다. 고해상도 경과시간과 이미 전달한 PCM 프레임 수의 차이만큼 무음을 보충하므로 Windows 타이머 해상도와 관계없이 오디오 시계가 실제 시간과 같은 속도로 진행합니다. + +각 RTSP 출력은 오디오와 영상 패킷을 기다리는 muxer interleave 상한을 기본 10초 대신 100ms로 제한하고 패킷을 즉시 flush합니다. 한 트랙이 잠시 늦어져도 다른 트랙과 전체 FFmpeg 필터 그래프가 장시간 정지하지 않습니다. + GPU, 모니터, 오디오, NIC, FFmpeg 지원, RTSP 포트를 확인하는 PowerShell 명령은 `config.ini` 위쪽에 모두 주석 형태로 포함되어 있습니다. INI를 해석한 최종 설정값만 확인하고 종료할 수도 있습니다. @@ -302,7 +316,7 @@ GPU 우선순위를 바꾸는 예시: 채널 수와 영상 설정을 바꾸는 예시: ```powershell -.\screen-rtsp.ps1 -ChannelCount 2 -FrameRate 30 -VideoBitrate 8000k -AudioBitrate 192k +.\screen-rtsp.ps1 -ChannelCount 2 -FrameRate 20 -GopSeconds 2 -VideoBitrate 8000k -AudioBitrate 192k ``` 특정 ASIX NIC와 Gateway 통신을 필수 조건으로 사용하는 노트북 예시: diff --git a/config.ini b/config.ini index 8a435bf..33ae9e3 100644 --- a/config.ini +++ b/config.ini @@ -91,7 +91,10 @@ PollIntervalSec=1 [Video] # 화면 캡처 및 RTSP 영상 프레임률입니다. 1~240 범위에서 설정합니다. -FrameRate=30 +FrameRate=20 + +# IDR/GOP 간격(초). 20fps에서 2초이면 GOP는 40프레임입니다. +GopSeconds=2 Bitrate=8000k # 앞에 있는 GPU부터 3초 이상 실제 인코딩, 주기 IDR 및 2채널 RTSP 수신을 시험합니다. diff --git a/screen-rtsp.ps1 b/screen-rtsp.ps1 index baebc5a..7f8a072 100644 --- a/screen-rtsp.ps1 +++ b/screen-rtsp.ps1 @@ -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