From 605a6b2b280c0b2d555f0e2fad56b289361ce29c Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sun, 07 Jun 2026 11:28:52 +0800
Subject: [PATCH] Add X-ASR live captions

---
 privatevoice.src/app.go |  129 ++++++++++++++++++++++++++++++++++++++++--
 1 files changed, 121 insertions(+), 8 deletions(-)

diff --git a/privatevoice.src/app.go b/privatevoice.src/app.go
index d4e1ccd..ad5b0e4 100755
--- a/privatevoice.src/app.go
+++ b/privatevoice.src/app.go
@@ -3,6 +3,7 @@
 import (
 	"context"
 	"fmt"
+	"strings"
 	"sync"
 	"sync/atomic"
 	"time"
@@ -27,8 +28,8 @@
 )
 
 const (
-	appVersion        = "2.1.29"
-	appBuild          = "20260607.0147"
+	appVersion        = "2.1.30"
+	appBuild          = "20260607.1127"
 	appDisplayVersion = appVersion + " (build " + appBuild + ")"
 	appName           = "PrivateVoice Dictation"
 
@@ -37,6 +38,8 @@
 	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.
@@ -75,6 +78,8 @@
 	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 {
@@ -421,6 +426,7 @@
 		a.isRecording = false
 		return
 	}
+	a.startLiveCaptionLocked(overlay.StatusRecording)
 	a.startRecordingTimer(overlay.StatusRecording)
 }
 
@@ -433,6 +439,7 @@
 
 	if cancel {
 		logger.Info("Recording cancelled (combination key)")
+		a.stopLiveCaptionLocked()
 		a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
 		a.recorder.Stop()
 		if a.cfg.SoundFeedback {
@@ -442,6 +449,7 @@
 		return
 	}
 
+	a.stopLiveCaptionLocked()
 	hasVoice := a.recorder.HasVoiceActivity()
 	a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
 	go func() {
@@ -474,6 +482,7 @@
 		a.isRecording = false
 		return
 	}
+	a.startLiveCaptionLocked(overlay.StatusFreetalking)
 	a.startRecordingTimer(overlay.StatusFreetalking)
 }
 
@@ -491,15 +500,120 @@
 			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() {
@@ -510,6 +624,7 @@
 	a.isRecording = false
 	a.lastStopTime = time.Now()
 
+	a.stopLiveCaptionLocked()
 	hasVoice := a.recorder.HasVoiceActivity()
 	logger.Info("Tap recording stopped")
 	a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
@@ -556,6 +671,7 @@
 		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.
@@ -742,12 +858,9 @@
 
 func (a *App) replaceEngine(eng engine.Engine) {
 	a.engineMu.Lock()
-	defer a.engineMu.Unlock()
-
-	a.mu.Lock()
 	old := a.eng
 	a.eng = eng
-	a.mu.Unlock()
+	a.engineMu.Unlock()
 
 	if old != nil && old != eng {
 		old.Close()
@@ -961,8 +1074,8 @@
 
 // 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
 }
 

--
Gitblit v1.9.3