11 files modified
2 files added
| | |
| | | # 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) |
| | | |
| | | ### 实验候选包 |
| | |
| | | import ( |
| | | "context" |
| | | "fmt" |
| | | "strings" |
| | | "sync" |
| | | "sync/atomic" |
| | | "time" |
| | |
| | | ) |
| | | |
| | | const ( |
| | | appVersion = "2.1.29" |
| | | appBuild = "20260607.0147" |
| | | appVersion = "2.1.30" |
| | | appBuild = "20260607.1127" |
| | | appDisplayVersion = appVersion + " (build " + appBuild + ")" |
| | | appName = "PrivateVoice Dictation" |
| | | |
| | |
| | | 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. |
| | |
| | | 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 { |
| | |
| | | a.isRecording = false |
| | | return |
| | | } |
| | | a.startLiveCaptionLocked(overlay.StatusRecording) |
| | | a.startRecordingTimer(overlay.StatusRecording) |
| | | } |
| | | |
| | |
| | | |
| | | if cancel { |
| | | logger.Info("Recording cancelled (combination key)") |
| | | a.stopLiveCaptionLocked() |
| | | a.indicator.SetStatus(overlay.StatusCancelled, "已取消") |
| | | a.recorder.Stop() |
| | | if a.cfg.SoundFeedback { |
| | |
| | | return |
| | | } |
| | | |
| | | a.stopLiveCaptionLocked() |
| | | hasVoice := a.recorder.HasVoiceActivity() |
| | | a.indicator.SetStatus(overlay.StatusProcessing, "识别中") |
| | | go func() { |
| | |
| | | a.isRecording = false |
| | | return |
| | | } |
| | | a.startLiveCaptionLocked(overlay.StatusFreetalking) |
| | | a.startRecordingTimer(overlay.StatusFreetalking) |
| | | } |
| | | |
| | |
| | | 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() { |
| | |
| | | a.isRecording = false |
| | | a.lastStopTime = time.Now() |
| | | |
| | | a.stopLiveCaptionLocked() |
| | | hasVoice := a.recorder.HasVoiceActivity() |
| | | logger.Info("Tap recording stopped") |
| | | a.indicator.SetStatus(overlay.StatusProcessing, "识别中") |
| | |
| | | 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. |
| | |
| | | |
| | | 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() |
| | |
| | | |
| | | // 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 |
| | | } |
| | | |
| New file |
| | |
| | | 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) |
| | | } |
| | | } |
| | |
| | | <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> |
| | |
| | | <assemblyIdentity |
| | | type="win32" |
| | | name="PrivateVoice.Dictation" |
| | | version="2.1.29.0" |
| | | version="2.1.30.0" |
| | | processorArchitecture="*"/> |
| | | <dependency> |
| | | <dependentAssembly> |
| | |
| | | { |
| | | "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" |
| | | }, |
| | |
| | | { |
| | | "name": "privatevoice-dictation-frontend", |
| | | "private": true, |
| | | "version": "2.1.29", |
| | | "version": "2.1.30", |
| | | "type": "module", |
| | | "scripts": { |
| | | "dev": "vite dev", |
| | |
| | | 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' }, |
| | |
| | | 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 |
| | | samples := pcm16LEToFloat32(pcm) |
| | | logger.Info("Recording stopped, %d samples captured", len(samples)) |
| | | return samples |
| | | } |
| | | |
| | | logger.Info("Recording stopped, %d samples captured", numSamples) |
| | | 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. |
| | |
| | | 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() |
| | | |
| New file |
| | |
| | | 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 |
| | | } |
| | |
| | | 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() |
| | |
| | | |
| | | 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 |
| | |
| | | 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) { |
| | |
| | | stream := sherpa.NewOfflineStream(e.recognizer) |
| | | defer sherpa.DeleteOfflineStream(stream) |
| | | |
| | | stream.AcceptWaveform(16000, samples) |
| | | stream.AcceptWaveform(asrSampleRate, samples) |
| | | |
| | | e.recognizer.Decode(stream) |
| | | result := stream.GetResult() |
| | |
| | | } |
| | | |
| | | 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 { |
| | |
| | | } |
| | | |
| | | func (e *xasrStreamingEngine) Close() { |
| | | e.mu.Lock() |
| | | defer e.mu.Unlock() |
| | | |
| | | if e.recognizer != nil { |
| | | sherpa.DeleteOnlineRecognizer(e.recognizer) |
| | | e.recognizer = nil |
| | |
| | | } |
| | | } |
| | | |
| | | 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") |