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