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/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/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()); 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: