From cb1a058807e8fcd3e369e2c551aa5b5ebf6b91d2 Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Mon, 13 Apr 2026 00:34:31 -0700 Subject: [PATCH 1/2] Optimize pack load performance with indexing and deferred voice command building Pack load for Codetracker Map Tracker was taking ~7.8s. Three optimizations: 1. Dictionary index for GetPersistableLocationReference/ItemReference: Replace O(n) List.IndexOf with O(1) dictionary lookup. LocationDatabase and ItemDatabase now maintain a Dictionary populated on insert. Saves ~2.9s from 583 location lookups during voice command building. 2. Defer BuildCommandMap to background thread: Voice recognition command map building (~5s) now runs on Task.Run with snapshots of item/location data. Atomic swap under lock ensures thread safety. Cancellable on pack switch. Completely removes voice recognition from the critical path. 3. Code-to-provider index in ItemDatabase: New BuildCodeIndex() creates a Dictionary> mapping codes to their providers. LuaItems (dynamic Lua callbacks) fall back to brute-force iteration. All item types implement GetAllProvidedCodes() to expose their static code sets. ProviderCountForCode and related methods use the index when available, avoiding iteration over all items for each code query. Co-Authored-By: Claude Opus 4.6 --- EmoTracker.Data/ItemDatabase.cs | 174 +++++++++++++-- EmoTracker.Data/Items/BlankItem.cs | 4 + EmoTracker.Data/Items/CompositeToggleItem.cs | 2 + EmoTracker.Data/Items/ConsumableItem.cs | 3 + EmoTracker.Data/Items/ItemBase.cs | 8 + EmoTracker.Data/Items/ProgressiveItem.cs | 11 + .../Items/ProgressiveToggleItem.cs | 14 ++ .../Items/SectionChestsProxyItem.cs | 3 + EmoTracker.Data/Items/StaticItem.cs | 3 + EmoTracker.Data/Items/ToggleBadgedItem.cs | 3 + EmoTracker.Data/Items/ToggleItem.cs | 3 + EmoTracker.Data/LocationDatabase.cs | 7 +- EmoTracker.Data/Tracker.cs | 2 + .../VoiceRecognitionExtensionAvalonia.cs | 210 +++++++++++++----- 14 files changed, 381 insertions(+), 66 deletions(-) diff --git a/EmoTracker.Data/ItemDatabase.cs b/EmoTracker.Data/ItemDatabase.cs index a81bed8..7dfc04d 100644 --- a/EmoTracker.Data/ItemDatabase.cs +++ b/EmoTracker.Data/ItemDatabase.cs @@ -2,6 +2,7 @@ using EmoTracker.Data.Items; using EmoTracker.Data.JSON; using EmoTracker.Data.Locations; +using EmoTracker.Data.Scripting; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System; @@ -14,6 +15,14 @@ namespace EmoTracker.Data public class ItemDatabase : Singleton, ICodeProvider { ObservableCollection mItems = new ObservableCollection(); + Dictionary mItemIndex = new Dictionary(); + + // Code→provider index: maps lowercase code strings to lists of items that can provide them. + // LuaItems have dynamic code providers (Lua callbacks) and cannot be statically indexed, + // so they are kept in a separate list and brute-force checked as a fallback. + Dictionary> mCodeToProviders = new Dictionary>(StringComparer.OrdinalIgnoreCase); + List mDynamicCodeItems = new List(); + bool mCodeIndexBuilt = false; public IEnumerable Items { @@ -32,13 +41,64 @@ public void Reset() item.Dispose(); } - mItems.Clear(); + mItems.Clear(); + mItemIndex.Clear(); + mCodeToProviders.Clear(); + mDynamicCodeItems.Clear(); + mCodeIndexBuilt = false; + } + + /// + /// Builds the code→provider lookup index. Should be called once after all items are loaded. + /// Items that return null from GetAllProvidedCodes (e.g. LuaItem) are placed in the + /// dynamic fallback list and checked via brute-force on every query. + /// + public void BuildCodeIndex() + { + mCodeToProviders.Clear(); + mDynamicCodeItems.Clear(); + + foreach (var item in mItems) + { + if (item is ItemBase itemBase) + { + var codes = itemBase.GetAllProvidedCodes(); + if (codes == null) + { + // Dynamic code provider (e.g. LuaItem) — must be brute-force checked + mDynamicCodeItems.Add(item); + } + else + { + foreach (string code in codes) + { + string key = code.ToLower(); + if (!mCodeToProviders.TryGetValue(key, out var list)) + { + list = new List(); + mCodeToProviders[key] = list; + } + list.Add(item); + } + } + } + else + { + // Non-ItemBase implementors — treat as dynamic + mDynamicCodeItems.Add(item); + } + } + + mCodeIndexBuilt = true; } public void RegisterItem(ITrackableItem item) { - if (!mItems.Contains(item)) + if (!mItemIndex.ContainsKey(item)) + { + mItemIndex[item] = mItems.Count; mItems.Add(item); + } } public bool LegacyLoad(IGamePackage package) @@ -73,7 +133,10 @@ public bool IncrementalLoad(string path, IGamePackage package, bool bLegacy = fa { ITrackableItem instance = ItemBase.CreateItem(item, package); if (instance != null) + { + mItemIndex[instance] = mItems.Count; mItems.Add(instance); + } } } @@ -91,6 +154,31 @@ public bool IncrementalLoad(string path, IGamePackage package, bool bLegacy = fa internal bool CodeIsProvided(string code) { + code = code.ToLower(); + + if (mCodeIndexBuilt) + { + // Check indexed items + if (mCodeToProviders.TryGetValue(code, out var indexed)) + { + foreach (var item in indexed) + { + if (item.ProvidesCode(code) > 0) + return true; + } + } + + // Check dynamic items (LuaItems) + foreach (var item in mDynamicCodeItems) + { + if (item.ProvidesCode(code) > 0) + return true; + } + + return false; + } + + // Fallback: no index built yet foreach (ITrackableItem item in Items) { if (item.ProvidesCode(code) > 0) @@ -112,24 +200,64 @@ public uint ProviderCountForCode(string code, out AccessibilityLevel maxAccessib // Item codes never constrain accessibility maxAccessibilityLevel = AccessibilityLevel.Normal; - uint nCount = 0; - foreach (ITrackableItem item in Items) + if (mCodeIndexBuilt) { - nCount += item.ProvidesCode(code); + uint nCount = 0; + + // Check indexed items first + if (mCodeToProviders.TryGetValue(code, out var indexed)) + { + foreach (var item in indexed) + nCount += item.ProvidesCode(code); + } + + // Check dynamic items (LuaItems) + foreach (var item in mDynamicCodeItems) + nCount += item.ProvidesCode(code); + + return nCount; } - return nCount; + // Fallback: no index built yet + { + uint nCount = 0; + foreach (ITrackableItem item in Items) + nCount += item.ProvidesCode(code); + return nCount; + } } internal ITrackableItem FindProvidingItemForCode(string code) { - if (!string.IsNullOrWhiteSpace(code)) + if (string.IsNullOrWhiteSpace(code)) + return null; + + code = code.ToLower(); + + if (mCodeIndexBuilt) { - foreach (ITrackableItem item in Items) + if (mCodeToProviders.TryGetValue(code, out var indexed)) + { + foreach (var item in indexed) + { + if (item.CanProvideCode(code)) + return item; + } + } + + foreach (var item in mDynamicCodeItems) { if (item.CanProvideCode(code)) return item; } + + return null; + } + + foreach (ITrackableItem item in Items) + { + if (item.CanProvideCode(code)) + return item; } return null; @@ -139,13 +267,35 @@ public ITrackableItem[] FindProvidingItemsForCode(string code) { List found = new List(); - if (!string.IsNullOrWhiteSpace(code)) + if (string.IsNullOrWhiteSpace(code)) + return found.ToArray(); + + code = code.ToLower(); + + if (mCodeIndexBuilt) { - foreach (ITrackableItem item in Items) + if (mCodeToProviders.TryGetValue(code, out var indexed)) + { + foreach (var item in indexed) + { + if (item.CanProvideCode(code)) + found.Add(item); + } + } + + foreach (var item in mDynamicCodeItems) { if (item.CanProvideCode(code)) found.Add(item); } + + return found.ToArray(); + } + + foreach (ITrackableItem item in Items) + { + if (item.CanProvideCode(code)) + found.Add(item); } return found.ToArray(); @@ -204,9 +354,7 @@ internal bool Load(JObject root) public string GetPersistableItemReference(ITrackableItem item, bool allowAnyType = false) { - int idx = mItems.IndexOf(item); - - if (idx < 0) + if (!mItemIndex.TryGetValue(item, out int idx)) throw new InvalidOperationException("Cannot generate persistable reference for item that is not in the ItemDatabase"); string jsonTypeTag = JsonTypeTagsAttribute.GetDefaultTagForType(item.GetType()); diff --git a/EmoTracker.Data/Items/BlankItem.cs b/EmoTracker.Data/Items/BlankItem.cs index f516342..bdaf329 100644 --- a/EmoTracker.Data/Items/BlankItem.cs +++ b/EmoTracker.Data/Items/BlankItem.cs @@ -1,5 +1,7 @@ using EmoTracker.Core; using Newtonsoft.Json.Linq; +using System.Collections.Generic; +using System.Linq; namespace EmoTracker.Data.Items { @@ -16,6 +18,8 @@ public override bool CanProvideCode(string code) return false; } + public override IEnumerable GetAllProvidedCodes() => Enumerable.Empty(); + public override void OnLeftClick() { } diff --git a/EmoTracker.Data/Items/CompositeToggleItem.cs b/EmoTracker.Data/Items/CompositeToggleItem.cs index 3bdb17c..d39c7e8 100644 --- a/EmoTracker.Data/Items/CompositeToggleItem.cs +++ b/EmoTracker.Data/Items/CompositeToggleItem.cs @@ -29,6 +29,8 @@ public override bool CanProvideCode(string code) return false; } + public override IEnumerable GetAllProvidedCodes() => mProvidedCodes.ProvidedCodes; + public override uint ProvidesCode(string code) { if (mProvidedCodes.ProvidesCode(code)) diff --git a/EmoTracker.Data/Items/ConsumableItem.cs b/EmoTracker.Data/Items/ConsumableItem.cs index a1a7422..1fca36b 100644 --- a/EmoTracker.Data/Items/ConsumableItem.cs +++ b/EmoTracker.Data/Items/ConsumableItem.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Newtonsoft.Json.Linq; using EmoTracker.Core; using EmoTracker.Data.JSON; @@ -115,6 +116,8 @@ public override bool CanProvideCode(string code) return mCodeProvider.ProvidesCode(code); } + public override IEnumerable GetAllProvidedCodes() => mCodeProvider.ProvidedCodes; + public override uint ProvidesCode(string code) { if (AvailableCount > 0 && mCodeProvider.ProvidesCode(code)) diff --git a/EmoTracker.Data/Items/ItemBase.cs b/EmoTracker.Data/Items/ItemBase.cs index ccb4e3f..87aa30b 100644 --- a/EmoTracker.Data/Items/ItemBase.cs +++ b/EmoTracker.Data/Items/ItemBase.cs @@ -3,6 +3,7 @@ using EmoTracker.Data.JSON; using EmoTracker.Data.Media; using Newtonsoft.Json.Linq; +using System.Collections.Generic; using System.Linq; namespace EmoTracker.Data.Items @@ -106,6 +107,13 @@ public void InvalidateAccessibility() public abstract bool CanProvideCode(string code); public abstract void AdvanceToCode(string code = null); + /// + /// Returns the set of all codes this item can potentially provide, for indexing purposes. + /// Returns null if the item's codes are dynamic and cannot be statically enumerated + /// (e.g. LuaItem with a Lua callback). + /// + public virtual IEnumerable GetAllProvidedCodes() => null; + #region -- Static Methods --- diff --git a/EmoTracker.Data/Items/ProgressiveItem.cs b/EmoTracker.Data/Items/ProgressiveItem.cs index 2a8a288..997a5d7 100644 --- a/EmoTracker.Data/Items/ProgressiveItem.cs +++ b/EmoTracker.Data/Items/ProgressiveItem.cs @@ -111,6 +111,17 @@ public override bool CanProvideCode(string code) return false; } + public override IEnumerable GetAllProvidedCodes() + { + var codes = new HashSet(); + foreach (Stage stage in StagesInternal) + { + foreach (string code in stage.ProvidedCodes) + codes.Add(code); + } + return codes; + } + public override uint ProvidesCode(string code) { if (CurrentStageInstance != null) diff --git a/EmoTracker.Data/Items/ProgressiveToggleItem.cs b/EmoTracker.Data/Items/ProgressiveToggleItem.cs index b928e93..1ec4a00 100644 --- a/EmoTracker.Data/Items/ProgressiveToggleItem.cs +++ b/EmoTracker.Data/Items/ProgressiveToggleItem.cs @@ -79,6 +79,20 @@ public override bool CanProvideCode(string code) return false; } + public override IEnumerable GetAllProvidedCodes() + { + var codes = new HashSet(); + foreach (Stage stage in mStages.Values) + { + if (stage != null) + { + foreach (string code in stage.ProvidedCodes) + codes.Add(code); + } + } + return codes; + } + public override uint ProvidesCode(string code) { Stage stageDef; diff --git a/EmoTracker.Data/Items/SectionChestsProxyItem.cs b/EmoTracker.Data/Items/SectionChestsProxyItem.cs index 296e2d5..b60e9af 100644 --- a/EmoTracker.Data/Items/SectionChestsProxyItem.cs +++ b/EmoTracker.Data/Items/SectionChestsProxyItem.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using EmoTracker.Core; using EmoTracker.Data.JSON; using EmoTracker.Data.Locations; @@ -81,6 +82,8 @@ public override bool CanProvideCode(string code) return mCodeProvider.ProvidesCode(code); } + public override IEnumerable GetAllProvidedCodes() => mCodeProvider.ProvidedCodes; + public override void OnLeftClick() { if (AllowManipulation && Section.AvailableChestCount > 0) diff --git a/EmoTracker.Data/Items/StaticItem.cs b/EmoTracker.Data/Items/StaticItem.cs index 0f82242..070dd13 100644 --- a/EmoTracker.Data/Items/StaticItem.cs +++ b/EmoTracker.Data/Items/StaticItem.cs @@ -2,6 +2,7 @@ using EmoTracker.Data.JSON; using EmoTracker.Data.Media; using Newtonsoft.Json.Linq; +using System.Collections.Generic; namespace EmoTracker.Data.Items { @@ -20,6 +21,8 @@ public override bool CanProvideCode(string code) return mCodeProvider.ProvidesCode(code); } + public override IEnumerable GetAllProvidedCodes() => mCodeProvider.ProvidedCodes; + public override void OnLeftClick() { } diff --git a/EmoTracker.Data/Items/ToggleBadgedItem.cs b/EmoTracker.Data/Items/ToggleBadgedItem.cs index e1a1f3b..a1707ce 100644 --- a/EmoTracker.Data/Items/ToggleBadgedItem.cs +++ b/EmoTracker.Data/Items/ToggleBadgedItem.cs @@ -2,6 +2,7 @@ using EmoTracker.Data.JSON; using EmoTracker.Data.Media; using Newtonsoft.Json.Linq; +using System.Collections.Generic; namespace EmoTracker.Data.Items { @@ -85,6 +86,8 @@ public override bool CanProvideCode(string code) return mCodeProvider.ProvidesCode(code); } + public override IEnumerable GetAllProvidedCodes() => mCodeProvider.ProvidedCodes; + public override void AdvanceToCode(string code = null) { Active = true; diff --git a/EmoTracker.Data/Items/ToggleItem.cs b/EmoTracker.Data/Items/ToggleItem.cs index d1d3b63..258effa 100644 --- a/EmoTracker.Data/Items/ToggleItem.cs +++ b/EmoTracker.Data/Items/ToggleItem.cs @@ -2,6 +2,7 @@ using EmoTracker.Data.JSON; using EmoTracker.Data.Media; using Newtonsoft.Json.Linq; +using System.Collections.Generic; namespace EmoTracker.Data.Items { @@ -106,6 +107,8 @@ public override bool CanProvideCode(string code) return mCodeProvider.ProvidesCode(code); } + public override IEnumerable GetAllProvidedCodes() => mCodeProvider.ProvidedCodes; + public override void AdvanceToCode(string code = null) { Active = true; diff --git a/EmoTracker.Data/LocationDatabase.cs b/EmoTracker.Data/LocationDatabase.cs index b747a73..a6f8865 100644 --- a/EmoTracker.Data/LocationDatabase.cs +++ b/EmoTracker.Data/LocationDatabase.cs @@ -31,6 +31,7 @@ public virtual void Dispose() Location mLastClearedLocation; ObservableCollection mAllLocations = new ObservableCollection(); + Dictionary mLocationIndex = new Dictionary(); ObservableCollection mPinnedLocations = new ObservableCollection(); ObservableCollection mVisibleLocations = new ObservableCollection(); @@ -105,6 +106,7 @@ public void Reset() { LastClearedLocation = null; mAllLocations.Clear(); + mLocationIndex.Clear(); mPinnedLocations.Clear(); mVisibleLocations.Clear(); mRoot = new Location() @@ -546,6 +548,7 @@ Location LoadLocation(IGamePackage package, Location parent, JObject data) } } + mLocationIndex[instance] = mAllLocations.Count; mAllLocations.Add(instance); var children = data.GetValue("children"); @@ -691,9 +694,7 @@ internal bool Load(JObject root) public string GetPersistableLocationReference(Location location) { - int idx = mAllLocations.IndexOf(location); - - if (idx < 0) + if (!mLocationIndex.TryGetValue(location, out int idx)) throw new InvalidOperationException("Cannot generate persistable reference for location that is not in the LocationDatabase"); if (!string.IsNullOrWhiteSpace(location.Name)) diff --git a/EmoTracker.Data/Tracker.cs b/EmoTracker.Data/Tracker.cs index 64590d4..fee1dff 100644 --- a/EmoTracker.Data/Tracker.cs +++ b/EmoTracker.Data/Tracker.cs @@ -499,6 +499,8 @@ public void Reload() mbReloadInProgress = false; + ItemDatabase.Instance.BuildCodeIndex(); + if (OnPackageLoadComplete != null) OnPackageLoadComplete(this, EventArgs.Empty); diff --git a/EmoTracker/Extensions/VoiceRecognition/VoiceRecognitionExtensionAvalonia.cs b/EmoTracker/Extensions/VoiceRecognition/VoiceRecognitionExtensionAvalonia.cs index 9cf5e44..111223d 100644 --- a/EmoTracker/Extensions/VoiceRecognition/VoiceRecognitionExtensionAvalonia.cs +++ b/EmoTracker/Extensions/VoiceRecognition/VoiceRecognitionExtensionAvalonia.cs @@ -38,6 +38,8 @@ public class VoiceRecognitionExtension : ObservableObject, Extension public string UID => "emotracker_voice_recognition"; public int Priority => -10000; + private CancellationTokenSource _buildCommandMapCts; + private bool _active = false; public bool Active { @@ -92,7 +94,7 @@ public VoiceRecognitionExtension() public void Start() { } public void Stop() => Active = false; - public void OnPackageLoaded() => BuildCommandMap(); + public void OnPackageLoaded() => BuildCommandMapAsync(); public void OnPackageUnloaded() { } public JToken SerializeToJson() => null; public bool DeserializeFromJson(JToken token) => true; @@ -315,10 +317,13 @@ private void RecognitionLoop(CancellationToken token) private void AddCommand(string phrase, Action action) { phrase = NormalizePhrase(phrase.Trim().ToLowerInvariant()); - if (!_commandMap.ContainsKey(phrase)) + lock (_commandMap) { - _commandMap[phrase] = action; - _phraseList.Add(phrase); + if (!_commandMap.ContainsKey(phrase)) + { + _commandMap[phrase] = action; + _phraseList.Add(phrase); + } } } @@ -359,38 +364,101 @@ private static IEnumerable GetItemNameVariants(ITrackableItem item) private string BuildGrammarJson() { - var sb = new StringBuilder("["); - for (int i = 0; i < _phraseList.Count; i++) + lock (_commandMap) { - if (i > 0) sb.Append(','); - sb.Append('"').Append(_phraseList[i].Replace("\\", "\\\\").Replace("\"", "\\\"")).Append('"'); + var sb = new StringBuilder("["); + for (int i = 0; i < _phraseList.Count; i++) + { + if (i > 0) sb.Append(','); + sb.Append('"').Append(_phraseList[i].Replace("\\", "\\\\").Replace("\"", "\\\"")).Append('"'); + } + sb.Append(",\"[unk]\"]"); + return sb.ToString(); } - sb.Append(",\"[unk]\"]"); - return sb.ToString(); } - private void BuildCommandMap() + private void BuildCommandMapAsync() + { + // Cancel any in-flight background build + _buildCommandMapCts?.Cancel(); + var cts = new CancellationTokenSource(); + _buildCommandMapCts = cts; + + // Snapshot all data we need from the UI thread before going to background. + // Item/location databases are only mutated on the UI thread during pack load, + // and OnPackageLoaded fires after load completes, so this snapshot is safe. + var itemSnapshots = new List<(ITrackableItem item, string code, string[] names)>(); + foreach (var item in ItemDatabase.Instance.Items) + { + if (string.IsNullOrWhiteSpace(item.Name)) continue; + string code = ItemDatabase.Instance.GetPersistableItemReference(item); + string[] names = GetItemNameVariants(item).ToArray(); + itemSnapshots.Add((item, code, names)); + } + + var locationSnapshots = new List<(Location location, string locCode, string[] phrases)>(); + foreach (var location in LocationDatabase.Instance.VisibleLocations) + { + string locCode = LocationDatabase.Instance.GetPersistableLocationReference(location); + string[] phrases = GetLocationPhrases(location).ToArray(); + locationSnapshots.Add((location, locCode, phrases)); + } + + var capturableSnapshots = itemSnapshots + .Where(s => s.item.Capturable) + .ToList(); + + Task.Run(() => + { + try + { + BuildCommandMapCore(itemSnapshots, locationSnapshots, capturableSnapshots, cts.Token); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + Serilog.Log.Warning(ex, "[Voice] Failed to build command map"); + } + }, cts.Token); + } + + private void BuildCommandMapCore( + List<(ITrackableItem item, string code, string[] names)> itemSnapshots, + List<(Location location, string locCode, string[] phrases)> locationSnapshots, + List<(ITrackableItem item, string code, string[] names)> capturableSnapshots, + CancellationToken token) { - _commandMap.Clear(); - _phraseList.Clear(); + var commandMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + var phraseList = new List(); + + void addCommand(string phrase, Action action) + { + phrase = NormalizePhrase(phrase.Trim().ToLowerInvariant()); + if (!commandMap.ContainsKey(phrase)) + { + commandMap[phrase] = action; + phraseList.Add(phrase); + } + } string[] wakes = { "hey tracker", "hey babe" }; foreach (var wake in wakes) { - foreach (var item in ItemDatabase.Instance.Items) + token.ThrowIfCancellationRequested(); + + foreach (var (item, code, names) in itemSnapshots) { - if (string.IsNullOrWhiteSpace(item.Name)) continue; - string code = ItemDatabase.Instance.GetPersistableItemReference(item); + token.ThrowIfCancellationRequested(); - foreach (var itemName in GetItemNameVariants(item)) + foreach (var itemName in names) { if (item is ToggleItem || item is ProgressiveToggleItem) { foreach (var pfx in new[] { "track", "track a", "track the", "toggle", "toggle the" }) { - AddCommand($"{wake} {pfx} {itemName}", () => ExecuteToggle(code, true)); - AddCommand($"{wake} {pfx} {itemName} off", () => ExecuteToggle(code, false)); + addCommand($"{wake} {pfx} {itemName}", () => ExecuteToggle(code, true)); + addCommand($"{wake} {pfx} {itemName} off", () => ExecuteToggle(code, false)); } if (item is ProgressiveToggleItem progToggle) @@ -404,7 +472,7 @@ private void BuildCommandMap() if (string.IsNullOrWhiteSpace(privateCode)) continue; string pc = privateCode; foreach (var pfx in new[] { "track", "mark", "set" }) - AddCommand($"{wake} {pfx} {itemName} as {pc.ToLowerInvariant()}", () => ExecuteSetSecondaryCode(code, pc)); + addCommand($"{wake} {pfx} {itemName} as {pc.ToLowerInvariant()}", () => ExecuteSetSecondaryCode(code, pc)); } } } @@ -413,8 +481,8 @@ private void BuildCommandMap() { foreach (var pfx in new[] { "track", "track a", "track the" }) { - AddCommand($"{wake} {pfx} {itemName}", () => ExecuteAdvanceProgressive(code, null)); - AddCommand($"{wake} {pfx} {itemName} down", () => ExecuteAdvanceProgressive(code, "down")); + addCommand($"{wake} {pfx} {itemName}", () => ExecuteAdvanceProgressive(code, null)); + addCommand($"{wake} {pfx} {itemName} down", () => ExecuteAdvanceProgressive(code, "down")); } foreach (var stage in progressive.Stages) @@ -424,39 +492,46 @@ private void BuildCommandMap() if (string.IsNullOrWhiteSpace(stageCode)) continue; string sc = stageCode; foreach (var pfx in new[] { "track", "track a", "track the", "set", "set the" }) - AddCommand($"{wake} {pfx} {itemName} as {sc.ToLowerInvariant()}", () => ExecuteAdvanceProgressive(code, sc)); + addCommand($"{wake} {pfx} {itemName} as {sc.ToLowerInvariant()}", () => ExecuteAdvanceProgressive(code, sc)); } } } else if (item is ConsumableItem) { foreach (var pfx in new[] { "track", "track a", "track an", "add a", "add an" }) - AddCommand($"{wake} {pfx} {itemName}", () => ExecuteIncrementConsumable(code)); + addCommand($"{wake} {pfx} {itemName}", () => ExecuteIncrementConsumable(code)); foreach (var pfx in new[] { "remove", "remove a", "remove an" }) - AddCommand($"{wake} {pfx} {itemName}", () => ExecuteDecrementConsumable(code)); + addCommand($"{wake} {pfx} {itemName}", () => ExecuteDecrementConsumable(code)); } } } - foreach (var location in LocationDatabase.Instance.VisibleLocations) - RegisterLocationCommands(wake, location); + foreach (var (location, locCode, phrases) in locationSnapshots) + { + token.ThrowIfCancellationRequested(); + + foreach (var phrase in phrases) + { + addCommand($"{wake} clear {phrase}", () => ExecuteClearLocation(locCode)); + addCommand($"{wake} reset {phrase}", () => ExecuteResetLocation(locCode)); + addCommand($"{wake} pin {phrase}", () => ExecutePinLocation(locCode, true)); + addCommand($"{wake} remove pin for {phrase}", () => ExecutePinLocation(locCode, false)); + } + } // Capturable items × locations - var capturables = ItemDatabase.Instance.Items - .Where(i => i.Capturable && !string.IsNullOrWhiteSpace(i.Name)) - .ToList(); - foreach (var item in capturables) + foreach (var (item, itemCode, names) in capturableSnapshots) { - string itemCode = ItemDatabase.Instance.GetPersistableItemReference(item); - foreach (var itemName in GetItemNameVariants(item)) + token.ThrowIfCancellationRequested(); + + foreach (var itemName in names) { - foreach (var location in LocationDatabase.Instance.VisibleLocations) + foreach (var (location, locCode, locPhrases) in locationSnapshots) { - string locCode = LocationDatabase.Instance.GetPersistableLocationReference(location); - foreach (var locPhrase in GetLocationPhrases(location)) + foreach (var locPhrase in locPhrases) { foreach (var prep in new[] { "on", "at" }) - AddCommand($"{wake} mark {itemName} {prep} {locPhrase}", () => ExecuteCapture(itemCode, locCode)); + addCommand($"{wake} mark {itemName} {prep} {locPhrase}", () => ExecuteCapture(itemCode, locCode)); } } } @@ -467,32 +542,63 @@ private void BuildCommandMap() foreach (var feature in new[] { "show all locations", "chat hud" }) { string o = op, f = feature; - AddCommand($"{wake} {op} {feature}", () => ExecuteSetOption(o, f)); + addCommand($"{wake} {op} {feature}", () => ExecuteSetOption(o, f)); } // Control - AddCommand($"{wake} stop listening", () => { SpeakAsync("Okay, I'm no longer listening."); Active = false; }); - AddCommand($"{wake} undo that", () => + addCommand($"{wake} stop listening", () => { SpeakAsync("Okay, I'm no longer listening."); Active = false; }); + addCommand($"{wake} undo that", () => { SpeakAsync("Okay, I'll undo the last operation"); (TransactionProcessor.Current as IUndoableTransactionProcessor)?.Undo(); }); } + + token.ThrowIfCancellationRequested(); + + // Atomically swap in the new command map + lock (_commandMap) + { + _commandMap.Clear(); + foreach (var kvp in commandMap) + _commandMap[kvp.Key] = kvp.Value; + + _phraseList.Clear(); + _phraseList.AddRange(phraseList); + } } - private void RegisterLocationCommands(string wake, Location location) + /// + /// Synchronous BuildCommandMap used when starting recognition (needs grammar immediately). + /// + private void BuildCommandMap() { - if (location == null) return; - string locCode = LocationDatabase.Instance.GetPersistableLocationReference(location); - foreach (var phrase in GetLocationPhrases(location)) + var itemSnapshots = new List<(ITrackableItem item, string code, string[] names)>(); + foreach (var item in ItemDatabase.Instance.Items) + { + if (string.IsNullOrWhiteSpace(item.Name)) continue; + string code = ItemDatabase.Instance.GetPersistableItemReference(item); + string[] names = GetItemNameVariants(item).ToArray(); + itemSnapshots.Add((item, code, names)); + } + + var locationSnapshots = new List<(Location location, string locCode, string[] phrases)>(); + foreach (var location in LocationDatabase.Instance.VisibleLocations) { - AddCommand($"{wake} clear {phrase}", () => ExecuteClearLocation(locCode)); - AddCommand($"{wake} reset {phrase}", () => ExecuteResetLocation(locCode)); - AddCommand($"{wake} pin {phrase}", () => ExecutePinLocation(locCode, true)); - AddCommand($"{wake} remove pin for {phrase}", () => ExecutePinLocation(locCode, false)); + string locCode = LocationDatabase.Instance.GetPersistableLocationReference(location); + string[] phrases = GetLocationPhrases(location).ToArray(); + locationSnapshots.Add((location, locCode, phrases)); } + + var capturableSnapshots = itemSnapshots + .Where(s => s.item.Capturable) + .ToList(); + + BuildCommandMapCore(itemSnapshots, locationSnapshots, capturableSnapshots, CancellationToken.None); } + // RegisterLocationCommands has been inlined into BuildCommandMapCore + private static IEnumerable GetLocationPhrases(Location location) { if (!string.IsNullOrWhiteSpace(location.Name)) @@ -509,8 +615,12 @@ private void OnRecognized(string text) { Listening = false; - if (_commandMap.TryGetValue(text, out var action)) - action(); + Action action; + lock (_commandMap) + { + _commandMap.TryGetValue(text, out action); + } + action?.Invoke(); if (text.StartsWith("hey babe", StringComparison.OrdinalIgnoreCase)) SpeakAsync(GetBabeResponse()); From 6572270ed95d65bc331ac53c6151089d7769e521 Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Mon, 13 Apr 2026 00:43:14 -0700 Subject: [PATCH 2/2] Filter Voice, NDI, and MCP logs from developer console and harden NDI exception handling The developer console (ScriptManager) is intended for pack developers to debug their Lua scripts and pack definitions. Infrastructure subsystem messages from Voice Recognition, NDI, and MCP were leaking into it via the Serilog DeveloperConsoleSink, confusing users with errors unrelated to their packs (e.g. PortAudio enumeration failures, NDI init warnings). Changes: - DeveloperConsoleSink now filters out log messages prefixed with [Voice], [NDI], or [MCP]. These still go to the file log and stdout. - NdiSendContainer.OnAttachedToVisualTree wrapped in try-catch so NDI init/runtime errors are caught and logged rather than propagating. - NdiSendContainer.Dispose NDI cleanup wrapped in try-catch to prevent exceptions during teardown from surfacing. Closes #37 Co-Authored-By: Claude Opus 4.6 --- EmoTracker/Extensions/NDI/NdiSendContainer.cs | 102 ++++++++++-------- EmoTracker/Services/LogService.cs | 12 +++ 2 files changed, 70 insertions(+), 44 deletions(-) diff --git a/EmoTracker/Extensions/NDI/NdiSendContainer.cs b/EmoTracker/Extensions/NDI/NdiSendContainer.cs index d7b72f2..63f85f2 100644 --- a/EmoTracker/Extensions/NDI/NdiSendContainer.cs +++ b/EmoTracker/Extensions/NDI/NdiSendContainer.cs @@ -228,38 +228,45 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) return; } - NdiLibrary.EnsureRuntimeOnPath(); - - if (!NDIlib.initialize()) + try { - Log.Warning("[NDI] NDIlib.initialize() returned false. CPU unsupported or runtime not installed."); - return; - } - _ndiInitialized = true; + NdiLibrary.EnsureRuntimeOnPath(); - InitializeNdi(); - if (_sendInstancePtr == IntPtr.Zero) - { - Log.Warning("[NDI] NDIlib.send_create returned IntPtr.Zero for {Name}", NdiName); + if (!NDIlib.initialize()) + { + Log.Warning("[NDI] NDIlib.initialize() returned false. CPU unsupported or runtime not installed."); + return; + } + _ndiInitialized = true; + + InitializeNdi(); + if (_sendInstancePtr == IntPtr.Zero) + { + Log.Warning("[NDI] NDIlib.send_create returned IntPtr.Zero for {Name}", NdiName); + } + else + { + Log.Information("[NDI] Sender created for {Name} (ptr={Ptr:X})", NdiName, _sendInstancePtr.ToInt64()); + } + + _exitThread = false; + _sendThread = new Thread(SendThreadProc) { IsBackground = true, Name = "AvaloniaNdiSendThread" }; + _sendThread.Start(); + + // Always start a DispatcherTimer, but at different rates depending + // on whether an external driver is also feeding us captures: + // - Internal driver only: timer fires at the configured NDI frame + // rate (primary capture source). + // - External driver + slow poll: timer fires at a slow interval + // (~250ms) purely to poll receiver count and catch transitions + // when the external driver is idle (e.g. main window not + // rendering because user isn't interacting). + StartCaptureTimer(); } - else + catch (Exception ex) { - Log.Information("[NDI] Sender created for {Name} (ptr={Ptr:X})", NdiName, _sendInstancePtr.ToInt64()); + Log.Warning(ex, "[NDI] Failed to initialize NDI: {Msg}", ex.Message); } - - _exitThread = false; - _sendThread = new Thread(SendThreadProc) { IsBackground = true, Name = "AvaloniaNdiSendThread" }; - _sendThread.Start(); - - // Always start a DispatcherTimer, but at different rates depending - // on whether an external driver is also feeding us captures: - // - Internal driver only: timer fires at the configured NDI frame - // rate (primary capture source). - // - External driver + slow poll: timer fires at a slow interval - // (~250ms) purely to poll receiver count and catch transitions - // when the external driver is idle (e.g. main window not - // rendering because user isn't interacting). - StartCaptureTimer(); } protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) @@ -711,29 +718,36 @@ protected virtual void Dispose(bool disposing) _rtb = null; } - lock (_sendInstanceLock) + try { - if (_sendInstancePtr != IntPtr.Zero) + lock (_sendInstanceLock) { - NDIlib.send_destroy(_sendInstancePtr); - _sendInstancePtr = IntPtr.Zero; + if (_sendInstancePtr != IntPtr.Zero) + { + NDIlib.send_destroy(_sendInstancePtr); + _sendInstancePtr = IntPtr.Zero; + } } - } - // Only balance NDIlib.destroy() against our own successful initialize(). - // A dormant container (NdiEnabled=false) never called initialize, so it - // must not call destroy — otherwise it would tear down the shared NDI - // library state while another container (e.g. HiddenBroadcastWindow) is - // still actively using it. - if (_ndiInitialized) - { - NDIlib.destroy(); - _ndiInitialized = false; - Log.Debug("[NDI] NdiSendContainer disposed; NDIlib.destroy() called."); + // Only balance NDIlib.destroy() against our own successful initialize(). + // A dormant container (NdiEnabled=false) never called initialize, so it + // must not call destroy — otherwise it would tear down the shared NDI + // library state while another container (e.g. HiddenBroadcastWindow) is + // still actively using it. + if (_ndiInitialized) + { + NDIlib.destroy(); + _ndiInitialized = false; + Log.Debug("[NDI] NdiSendContainer disposed; NDIlib.destroy() called."); + } + else + { + Log.Debug("[NDI] NdiSendContainer disposed; skipping NDIlib.destroy() (was dormant)."); + } } - else + catch (Exception ex) { - Log.Debug("[NDI] NdiSendContainer disposed; skipping NDIlib.destroy() (was dormant)."); + Log.Warning(ex, "[NDI] Error during NDI cleanup: {Msg}", ex.Message); } } } diff --git a/EmoTracker/Services/LogService.cs b/EmoTracker/Services/LogService.cs index 06ae47b..f38a776 100644 --- a/EmoTracker/Services/LogService.cs +++ b/EmoTracker/Services/LogService.cs @@ -12,6 +12,10 @@ class DeveloperConsoleSink : ILogEventSink { private readonly IFormatProvider mFormatProvider; + // Log messages from these subsystems are internal infrastructure concerns + // and should not surface in the pack developer console. + private static readonly string[] sExcludedPrefixes = { "[Voice]", "[NDI]", "[MCP]" }; + public DeveloperConsoleSink(IFormatProvider formatProvider) { mFormatProvider = formatProvider; @@ -21,6 +25,14 @@ public void Emit(LogEvent logEvent) { var message = logEvent.RenderMessage(mFormatProvider); + // Filter out infrastructure subsystem messages that are not relevant + // to pack developers using the developer console. + foreach (var prefix in sExcludedPrefixes) + { + if (message.StartsWith(prefix, StringComparison.Ordinal)) + return; + } + switch (logEvent.Level) { case LogEventLevel.Information: