| | |
| | | import ( |
| | | "context" |
| | | "fmt" |
| | | "strings" |
| | | "sync" |
| | | "sync/atomic" |
| | | "time" |
| | |
| | | "voicesnap/internal/history" |
| | | "voicesnap/internal/hotkey" |
| | | "voicesnap/internal/input" |
| | | "voicesnap/internal/language" |
| | | "voicesnap/internal/logger" |
| | | "voicesnap/internal/overlay" |
| | | "voicesnap/internal/sound" |
| | |
| | | ) |
| | | |
| | | const ( |
| | | appVersion = "2.1.19" |
| | | appBuild = "20260602.2022" |
| | | appVersion = "2.1.30" |
| | | appBuild = "20260607.1127" |
| | | appDisplayVersion = appVersion + " (build " + appBuild + ")" |
| | | appName = "PrivateVoice Input" |
| | | appName = "PrivateVoice Dictation" |
| | | |
| | | silenceThreshold = 0.05 // RMS below this = silence (matches HasVoiceActivity) |
| | | silenceTimeoutDuration = 3 * time.Second // auto-stop after this much silence in free-talk |
| | | silenceGracePeriod = 2 * time.Second // don't auto-stop within first 2s of free-talk |
| | | holdActivationDelay = 180 * time.Millisecond |
| | | doneIndicatorHideDelayMs = 250 |
| | | liveCaptionInterval = 120 * time.Millisecond |
| | | liveCaptionMaxBytes = 108 |
| | | ) |
| | | |
| | | // App holds all application state and orchestration logic. |
| | |
| | | cfg *config.Config |
| | | recorder *audio.Recorder |
| | | eng engine.Engine |
| | | engineMu sync.Mutex |
| | | hk hotkey.Listener |
| | | paster input.Paster |
| | | history *history.Store |
| | |
| | | lastHotkeyBlocked time.Time |
| | | silenceSince time.Time // when continuous silence started (free-talk only) |
| | | freeTalkStart time.Time // when free-talk mode started |
| | | liveCaptionCancel context.CancelFunc |
| | | liveCaptionSeq uint64 |
| | | } |
| | | |
| | | func RunApp() error { |
| | |
| | | // Create services for Wails bindings |
| | | appService := services.NewAppService(app.cfg, appDisplayVersion) |
| | | configService := services.NewConfigService(app.cfg) |
| | | engineService := services.NewEngineService() |
| | | engineService := services.NewEngineService(app.cfg) |
| | | app.engineService = engineService |
| | | hotkeyService := services.NewHotkeyService(app.cfg) |
| | | permissionService := services.NewPermissionService() |
| | |
| | | tray.SetTemplateIcon(trayIcon) |
| | | |
| | | showLabel, exitLabel := "Show Settings", "Exit" |
| | | if isChinese() { |
| | | if language.Resolve(app.cfg.LanguageMode, app.cfg.LanguageID, language.NewSystemDetector()).UILocale == "zh" { |
| | | showLabel, exitLabel = "显示设置", "退出" |
| | | } |
| | | |
| | |
| | | a.isRecording = false |
| | | return |
| | | } |
| | | a.startLiveCaptionLocked(overlay.StatusRecording) |
| | | a.startRecordingTimer(overlay.StatusRecording) |
| | | } |
| | | |
| | |
| | | |
| | | if cancel { |
| | | logger.Info("Recording cancelled (combination key)") |
| | | a.stopLiveCaptionLocked() |
| | | a.indicator.SetStatus(overlay.StatusCancelled, "已取消") |
| | | a.recorder.Stop() |
| | | if a.cfg.SoundFeedback { |
| | |
| | | return |
| | | } |
| | | |
| | | a.stopLiveCaptionLocked() |
| | | hasVoice := a.recorder.HasVoiceActivity() |
| | | a.indicator.SetStatus(overlay.StatusProcessing, "识别中") |
| | | go func() { |
| | |
| | | a.isRecording = false |
| | | return |
| | | } |
| | | a.startLiveCaptionLocked(overlay.StatusFreetalking) |
| | | a.startRecordingTimer(overlay.StatusFreetalking) |
| | | } |
| | | |
| | |
| | | case <-ticker.C: |
| | | a.mu.Lock() |
| | | recording := a.isRecording |
| | | captionActive := a.liveCaptionCancel != nil |
| | | a.mu.Unlock() |
| | | if !recording { |
| | | return |
| | | } |
| | | if captionActive { |
| | | continue |
| | | } |
| | | d := time.Since(start) |
| | | a.indicator.SetStatus(status, fmt.Sprintf("%d:%02d", int(d.Minutes()), int(d.Seconds())%60)) |
| | | } |
| | | } |
| | | }() |
| | | } |
| | | |
| | | func (a *App) startLiveCaptionLocked(status overlay.Status) { |
| | | if a.liveCaptionCancel != nil { |
| | | return |
| | | } |
| | | |
| | | a.engineMu.Lock() |
| | | streamingEng, ok := a.eng.(engine.StreamingEngine) |
| | | if !ok { |
| | | a.engineMu.Unlock() |
| | | return |
| | | } |
| | | |
| | | ctx, cancel := context.WithCancel(a.ctx) |
| | | a.liveCaptionSeq++ |
| | | seq := a.liveCaptionSeq |
| | | a.liveCaptionCancel = cancel |
| | | go a.runLiveCaption(ctx, seq, status, streamingEng, a.engineMu.Unlock) |
| | | } |
| | | |
| | | func (a *App) stopLiveCaptionLocked() { |
| | | if a.liveCaptionCancel == nil { |
| | | return |
| | | } |
| | | a.liveCaptionCancel() |
| | | a.liveCaptionCancel = nil |
| | | a.liveCaptionSeq++ |
| | | } |
| | | |
| | | func (a *App) clearLiveCaption(seq uint64) { |
| | | a.mu.Lock() |
| | | defer a.mu.Unlock() |
| | | if a.liveCaptionSeq == seq { |
| | | a.liveCaptionCancel = nil |
| | | } |
| | | } |
| | | |
| | | func (a *App) runLiveCaption(ctx context.Context, seq uint64, status overlay.Status, streamingEng engine.StreamingEngine, releaseEngine func()) { |
| | | defer a.clearLiveCaption(seq) |
| | | defer releaseEngine() |
| | | |
| | | session, err := streamingEng.NewStreamingSession() |
| | | if err != nil { |
| | | logger.Error("Live caption disabled: %v", err) |
| | | return |
| | | } |
| | | defer session.Close() |
| | | |
| | | ticker := time.NewTicker(liveCaptionInterval) |
| | | defer ticker.Stop() |
| | | |
| | | offset := 0 |
| | | lastDisplay := "" |
| | | for { |
| | | select { |
| | | case <-ctx.Done(): |
| | | return |
| | | case <-ticker.C: |
| | | samples, nextOffset := a.recorder.ReadSamplesSince(offset) |
| | | offset = nextOffset |
| | | if len(samples) == 0 { |
| | | continue |
| | | } |
| | | partial, err := session.Accept(samples) |
| | | if err != nil { |
| | | logger.Error("Live caption failed: %v", err) |
| | | return |
| | | } |
| | | display := liveCaptionDisplayText(a.userdict.Apply(textproc.PostProcess(partial))) |
| | | if display == "" || display == lastDisplay { |
| | | continue |
| | | } |
| | | |
| | | a.mu.Lock() |
| | | active := a.isRecording && a.liveCaptionSeq == seq |
| | | a.mu.Unlock() |
| | | if !active { |
| | | return |
| | | } |
| | | |
| | | lastDisplay = display |
| | | a.indicator.SetStatus(status, display) |
| | | } |
| | | } |
| | | } |
| | | |
| | | func liveCaptionDisplayText(text string) string { |
| | | text = strings.TrimSpace(text) |
| | | if text == "" || len([]byte(text)) <= liveCaptionMaxBytes { |
| | | return text |
| | | } |
| | | |
| | | runes := []rune(text) |
| | | for len(runes) > 0 && len([]byte("..."+string(runes))) > liveCaptionMaxBytes { |
| | | runes = runes[1:] |
| | | } |
| | | if len(runes) == 0 { |
| | | return "" |
| | | } |
| | | return "..." + string(runes) |
| | | } |
| | | |
| | | func (a *App) stopTapRecordingLocked() { |
| | |
| | | a.isRecording = false |
| | | a.lastStopTime = time.Now() |
| | | |
| | | a.stopLiveCaptionLocked() |
| | | hasVoice := a.recorder.HasVoiceActivity() |
| | | logger.Info("Tap recording stopped") |
| | | a.indicator.SetStatus(overlay.StatusProcessing, "识别中") |
| | |
| | | a.isFreetalking = false |
| | | a.isRecording = false |
| | | a.lastStopTime = time.Now() |
| | | a.stopLiveCaptionLocked() |
| | | // MUST stop device in a separate goroutine: we are inside the audio |
| | | // data callback, and device.Stop() waits for in-flight callbacks to |
| | | // finish — calling it here would deadlock. |
| | |
| | | return |
| | | } |
| | | |
| | | text, err := a.eng.Recognize(samples) |
| | | a.engineMu.Lock() |
| | | eng := a.eng |
| | | if eng == nil { |
| | | a.engineMu.Unlock() |
| | | logger.Error("Recognition skipped: engine not ready") |
| | | a.indicator.SetStatus(overlay.StatusError, "引擎未就绪") |
| | | a.delayedHideIf(autoHide, 2000) |
| | | return |
| | | } |
| | | text, err := eng.Recognize(samples) |
| | | a.engineMu.Unlock() |
| | | if err != nil { |
| | | logger.Error("Recognition failed: %v", err) |
| | | a.indicator.SetStatus(overlay.StatusError, "错误") |
| | |
| | | eng, err := engine.New() |
| | | if err != nil { |
| | | logger.Error("Engine initialization failed: %v", err) |
| | | a.replaceEngine(nil) |
| | | status := "need_model" |
| | | if modelExists { |
| | | status = "error" |
| | |
| | | return |
| | | } |
| | | |
| | | a.mu.Lock() |
| | | a.eng = eng |
| | | a.mu.Unlock() |
| | | a.replaceEngine(eng) |
| | | |
| | | logger.Info("ASR engine ready: %s", eng.HardwareInfo()) |
| | | if a.engineService != nil { |
| | |
| | | a.indicator.SetStatus(overlay.StatusReady, hotkeyReadyText(keyName, hotkeyMode)) |
| | | a.indicator.Show() |
| | | a.delayedHideIf(autoHide, 2000) |
| | | } |
| | | |
| | | func (a *App) replaceEngine(eng engine.Engine) { |
| | | a.engineMu.Lock() |
| | | old := a.eng |
| | | a.eng = eng |
| | | a.engineMu.Unlock() |
| | | |
| | | if old != nil && old != eng { |
| | | old.Close() |
| | | } |
| | | } |
| | | |
| | | func hotkeyReadyText(keyName, mode string) string { |
| | |
| | | if a.recorder != nil { |
| | | a.recorder.Close() |
| | | } |
| | | if a.eng != nil { |
| | | a.eng.Close() |
| | | } |
| | | a.replaceEngine(nil) |
| | | a.wg.Wait() |
| | | logger.Info("Cleanup complete") |
| | | } |
| | |
| | | |
| | | // GetEngine returns the current engine (may be nil). |
| | | func (a *App) GetEngine() engine.Engine { |
| | | a.mu.Lock() |
| | | defer a.mu.Unlock() |
| | | a.engineMu.Lock() |
| | | defer a.engineMu.Unlock() |
| | | return a.eng |
| | | } |
| | | |
| | | // SetEngine sets the engine after model download. |
| | | func (a *App) SetEngine(eng engine.Engine) { |
| | | a.mu.Lock() |
| | | defer a.mu.Unlock() |
| | | a.eng = eng |
| | | a.replaceEngine(eng) |
| | | } |