From 24f551a39eae849d891da26cc91f90d354e3a2db Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sun, 12 Jul 2026 02:44:05 +0800
Subject: [PATCH] chore(mas): refresh codebase memory after split
---
C1.source/privatevoice.src/app.go | 421 ++++++++++++++++++++++++++++++++++++++++++++++++---
1 files changed, 390 insertions(+), 31 deletions(-)
diff --git a/C1.source/privatevoice.src/app.go b/C1.source/privatevoice.src/app.go
index 908337b..7f22693 100755
--- a/C1.source/privatevoice.src/app.go
+++ b/C1.source/privatevoice.src/app.go
@@ -2,6 +2,7 @@
import (
"context"
+ "errors"
"fmt"
"runtime"
"strings"
@@ -22,6 +23,7 @@
"voicesnap/internal/modelselection"
"voicesnap/internal/overlay"
"voicesnap/internal/sound"
+ "voicesnap/internal/textoutput"
"voicesnap/internal/textproc"
"voicesnap/internal/userdict"
"voicesnap/services"
@@ -30,11 +32,14 @@
"github.com/wailsapp/wails/v3/pkg/events"
)
+var (
+ appVersion = "2.2.2"
+ appBuild = "20260703.0335"
+)
+
const (
- appVersion = "2.2.2"
- appBuild = "20260703.0335"
- appDisplayVersion = appVersion + " (build " + appBuild + ")"
appName = "PrivateVoice Dictation"
+ textOutputUpdated = "text-output:updated"
silenceThreshold = 0.05 // RMS below this = silence (matches HasVoiceActivity)
silenceTimeoutDuration = 3 * time.Second // auto-stop after this much silence in free-talk
@@ -47,6 +52,15 @@
liveCaptionMaxBytes = 108
)
+func appDisplayVersion() string {
+ return appVersion + " (build " + appBuild + ")"
+}
+
+var (
+ holdStartupSampleWaitTimeout = 2 * time.Second
+ holdStartupSamplePollInterval = 25 * time.Millisecond
+)
+
// App holds all application state and orchestration logic.
type App struct {
ctx context.Context
@@ -54,7 +68,7 @@
wg sync.WaitGroup
cfg *config.Config
- recorder *audio.Recorder
+ recorder appRecorder
eng engine.Engine
engineMu sync.Mutex
engineInitMu sync.Mutex
@@ -63,6 +77,7 @@
engineFactory engineFactoryFunc
hk hotkey.Listener
paster input.Paster
+ outputRouter *textoutput.Router
history *history.Store
userdict *userdict.Store
@@ -76,6 +91,8 @@
isRecording bool
isStoppingRecording bool
isHoldRecordingPending bool
+ isHoldRecorderStarting bool
+ isHoldStopPending bool
isFreetalking bool
hotkeyActive bool
hotkeyPressTime time.Time
@@ -93,9 +110,22 @@
liveCaptionSeq uint64
releaseTailCaptureNanos atomic.Int64
holdPreCaptureEnabled atomic.Bool
+ holdActivationTimer *time.Timer
+ holdActivationSeq uint64
}
type engineFactoryFunc func(initID uint64) (engine.Engine, engineMetadata, bool, error)
+
+type appRecorder interface {
+ OnVolume(func(float64))
+ OnDeviceChange(func(string))
+ Start() error
+ Stop()
+ StopAndGetSamples() []float32
+ HasVoiceActivity() bool
+ ReadSamplesSince(offset int) ([]float32, int)
+ Close()
+}
type engineMetadata struct {
InitID uint64
@@ -131,13 +161,15 @@
app.cfg = cfg
// Initialize audio recorder
- app.recorder = audio.NewRecorder()
+ recorder := audio.NewRecorder()
+ app.recorder = recorder
// Initialize platform-specific hotkey listener
app.hk = hotkey.New()
// Initialize platform-specific paster
app.paster = input.NewPaster()
+ app.outputRouter = textoutput.NewRouter(app.paster)
// Initialize history store
app.history = history.New()
@@ -146,18 +178,20 @@
app.userdict = userdict.New()
// Create services for Wails bindings
- appService := services.NewAppService(app.cfg, appDisplayVersion)
+ displayVersion := appDisplayVersion()
+ appService := services.NewAppService(app.cfg, displayVersion)
configService := services.NewConfigService(app.cfg)
engineService := services.NewEngineService(app.cfg)
app.engineService = engineService
hotkeyService := services.NewHotkeyService(app.cfg)
permissionService := services.NewPermissionService()
updaterService := services.NewUpdaterService(appVersion)
- audioService := services.NewAudioService(app.recorder, app.cfg)
+ audioService := services.NewAudioService(recorder, app.cfg)
historyService := services.NewHistoryService(app.history)
userDictService := services.NewUserDictService(app.userdict)
correctionCSVService := services.NewCorrectionCSVService(app.history, app.userdict)
- diagnosticsService := services.NewDiagnosticsService(app.cfg, appDisplayVersion)
+ diagnosticsService := services.NewDiagnosticsService(app.cfg, displayVersion)
+ textOutputService := services.NewTextOutputService(app.outputRouter)
// Create Wails application
wailsApp := application.New(application.Options{
@@ -175,6 +209,7 @@
application.NewService(userDictService),
application.NewService(correctionCSVService),
application.NewService(diagnosticsService),
+ application.NewService(textOutputService),
},
Assets: application.AssetOptions{
Handler: application.AssetFileServerFS(assets),
@@ -294,6 +329,23 @@
a.mu.Lock()
defer a.mu.Unlock()
+ if config.IsHotkeyUnset(a.cfg.HotkeyVK) {
+ if !a.hotkeyPollSeen || a.lastHotkeyDown {
+ a.hotkeyPollSeen = true
+ a.lastHotkeyDown = false
+ logger.Info(
+ "Hotkey poll state: key=unset vk=0x0 mode=%s down=false active=%t recording=%t freetalk=%t engineReady=%t recordingHotkey=%t",
+ config.NormalizeHotkeyMode(a.cfg.HotkeyMode),
+ a.hotkeyActive,
+ a.isRecording,
+ a.isFreetalking,
+ a.eng != nil,
+ a.isRecordingHotkey,
+ )
+ }
+ return
+ }
+
isDown := a.hk.IsKeyDown(a.cfg.HotkeyVK)
if !a.hotkeyPollSeen || a.lastHotkeyDown != isDown {
a.hotkeyPollSeen = true
@@ -323,10 +375,40 @@
// Escape cancels any active recording
if a.cfg.HotkeyVK != 0x1B && (a.isRecording || a.isFreetalking) && a.hk.IsKeyDown(0x1B) {
+ if a.isHoldRecordingPending {
+ a.cancelHoldPreCaptureLocked("Escape")
+ a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
+ if a.cfg.SoundFeedback {
+ sound.PlayCancel()
+ }
+ a.delayedHide(1000)
+ return
+ }
+ if a.isHoldRecorderStarting {
+ a.isCombination = true
+ a.isFreetalking = false
+ a.isRecording = false
+ a.isHoldRecordingPending = false
+ a.isHoldStopPending = true
+ a.cancelHoldActivationTimerLocked()
+ a.lastStopTime = time.Now()
+ a.stopLiveCaptionLocked()
+ logger.Info("Recording cancellation deferred until recorder start completes (Escape)")
+ a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
+ if a.cfg.SoundFeedback {
+ sound.PlayCancel()
+ }
+ a.delayedHide(1000)
+ a.recorder.Stop()
+ return
+ }
a.isCombination = true
a.isFreetalking = false
a.isRecording = false
a.isHoldRecordingPending = false
+ a.isHoldRecorderStarting = false
+ a.isHoldStopPending = false
+ a.cancelHoldActivationTimerLocked()
a.lastStopTime = time.Now()
a.stopLiveCaptionLocked()
a.recorder.Stop()
@@ -417,6 +499,7 @@
return a.holdPreCaptureEnabled.Load() &&
!a.isRecording &&
!a.isStoppingRecording &&
+ !a.isHoldRecorderStarting &&
!a.isFreetalking &&
time.Since(a.lastStopTime) > 500*time.Millisecond
}
@@ -425,21 +508,133 @@
if a.isRecording || a.isStoppingRecording || a.isFreetalking {
return
}
+ a.isRecording = true
+ a.isHoldRecordingPending = true
+ a.isHoldRecorderStarting = true
+ a.isHoldStopPending = false
+ a.scheduleHoldActivationTimerLocked()
+ logger.Info("Hold pre-capture start requested")
+ go a.startHoldPreCaptureRecorder()
+}
+
+func (a *App) startHoldPreCaptureRecorder() {
if err := a.recorder.Start(); err != nil {
+ a.mu.Lock()
+ releasePipelineActive := a.isHoldRecorderStarting && !a.isRecording && !a.isHoldRecordingPending && !a.isHoldStopPending
+ cancelPending := a.isHoldRecorderStarting && !a.isRecording && a.isHoldStopPending
+ wasRecording := a.isRecording || a.isHoldRecordingPending || a.isHoldRecorderStarting
+ a.isRecording = false
+ a.isHoldRecordingPending = false
+ a.isHoldRecorderStarting = false
+ a.isHoldStopPending = false
+ a.cancelHoldActivationTimerLocked()
+ if releasePipelineActive {
+ a.mu.Unlock()
+ logger.Error("Hold pre-capture recorder start failed after release pipeline started: %v", err)
+ return
+ }
+ if cancelPending {
+ a.mu.Unlock()
+ logger.Error("Hold pre-capture recorder start failed after cancellation: %v", err)
+ return
+ }
+ if wasRecording {
+ a.stopLiveCaptionLocked()
+ a.setRecordingStartErrorLocked(err)
+ a.delayedHide(2000)
+ }
+ a.mu.Unlock()
logger.Error("Failed to start hold pre-capture: %v", err)
return
}
- a.isRecording = true
- a.isHoldRecordingPending = true
+
+ a.mu.Lock()
+ a.isHoldRecorderStarting = false
+ if !a.isRecording && !a.isHoldStopPending {
+ a.mu.Unlock()
+ logger.Info("Hold pre-capture recorder start completed after release pipeline started")
+ return
+ }
+ shouldStop := !a.isRecording || a.isHoldStopPending
+ if shouldStop {
+ a.isRecording = false
+ a.isHoldRecordingPending = false
+ stopPending := a.isHoldStopPending
+ a.isHoldStopPending = false
+ a.lastStopTime = time.Now()
+ a.stopLiveCaptionLocked()
+ a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
+ a.delayedHide(1000)
+ a.mu.Unlock()
+ if !stopPending {
+ a.recorder.Stop()
+ }
+ return
+ }
+ a.mu.Unlock()
logger.Info("Hold pre-capture started")
+}
+
+func (a *App) scheduleHoldActivationTimerLocked() {
+ a.cancelHoldActivationTimerLocked()
+ a.holdActivationSeq++
+ seq := a.holdActivationSeq
+ a.holdActivationTimer = time.AfterFunc(holdActivationDelay, func() {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+
+ if a.holdActivationSeq != seq {
+ return
+ }
+ a.holdActivationTimer = nil
+ if a.ctx != nil {
+ select {
+ case <-a.ctx.Done():
+ return
+ default:
+ }
+ }
+ if !a.hotkeyActive || !a.isHoldRecordingPending || !a.isRecording {
+ return
+ }
+ if a.hk == nil || !a.hk.IsKeyDown(a.cfg.HotkeyVK) {
+ a.cancelHoldPreCaptureLocked("released before activation")
+ return
+ }
+ if a.eng == nil {
+ a.cancelHoldPreCaptureLocked("engine not ready")
+ return
+ }
+ if !a.isCombination && a.hk != nil && a.hk.IsAnyOtherKeyPressedSince(a.cfg.HotkeyVK, a.hotkeyPressTime) {
+ a.isCombination = true
+ logger.Info("Hotkey marked as combination: key=%s", hotkey.GetKeyName(a.cfg.HotkeyVK))
+ a.cancelHoldPreCaptureLocked("combination key")
+ return
+ }
+ if a.isCombination {
+ return
+ }
+ logger.Info("Hotkey action: confirm hold pre-capture after %dms", time.Since(a.hotkeyPressTime).Milliseconds())
+ a.confirmHoldPreCaptureLocked()
+ })
+}
+
+func (a *App) cancelHoldActivationTimerLocked() {
+ a.holdActivationSeq++
+ if a.holdActivationTimer != nil {
+ a.holdActivationTimer.Stop()
+ a.holdActivationTimer = nil
+ }
}
func (a *App) confirmHoldPreCaptureLocked() {
if !a.isHoldRecordingPending || !a.isRecording {
a.isHoldRecordingPending = false
+ a.cancelHoldActivationTimerLocked()
return
}
a.isHoldRecordingPending = false
+ a.cancelHoldActivationTimerLocked()
a.hideGen.Add(1)
logger.Info("Hold pre-capture confirmed")
@@ -458,8 +653,10 @@
return
}
a.isHoldRecordingPending = false
+ a.cancelHoldActivationTimerLocked()
if a.isRecording {
a.isRecording = false
+ a.isHoldStopPending = a.isHoldRecorderStarting
a.lastStopTime = time.Now()
a.recorder.Stop()
}
@@ -537,6 +734,8 @@
if err := a.recorder.Start(); err != nil {
logger.Error("Failed to start recording: %v", err)
a.isRecording = false
+ a.setRecordingStartErrorLocked(err)
+ a.delayedHide(2000)
return
}
a.startLiveCaptionLocked(overlay.StatusRecording)
@@ -550,6 +749,39 @@
a.isRecording = false
a.isHoldRecordingPending = false
a.lastStopTime = time.Now()
+
+ if a.isHoldRecorderStarting {
+ a.stopLiveCaptionLocked()
+ if cancel {
+ a.isHoldStopPending = true
+ logger.Info("Hold recording stop deferred until recorder start completes")
+ logger.Info("Recording cancelled (combination key)")
+ a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
+ if a.cfg.SoundFeedback {
+ sound.PlayCancel()
+ }
+ a.delayedHide(1000)
+ a.recorder.Stop()
+ return
+ }
+
+ logger.Info("Hold recording stop proceeds while recorder start is pending")
+ a.isStoppingRecording = true
+ a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
+ go func() {
+ perfID := newPerfTraceID()
+ pipelineStart := time.Now()
+ logger.Info("PERF pipeline_start id=%d mode=hold_starting_release", perfID)
+ samples, hasVoice, ok := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart, true)
+ if !ok {
+ a.indicator.SetStatus(overlay.StatusError, "录音未就绪")
+ a.delayedHide(2000)
+ return
+ }
+ a.recognizeAndPaste(hasVoice, samples, perfID, pipelineStart)
+ }()
+ return
+ }
if cancel {
logger.Info("Recording cancelled (combination key)")
@@ -570,7 +802,12 @@
perfID := newPerfTraceID()
pipelineStart := time.Now()
logger.Info("PERF pipeline_start id=%d mode=hold", perfID)
- samples, hasVoice := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart)
+ samples, hasVoice, ok := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart, false)
+ if !ok {
+ a.indicator.SetStatus(overlay.StatusError, "录音未就绪")
+ a.delayedHide(2000)
+ return
+ }
a.recognizeAndPaste(hasVoice, samples, perfID, pipelineStart)
}()
}
@@ -597,10 +834,20 @@
logger.Error("Failed to start tap recording: %v", err)
a.isFreetalking = false
a.isRecording = false
+ a.setRecordingStartErrorLocked(err)
+ a.delayedHide(2000)
return
}
a.startLiveCaptionLocked(overlay.StatusFreetalking)
a.startRecordingTimer(overlay.StatusFreetalking)
+}
+
+func (a *App) setRecordingStartErrorLocked(err error) {
+ if errors.Is(err, audio.ErrMicrophonePermission) {
+ a.indicator.SetStatus(overlay.StatusError, "麦克风权限")
+ return
+ }
+ a.indicator.SetStatus(overlay.StatusError, "录音启动失败")
}
// startRecordingTimer updates the indicator text with elapsed time every second.
@@ -759,7 +1006,12 @@
perfID := newPerfTraceID()
pipelineStart := time.Now()
logger.Info("PERF pipeline_start id=%d mode=tap", perfID)
- samples, hasVoice := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart)
+ samples, hasVoice, ok := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart, false)
+ if !ok {
+ a.indicator.SetStatus(overlay.StatusError, "录音未就绪")
+ a.delayedHide(2000)
+ return
+ }
a.recognizeAndPaste(hasVoice, samples, perfID, pipelineStart)
}()
}
@@ -859,8 +1111,61 @@
return time.Duration(nanos)
}
-func (a *App) stopRecorderAfterReleaseTailCapture(perfID int64, pipelineStart time.Time) ([]float32, bool) {
+func (a *App) waitForHoldStartupSamples(perfID int64, pipelineStart time.Time) bool {
+ deadline := time.NewTimer(holdStartupSampleWaitTimeout)
+ defer deadline.Stop()
+ ticker := time.NewTicker(holdStartupSamplePollInterval)
+ defer ticker.Stop()
+
+ for {
+ samples, _ := a.recorder.ReadSamplesSince(0)
+ hasVoice := a.recorder.HasVoiceActivity()
+
+ a.mu.Lock()
+ starting := a.isHoldRecorderStarting
+ a.mu.Unlock()
+
+ if len(samples) > 0 {
+ logger.Info(
+ "PERF hold_startup_sample_wait_done id=%d result=samples samples=%d has_voice=%t since_start_ms=%d",
+ perfID,
+ len(samples),
+ hasVoice,
+ perfSinceMS(pipelineStart),
+ )
+ return true
+ }
+ if !starting {
+ logger.Info("PERF hold_startup_sample_wait_done id=%d result=start_complete_without_samples since_start_ms=%d", perfID, perfSinceMS(pipelineStart))
+ return true
+ }
+
+ select {
+ case <-a.ctx.Done():
+ logger.Info("PERF hold_startup_sample_wait_done id=%d result=app_stopping since_start_ms=%d", perfID, perfSinceMS(pipelineStart))
+ return false
+ case <-deadline.C:
+ logger.Error("Hold release reached audio startup sample wait timeout without PCM")
+ logger.Info("PERF hold_startup_sample_wait_done id=%d result=timeout timeout_ms=%d since_start_ms=%d", perfID, holdStartupSampleWaitTimeout.Milliseconds(), perfSinceMS(pipelineStart))
+ return false
+ case <-ticker.C:
+ }
+ }
+}
+
+func (a *App) stopRecorderAfterReleaseTailCapture(perfID int64, pipelineStart time.Time, waitForStartupSamples bool) ([]float32, bool, bool) {
a.waitForReleaseTailCapture(perfID)
+
+ if waitForStartupSamples && !a.waitForHoldStartupSamples(perfID, pipelineStart) {
+ stopStart := time.Now()
+ a.recorder.Stop()
+ stopElapsed := time.Since(stopStart)
+ a.mu.Lock()
+ a.isStoppingRecording = false
+ a.mu.Unlock()
+ logger.Info("PERF audio_stop_done id=%d result=audio_not_ready stop_ms=%d samples=0 audio_ms=0 has_voice=false since_start_ms=%d", perfID, perfDurationMS(stopElapsed), perfSinceMS(pipelineStart))
+ return nil, false, false
+ }
stopStart := time.Now()
samples := a.recorder.StopAndGetSamples()
@@ -885,10 +1190,10 @@
perfSinceMS(pipelineStart),
)
- return samples, hasVoice
+ return samples, hasVoice, true
}
-// recognizeAndPaste runs ASR on the recorded samples and pastes the result.
+// recognizeAndPaste runs ASR on the recorded samples and routes the result to text output.
// Shared by both hold and tap modes.
func (a *App) recognizeAndPaste(hasVoice bool, samples []float32, perfID int64, pipelineStart time.Time) {
// Snapshot config values under lock to avoid data races
@@ -897,17 +1202,32 @@
hotkeyVK := a.cfg.HotkeyVK
autoHide := a.cfg.AutoHide
copyToClipboard := a.cfg.CopyToClipboard
+ autoPasteExperiment := a.cfg.AutoPasteExperiment
a.mu.Unlock()
logger.Info(
- "PERF recognize_pipeline_input id=%d has_voice=%t samples=%d audio_ms=%d copy_to_clipboard=%t since_start_ms=%d",
+ "PERF recognize_pipeline_input id=%d has_voice=%t samples=%d audio_ms=%d copy_to_clipboard=%t auto_paste_experiment=%t since_start_ms=%d",
perfID,
hasVoice,
len(samples),
perfAudioDurationMS(samples),
copyToClipboard,
+ autoPasteExperiment,
perfSinceMS(pipelineStart),
)
+
+ outputOptions := textoutput.OutputOptions{
+ KeepClipboard: copyToClipboard,
+ AutoPasteExperiment: autoPasteExperiment,
+ }
+ if autoPasteExperiment {
+ outputOptions.AutoPasteGuard = a.outputRouter.CaptureAutoPasteGuard()
+ logger.Info(
+ "Text output auto-paste experiment guard captured ok=%t identity_present=%t",
+ outputOptions.AutoPasteGuard.OK,
+ outputOptions.AutoPasteGuard.Identity != "",
+ )
+ }
if !hasVoice {
logger.Info("No voice activity detected")
@@ -978,7 +1298,8 @@
return
}
- logger.Info("Recognized: %s", text)
+ textRunes := len([]rune(text))
+ logger.Info("Recognized text produced: text_runes=%d", textRunes)
historyStart := time.Now()
a.history.Add(text)
logger.Info("PERF history_done id=%d history_ms=%d since_start_ms=%d", perfID, perfSinceMS(historyStart), perfSinceMS(pipelineStart))
@@ -994,23 +1315,41 @@
perfSinceMS(pipelineStart),
)
- pasteStart := time.Now()
- if err := a.paster.Paste(text, copyToClipboard); err != nil {
- pasteElapsed := time.Since(pasteStart)
- logger.Info("PERF paste_done id=%d result=error paste_ms=%d since_start_ms=%d", perfID, perfDurationMS(pasteElapsed), perfSinceMS(pipelineStart))
- logger.Error("Paste failed, trying fallback: %v", err)
- typeStart := time.Now()
- if err := a.paster.TypeText(text); err != nil {
- logger.Info("PERF fallback_type_done id=%d result=error type_ms=%d total_ms=%d", perfID, perfSinceMS(typeStart), perfSinceMS(pipelineStart))
- logger.Error("Fallback type also failed: %v", err)
+ outputStart := time.Now()
+ output := a.outputRouter.OutputWithOptions(text, outputOptions)
+ a.emitTextOutputUpdated(output)
+ if !output.OK {
+ logger.Info(
+ "PERF text_output_done id=%d result=error output_ms=%d text_runes=%d fallback_available=%t since_start_ms=%d",
+ perfID,
+ perfDurationMS(time.Since(outputStart)),
+ textRunes,
+ output.FallbackAvailable,
+ perfSinceMS(pipelineStart),
+ )
+ logger.Error(
+ "Text output failed: needs_permission=%t fallback_available=%t direct_inserted=%t text_runes=%d",
+ output.NeedsPermission,
+ output.FallbackAvailable,
+ output.DirectInserted,
+ textRunes,
+ )
+ if output.FallbackAvailable {
+ a.indicator.SetStatus(overlay.StatusError, "文本已保留")
+ } else {
a.indicator.SetStatus(overlay.StatusError, "需辅助权限")
- a.delayedHideIf(autoHide, 2500)
- return
}
- logger.Info("PERF fallback_type_done id=%d result=success type_ms=%d total_ms=%d", perfID, perfSinceMS(typeStart), perfSinceMS(pipelineStart))
- } else {
- logger.Info("PERF paste_done id=%d result=success paste_ms=%d since_start_ms=%d", perfID, perfSinceMS(pasteStart), perfSinceMS(pipelineStart))
+ a.delayedHideIf(autoHide, 2500)
+ return
}
+ logger.Info(
+ "PERF text_output_done id=%d result=success direct_inserted=%t output_ms=%d text_runes=%d since_start_ms=%d",
+ perfID,
+ output.DirectInserted,
+ perfDurationMS(time.Since(outputStart)),
+ textRunes,
+ perfSinceMS(pipelineStart),
+ )
a.indicator.SetStatus(overlay.StatusDone, "完成")
if soundFeedback {
@@ -1111,6 +1450,23 @@
return "total"
default:
return ""
+ }
+}
+
+func (a *App) emitTextOutputUpdated(result textoutput.Result) {
+ if a == nil || a.wailsApp == nil {
+ return
+ }
+ a.wailsApp.Event.Emit(textOutputUpdated, textOutputUpdatePayload(result))
+}
+
+func textOutputUpdatePayload(result textoutput.Result) map[string]interface{} {
+ return map[string]interface{}{
+ "ok": result.OK,
+ "needsPermission": result.NeedsPermission,
+ "directInserted": result.DirectInserted,
+ "fallbackAvailable": result.FallbackAvailable,
+ "hasText": result.Text != "",
}
}
@@ -1357,6 +1713,9 @@
}
func hotkeyReadyText(keyName, mode string) string {
+ if keyName == "" {
+ return "触发键未设置"
+ }
if mode == config.HotkeyModeTap {
return "点按" + keyName + "说话"
}
--
Gitblit v1.9.3