From 1e7311aa938f736fee22e9c362f9f073e83cc099 Mon Sep 17 00:00:00 2001 From: Jubarte Date: Thu, 9 Jul 2026 10:41:50 -0300 Subject: [PATCH] fix: update version in package.json and add tests for audio restore logic --- package.json | 2 +- scripts/system_audio_controller.ps1 | 95 ++++++++++++++++++++++++++++- scripts/test-duck-restore.js | 1 + src/main/main.js | 27 ++++++-- 4 files changed, 116 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 814c599..b3f12a2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openflow", "productName": "OpenFlow", - "version": "1.3.042", + "version": "1.3.043", "description": "Cross-platform desktop dictation app OpenFlow using Electron and Faster-Whisper.", "main": "src/main/main.js", "scripts": { diff --git a/scripts/system_audio_controller.ps1 b/scripts/system_audio_controller.ps1 index 40a028f..aa81a63 100644 --- a/scripts/system_audio_controller.ps1 +++ b/scripts/system_audio_controller.ps1 @@ -368,9 +368,82 @@ namespace OpenFlow.Audio return live.Muted || live.Volume <= NearSilentVolume; } + private static HashSet GetProcessNamesAllowingFallback( + List snapshots, + List liveSessions) + { + var allowed = new HashSet(StringComparer.OrdinalIgnoreCase); + if (snapshots == null || snapshots.Count == 0 || liveSessions == null) + { + return allowed; + } + + var snapshotCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var snapshot in snapshots) + { + if (snapshot == null || string.IsNullOrEmpty(snapshot.ProcessName)) + { + continue; + } + + int count; + snapshotCounts.TryGetValue(snapshot.ProcessName, out count); + snapshotCounts[snapshot.ProcessName] = count + 1; + } + + var duckedCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); + var totalCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var live in liveSessions) + { + if (live == null || string.IsNullOrEmpty(live.ProcessName)) + { + continue; + } + + int total; + totalCounts.TryGetValue(live.ProcessName, out total); + totalCounts[live.ProcessName] = total + 1; + + if (!LooksDucked(live)) + { + continue; + } + + int ducked; + duckedCounts.TryGetValue(live.ProcessName, out ducked); + duckedCounts[live.ProcessName] = ducked + 1; + } + + foreach (var pair in snapshotCounts) + { + int ducked; + int total; + if (!duckedCounts.TryGetValue(pair.Key, out ducked)) + { + ducked = 0; + } + + if (!totalCounts.TryGetValue(pair.Key, out total)) + { + total = 0; + } + + // Process-name-only restore is safe only when every live session for that + // process is still ducked and counts line up with pending snapshots — avoids + // restoring an unrelated muted Chrome tab while the real duck is still pending. + if (pair.Value > 0 && pair.Value == ducked && ducked == total) + { + allowed.Add(pair.Key); + } + } + + return allowed; + } + private static AudioSessionSnapshot FindRestoreSnapshotForLive( LiveAudioSession live, bool allowProcessNameFallback, + HashSet processNamesAllowingFallback, Dictionary> byInstanceId, Dictionary> bySessionId, Dictionary> byProcessId, @@ -402,8 +475,11 @@ namespace OpenFlow.Audio // Process-name fallback: Chrome/media apps often recycle PID + instance id after a // long silence. Only use this when allowed (typically for still-ducked live sessions) - // so we never leave a muted chrome.exe stranded while a new healthy stream steals the snapshot. - if (allowProcessNameFallback) + // and when pending/live counts are unambiguous for that process name. + if (allowProcessNameFallback && + processNamesAllowingFallback != null && + !string.IsNullOrEmpty(live.ProcessName) && + processNamesAllowingFallback.Contains(live.ProcessName)) { return FindUnrestoredSnapshotByProcessName(byProcessName, live.ProcessName, restored); } @@ -467,6 +543,7 @@ namespace OpenFlow.Audio var restored = new HashSet(); var liveList = liveSessions ?? new List(); + var processNamesAllowingFallback = GetProcessNamesAllowingFallback(snapshots, liveList); // Pass 1: fix sessions that still look ducked, including process-name fallback for PID drift. foreach (var live in liveList) @@ -479,6 +556,7 @@ namespace OpenFlow.Audio var snapshot = FindRestoreSnapshotForLive( live, true, + processNamesAllowingFallback, byInstanceId, bySessionId, byProcessId, @@ -504,6 +582,7 @@ namespace OpenFlow.Audio var snapshot = FindRestoreSnapshotForLive( live, false, + null, byInstanceId, bySessionId, byProcessId, @@ -1788,6 +1867,18 @@ function Invoke-DuckRestoreSelfTest { $planHold.Matches.Count -eq 0 -and $planHold.Pending.Count -eq 1 ) -Failures $failures + # 5b) Unrelated muted tab must not consume a pending snapshot for another tab + $snapOtherTab = New-TestSnapshot -InstanceId 'other-old' -SessionId 'other-sess' -ProcessId 72 -ProcessName 'chrome' + $liveOtherMuted = New-TestLiveSession -InstanceId 'other-muted' -SessionId 'other-muted-sess' -ProcessId 73 -ProcessName 'chrome' -Muted $true + $liveOtherHealthy = New-TestLiveSession -InstanceId 'other-healthy' -SessionId 'other-healthy-sess' -ProcessId 74 -ProcessName 'chrome' -Muted $false -Volume 0.9 + $planOtherTab = [OpenFlow.Audio.SessionVolumeController]::MatchSnapshotsForRestore( + (New-SnapshotListOf $snapOtherTab), + (New-LiveSessionListOf $liveOtherMuted $liveOtherHealthy) + ) + Assert-SelfTest -Name 'unrelated-muted-tab-skips-process-name-fallback' -Condition ( + $planOtherTab.Matches.Count -eq 0 -and $planOtherTab.Pending.Count -eq 1 + ) -Failures $failures + # 6) Previously-ducked detection for re-owning leftover mutes $prior = New-TestSnapshot -InstanceId 'p-old' -SessionId 'p-sess' -ProcessId 5 -ProcessName 'msedge' -Volume 0.55 $priorList = New-SnapshotListOf $prior diff --git a/scripts/test-duck-restore.js b/scripts/test-duck-restore.js index a6c8c8c..a55c384 100644 --- a/scripts/test-duck-restore.js +++ b/scripts/test-duck-restore.js @@ -76,6 +76,7 @@ function runControllerSelfTest() { 'delayed-reappearance-first-miss-pending', 'delayed-reappearance-second-pass-restores', 'healthy-stream-does-not-steal-process-name-snapshot', + 'unrelated-muted-tab-skips-process-name-fallback', 'previously-ducked-process-name-match', 'stable-session-id-after-instance-recycle', ]; diff --git a/src/main/main.js b/src/main/main.js index 36897b5..d97eb38 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -2886,12 +2886,15 @@ function clearPendingAudioRestores() { pendingAudioRestoreFollowupsRemaining = 0; } -function scheduleAudioRestoreTimer(delayMs) { +function scheduleAudioRestoreTimer(delayMs, onFire) { const timer = setTimeout(() => { pendingAudioRestoreTimers.delete(timer); if (captureMuteDepth > 0 || isQuitting) { return; } + if (typeof onFire === 'function') { + onFire(); + } sendAudioCommand('restore-pending'); }, delayMs); @@ -2907,10 +2910,22 @@ function schedulePendingAudioRestores() { } clearPendingAudioRestores(); - pendingAudioRestoreFollowupsRemaining = AUDIO_PENDING_RESTORE_FOLLOWUP_MAX; + // Defer follow-up budget until fixed delays finish so early restore-complete + // responses do not exhaust retries before the final 5-minute restore attempt. + pendingAudioRestoreFollowupsRemaining = 0; - for (const delayMs of AUDIO_PENDING_RESTORE_DELAYS_MS) { - scheduleAudioRestoreTimer(delayMs); + const delays = AUDIO_PENDING_RESTORE_DELAYS_MS; + for (let index = 0; index < delays.length; index += 1) { + const delayMs = delays[index]; + const isLastFixedDelay = index === delays.length - 1; + scheduleAudioRestoreTimer( + delayMs, + isLastFixedDelay + ? () => { + pendingAudioRestoreFollowupsRemaining = AUDIO_PENDING_RESTORE_FOLLOWUP_MAX; + } + : undefined, + ); } } @@ -3752,8 +3767,8 @@ function handleAudioControllerEvent(event) { const pendingCount = Number(event?.payload?.pending); if (Number.isFinite(pendingCount) && pendingCount > 0 && captureMuteDepth <= 0 && !isQuitting) { // Controller still has unmatched ducked sessions (e.g. Chrome recreated later). - // scheduleFollowupPendingAudioRestore no-ops once the budget is exhausted — - // do not re-arm here, or unresolvable pending sessions retry forever. + // scheduleFollowupPendingAudioRestore no-ops while fixed timers run or once the + // budget is exhausted — do not re-arm here, or unresolvable pending sessions retry forever. scheduleFollowupPendingAudioRestore(); } else if (Number.isFinite(pendingCount) && pendingCount === 0) { pendingAudioRestoreFollowupsRemaining = 0;