-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathMainWindow.axaml.cs
More file actions
1279 lines (1167 loc) · 49.6 KB
/
Copy pathMainWindow.axaml.cs
File metadata and controls
1279 lines (1167 loc) · 49.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
using System.Diagnostics;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Threading;
using NAudio.CoreAudioApi;
using Babelive.Audio;
using Babelive.Translation;
using MsBox.Avalonia;
using MsBox.Avalonia.Enums;
using MsbIcon = MsBox.Avalonia.Enums.Icon;
namespace Babelive;
public partial class MainWindow : Window
{
private string _apiKey = string.Empty;
// Per-stream timestamp of the most recent transcript delta arrival.
// Used by AppendWithPauseBreak to inject a newline before a delta that
// arrives after a long silence — the API's realtime translator usually
// pauses 0.5-2 s between sentences but streams sub-sentence chunks
// 50-300 ms apart, so a gap > SentencePauseThresholdMs almost always
// means "new sentence" (more reliable than punctuation alone — the
// model often omits trailing 。 in Chinese / 、 chains in lists).
private DateTime _lastSourceDeltaUtc;
private DateTime _lastTranslationDeltaUtc;
private const int SentencePauseThresholdMs = 800;
private LoopbackCapture? _capture;
private AudioPlayer? _player;
// Source-monitor player: only created in CABLE/virtual-cable capture mode
// (when the user routed source audio into a virtual cable so we could
// bypass Teams/DRM loopback protection — see PREVENT_LOOPBACK_CAPTURE).
// The virtual cable has no physical speaker, so without this the user
// would hear ONLY the translation, never the original. Plays the same
// PCM stream the translator sees, on the Playback device.
private AudioPlayer? _monitorPlayer;
private RealtimeTranslatorClient? _client;
private DuckController? _ducker;
private EndpointMuter? _endpointMuter;
private DefaultDeviceSetter? _defaultDeviceSetter;
private AppAudioRouter? _appRouter;
private RecordingSession? _recording;
private bool _running;
// Public surface so the lyric overlay (LyricWindow) and tray icon can
// observe state and trigger actions without owning any of the audio
// plumbing themselves.
public bool IsRunning => _running;
public bool IsRecording => _recording != null;
public event Action<string>? OnTranslatedDelta;
public event Action<string>? OnSourceDelta;
public event Action? OnRunningChanged;
public event Action? OnRecordingChanged;
public event Action<float>? OnVolumeChanged;
public event Action? OnTranscriptCleared;
public Task ToggleRunningAsync() => _running ? StopAsync() : StartAsync();
/// <summary>
/// Start or stop recording on demand. Independent of the Start/Stop
/// translation cycle — recording can begin and end at any moment while
/// translation is running, or it can implicitly Start translation first
/// if the user hits record before pressing Start.
/// </summary>
public async Task ToggleRecordingAsync()
{
if (_recording != null) { StopRecording(); return; }
// Recording needs the live capture + client pipeline. If the user
// hasn't pressed Start yet, do it for them — the record button is
// their entry point.
if (!_running) await StartAsync();
if (!_running) return; // start failed (no API key, etc.)
StartRecording();
}
private void StartRecording()
{
if (_recording != null) return;
try
{
var basePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"Babelive", "Recordings");
// Source side stays unsuffixed (we don't know its language for
// sure); only the target side gets the user-selected code.
var langText = LangCombo.SelectedItem as string ?? "zh";
var targetLang = langText.Split(' ')[0];
_recording = new RecordingSession(basePath, targetLang);
StatusText.Text = $"recording → {_recording.OutputDir}";
}
catch (Exception ex)
{
Trace.WriteLine($"[Recording] init failed: {ex}");
StatusText.Text = $"recording failed: {ex.Message}";
return;
}
try { OnRecordingChanged?.Invoke(); } catch { /* ignore */ }
}
private void StopRecording()
{
if (_recording == null) return;
var dir = _recording.OutputDir;
try { _recording.Dispose(); } catch { /* ignore */ }
_recording = null;
StatusText.Text = $"recording saved → {dir}";
try { OnRecordingChanged?.Invoke(); } catch { /* ignore */ }
}
// Translation playback volume — applied as a PCM-level gain on every
// chunk pushed to the player (see _client.AudioOut wiring in StartAsync).
//
// Range 0..2.0:
// <1.0 → attenuation (each sample × gain, lossless)
// =1.0 → unity, pass-through (no scaling)
// >1.0 → amplification (each sample × gain, clipped to PCM16 ceiling).
// Needed because the realtime API's TTS is quieter than typical
// system audio (~-6 to -12 dBFS), so users often want >100% to
// match what they were hearing before they hit Start.
//
// CRITICAL: we do NOT route this through _player.Volume. NAudio's
// WasapiOut.Volume writes the session-level SimpleAudioVolume, which
// shows up as the Babelive slider in Windows' Volume Mixer and would
// affect every Babelive WasapiOut on that device. PCM-level scaling
// keeps the operation completely inside this process's translation
// stream — Windows mixer stays untouched.
private float _translationVolume = 1.0f;
public float TranslationVolume
{
get => _translationVolume;
set
{
_translationVolume = Math.Clamp(value, 0f, 2f);
try { OnVolumeChanged?.Invoke(_translationVolume); } catch { /* ignore */ }
}
}
private List<MMDevice> _captureDevices = new();
private List<MMDevice> _playDevices = new();
// Parallel to CaptureCombo.Items: each combo entry maps to exactly one
// CaptureSource. Index 0 is the "All system audio (no echo)" entry on
// Win10 20348+ machines; remaining entries are per-render-device legacy
// loopbacks.
private List<CaptureSource> _captureSources = new();
// User-managed API endpoint + key overrides (persisted to %APPDATA%).
private AppSettings _appSettings = new();
public MainWindow()
{
// Don't define InitializeComponent() ourselves — Avalonia's XamlIl
// compiler generates a partial that both loads the XAML AND assigns
// every x:Name to its strongly-typed field. Overriding it with a
// hand-rolled "just AvaloniaXamlLoader.Load(this)" leaves the
// x:Name fields null and crashes on the first access.
InitializeComponent();
// Window.Icon controls the taskbar / Alt-Tab / window-thumb icon.
// Avalonia doesn't auto-derive it from anything; we render the same
// amber-disc + 译 glyph the tray uses. Render the bitmap ONCE and
// reuse for both the taskbar icon (via WindowIcon stream) and the
// title-bar Image control (direct Bitmap).
var iconBmp = AppIcon.BuildBitmap();
TitleIcon.Source = iconBmp;
Icon = AppIcon.Build();
ApplyAppSettings();
PopulateLanguages();
PopulateDevices();
// Initialize the translation-volume slider and keep it in sync when
// anything else (lyric overlay 🔉/🔊, Start-time read of session
// volume) writes to TranslationVolume. The tolerance check inside
// OnVolumeChanged breaks the slider ↔ property loop.
TranslationVolumeSlider.Value = _translationVolume * 100.0;
OnVolumeChanged += vol =>
{
var target = vol * 100.0;
if (Math.Abs(TranslationVolumeSlider.Value - target) > 0.5)
TranslationVolumeSlider.Value = target;
};
// Recording: show the destination folder + keep the main-window's
// record button label in sync with the lyric overlay's red dot.
RecordingsFolderBox.Text = RecordingsBasePath;
OnRecordingChanged += UpdateRecordMainBtn;
UpdateRecordMainBtn();
}
private static string RecordingsBasePath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"Babelive", "Recordings");
private void UpdateRecordMainBtn()
{
Dispatcher.UIThread.Post(() =>
{
RecordMainBtn.Content = IsRecording ? "■ Stop" : "● Record";
ToolTip.SetTip(RecordMainBtn,
IsRecording
? "Stop recording (saves source.wav + translation.wav + SRTs)"
: "Record this segment (audio + transcripts) — same as the red dot on the lyric overlay.");
});
}
private async void RecordMainBtn_Click(object? sender, RoutedEventArgs e)
{
try { await ToggleRecordingAsync(); }
catch (Exception ex)
{
await MessageBoxManager.GetMessageBoxStandard(
"Recording failed", ex.Message, ButtonEnum.Ok, MsbIcon.Warning)
.ShowWindowDialogAsync(this);
}
}
private void RecordingsFolderBox_PointerPressed(object? sender, PointerPressedEventArgs e)
{
// Click the path → open it in Explorer. Create the folder lazily if
// it doesn't exist yet (first launch, before any recording).
try
{
Directory.CreateDirectory(RecordingsBasePath);
Process.Start(new ProcessStartInfo
{
FileName = RecordingsBasePath,
UseShellExecute = true,
});
e.Handled = true;
}
catch (Exception ex)
{
Trace.WriteLine($"[OpenRecordingsFolder] {ex}");
}
}
/// <summary>
/// Sole source of <see cref="_apiKey"/> — read from
/// <see cref="AppSettings"/> on disk. Called at startup and after the
/// API settings dialog saves.
/// </summary>
private void ApplyAppSettings()
{
_appSettings = AppSettings.Load();
_apiKey = _appSettings.ApiKey?.Trim() ?? string.Empty;
if (string.IsNullOrEmpty(_apiKey))
StatusText.Text = "no API key — click API… to set one";
}
private async void ApiBtn_Click(object? sender, RoutedEventArgs e)
{
if (await ShowApiSettingsDialog())
{
ApplyAppSettings();
if (!_running && !string.IsNullOrEmpty(_apiKey))
StatusText.Text = "idle";
}
}
private async void ProxyBtn_Click(object? sender, RoutedEventArgs e)
{
var dlg = new ProxySettingsWindow(_appSettings);
if (await dlg.ShowDialog<bool>(this))
{
// Re-load to pick up the new proxy fields the dialog just wrote.
ApplyAppSettings();
if (!_running)
{
StatusText.Text = _appSettings.ProxyEnabled
? $"proxy: {_appSettings.ProxyType} {_appSettings.ProxyHost}:{_appSettings.ProxyPort}"
: "proxy disabled";
}
}
}
/// <summary>
/// Open babelive.org in the user's default browser. ProcessStartInfo with
/// UseShellExecute=true is required for URL launches on .NET Core+ — the
/// default UseShellExecute=false only lets you run executables.
/// </summary>
private void AboutBtn_Click(object? sender, RoutedEventArgs e)
{
try
{
Process.Start(new ProcessStartInfo("https://babelive.org")
{
UseShellExecute = true
});
}
catch (Exception ex)
{
StatusText.Text = $"couldn't open browser: {ex.Message}";
}
}
/// <summary>
/// Open the API settings dialog modally. In Avalonia ShowDialog is async
/// and only valid against a realized owner window. We use `this` directly
/// since MainWindow is the orchestrator — for the "started from
/// LyricWindow → Start → no key" path the lyric overlay calls into us
/// after MainWindow exists.
/// </summary>
private async Task<bool> ShowApiSettingsDialog()
{
var dlg = new ApiSettingsWindow(_appSettings);
// ShowDialog<bool> awaits until the dialog closes and returns its
// Close(result) value. ApiSettingsWindow sets true on Save, false
// on Cancel.
return await dlg.ShowDialog<bool>(this);
}
// ---- setup -----------------------------------------------------------
private void PopulateLanguages()
{
foreach (var (code, name) in LanguageCodes.All)
LangCombo.Items.Add($"{code} — {name}");
LangCombo.SelectedIndex = 0; // zh
}
private void PopulateDevices()
{
try
{
using var enumerator = new MMDeviceEnumerator();
var defaultRender = enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
var renderDevices = enumerator.EnumerateAudioEndPoints(
DataFlow.Render, DeviceState.Active).ToList();
_captureDevices = renderDevices;
RebuildCaptureSourceList(preserveSelection: false);
RebuildPlaybackList(preserveSelection: false);
}
catch (Exception e)
{
StatusText.Text = $"device enumeration failed: {e.Message}";
}
}
/// <summary>
/// Single refresh button shared between the Capture and Playback rows.
/// Re-enumerates running processes / audio sessions AND render devices in
/// one shot. Preserves the previous selection on each list when possible.
/// </summary>
private void RefreshAllBtn_Click(object? sender, RoutedEventArgs e)
{
RebuildCaptureSourceList(preserveSelection: true);
RebuildPlaybackList(preserveSelection: true);
}
/// <summary>
/// Rebuild the playback dropdown by re-enumerating active render
/// endpoints. Preserves the previous selection by device ID when
/// possible; otherwise falls back to smart-default scoring.
/// </summary>
private void RebuildPlaybackList(bool preserveSelection)
{
string? prevId = null;
if (preserveSelection
&& PlayCombo.SelectedIndex > 0 // 0 = "(system default)"
&& PlayCombo.SelectedIndex - 1 < _playDevices.Count)
{
prevId = _playDevices[PlayCombo.SelectedIndex - 1].ID;
}
using (var enumerator = new MMDeviceEnumerator())
{
_playDevices = enumerator.EnumerateAudioEndPoints(
DataFlow.Render, DeviceState.Active).ToList();
}
PlayCombo.Items.Clear();
PlayCombo.Items.Add("(system default)");
foreach (var d in _playDevices)
PlayCombo.Items.Add(d.FriendlyName);
int restored = -1;
if (prevId != null)
{
for (int i = 0; i < _playDevices.Count; i++)
{
if (_playDevices[i].ID == prevId) { restored = i + 1; break; }
}
}
if (restored < 0)
{
int bestIdx = -1;
int bestScore = 0;
for (int i = 0; i < _playDevices.Count; i++)
{
int score = ScorePlaybackCandidate(_playDevices[i], captureId: null);
if (score > bestScore) { bestScore = score; bestIdx = i; }
}
restored = bestIdx >= 0 ? bestIdx + 1 : 0;
}
PlayCombo.SelectedIndex = restored;
}
// ---- buttons ---------------------------------------------------------
private async void StartBtn_Click(object? sender, RoutedEventArgs e)
{
if (_running) await StopAsync();
else await StartAsync();
}
private void ClearBtn_Click(object? sender, RoutedEventArgs e)
{
SourceText.Text = string.Empty;
TargetText.Text = string.Empty;
// Reset pause-break timing so the next delta after the clear doesn't
// start with an unwanted blank line (otherwise the long Clear-to-
// next-delta gap would trip the pause-break heuristic).
_lastSourceDeltaUtc = default;
_lastTranslationDeltaUtc = default;
// Tell the lyric overlay to revert to its default welcome state so
// the on-screen subtitles match the just-cleared transcript panes.
try { OnTranscriptCleared?.Invoke(); } catch { /* ignore */ }
}
private void DuckSlider_ValueChanged(object? sender,
RangeBaseValueChangedEventArgs e)
{
// Live-update if a session is currently active. The monitor reads
// _ducker.DuckRatio per PCM chunk, so its gain updates on the next
// ~10 ms tick — no extra call needed.
if (_ducker != null) _ducker.DuckRatio = (float)(e.NewValue / 100.0);
}
private void TranslationVolumeSlider_ValueChanged(object? sender,
RangeBaseValueChangedEventArgs e)
{
// Writes to TranslationVolume, which forwards to _player.Volume and
// raises OnVolumeChanged. The constructor's OnVolumeChanged handler
// guards against the resulting feedback loop via a 0.5% tolerance.
TranslationVolume = (float)(e.NewValue / 100.0);
}
/// <summary>
/// Shared handler for both volume sliders: a mouse-wheel notch over a
/// hovered slider nudges Value by <see cref="RangeBase.SmallChange"/>
/// (5% for both). Avalonia's Slider has no built-in wheel support, but
/// the gesture is standard everywhere else (browser audio sliders,
/// system volume mixer, …) so users will expect it. Marks the event
/// handled so it doesn't bubble up and scroll a parent ScrollViewer.
/// </summary>
private void Slider_PointerWheelChanged(object? sender, PointerWheelEventArgs e)
{
if (sender is not Slider s || !s.IsEnabled) return;
double step = s.SmallChange > 0 ? s.SmallChange : 1;
if (e.Delta.Y > 0)
s.Value = Math.Min(s.Maximum, s.Value + step);
else if (e.Delta.Y < 0)
s.Value = Math.Max(s.Minimum, s.Value - step);
e.Handled = true;
}
/// <summary>
/// Rebuild the capture-source dropdown contents (well-known apps,
/// audio-active processes, render-device loopbacks). When called from
/// the Refresh button, tries to keep the user's previous selection.
/// </summary>
private void RebuildCaptureSourceList(bool preserveSelection)
{
CaptureSource? prev = null;
if (preserveSelection
&& CaptureCombo.SelectedIndex >= 0
&& CaptureCombo.SelectedIndex < _captureSources.Count)
{
prev = _captureSources[CaptureCombo.SelectedIndex];
}
CaptureCombo.Items.Clear();
_captureSources = new List<CaptureSource>();
if (ProcessLoopbackCapture.IsSupported)
{
CaptureCombo.Items.Add("All system audio (no echo, recommended)");
_captureSources.Add(CaptureSource.SystemAudioExceptSelf);
}
if (ProcessLoopbackCapture.IsSupported)
{
var seen = new HashSet<uint>();
foreach (var (pid, display) in FindWellKnownAppProcesses())
{
CaptureCombo.Items.Add($"Only: {display} [PID {pid}]");
_captureSources.Add(new IncludeProcessSource(pid));
seen.Add(pid);
}
foreach (var (pid, display) in EnumerateOtherAudioSessionProcesses(seen))
{
CaptureCombo.Items.Add($"Only: {display} [PID {pid}]");
_captureSources.Add(new IncludeProcessSource(pid));
}
}
foreach (var d in _captureDevices)
{
CaptureCombo.Items.Add($"Loopback: {d.FriendlyName}");
_captureSources.Add(new DeviceCaptureSource(d));
}
int restored = -1;
if (prev != null)
{
for (int i = 0; i < _captureSources.Count; i++)
{
if (CaptureSourceMatches(prev, _captureSources[i])) { restored = i; break; }
}
}
CaptureCombo.SelectedIndex = restored >= 0 ? restored : 0;
}
private static bool CaptureSourceMatches(CaptureSource a, CaptureSource b) => (a, b) switch
{
(ExcludeProcessSource ax, ExcludeProcessSource bx) => ax.Pid == bx.Pid,
(IncludeProcessSource ax, IncludeProcessSource bx) => ax.Pid == bx.Pid,
(DeviceCaptureSource ax, DeviceCaptureSource bx) => ax.Device.ID == bx.Device.ID,
_ => false,
};
// ---- lifecycle -------------------------------------------------------
private async Task StartAsync()
{
// Reset pause-break timing: a previous Stop→Start cycle's last-delta
// timestamp would otherwise look like a multi-minute "pause" the
// moment the new session's first delta arrives, inserting a blank
// line at the top of the fresh transcript.
_lastSourceDeltaUtc = default;
_lastTranslationDeltaUtc = default;
if (string.IsNullOrEmpty(_apiKey))
{
var answer = await MessageBoxManager.GetMessageBoxStandard(
"Missing API key",
"No API key is configured.\n\nOpen API settings now?",
ButtonEnum.YesNo, MsbIcon.Warning).ShowWindowDialogAsync(this);
if (answer != ButtonResult.Yes) return;
if (!await ShowApiSettingsDialog()) return;
ApplyAppSettings();
if (string.IsNullOrEmpty(_apiKey)) return;
}
var langText = LangCombo.SelectedItem as string ?? "zh";
var targetLang = langText.Split(' ')[0];
if (CaptureCombo.SelectedIndex < 0 || _captureSources.Count == 0)
{
await MessageBoxManager.GetMessageBoxStandard(
"Setup", "No capture source selected.",
ButtonEnum.Ok, MsbIcon.Warning).ShowWindowDialogAsync(this);
return;
}
var captureSource = _captureSources[CaptureCombo.SelectedIndex];
// Teams/Skype redirect logic preserved from WPF version — unchanged.
string? captureRedirectNote = null;
List<uint>? protectedPidsToRoute = null;
MMDevice? protectedRoutingCable = null;
if (captureSource is IncludeProcessSource protectedCheck
&& IsLoopbackProtectedProcess(protectedCheck.Pid))
{
var cable = FindVirtualCableRender(_captureDevices);
if (cable != null)
{
captureSource = new DeviceCaptureSource(cable);
protectedRoutingCable = cable;
if (AppAudioRouter.IsSupported)
{
try
{
var tree = ProcessTree.EnumerateTree("ms-teams", "Teams", "Skype");
if (tree.Count > 0) protectedPidsToRoute = tree;
}
catch { /* P/Invoke fail — fall through; user can route manually */ }
}
var appLabel = LoopbackProtectedAppLabel(protectedCheck.Pid);
int n = protectedPidsToRoute?.Count ?? 0;
captureRedirectNote = n > 0
? $"{appLabel} blocks loopback — routed {n} process(es) to {cable.FriendlyName}"
: $"{appLabel} blocks loopback — using {cable.FriendlyName} " +
$"(set {appLabel}'s output device to it manually)";
}
}
MMDevice? playDevice = PlayCombo.SelectedIndex > 0
? _playDevices[PlayCombo.SelectedIndex - 1]
: null;
var url = AltUrlCheck.IsChecked == true
? RealtimeTranslatorClient.BuildAltUrl(_appSettings.BaseUrl)
: RealtimeTranslatorClient.BuildDefaultUrl(_appSettings.BaseUrl);
try
{
_capture = new LoopbackCapture(captureSource);
// Per-app routing for Teams/Skype — same as WPF version.
if (protectedPidsToRoute != null && protectedRoutingCable != null)
{
_appRouter ??= new AppAudioRouter();
foreach (var pid in protectedPidsToRoute)
{
try { _appRouter.Route(pid, protectedRoutingCable.ID); }
catch { /* per-PID failure */ }
}
}
// Always wire the recording-source-PCM tap. The handler is
// null-safe (`_recording?.`) so it's a no-op until the user
// hits the record button on the lyric overlay (or a later
// session is otherwise started via ToggleRecordingAsync).
_capture.Pcm24KHzAvailable += pcm => _recording?.WriteSourcePcm(pcm);
_capture.Start();
if (MuteCheck.IsChecked != true)
{
_player = new AudioPlayer(playDevice);
// _player.Volume is intentionally left at its session default.
// Translation volume is applied at PCM level in _client.AudioOut
// below — see the comment on TranslationVolume's setter.
_player.Start();
}
// CABLE-mode source monitor — unchanged.
if (MuteCheck.IsChecked != true
&& MuteOtherDevicesCheck.IsChecked != true
&& captureSource is DeviceCaptureSource cableSrc
&& IsVirtualCableDevice(cableSrc.Device))
{
_monitorPlayer = new AudioPlayer(playDevice);
_monitorPlayer.Start();
_capture.Pcm24KHzAvailable += pcm =>
{
var mp = _monitorPlayer;
if (mp == null) return;
var dc = _ducker;
float gain = (dc != null && dc.IsTranslationActive)
? dc.DuckRatio : 1f;
mp.Push(gain >= 0.999f ? pcm : ScalePcm16(pcm, gain));
};
}
// Source-volume ducking is implicit: slider at 100% means
// "don't touch other apps" → skip _ducker entirely. The CABLE-
// mode source monitor's PCM-level scaler then sees _ducker==null
// and uses gain=1.0, matching the no-duck intent.
var duckRatio = (float)(DuckSlider.Value / 100.0);
if (duckRatio < 0.99f)
{
MMDevice deviceToDuck = captureSource switch
{
DeviceCaptureSource dcs => dcs.Device,
_ => playDevice ?? LoopbackCapture.DefaultRenderDevice(),
};
_ducker = new DuckController(new AudioDucker(deviceToDuck, duckRatio));
}
if (MuteOtherDevicesCheck.IsChecked == true)
{
string listenId;
if (playDevice != null) listenId = playDevice.ID;
else
{
try { listenId = LoopbackCapture.DefaultRenderDevice().ID; }
catch { listenId = ""; }
}
MMDevice? fallback = null;
if (!string.IsNullOrEmpty(listenId))
{
fallback = _captureDevices
.Where(d => d.ID != listenId)
.OrderByDescending(ScoreFallbackDefaultDevice)
.FirstOrDefault(d => ScoreFallbackDefaultDevice(d) > 0);
}
if (fallback != null)
{
try
{
using var enumerator = new MMDeviceEnumerator();
var currentDefault = enumerator.GetDefaultAudioEndpoint(
DataFlow.Render, Role.Multimedia);
if (currentDefault.ID == listenId)
{
_defaultDeviceSetter = new DefaultDeviceSetter();
_defaultDeviceSetter.SetDefault(fallback.ID);
}
}
catch { /* best-effort */ }
}
if (fallback != null && AppAudioRouter.IsSupported)
{
_appRouter ??= new AppAudioRouter();
try
{
if (captureSource is IncludeProcessSource ips)
{
_appRouter.Route(ips.Pid, fallback.ID);
}
else
{
_appRouter.RouteAllSessionsOn(listenId, fallback.ID);
}
}
catch (Exception routingEx)
{
StatusText.Text = $"per-app routing failed: {routingEx.Message}";
Trace.WriteLine($"[AppAudioRouter] {routingEx}");
}
if (!string.IsNullOrEmpty(_appRouter.LastError))
{
Trace.WriteLine($"[AppAudioRouter partial] {_appRouter.LastError}");
}
}
_endpointMuter = new EndpointMuter();
if (!string.IsNullOrEmpty(listenId))
_endpointMuter.MuteAllExcept(new[] { listenId });
}
_client = new RealtimeTranslatorClient(
_apiKey, targetLang, _capture.Reader, url, _appSettings.BuildProxy())
{
EchoSuppressionEnabled = EchoSuppressCheck.IsChecked == true,
};
_client.SourceTranscript += s =>
{
OnUi(() => AppendWithPauseBreak(SourceText, s, ref _lastSourceDeltaUtc));
try { OnSourceDelta?.Invoke(s); } catch { /* ignore handler errors */ }
_recording?.AppendSourceDelta(s);
};
_client.TranslatedTranscript += s =>
{
OnUi(() => AppendWithPauseBreak(TargetText, s, ref _lastTranslationDeltaUtc));
try { OnTranslatedDelta?.Invoke(s); } catch { /* ignore handler errors */ }
_recording?.AppendTranslationDelta(s);
};
_client.AudioOut += b =>
{
// Apply PCM-level translation gain so the slider doesn't
// touch _player.Volume (= session SimpleAudioVolume, which
// would surface in Windows Volume Mixer). Skip the scaler
// only at near-unity gain (1% tolerance) — gains both above
// and below 1.0 need scaling.
var gain = _translationVolume;
_player?.Push(Math.Abs(gain - 1f) < 0.01f ? b : ScalePcm16(b, gain));
_ducker?.Notify(b.Length);
// Record the UNSCALED original so the .wav captures full
// dynamic range regardless of playback volume.
_recording?.WriteTranslationPcm(b);
};
_client.Status += s => OnUi(() => StatusText.Text = s);
_client.Error += s => OnUi(() =>
{
StatusText.Text = $"error: {Truncate(s, 120)}";
Append(TargetText, $"\n[error] {s}\n");
});
_client.Start();
_running = true;
StartBtn.Content = "Stop";
LangCombo.IsEnabled = CaptureCombo.IsEnabled = PlayCombo.IsEnabled = false;
MuteCheck.IsEnabled = AltUrlCheck.IsEnabled =
EchoSuppressCheck.IsEnabled = MuteOtherDevicesCheck.IsEnabled = false;
var statusSuffix = _client.LogPath != null ? $" log: {_client.LogPath}" : "";
StatusText.Text = captureRedirectNote != null
? $"running → {targetLang} ⚠ {captureRedirectNote}"
: $"running → {targetLang}{statusSuffix}";
try { OnRunningChanged?.Invoke(); } catch { /* ignore */ }
}
catch (Exception ex)
{
await MessageBoxManager.GetMessageBoxStandard(
"Start failed",
$"{ex.GetType().Name}: {ex.Message}\n\n{ex.StackTrace}",
ButtonEnum.Ok, MsbIcon.Error).ShowWindowDialogAsync(this);
await CleanupAsync();
}
}
private async Task StopAsync()
{
// Snapshot the recording path BEFORE CleanupAsync nulls _recording,
// so we can surface it in the status line.
var recordingDir = _recording?.OutputDir;
await CleanupAsync();
_running = false;
StartBtn.Content = "Start";
LangCombo.IsEnabled = CaptureCombo.IsEnabled = PlayCombo.IsEnabled = true;
MuteCheck.IsEnabled = AltUrlCheck.IsEnabled =
EchoSuppressCheck.IsEnabled = MuteOtherDevicesCheck.IsEnabled = true;
StatusText.Text = recordingDir != null
? $"stopped — recording saved to {recordingDir}"
: "stopped";
try { OnRunningChanged?.Invoke(); } catch { /* ignore */ }
}
private async Task CleanupAsync()
{
if (_client != null)
{
try { await _client.StopAsync(); } catch { /* ignore */ }
try { _client.Dispose(); } catch { /* ignore */ }
_client = null;
}
if (_capture != null)
{
try { _capture.Stop(); } catch { /* ignore */ }
try { _capture.Dispose(); } catch { /* ignore */ }
_capture = null;
}
if (_player != null)
{
try { _player.Dispose(); } catch { /* ignore */ }
_player = null;
}
if (_monitorPlayer != null)
{
try { _monitorPlayer.Dispose(); } catch { /* ignore */ }
_monitorPlayer = null;
}
// Recording: after client + capture are stopped, no more PCM /
// delta events will fire. Safe to flush and close the WAV/SRT writers.
if (_recording != null)
{
try { _recording.Dispose(); } catch { /* ignore */ }
_recording = null;
try { OnRecordingChanged?.Invoke(); } catch { /* ignore */ }
}
if (_ducker != null)
{
try { _ducker.Dispose(); } catch { /* ignore */ }
_ducker = null;
}
if (_appRouter != null)
{
try { _appRouter.Dispose(); } catch { /* ignore */ }
_appRouter = null;
}
if (_endpointMuter != null)
{
try { _endpointMuter.Dispose(); } catch { /* ignore */ }
_endpointMuter = null;
}
if (_defaultDeviceSetter != null)
{
try { _defaultDeviceSetter.Dispose(); } catch { /* ignore */ }
_defaultDeviceSetter = null;
}
}
protected override async void OnClosed(EventArgs e)
{
await CleanupAsync();
base.OnClosed(e);
}
// ---- ui helpers ------------------------------------------------------
private void OnUi(Action a)
{
if (Dispatcher.UIThread.CheckAccess()) a();
else Dispatcher.UIThread.Post(a);
}
private static void Append(TextBox tb, string text)
{
// Avalonia TextBox has no AppendText; concat + reset CaretIndex
// implicitly scrolls the caret into view. Sentence breaks are
// injected first so each "." / "?" / "!" / "。" / "?" / "!" lands
// on its own line (the realtime API streams sub-sentence chunks).
tb.Text = (tb.Text ?? string.Empty) + InsertSentenceBreaks(text);
tb.CaretIndex = tb.Text!.Length;
}
/// <summary>
/// Append a transcript delta to <paramref name="tb"/>, but first inject
/// a newline if the API has been silent for more than the pause
/// threshold — that's a strong signal of a new sentence even when the
/// model omits a trailing terminator (common in Chinese output where
/// the API streams clause-by-clause separated by 、,or , without 。).
///
/// State is per-stream via <paramref name="lastDeltaUtc"/>. Always
/// updated before delegating to <see cref="Append"/>, which still does
/// the punctuation-based splitting as before.
/// </summary>
private static void AppendWithPauseBreak(TextBox tb, string delta, ref DateTime lastDeltaUtc)
{
var now = DateTime.UtcNow;
bool injectBreak =
lastDeltaUtc != default
&& (now - lastDeltaUtc).TotalMilliseconds > SentencePauseThresholdMs;
lastDeltaUtc = now;
if (injectBreak)
{
// Avoid stacking a second newline if the previous chunk already
// ended one (e.g. via the punctuation splitter).
var current = tb.Text ?? string.Empty;
if (current.Length > 0 && !current.EndsWith('\n'))
delta = "\n" + delta;
}
Append(tb, delta);
}
/// <summary>
/// Walk <paramref name="text"/> and put a newline after every sentence
/// terminator that isn't part of an ellipsis ("..." stays on one line).
/// Skips a single trailing space so the next sentence doesn't start
/// with an awkward leading blank. Only used for the settings-window
/// transcript panes — the lyric overlay sees raw deltas.
/// </summary>
private static string InsertSentenceBreaks(string text)
{
if (string.IsNullOrEmpty(text)) return text;
var sb = new System.Text.StringBuilder(text.Length + 8);
for (int i = 0; i < text.Length; i++)
{
char c = text[i];
sb.Append(c);
if (!IsSentenceEnd(c)) continue;
char next = i + 1 < text.Length ? text[i + 1] : '\0';
// Ellipsis / "??" / "!!" runs: defer the newline until after the
// last terminator in the run.
if (IsSentenceEnd(next)) continue;
// Already a newline → don't double up.
if (next == '\n' || next == '\r') continue;
sb.Append('\n');
// Trim a single leading space on the next sentence (English).
if (next == ' ') i++;
}
return sb.ToString();
}
private static bool IsSentenceEnd(char c)
=> c == '.' || c == '?' || c == '!'
|| c == '。' || c == '?' || c == '!';
private static string Truncate(string s, int n) =>
s.Length <= n ? s : s[..n] + "…";
// ---- title bar drag --------------------------------------------------
// Avalonia's WindowChrome equivalent: ExtendClientArea hints on the
// Window. Drag-to-move is implemented by calling BeginMoveDrag on a
// PointerPressed in the title-bar grid. Caption buttons handle their
// own clicks (e.Handled=true via Click event semantics) so they don't
// start a drag.
private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
{
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return;
// Double-click toggles maximize, matching native Windows chrome.
if (e.ClickCount == 2)
{
ToggleMaximized();
e.Handled = true;
return;
}
BeginMoveDrag(e);
}
private void MinBtn_Click(object? sender, RoutedEventArgs e)
=> WindowState = WindowState.Minimized;
private void MaxBtn_Click(object? sender, RoutedEventArgs e)
=> ToggleMaximized();
private void CloseBtn_Click(object? sender, RoutedEventArgs e)
=> Close();
private void ToggleMaximized()
=> WindowState = WindowState == WindowState.Maximized
? WindowState.Normal
: WindowState.Maximized;
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property != WindowStateProperty) return;
bool max = WindowState == WindowState.Maximized;
// Glyph + tooltip flip so the button "shows what clicking it does":
// □ → currently Normal, click to Maximize
// ❐ → currently Maximized, click to Restore
if (MaxBtn != null)
{
MaxBtn.Content = max ? "❐" : "□";
ToolTip.SetTip(MaxBtn, max ? "Restore" : "Maximize");
}
// Hide the 12 px corner radius at the screen edges when maximized,
// otherwise the rounded clipping looks like a bug.