Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
6c5b6fd
Add Whisper.net and NAudio dependencies for audio transcription
TheJoeFin Jul 10, 2026
f1d51ae
Add AudioTranscriptionModel setting
TheJoeFin Jul 10, 2026
220f72a
Add local Whisper audio transcription utilities
TheJoeFin Jul 10, 2026
f2e6105
Wire audio transcription into EditTextWindow
TheJoeFin Jul 10, 2026
8e25160
Add Whisper.net and NAudio dependencies for audio transcription
TheJoeFin Jul 10, 2026
56c89b5
Add AudioTranscriptionModel setting
TheJoeFin Jul 10, 2026
7832089
Add local Whisper audio transcription utilities
TheJoeFin Jul 10, 2026
d8001df
Wire audio transcription into EditTextWindow
TheJoeFin Jul 10, 2026
bc3cbe0
rebase onto main
TheJoeFin Aug 20, 2026
afa2d98
update styles and fix old dropdown
TheJoeFin Aug 20, 2026
7b8b408
update packages and fix API change
TheJoeFin Aug 20, 2026
b1f8772
Add user setting and UI for audio transcription toggle
TheJoeFin Aug 20, 2026
8d90ac9
Remove duplicate Windows App SDK package references
TheJoeFin Aug 20, 2026
95d3b77
Migrate test project to Microsoft.Testing.Platform
TheJoeFin Aug 20, 2026
4bfef08
Use dotnet test --project in CI workflows
TheJoeFin Aug 20, 2026
dfb5ed5
Add simultaneous mic+system audio capture for live transcription
TheJoeFin Aug 22, 2026
900720e
Refactor translation to use WinAiTranslator utility
TheJoeFin Aug 22, 2026
78961b5
Add secure LAF token injection for builds and docs
TheJoeFin Aug 22, 2026
1274b35
Refactor: centralize Windows AI model usage & errors
TheJoeFin Aug 22, 2026
d56bebe
Add AI-powered meeting notes summarization feature
TheJoeFin Aug 22, 2026
a834325
Add robust recovery for dropped Windows AI connections
TheJoeFin Aug 22, 2026
10dcb61
Add Ctrl+N hotkey to open selection in a new Edit Text window
TheJoeFin Aug 23, 2026
2754eca
Open Summarize and Meeting Notes results in a new window
TheJoeFin Aug 23, 2026
9a8e12d
Merge pull request #679 from TheJoeFin/LAF-Translate
TheJoeFin Aug 23, 2026
3fb3cdb
Add hot-words and file-info helpers to AudioTranscriptionUtilities
TheJoeFin Aug 23, 2026
058afa7
Add picker mode to QuickSimpleLookup
TheJoeFin Aug 23, 2026
b6f1c13
Add "Open Audio / Video..." window with hot words support
TheJoeFin Aug 23, 2026
141da31
Fix QSL CSV parsing dropping commas in long values
TheJoeFin Aug 23, 2026
120c6b5
Add toast notification when audio transcription completes
TheJoeFin Aug 23, 2026
f85954f
Add option to include timecodes in transcription output
TheJoeFin Aug 23, 2026
610c26d
Cache Grab Templates in memory to avoid disk I/O on every menu open
TheJoeFin Aug 23, 2026
bf89591
Load Grab Template menu items only when that submenu opens
TheJoeFin Aug 23, 2026
aa83e44
Reuse cached language list for capture menus instead of re-probing
TheJoeFin Aug 23, 2026
53c3595
Add Select-Black.ico and Select-White.ico to project
TheJoeFin Aug 23, 2026
94f1c52
Add user setting TrayIconStyle with default "Color"
TheJoeFin Aug 23, 2026
06ce22e
Support tray icon style refresh and new style enum
TheJoeFin Aug 23, 2026
8042e36
Add user setting for tray icon style (color/monochrome)
TheJoeFin Aug 23, 2026
135f770
Add "Just Icon" option to transcribe button context menu
TheJoeFin Aug 23, 2026
6b995a4
Add determinate progress bars for audio transcription
TheJoeFin Aug 24, 2026
ec5fb89
Update WindowsAppSDK and PdfPig NuGet packages
TheJoeFin Aug 24, 2026
05ddd50
Move the audio debug log to LocalAppData and cap its size
TheJoeFin Aug 24, 2026
2317a6a
Keep the Whisper factory alive while transcriptions still use it
TheJoeFin Aug 24, 2026
d8bd88a
Fix transcription editor sync, CTS leak, and late error dialog
TheJoeFin Aug 24, 2026
05e911b
Report summarize failures instead of opening them as a summary
TheJoeFin Aug 24, 2026
f435260
Release the shared language model under its lock
TheJoeFin Aug 24, 2026
a3bf1e6
Only show the post-grab translation dialog on real failures
TheJoeFin Aug 24, 2026
02ac34d
Stop GrabFrame translation from running re-entrantly
TheJoeFin Aug 24, 2026
1f3dd5c
Return copies from the grab template cache, and guard it
TheJoeFin Aug 24, 2026
58749ba
Show media durations longer than an hour correctly
TheJoeFin Aug 24, 2026
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
14 changes: 13 additions & 1 deletion .github/workflows/Release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ env:
ARTIFACT_SIGNING_ENDPOINT: 'https://eus.codesigning.azure.net/'
ARTIFACT_SIGNING_ACCOUNT_NAME: 'JoeFinAppsSigningCerts'
ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME: 'JoeFinApps'
# Unlock token for the Windows AI language model (a Limited Access Feature). Absent secrets
# leave the properties empty, which just builds without on-device text AI rather than failing.
LAF_TOKEN: ${{ secrets.LAF_TOKEN }}
LAF_PUBLISHER_ID: ${{ secrets.LAF_PUBLISHER_ID }}

jobs:
build:
Expand All @@ -47,7 +51,7 @@ jobs:
run: dotnet restore ${{ env.PROJECT_PATH }}

- name: Run tests
run: dotnet test ${{ env.TEST_PATH }} -r win-x64
run: dotnet test --project ${{ env.TEST_PATH }} -r win-x64

- name: Compute build version, archive paths, and release metadata
id: compute
Expand Down Expand Up @@ -89,6 +93,8 @@ jobs:
-p:PublishReadyToRun=false
-p:PublishSingleFile=true
-p:CopyOutputSymbolsToPublishDirectory=false
-p:LafToken=$env:LAF_TOKEN
-p:LafPublisherId=$env:LAF_PUBLISHER_ID
--nologo

- name: Build x64 self-contained
Expand All @@ -103,6 +109,8 @@ jobs:
-p:PublishReadyToRun=true
-p:PublishSingleFile=true
-p:CopyOutputSymbolsToPublishDirectory=false
-p:LafToken=$env:LAF_TOKEN
-p:LafPublisherId=$env:LAF_PUBLISHER_ID
--nologo

- name: Build ARM64 framework-dependent
Expand All @@ -116,6 +124,8 @@ jobs:
-p:PublishSingleFile=true
-p:EnableMsixTooling=true
-p:CopyOutputSymbolsToPublishDirectory=false
-p:LafToken=$env:LAF_TOKEN
-p:LafPublisherId=$env:LAF_PUBLISHER_ID
--nologo

- name: Build ARM64 self-contained
Expand All @@ -129,6 +139,8 @@ jobs:
-p:PublishSingleFile=true
-p:EnableMsixTooling=true
-p:CopyOutputSymbolsToPublishDirectory=false
-p:LafToken=$env:LAF_TOKEN
-p:LafPublisherId=$env:LAF_PUBLISHER_ID
--nologo

- name: Rename ARM64 executables
Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/buildDev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ concurrency:
env:
PROJECT_PATH: "Text-Grab/Text-Grab.csproj"
TEST_PATH: "Tests/Tests.csproj"
# Unlock token for the Windows AI language model (a Limited Access Feature). Absent secrets
# leave the properties empty, which just builds without on-device text AI rather than failing.
LAF_TOKEN: ${{ secrets.LAF_TOKEN }}
LAF_PUBLISHER_ID: ${{ secrets.LAF_PUBLISHER_ID }}

jobs:
build:
Expand All @@ -27,10 +31,10 @@ jobs:
- name: Build
run: dotnet build ${{ env.PROJECT_PATH }} -p:EnableMsixTooling=true
- name: Test
run: dotnet test ${{ env.TEST_PATH }} -r win-x64
run: dotnet test --project ${{ env.TEST_PATH }} -r win-x64

- name: Build for release and Publish
run: dotnet publish ${{ env.PROJECT_PATH }} -c Release --self-contained -r win-x64 -p:PublishSingleFile=true -p:EnableMsixTooling=true -o publish
run: dotnet publish ${{ env.PROJECT_PATH }} -c Release --self-contained -r win-x64 -p:PublishSingleFile=true -p:EnableMsixTooling=true -p:LafToken=$env:LAF_TOKEN -p:LafPublisherId=$env:LAF_PUBLISHER_ID -o publish

- name: Upload artifact
uses: actions/upload-artifact@v7
Expand Down
12 changes: 2 additions & 10 deletions Tests/Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

<PropertyGroup>
<TargetFramework>net10.0-windows10.0.22621</TargetFramework>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Platforms>x64;x86;ARM64</Platforms>
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
Expand All @@ -13,16 +14,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="NCalcAsync" Version="6.4.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="NCalcAsync" Version="7.1.0" />
<PackageReference Include="Xunit.StaFact" Version="3.0.13" />
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="BenchmarkDotNet" Version="0.15.8" />
Expand Down
95 changes: 95 additions & 0 deletions Tests/WinAiMeetingNotesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Text_Grab.Utilities;

namespace Tests;

/// <summary>
/// Tests for the text splitting behind "Summarize as Meeting Notes". The model calls themselves need
/// a Copilot+ device, but the chunking that decides what gets sent to the model does not.
/// </summary>
public class WinAiMeetingNotesTests
{
private const int Target = 100;

/// <summary>Text that already fits is sent to the model in one piece.</summary>
[Fact]
public void SplitIntoParts_ShortText_ReturnsSinglePart()
{
string input = "Standup notes: shipped the OCR fix, starting on the settings page next.";

List<string> parts = WinAiMeetingNotes.SplitIntoParts(input, Target);

Assert.Single(parts);
Assert.Equal(input, parts[0]);
}

[Fact]
public void SplitIntoParts_TextExactlyAtTarget_ReturnsSinglePart()
{
string input = new('a', Target);

List<string> parts = WinAiMeetingNotes.SplitIntoParts(input, Target);

Assert.Single(parts);
}

[Fact]
public void SplitIntoParts_LongText_EveryPartWithinTarget()
{
string input = string.Join(" ", Enumerable.Repeat("discussed the roadmap and agreed on dates", 40));

List<string> parts = WinAiMeetingNotes.SplitIntoParts(input, Target);

Assert.True(parts.Count > 1);
Assert.All(parts, part => Assert.True(part.Length <= Target, $"Part was {part.Length} characters."));
}

/// <summary>Splitting must not lose or reorder any of the meeting text.</summary>
[Fact]
public void SplitIntoParts_LongText_PreservesAllWords()
{
string input = string.Join("\n", Enumerable.Range(0, 60).Select(index => $"Speaker {index}: point number {index}"));

List<string> parts = WinAiMeetingNotes.SplitIntoParts(input, Target);

string[] originalWords = input.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
string[] splitWords = string.Join(" ", parts).Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);

Assert.Equal(originalWords, splitWords);
}

/// <summary>A blank line is the most natural place to break a transcript.</summary>
[Fact]
public void SplitIntoParts_ParagraphBreaks_PrefersBlankLines()
{
string paragraph = new('a', 60);
string input = string.Join("\n\n", paragraph, paragraph, paragraph);

List<string> parts = WinAiMeetingNotes.SplitIntoParts(input, Target);

Assert.All(parts, part => Assert.Equal(paragraph, part));
}

/// <summary>Text with nowhere good to break still terminates, splitting mid-word as a last resort.</summary>
[Fact]
public void SplitIntoParts_NoBreakCharacters_StillSplits()
{
string input = new('x', Target * 3);

List<string> parts = WinAiMeetingNotes.SplitIntoParts(input, Target);

Assert.Equal(3, parts.Count);
Assert.All(parts, part => Assert.Equal(Target, part.Length));
}

[Fact]
public void SplitIntoParts_EmptyText_ReturnsSinglePart()
{
List<string> parts = WinAiMeetingNotes.SplitIntoParts(string.Empty, Target);

Assert.Single(parts);
Assert.Equal(string.Empty, parts[0]);
}
}
2 changes: 2 additions & 0 deletions Text-Grab-Package/Package.appxmanifest
Original file line number Diff line number Diff line change
Expand Up @@ -157,5 +157,7 @@
<Capabilities>
<rescap:Capability Name="runFullTrust" />
<systemai:Capability Name="systemAIModels"/>
<!-- Required for live microphone transcription (NAudio capture + local Whisper). -->
<DeviceCapability Name="microphone" />
</Capabilities>
</Package>
18 changes: 18 additions & 0 deletions Text-Grab/App.config
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@
<setting name="AppTheme" serializeAs="String">
<value>System</value>
</setting>
<setting name="TrayIconStyle" serializeAs="String">
<value>Color</value>
</setting>
<setting name="FsgSendEtwToggle" serializeAs="String">
<value>False</value>
</setting>
Expand Down Expand Up @@ -193,9 +196,18 @@
<setting name="EtwShowSimilarMatches" serializeAs="String">
<value>False</value>
</setting>
<setting name="EtwShowTranscribe" serializeAs="String">
<value>True</value>
</setting>
<setting name="TranscribeButtonJustIcon" serializeAs="String">
<value>False</value>
</setting>
<setting name="EtwNormalizeLineEndingsOnPaste" serializeAs="String">
<value>True</value>
</setting>
<setting name="AudioTranscriptionModel" serializeAs="String">
<value>BaseMultilingual</value>
</setting>
<setting name="EtwSpellCheckMode" serializeAs="String">
<value>Auto</value>
</setting>
Expand Down Expand Up @@ -292,6 +304,12 @@
<setting name="HdrBorderlessGranted" serializeAs="String">
<value>False</value>
</setting>
<setting name="NotifyOnTranscriptionComplete" serializeAs="String">
<value>True</value>
</setting>
<setting name="IncludeTimecodesInTranscription" serializeAs="String">
<value>False</value>
</setting>
</Text_Grab.Properties.Settings>
</userSettings>
</configuration>
2 changes: 2 additions & 0 deletions Text-Grab/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ public static void SetTheme(object? sender = null, EventArgs? e = null)
// for now this is best but... not ideal
ApplicationAccentColorManager.ApplySystemAccent();

NotifyIconUtilities.RefreshTrayIconStyle();

// TODO: try to apply the teal color again, maybe something in WPFUI is broken
// Color teal = (Color)ColorConverter.ConvertFromString("#308E98");

Expand Down
8 changes: 8 additions & 0 deletions Text-Grab/Controls/BottomBarSettings.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,14 @@
Show Cursor/Selection Text
</TextBlock>
</ui:ToggleSwitch>
<ui:ToggleSwitch
x:Name="ShowTranscribeToggle"
Margin="0,12,0,0"
VerticalContentAlignment="Center">
<TextBlock VerticalAlignment="Center" Style="{StaticResource TextBodyNormal}">
Show Transcribe
</TextBlock>
</ui:ToggleSwitch>
<ui:ToggleSwitch
x:Name="ShowScrollbarCheckBox"
Margin="0,12,0,0"
Expand Down
2 changes: 2 additions & 0 deletions Text-Grab/Controls/BottomBarSettings.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public BottomBarSettings()
_leftListView = CollectionViewSource.GetDefaultView(ButtonsInLeftList);

ShowCursorTextCheckBox.IsChecked = DefaultSettings.ShowCursorText;
ShowTranscribeToggle.IsChecked = DefaultSettings.EtwShowTranscribe;
ShowScrollbarCheckBox.IsChecked = DefaultSettings.ScrollBottomBar;
ShowLanguagePickerToggle.IsChecked = DefaultSettings.EtwShowLangPicker;
ShowWordCountToggle.IsChecked = DefaultSettings.EtwShowWordCount;
Expand Down Expand Up @@ -141,6 +142,7 @@ private void MoveUpButton_Click(object sender, RoutedEventArgs e)
private void SaveBTN_Click(object sender, RoutedEventArgs e)
{
DefaultSettings.ShowCursorText = ShowCursorTextCheckBox.IsChecked ?? true;
DefaultSettings.EtwShowTranscribe = ShowTranscribeToggle.IsChecked ?? true;
DefaultSettings.ScrollBottomBar = ShowScrollbarCheckBox.IsChecked ?? true;
DefaultSettings.EtwShowLangPicker = ShowLanguagePickerToggle.IsChecked ?? true;
DefaultSettings.EtwShowWordCount = ShowWordCountToggle.IsChecked ?? true;
Expand Down
14 changes: 14 additions & 0 deletions Text-Grab/Controls/NotifyIconWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ private void Window_Loaded(object sender, RoutedEventArgs e)
HideFromAltTab();
NotifyIcon.Visibility = Visibility.Visible;

ApplyTrayIconStyle();

string toolTipText = "Text Grab";
TextGrabMode defaultLaunchSetting = Enum.Parse<TextGrabMode>(DefaultSettings.DefaultLaunch, true);

Expand All @@ -91,6 +93,18 @@ private void Window_Loaded(object sender, RoutedEventArgs e)
NotifyIcon.TooltipText = toolTipText;
}

public void ApplyTrayIconStyle()
{
bool isMonochrome = Enum.TryParse(DefaultSettings.TrayIconStyle, true, out TrayIconStyle style)
&& style == TrayIconStyle.Monochrome;

string iconPath = isMonochrome
? (SystemThemeUtility.IsLightTheme() ? "/Images/Select-Black.ico" : "/Images/Select-White.ico")
: "/Images/TealSelect40.png";

NotifyIcon.Icon = new BitmapImage(new Uri($"pack://application:,,,{iconPath}"));
}

private void EditWindowMenuItem_Click(object sender, RoutedEventArgs e)
{
EditTextWindow etw = new();
Expand Down
22 changes: 18 additions & 4 deletions Text-Grab/Controls/WordBorder.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,7 @@ private void EditWordTextBox_ContextMenuOpening(object sender, ContextMenuEventA
translateSeparator = separator;
}

if (WindowsAiUtilities.CanDeviceUseWinAI())
if (WinAiTranslator.IsAvailable())
{
if (translateMenuItem != null)
{
Expand Down Expand Up @@ -680,12 +680,13 @@ private async void TranslateWordMenuItem_Click(object sender, RoutedEventArgs e)
if (string.IsNullOrWhiteSpace(Word))
return;

if (!WindowsAiUtilities.CanDeviceUseWinAI())
(bool available, string? reason) = WinAiTranslator.CheckAvailability();
if (!available)
{
await new Wpf.Ui.Controls.MessageBox
{
Title = "Translation Not Available",
Content = "Windows AI is not available on this device.",
Content = reason ?? "Windows AI is not available on this device.",
CloseButtonText = "OK"
}.ShowDialogAsync();
return;
Expand All @@ -700,7 +701,20 @@ private async void TranslateWordMenuItem_Click(object sender, RoutedEventArgs e)
string targetLanguage = GetSystemLanguageName();

// Translate the word
string translatedText = await WindowsAiUtilities.TranslateText(originalWord, targetLanguage);
TranslationResult result = await WinAiTranslator.TranslateAsync(originalWord, targetLanguage);

if (!result.Succeeded)
{
await new Wpf.Ui.Controls.MessageBox
{
Title = result.Failure is TranslationFailure.NotNeeded ? "Nothing to Translate" : "Translation Failed",
Content = result.Message ?? "The word could not be translated.",
CloseButtonText = "OK"
}.ShowDialogAsync();
return;
}

string translatedText = result.Text;

// Update the word with translation
if (!string.IsNullOrWhiteSpace(translatedText) && translatedText != originalWord)
Expand Down
6 changes: 6 additions & 0 deletions Text-Grab/Enums.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ public enum AppTheme
Light = 2
}

public enum TrayIconStyle
{
Color = 0,
Monochrome = 1,
}

public enum CurrentCase
{
Lower = 0,
Expand Down
Binary file added Text-Grab/Images/Select-Black.ico
Binary file not shown.
Binary file added Text-Grab/Images/Select-White.ico
Binary file not shown.
8 changes: 8 additions & 0 deletions Text-Grab/Models/ButtonInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,14 @@ public static List<ButtonInfo> AllButtons
RequiresCopilotPlus = true
},
new()
{
OrderNumber = 8.15,
ButtonText = "Summarize as Meeting Notes",
ClickEvent = "MeetingNotesMenuItem_Click",
SymbolIcon = SymbolRegular.NotepadPerson20,
RequiresCopilotPlus = true
},
new()
{
OrderNumber = 8.2,
ButtonText = "Rewrite with Local AI",
Expand Down
Loading
Loading