Ariver
2026-06-07 605a6b2b280c0b2d555f0e2fad56b289361ce29c
Add X-ASR live captions
11 files modified
2 files added
418 ■■■■■ changed files
CHANGELOG.md 16 ●●●●● patch | view | raw | blame | history
privatevoice.src/app.go 129 ●●●●● patch | view | raw | blame | history
privatevoice.src/app_live_caption_test.go 20 ●●●●● patch | view | raw | blame | history
privatevoice.src/build/darwin/Info.plist 4 ●●●● patch | view | raw | blame | history
privatevoice.src/build/windows/wails.exe.manifest 2 ●●● patch | view | raw | blame | history
privatevoice.src/frontend/package-lock.json 4 ●●●● patch | view | raw | blame | history
privatevoice.src/frontend/package.json 2 ●●● patch | view | raw | blame | history
privatevoice.src/frontend/src/components/settings/AboutPage.svelte 2 ●●● patch | view | raw | blame | history
privatevoice.src/internal/audio/recorder.go 45 ●●●● patch | view | raw | blame | history
privatevoice.src/internal/audio/recorder_test.go 46 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/engine/engine.go 14 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/engine/engine_darwin.go 113 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/engine/engine_darwin_test.go 21 ●●●●● patch | view | raw | blame | history
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)
### 实验候选包
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
}
privatevoice.src/app_live_caption_test.go
New file
@@ -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)
    }
}
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>
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>
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"
      },
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",
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' },
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()
privatevoice.src/internal/audio/recorder_test.go
New file
@@ -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
}
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()
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
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")