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/internal/audio/recorder_test.go                   |   46 ++++++
 privatevoice.src/internal/engine/engine_darwin.go                  |  113 ++++++++++++++-
 privatevoice.src/internal/engine/engine_darwin_test.go             |   21 +++
 privatevoice.src/app_live_caption_test.go                          |   20 ++
 CHANGELOG.md                                                       |   16 ++
 privatevoice.src/frontend/src/components/settings/AboutPage.svelte |    2 
 privatevoice.src/build/darwin/Info.plist                           |    4 
 privatevoice.src/build/windows/wails.exe.manifest                  |    2 
 privatevoice.src/frontend/package.json                             |    2 
 privatevoice.src/app.go                                            |  129 +++++++++++++++++-
 privatevoice.src/internal/engine/engine.go                         |   14 ++
 privatevoice.src/frontend/package-lock.json                        |    4 
 privatevoice.src/internal/audio/recorder.go                        |   45 +++++-
 13 files changed, 384 insertions(+), 34 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1a3f527..86c2b3e 100755
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,21 @@
 # Changelog
 
+## v2.1.30 (2026-06-07)
+
+### X-ASR 实验修复
+
+- **X-ASR 尾句保护**:最终识别前追加 1.5 秒静音 padding,并保留 decode 过程中的最后一个非空结果,减少松开热键时最后一句丢失的问题。
+- **录音中实时字幕**:X-ASR 录音时复用现有动态浮窗显示小号实时字幕;松开热键后浮窗切到“识别中/完成”,最终文本仍按原流程粘贴到目标 App。
+- **流式 session 接口**:新增 streaming engine/session 抽象,当前只由 X-ASR 使用;SenseVoice、Qwen3-ASR、Moonshine、Parakeet 行为保持不变。
+- **录音增量读取**:Recorder 支持安全读取新增 PCM 样本,用于低延迟字幕,不影响最终录音样本收集。
+
+### 构建
+
+- build: `20260607.1127`
+- 说明: 本版本为 X-ASR 实时字幕与尾句修复实验候选包,不是 App Store 正式上传包。
+
+---
+
 ## v2.1.29 (2026-06-07)
 
 ### 实验候选包
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
 }
 
diff --git a/privatevoice.src/app_live_caption_test.go b/privatevoice.src/app_live_caption_test.go
new file mode 100644
index 0000000..55b8316
--- /dev/null
+++ b/privatevoice.src/app_live_caption_test.go
@@ -0,0 +1,20 @@
+package main
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestLiveCaptionDisplayTextFitsOverlayBuffer(t *testing.T) {
+	input := strings.Repeat("这是一句实时字幕", 12)
+	got := liveCaptionDisplayText(input)
+	if got == "" {
+		t.Fatal("caption should not be empty")
+	}
+	if len([]byte(got)) > liveCaptionMaxBytes {
+		t.Fatalf("caption bytes = %d, want <= %d", len([]byte(got)), liveCaptionMaxBytes)
+	}
+	if !strings.HasPrefix(got, "...") {
+		t.Fatalf("caption = %q, want leading truncation marker", got)
+	}
+}
diff --git a/privatevoice.src/build/darwin/Info.plist b/privatevoice.src/build/darwin/Info.plist
index 4ea4a40..594d068 100755
--- a/privatevoice.src/build/darwin/Info.plist
+++ b/privatevoice.src/build/darwin/Info.plist
@@ -17,9 +17,9 @@
     <key>CFBundlePackageType</key>
     <string>APPL</string>
     <key>CFBundleShortVersionString</key>
-    <string>2.1.29</string>
+    <string>2.1.30</string>
     <key>CFBundleVersion</key>
-    <string>20260607.0147</string>
+    <string>20260607.1127</string>
     <key>LSApplicationCategoryType</key>
     <string>public.app-category.productivity</string>
     <key>ITSAppUsesNonExemptEncryption</key>
diff --git a/privatevoice.src/build/windows/wails.exe.manifest b/privatevoice.src/build/windows/wails.exe.manifest
index 0b4a3d3..bf9c271 100755
--- a/privatevoice.src/build/windows/wails.exe.manifest
+++ b/privatevoice.src/build/windows/wails.exe.manifest
@@ -3,7 +3,7 @@
   <assemblyIdentity
     type="win32"
     name="PrivateVoice.Dictation"
-    version="2.1.29.0"
+    version="2.1.30.0"
     processorArchitecture="*"/>
   <dependency>
     <dependentAssembly>
diff --git a/privatevoice.src/frontend/package-lock.json b/privatevoice.src/frontend/package-lock.json
index c531e46..198db7d 100755
--- a/privatevoice.src/frontend/package-lock.json
+++ b/privatevoice.src/frontend/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "privatevoice-dictation-frontend",
-  "version": "2.1.29",
+  "version": "2.1.30",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "privatevoice-dictation-frontend",
-      "version": "2.1.29",
+      "version": "2.1.30",
       "dependencies": {
         "@wailsio/runtime": "latest"
       },
diff --git a/privatevoice.src/frontend/package.json b/privatevoice.src/frontend/package.json
index d5a0f09..f19f9b4 100755
--- a/privatevoice.src/frontend/package.json
+++ b/privatevoice.src/frontend/package.json
@@ -1,7 +1,7 @@
 {
   "name": "privatevoice-dictation-frontend",
   "private": true,
-  "version": "2.1.29",
+  "version": "2.1.30",
   "type": "module",
   "scripts": {
     "dev": "vite dev",
diff --git a/privatevoice.src/frontend/src/components/settings/AboutPage.svelte b/privatevoice.src/frontend/src/components/settings/AboutPage.svelte
index f541db4..7d27687 100644
--- a/privatevoice.src/frontend/src/components/settings/AboutPage.svelte
+++ b/privatevoice.src/frontend/src/components/settings/AboutPage.svelte
@@ -3,7 +3,7 @@
   import { t } from '../../lib/i18n'
   import AppIcon from '../shared/AppIcon.svelte'
 
-  let version = $state('2.1.29 (build 20260607.0147)')
+  let version = $state('2.1.30 (build 20260607.1127)')
   const currentYear = new Date().getFullYear().toString()
   const aboutLinks = [
     { labelKey: 'about.sourceProjectLabel', urlKey: 'about.sourceUrl' },
diff --git a/privatevoice.src/internal/audio/recorder.go b/privatevoice.src/internal/audio/recorder.go
index bef4072..5842248 100755
--- a/privatevoice.src/internal/audio/recorder.go
+++ b/privatevoice.src/internal/audio/recorder.go
@@ -165,16 +165,30 @@
 		return nil
 	}
 
-	// Convert 16-bit PCM to float32
-	numSamples := len(pcm) / 2
-	samples := make([]float32, numSamples)
-	for i := 0; i < numSamples; i++ {
-		sample := int16(pcm[i*2]) | int16(pcm[i*2+1])<<8
-		samples[i] = float32(sample) / 32768.0
-	}
-
-	logger.Info("Recording stopped, %d samples captured", numSamples)
+	samples := pcm16LEToFloat32(pcm)
+	logger.Info("Recording stopped, %d samples captured", len(samples))
 	return samples
+}
+
+// ReadSamplesSince returns newly captured samples after sampleOffset and the
+// next offset to pass on the following call. It is safe to call while recording.
+func (r *Recorder) ReadSamplesSince(sampleOffset int) ([]float32, int) {
+	r.mu.Lock()
+	totalSamples := len(r.pcmBuf) / 2
+	if sampleOffset < 0 {
+		sampleOffset = 0
+	}
+	if sampleOffset > totalSamples {
+		sampleOffset = totalSamples
+	}
+	startByte := sampleOffset * 2
+	pcm := append([]byte(nil), r.pcmBuf[startByte:]...)
+	r.mu.Unlock()
+
+	if len(pcm) < 2 {
+		return nil, totalSamples
+	}
+	return pcm16LEToFloat32(pcm), totalSamples
 }
 
 // HasVoiceActivity returns true if the max volume exceeded the silence threshold.
@@ -260,6 +274,19 @@
 	logger.Info("Audio device uninitialized")
 }
 
+func pcm16LEToFloat32(pcm []byte) []float32 {
+	numSamples := len(pcm) / 2
+	if numSamples == 0 {
+		return nil
+	}
+	samples := make([]float32, numSamples)
+	for i := 0; i < numSamples; i++ {
+		sample := int16(pcm[i*2]) | int16(pcm[i*2+1])<<8
+		samples[i] = float32(sample) / 32768.0
+	}
+	return samples
+}
+
 func (r *Recorder) onData(input []byte) {
 	r.mu.Lock()
 
diff --git a/privatevoice.src/internal/audio/recorder_test.go b/privatevoice.src/internal/audio/recorder_test.go
new file mode 100644
index 0000000..79d10cc
--- /dev/null
+++ b/privatevoice.src/internal/audio/recorder_test.go
@@ -0,0 +1,46 @@
+package audio
+
+import "testing"
+
+func TestReadSamplesSinceReturnsOnlyNewSamples(t *testing.T) {
+	r := &Recorder{
+		pcmBuf: pcm16LE(0, 16384, -32768),
+	}
+
+	samples, nextOffset := r.ReadSamplesSince(1)
+	if nextOffset != 3 {
+		t.Fatalf("next offset = %d, want 3", nextOffset)
+	}
+	if len(samples) != 2 {
+		t.Fatalf("sample count = %d, want 2", len(samples))
+	}
+	if samples[0] != 0.5 {
+		t.Fatalf("samples[0] = %f, want 0.5", samples[0])
+	}
+	if samples[1] != -1 {
+		t.Fatalf("samples[1] = %f, want -1", samples[1])
+	}
+}
+
+func TestReadSamplesSinceClampsOffset(t *testing.T) {
+	r := &Recorder{
+		pcmBuf: pcm16LE(0, 16384),
+	}
+
+	samples, nextOffset := r.ReadSamplesSince(99)
+	if nextOffset != 2 {
+		t.Fatalf("next offset = %d, want 2", nextOffset)
+	}
+	if len(samples) != 0 {
+		t.Fatalf("sample count = %d, want 0", len(samples))
+	}
+}
+
+func pcm16LE(values ...int16) []byte {
+	buf := make([]byte, len(values)*2)
+	for i, value := range values {
+		buf[i*2] = byte(value)
+		buf[i*2+1] = byte(value >> 8)
+	}
+	return buf
+}
diff --git a/privatevoice.src/internal/engine/engine.go b/privatevoice.src/internal/engine/engine.go
index 6473cb6..3f939c1 100755
--- a/privatevoice.src/internal/engine/engine.go
+++ b/privatevoice.src/internal/engine/engine.go
@@ -20,6 +20,20 @@
 	Close()
 }
 
+// StreamingEngine is implemented by engines that can expose partial results
+// while audio is still being captured.
+type StreamingEngine interface {
+	NewStreamingSession() (StreamingSession, error)
+}
+
+// StreamingSession receives incremental 16kHz mono PCM and returns the current
+// best transcript for the active utterance.
+type StreamingSession interface {
+	Accept(samples []float32) (string, error)
+	Finish() (string, error)
+	Close()
+}
+
 // ModelDir returns the path to the sensevoice model directory.
 func ModelDir() string {
 	resolved, err := resolveCurrentModel()
diff --git a/privatevoice.src/internal/engine/engine_darwin.go b/privatevoice.src/internal/engine/engine_darwin.go
index d18dd48..3fc59e1 100755
--- a/privatevoice.src/internal/engine/engine_darwin.go
+++ b/privatevoice.src/internal/engine/engine_darwin.go
@@ -4,12 +4,21 @@
 
 import (
 	"fmt"
+	"strings"
+	"sync"
 	"voicesnap/internal/logger"
 	"voicesnap/internal/model"
 
 	_ "github.com/k2-fsa/sherpa-onnx-go-macos"
 	sherpa "github.com/k2-fsa/sherpa-onnx-go/sherpa_onnx"
 )
+
+const (
+	asrSampleRate          = 16000
+	xasrTailPaddingSamples = asrSampleRate + asrSampleRate/2
+)
+
+var xasrTailPadding = make([]float32, xasrTailPaddingSamples)
 
 type sherpaEngine struct {
 	recognizer *sherpa.OfflineRecognizer
@@ -19,6 +28,14 @@
 type xasrStreamingEngine struct {
 	recognizer *sherpa.OnlineRecognizer
 	hwInfo     string
+	mu         sync.Mutex
+}
+
+type xasrStreamingSession struct {
+	engine   *xasrStreamingEngine
+	stream   *sherpa.OnlineStream
+	lastText string
+	finished bool
 }
 
 func newPlatformEngine(resolved model.ResolvedModel) (Engine, error) {
@@ -174,7 +191,7 @@
 	stream := sherpa.NewOfflineStream(e.recognizer)
 	defer sherpa.DeleteOfflineStream(stream)
 
-	stream.AcceptWaveform(16000, samples)
+	stream.AcceptWaveform(asrSampleRate, samples)
 
 	e.recognizer.Decode(stream)
 	result := stream.GetResult()
@@ -183,24 +200,97 @@
 }
 
 func (e *xasrStreamingEngine) Recognize(samples []float32) (string, error) {
-	stream := sherpa.NewOnlineStream(e.recognizer)
-	defer sherpa.DeleteOnlineStream(stream)
-
-	if len(samples) > 0 {
-		stream.AcceptWaveform(16000, samples)
+	session, err := e.NewStreamingSession()
+	if err != nil {
+		return "", err
 	}
-	stream.InputFinished()
+	defer session.Close()
 
+	if _, err := session.Accept(samples); err != nil {
+		return "", err
+	}
+	return session.Finish()
+}
+
+func (e *xasrStreamingEngine) NewStreamingSession() (StreamingSession, error) {
+	if e.recognizer == nil {
+		return nil, fmt.Errorf("X-ASR recognizer is not initialized")
+	}
+	stream := sherpa.NewOnlineStream(e.recognizer)
+	if stream == nil {
+		return nil, fmt.Errorf("failed to create X-ASR streaming session")
+	}
+	return &xasrStreamingSession{
+		engine: e,
+		stream: stream,
+	}, nil
+}
+
+func (s *xasrStreamingSession) Accept(samples []float32) (string, error) {
+	if len(samples) == 0 {
+		return s.lastText, nil
+	}
+	if s.stream == nil || s.finished {
+		return s.lastText, nil
+	}
+
+	s.engine.mu.Lock()
+	defer s.engine.mu.Unlock()
+
+	s.stream.AcceptWaveform(asrSampleRate, samples)
+	s.lastText = s.engine.decodeReadyLocked(s.stream, s.lastText)
+	return s.lastText, nil
+}
+
+func (s *xasrStreamingSession) Finish() (string, error) {
+	if s.stream == nil || s.finished {
+		return s.lastText, nil
+	}
+
+	s.engine.mu.Lock()
+	defer s.engine.mu.Unlock()
+
+	s.stream.AcceptWaveform(asrSampleRate, xasrTailPadding)
+	s.lastText = s.engine.decodeReadyLocked(s.stream, s.lastText)
+	s.stream.InputFinished()
+	s.lastText = s.engine.decodeReadyLocked(s.stream, s.lastText)
+	s.finished = true
+	return s.lastText, nil
+}
+
+func (s *xasrStreamingSession) Close() {
+	if s.stream == nil {
+		return
+	}
+
+	s.engine.mu.Lock()
+	defer s.engine.mu.Unlock()
+
+	sherpa.DeleteOnlineStream(s.stream)
+	s.stream = nil
+}
+
+func (e *xasrStreamingEngine) decodeReadyLocked(stream *sherpa.OnlineStream, lastText string) string {
 	for e.recognizer.IsReady(stream) {
 		e.recognizer.Decode(stream)
+		lastText = rememberNonEmptyText(lastText, onlineResultText(e.recognizer, stream))
 	}
+	return rememberNonEmptyText(lastText, onlineResultText(e.recognizer, stream))
+}
 
-	result := e.recognizer.GetResult(stream)
+func onlineResultText(recognizer *sherpa.OnlineRecognizer, stream *sherpa.OnlineStream) string {
+	result := recognizer.GetResult(stream)
 	if result == nil {
-		return "", nil
+		return ""
 	}
+	return result.Text
+}
 
-	return result.Text, nil
+func rememberNonEmptyText(lastText, nextText string) string {
+	if strings.TrimSpace(nextText) == "" {
+		return lastText
+	}
+	return nextText
 }
 
 func (e *sherpaEngine) HardwareInfo() string {
@@ -219,6 +309,9 @@
 }
 
 func (e *xasrStreamingEngine) Close() {
+	e.mu.Lock()
+	defer e.mu.Unlock()
+
 	if e.recognizer != nil {
 		sherpa.DeleteOnlineRecognizer(e.recognizer)
 		e.recognizer = nil
diff --git a/privatevoice.src/internal/engine/engine_darwin_test.go b/privatevoice.src/internal/engine/engine_darwin_test.go
index 50e2a44..fdfbf8e 100644
--- a/privatevoice.src/internal/engine/engine_darwin_test.go
+++ b/privatevoice.src/internal/engine/engine_darwin_test.go
@@ -142,6 +142,27 @@
 	}
 }
 
+func TestRememberNonEmptyTextKeepsLastPartial(t *testing.T) {
+	got := rememberNonEmptyText("最后一句", "   ")
+	if got != "最后一句" {
+		t.Fatalf("rememberNonEmptyText() = %q, want last non-empty text", got)
+	}
+
+	got = rememberNonEmptyText("旧内容", "新内容")
+	if got != "新内容" {
+		t.Fatalf("rememberNonEmptyText() = %q, want new non-empty text", got)
+	}
+}
+
+func TestXASRTailPaddingMatchesFlushWindow(t *testing.T) {
+	if xasrTailPaddingSamples != 24000 {
+		t.Fatalf("tail padding samples = %d, want 24000", xasrTailPaddingSamples)
+	}
+	if len(xasrTailPadding) != xasrTailPaddingSamples {
+		t.Fatalf("tail padding len = %d, want %d", len(xasrTailPadding), xasrTailPaddingSamples)
+	}
+}
+
 func TestXASRRealModelSmoke(t *testing.T) {
 	if os.Getenv("PRIVATEVOICE_XASR_SMOKE") != "1" {
 		t.Skip("set PRIVATEVOICE_XASR_SMOKE=1 to run the real X-ASR model smoke test")

--
Gitblit v1.9.3