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/engine/engine_darwin.go |  113 +++++++++++++++++++++++++++++++++++++++++++++++++++-----
 1 files changed, 103 insertions(+), 10 deletions(-)

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

--
Gitblit v1.9.3