Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
95 changes: 93 additions & 2 deletions scripts/system_audio_controller.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -368,9 +368,82 @@ namespace OpenFlow.Audio
return live.Muted || live.Volume <= NearSilentVolume;
}

private static HashSet<string> GetProcessNamesAllowingFallback(
List<AudioSessionSnapshot> snapshots,
List<LiveAudioSession> liveSessions)
{
var allowed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (snapshots == null || snapshots.Count == 0 || liveSessions == null)
{
return allowed;
}

var snapshotCounts = new Dictionary<string, int>(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<string, int>(StringComparer.OrdinalIgnoreCase);
var totalCounts = new Dictionary<string, int>(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<string> processNamesAllowingFallback,
Dictionary<string, List<AudioSessionSnapshot>> byInstanceId,
Dictionary<string, List<AudioSessionSnapshot>> bySessionId,
Dictionary<int, List<AudioSessionSnapshot>> byProcessId,
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -467,6 +543,7 @@ namespace OpenFlow.Audio

var restored = new HashSet<AudioSessionSnapshot>();
var liveList = liveSessions ?? new List<LiveAudioSession>();
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)
Expand All @@ -479,6 +556,7 @@ namespace OpenFlow.Audio
var snapshot = FindRestoreSnapshotForLive(
live,
true,
processNamesAllowingFallback,
byInstanceId,
bySessionId,
byProcessId,
Expand All @@ -504,6 +582,7 @@ namespace OpenFlow.Audio
var snapshot = FindRestoreSnapshotForLive(
live,
false,
null,
byInstanceId,
bySessionId,
byProcessId,
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions scripts/test-duck-restore.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];
Expand Down
27 changes: 21 additions & 6 deletions src/main/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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,
);
}
}

Expand Down Expand Up @@ -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;
Expand Down
Loading