Ariver
2026-07-11 015f1089ea875a153dd2a18ef42c25145d4868f5
C1.source/privatevoice.src/app.go
@@ -2,6 +2,7 @@
import (
   "context"
   "errors"
   "fmt"
   "runtime"
   "strings"
@@ -22,6 +23,7 @@
   "voicesnap/internal/modelselection"
   "voicesnap/internal/overlay"
   "voicesnap/internal/sound"
   "voicesnap/internal/textoutput"
   "voicesnap/internal/textproc"
   "voicesnap/internal/userdict"
   "voicesnap/services"
@@ -30,11 +32,14 @@
   "github.com/wailsapp/wails/v3/pkg/events"
)
var (
   appVersion = "2.2.2"
   appBuild   = "20260703.0335"
)
const (
   appVersion        = "2.2.2"
   appBuild          = "20260703.0335"
   appDisplayVersion = appVersion + " (build " + appBuild + ")"
   appName           = "PrivateVoice Dictation"
   textOutputUpdated = "text-output:updated"
   silenceThreshold         = 0.05            // RMS below this = silence (matches HasVoiceActivity)
   silenceTimeoutDuration   = 3 * time.Second // auto-stop after this much silence in free-talk
@@ -47,6 +52,15 @@
   liveCaptionMaxBytes      = 108
)
func appDisplayVersion() string {
   return appVersion + " (build " + appBuild + ")"
}
var (
   holdStartupSampleWaitTimeout  = 2 * time.Second
   holdStartupSamplePollInterval = 25 * time.Millisecond
)
// App holds all application state and orchestration logic.
type App struct {
   ctx    context.Context
@@ -54,7 +68,7 @@
   wg     sync.WaitGroup
   cfg           *config.Config
   recorder      *audio.Recorder
   recorder      appRecorder
   eng           engine.Engine
   engineMu      sync.Mutex
   engineInitMu  sync.Mutex
@@ -63,6 +77,7 @@
   engineFactory engineFactoryFunc
   hk            hotkey.Listener
   paster        input.Paster
   outputRouter  *textoutput.Router
   history       *history.Store
   userdict      *userdict.Store
@@ -76,6 +91,8 @@
   isRecording             bool
   isStoppingRecording     bool
   isHoldRecordingPending  bool
   isHoldRecorderStarting  bool
   isHoldStopPending       bool
   isFreetalking           bool
   hotkeyActive            bool
   hotkeyPressTime         time.Time
@@ -93,9 +110,22 @@
   liveCaptionSeq          uint64
   releaseTailCaptureNanos atomic.Int64
   holdPreCaptureEnabled   atomic.Bool
   holdActivationTimer     *time.Timer
   holdActivationSeq       uint64
}
type engineFactoryFunc func(initID uint64) (engine.Engine, engineMetadata, bool, error)
type appRecorder interface {
   OnVolume(func(float64))
   OnDeviceChange(func(string))
   Start() error
   Stop()
   StopAndGetSamples() []float32
   HasVoiceActivity() bool
   ReadSamplesSince(offset int) ([]float32, int)
   Close()
}
type engineMetadata struct {
   InitID                uint64
@@ -131,13 +161,15 @@
   app.cfg = cfg
   // Initialize audio recorder
   app.recorder = audio.NewRecorder()
   recorder := audio.NewRecorder()
   app.recorder = recorder
   // Initialize platform-specific hotkey listener
   app.hk = hotkey.New()
   // Initialize platform-specific paster
   app.paster = input.NewPaster()
   app.outputRouter = textoutput.NewRouter(app.paster)
   // Initialize history store
   app.history = history.New()
@@ -146,18 +178,20 @@
   app.userdict = userdict.New()
   // Create services for Wails bindings
   appService := services.NewAppService(app.cfg, appDisplayVersion)
   displayVersion := appDisplayVersion()
   appService := services.NewAppService(app.cfg, displayVersion)
   configService := services.NewConfigService(app.cfg)
   engineService := services.NewEngineService(app.cfg)
   app.engineService = engineService
   hotkeyService := services.NewHotkeyService(app.cfg)
   permissionService := services.NewPermissionService()
   updaterService := services.NewUpdaterService(appVersion)
   audioService := services.NewAudioService(app.recorder, app.cfg)
   audioService := services.NewAudioService(recorder, app.cfg)
   historyService := services.NewHistoryService(app.history)
   userDictService := services.NewUserDictService(app.userdict)
   correctionCSVService := services.NewCorrectionCSVService(app.history, app.userdict)
   diagnosticsService := services.NewDiagnosticsService(app.cfg, appDisplayVersion)
   diagnosticsService := services.NewDiagnosticsService(app.cfg, displayVersion)
   textOutputService := services.NewTextOutputService(app.outputRouter)
   // Create Wails application
   wailsApp := application.New(application.Options{
@@ -175,6 +209,7 @@
         application.NewService(userDictService),
         application.NewService(correctionCSVService),
         application.NewService(diagnosticsService),
         application.NewService(textOutputService),
      },
      Assets: application.AssetOptions{
         Handler: application.AssetFileServerFS(assets),
@@ -294,6 +329,23 @@
   a.mu.Lock()
   defer a.mu.Unlock()
   if config.IsHotkeyUnset(a.cfg.HotkeyVK) {
      if !a.hotkeyPollSeen || a.lastHotkeyDown {
         a.hotkeyPollSeen = true
         a.lastHotkeyDown = false
         logger.Info(
            "Hotkey poll state: key=unset vk=0x0 mode=%s down=false active=%t recording=%t freetalk=%t engineReady=%t recordingHotkey=%t",
            config.NormalizeHotkeyMode(a.cfg.HotkeyMode),
            a.hotkeyActive,
            a.isRecording,
            a.isFreetalking,
            a.eng != nil,
            a.isRecordingHotkey,
         )
      }
      return
   }
   isDown := a.hk.IsKeyDown(a.cfg.HotkeyVK)
   if !a.hotkeyPollSeen || a.lastHotkeyDown != isDown {
      a.hotkeyPollSeen = true
@@ -323,10 +375,40 @@
   // Escape cancels any active recording
   if a.cfg.HotkeyVK != 0x1B && (a.isRecording || a.isFreetalking) && a.hk.IsKeyDown(0x1B) {
      if a.isHoldRecordingPending {
         a.cancelHoldPreCaptureLocked("Escape")
         a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
         if a.cfg.SoundFeedback {
            sound.PlayCancel()
         }
         a.delayedHide(1000)
         return
      }
      if a.isHoldRecorderStarting {
         a.isCombination = true
         a.isFreetalking = false
         a.isRecording = false
         a.isHoldRecordingPending = false
         a.isHoldStopPending = true
         a.cancelHoldActivationTimerLocked()
         a.lastStopTime = time.Now()
         a.stopLiveCaptionLocked()
         logger.Info("Recording cancellation deferred until recorder start completes (Escape)")
         a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
         if a.cfg.SoundFeedback {
            sound.PlayCancel()
         }
         a.delayedHide(1000)
         a.recorder.Stop()
         return
      }
      a.isCombination = true
      a.isFreetalking = false
      a.isRecording = false
      a.isHoldRecordingPending = false
      a.isHoldRecorderStarting = false
      a.isHoldStopPending = false
      a.cancelHoldActivationTimerLocked()
      a.lastStopTime = time.Now()
      a.stopLiveCaptionLocked()
      a.recorder.Stop()
@@ -417,6 +499,7 @@
   return a.holdPreCaptureEnabled.Load() &&
      !a.isRecording &&
      !a.isStoppingRecording &&
      !a.isHoldRecorderStarting &&
      !a.isFreetalking &&
      time.Since(a.lastStopTime) > 500*time.Millisecond
}
@@ -425,21 +508,133 @@
   if a.isRecording || a.isStoppingRecording || a.isFreetalking {
      return
   }
   a.isRecording = true
   a.isHoldRecordingPending = true
   a.isHoldRecorderStarting = true
   a.isHoldStopPending = false
   a.scheduleHoldActivationTimerLocked()
   logger.Info("Hold pre-capture start requested")
   go a.startHoldPreCaptureRecorder()
}
func (a *App) startHoldPreCaptureRecorder() {
   if err := a.recorder.Start(); err != nil {
      a.mu.Lock()
      releasePipelineActive := a.isHoldRecorderStarting && !a.isRecording && !a.isHoldRecordingPending && !a.isHoldStopPending
      cancelPending := a.isHoldRecorderStarting && !a.isRecording && a.isHoldStopPending
      wasRecording := a.isRecording || a.isHoldRecordingPending || a.isHoldRecorderStarting
      a.isRecording = false
      a.isHoldRecordingPending = false
      a.isHoldRecorderStarting = false
      a.isHoldStopPending = false
      a.cancelHoldActivationTimerLocked()
      if releasePipelineActive {
         a.mu.Unlock()
         logger.Error("Hold pre-capture recorder start failed after release pipeline started: %v", err)
         return
      }
      if cancelPending {
         a.mu.Unlock()
         logger.Error("Hold pre-capture recorder start failed after cancellation: %v", err)
         return
      }
      if wasRecording {
         a.stopLiveCaptionLocked()
         a.setRecordingStartErrorLocked(err)
         a.delayedHide(2000)
      }
      a.mu.Unlock()
      logger.Error("Failed to start hold pre-capture: %v", err)
      return
   }
   a.isRecording = true
   a.isHoldRecordingPending = true
   a.mu.Lock()
   a.isHoldRecorderStarting = false
   if !a.isRecording && !a.isHoldStopPending {
      a.mu.Unlock()
      logger.Info("Hold pre-capture recorder start completed after release pipeline started")
      return
   }
   shouldStop := !a.isRecording || a.isHoldStopPending
   if shouldStop {
      a.isRecording = false
      a.isHoldRecordingPending = false
      stopPending := a.isHoldStopPending
      a.isHoldStopPending = false
      a.lastStopTime = time.Now()
      a.stopLiveCaptionLocked()
      a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
      a.delayedHide(1000)
      a.mu.Unlock()
      if !stopPending {
         a.recorder.Stop()
      }
      return
   }
   a.mu.Unlock()
   logger.Info("Hold pre-capture started")
}
func (a *App) scheduleHoldActivationTimerLocked() {
   a.cancelHoldActivationTimerLocked()
   a.holdActivationSeq++
   seq := a.holdActivationSeq
   a.holdActivationTimer = time.AfterFunc(holdActivationDelay, func() {
      a.mu.Lock()
      defer a.mu.Unlock()
      if a.holdActivationSeq != seq {
         return
      }
      a.holdActivationTimer = nil
      if a.ctx != nil {
         select {
         case <-a.ctx.Done():
            return
         default:
         }
      }
      if !a.hotkeyActive || !a.isHoldRecordingPending || !a.isRecording {
         return
      }
      if a.hk == nil || !a.hk.IsKeyDown(a.cfg.HotkeyVK) {
         a.cancelHoldPreCaptureLocked("released before activation")
         return
      }
      if a.eng == nil {
         a.cancelHoldPreCaptureLocked("engine not ready")
         return
      }
      if !a.isCombination && a.hk != nil && a.hk.IsAnyOtherKeyPressedSince(a.cfg.HotkeyVK, a.hotkeyPressTime) {
         a.isCombination = true
         logger.Info("Hotkey marked as combination: key=%s", hotkey.GetKeyName(a.cfg.HotkeyVK))
         a.cancelHoldPreCaptureLocked("combination key")
         return
      }
      if a.isCombination {
         return
      }
      logger.Info("Hotkey action: confirm hold pre-capture after %dms", time.Since(a.hotkeyPressTime).Milliseconds())
      a.confirmHoldPreCaptureLocked()
   })
}
func (a *App) cancelHoldActivationTimerLocked() {
   a.holdActivationSeq++
   if a.holdActivationTimer != nil {
      a.holdActivationTimer.Stop()
      a.holdActivationTimer = nil
   }
}
func (a *App) confirmHoldPreCaptureLocked() {
   if !a.isHoldRecordingPending || !a.isRecording {
      a.isHoldRecordingPending = false
      a.cancelHoldActivationTimerLocked()
      return
   }
   a.isHoldRecordingPending = false
   a.cancelHoldActivationTimerLocked()
   a.hideGen.Add(1)
   logger.Info("Hold pre-capture confirmed")
@@ -458,8 +653,10 @@
      return
   }
   a.isHoldRecordingPending = false
   a.cancelHoldActivationTimerLocked()
   if a.isRecording {
      a.isRecording = false
      a.isHoldStopPending = a.isHoldRecorderStarting
      a.lastStopTime = time.Now()
      a.recorder.Stop()
   }
@@ -537,6 +734,8 @@
   if err := a.recorder.Start(); err != nil {
      logger.Error("Failed to start recording: %v", err)
      a.isRecording = false
      a.setRecordingStartErrorLocked(err)
      a.delayedHide(2000)
      return
   }
   a.startLiveCaptionLocked(overlay.StatusRecording)
@@ -550,6 +749,39 @@
   a.isRecording = false
   a.isHoldRecordingPending = false
   a.lastStopTime = time.Now()
   if a.isHoldRecorderStarting {
      a.stopLiveCaptionLocked()
      if cancel {
         a.isHoldStopPending = true
         logger.Info("Hold recording stop deferred until recorder start completes")
         logger.Info("Recording cancelled (combination key)")
         a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
         if a.cfg.SoundFeedback {
            sound.PlayCancel()
         }
         a.delayedHide(1000)
         a.recorder.Stop()
         return
      }
      logger.Info("Hold recording stop proceeds while recorder start is pending")
      a.isStoppingRecording = true
      a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
      go func() {
         perfID := newPerfTraceID()
         pipelineStart := time.Now()
         logger.Info("PERF pipeline_start id=%d mode=hold_starting_release", perfID)
         samples, hasVoice, ok := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart, true)
         if !ok {
            a.indicator.SetStatus(overlay.StatusError, "录音未就绪")
            a.delayedHide(2000)
            return
         }
         a.recognizeAndPaste(hasVoice, samples, perfID, pipelineStart)
      }()
      return
   }
   if cancel {
      logger.Info("Recording cancelled (combination key)")
@@ -570,7 +802,12 @@
      perfID := newPerfTraceID()
      pipelineStart := time.Now()
      logger.Info("PERF pipeline_start id=%d mode=hold", perfID)
      samples, hasVoice := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart)
      samples, hasVoice, ok := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart, false)
      if !ok {
         a.indicator.SetStatus(overlay.StatusError, "录音未就绪")
         a.delayedHide(2000)
         return
      }
      a.recognizeAndPaste(hasVoice, samples, perfID, pipelineStart)
   }()
}
@@ -597,10 +834,20 @@
      logger.Error("Failed to start tap recording: %v", err)
      a.isFreetalking = false
      a.isRecording = false
      a.setRecordingStartErrorLocked(err)
      a.delayedHide(2000)
      return
   }
   a.startLiveCaptionLocked(overlay.StatusFreetalking)
   a.startRecordingTimer(overlay.StatusFreetalking)
}
func (a *App) setRecordingStartErrorLocked(err error) {
   if errors.Is(err, audio.ErrMicrophonePermission) {
      a.indicator.SetStatus(overlay.StatusError, "麦克风权限")
      return
   }
   a.indicator.SetStatus(overlay.StatusError, "录音启动失败")
}
// startRecordingTimer updates the indicator text with elapsed time every second.
@@ -759,7 +1006,12 @@
      perfID := newPerfTraceID()
      pipelineStart := time.Now()
      logger.Info("PERF pipeline_start id=%d mode=tap", perfID)
      samples, hasVoice := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart)
      samples, hasVoice, ok := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart, false)
      if !ok {
         a.indicator.SetStatus(overlay.StatusError, "录音未就绪")
         a.delayedHide(2000)
         return
      }
      a.recognizeAndPaste(hasVoice, samples, perfID, pipelineStart)
   }()
}
@@ -859,8 +1111,61 @@
   return time.Duration(nanos)
}
func (a *App) stopRecorderAfterReleaseTailCapture(perfID int64, pipelineStart time.Time) ([]float32, bool) {
func (a *App) waitForHoldStartupSamples(perfID int64, pipelineStart time.Time) bool {
   deadline := time.NewTimer(holdStartupSampleWaitTimeout)
   defer deadline.Stop()
   ticker := time.NewTicker(holdStartupSamplePollInterval)
   defer ticker.Stop()
   for {
      samples, _ := a.recorder.ReadSamplesSince(0)
      hasVoice := a.recorder.HasVoiceActivity()
      a.mu.Lock()
      starting := a.isHoldRecorderStarting
      a.mu.Unlock()
      if len(samples) > 0 {
         logger.Info(
            "PERF hold_startup_sample_wait_done id=%d result=samples samples=%d has_voice=%t since_start_ms=%d",
            perfID,
            len(samples),
            hasVoice,
            perfSinceMS(pipelineStart),
         )
         return true
      }
      if !starting {
         logger.Info("PERF hold_startup_sample_wait_done id=%d result=start_complete_without_samples since_start_ms=%d", perfID, perfSinceMS(pipelineStart))
         return true
      }
      select {
      case <-a.ctx.Done():
         logger.Info("PERF hold_startup_sample_wait_done id=%d result=app_stopping since_start_ms=%d", perfID, perfSinceMS(pipelineStart))
         return false
      case <-deadline.C:
         logger.Error("Hold release reached audio startup sample wait timeout without PCM")
         logger.Info("PERF hold_startup_sample_wait_done id=%d result=timeout timeout_ms=%d since_start_ms=%d", perfID, holdStartupSampleWaitTimeout.Milliseconds(), perfSinceMS(pipelineStart))
         return false
      case <-ticker.C:
      }
   }
}
func (a *App) stopRecorderAfterReleaseTailCapture(perfID int64, pipelineStart time.Time, waitForStartupSamples bool) ([]float32, bool, bool) {
   a.waitForReleaseTailCapture(perfID)
   if waitForStartupSamples && !a.waitForHoldStartupSamples(perfID, pipelineStart) {
      stopStart := time.Now()
      a.recorder.Stop()
      stopElapsed := time.Since(stopStart)
      a.mu.Lock()
      a.isStoppingRecording = false
      a.mu.Unlock()
      logger.Info("PERF audio_stop_done id=%d result=audio_not_ready stop_ms=%d samples=0 audio_ms=0 has_voice=false since_start_ms=%d", perfID, perfDurationMS(stopElapsed), perfSinceMS(pipelineStart))
      return nil, false, false
   }
   stopStart := time.Now()
   samples := a.recorder.StopAndGetSamples()
@@ -885,10 +1190,10 @@
      perfSinceMS(pipelineStart),
   )
   return samples, hasVoice
   return samples, hasVoice, true
}
// recognizeAndPaste runs ASR on the recorded samples and pastes the result.
// recognizeAndPaste runs ASR on the recorded samples and routes the result to text output.
// Shared by both hold and tap modes.
func (a *App) recognizeAndPaste(hasVoice bool, samples []float32, perfID int64, pipelineStart time.Time) {
   // Snapshot config values under lock to avoid data races
@@ -897,17 +1202,32 @@
   hotkeyVK := a.cfg.HotkeyVK
   autoHide := a.cfg.AutoHide
   copyToClipboard := a.cfg.CopyToClipboard
   autoPasteExperiment := a.cfg.AutoPasteExperiment
   a.mu.Unlock()
   logger.Info(
      "PERF recognize_pipeline_input id=%d has_voice=%t samples=%d audio_ms=%d copy_to_clipboard=%t since_start_ms=%d",
      "PERF recognize_pipeline_input id=%d has_voice=%t samples=%d audio_ms=%d copy_to_clipboard=%t auto_paste_experiment=%t since_start_ms=%d",
      perfID,
      hasVoice,
      len(samples),
      perfAudioDurationMS(samples),
      copyToClipboard,
      autoPasteExperiment,
      perfSinceMS(pipelineStart),
   )
   outputOptions := textoutput.OutputOptions{
      KeepClipboard:       copyToClipboard,
      AutoPasteExperiment: autoPasteExperiment,
   }
   if autoPasteExperiment {
      outputOptions.AutoPasteGuard = a.outputRouter.CaptureAutoPasteGuard()
      logger.Info(
         "Text output auto-paste experiment guard captured ok=%t identity_present=%t",
         outputOptions.AutoPasteGuard.OK,
         outputOptions.AutoPasteGuard.Identity != "",
      )
   }
   if !hasVoice {
      logger.Info("No voice activity detected")
@@ -978,7 +1298,8 @@
      return
   }
   logger.Info("Recognized: %s", text)
   textRunes := len([]rune(text))
   logger.Info("Recognized text produced: text_runes=%d", textRunes)
   historyStart := time.Now()
   a.history.Add(text)
   logger.Info("PERF history_done id=%d history_ms=%d since_start_ms=%d", perfID, perfSinceMS(historyStart), perfSinceMS(pipelineStart))
@@ -994,23 +1315,41 @@
      perfSinceMS(pipelineStart),
   )
   pasteStart := time.Now()
   if err := a.paster.Paste(text, copyToClipboard); err != nil {
      pasteElapsed := time.Since(pasteStart)
      logger.Info("PERF paste_done id=%d result=error paste_ms=%d since_start_ms=%d", perfID, perfDurationMS(pasteElapsed), perfSinceMS(pipelineStart))
      logger.Error("Paste failed, trying fallback: %v", err)
      typeStart := time.Now()
      if err := a.paster.TypeText(text); err != nil {
         logger.Info("PERF fallback_type_done id=%d result=error type_ms=%d total_ms=%d", perfID, perfSinceMS(typeStart), perfSinceMS(pipelineStart))
         logger.Error("Fallback type also failed: %v", err)
   outputStart := time.Now()
   output := a.outputRouter.OutputWithOptions(text, outputOptions)
   a.emitTextOutputUpdated(output)
   if !output.OK {
      logger.Info(
         "PERF text_output_done id=%d result=error output_ms=%d text_runes=%d fallback_available=%t since_start_ms=%d",
         perfID,
         perfDurationMS(time.Since(outputStart)),
         textRunes,
         output.FallbackAvailable,
         perfSinceMS(pipelineStart),
      )
      logger.Error(
         "Text output failed: needs_permission=%t fallback_available=%t direct_inserted=%t text_runes=%d",
         output.NeedsPermission,
         output.FallbackAvailable,
         output.DirectInserted,
         textRunes,
      )
      if output.FallbackAvailable {
         a.indicator.SetStatus(overlay.StatusError, "文本已保留")
      } else {
         a.indicator.SetStatus(overlay.StatusError, "需辅助权限")
         a.delayedHideIf(autoHide, 2500)
         return
      }
      logger.Info("PERF fallback_type_done id=%d result=success type_ms=%d total_ms=%d", perfID, perfSinceMS(typeStart), perfSinceMS(pipelineStart))
   } else {
      logger.Info("PERF paste_done id=%d result=success paste_ms=%d since_start_ms=%d", perfID, perfSinceMS(pasteStart), perfSinceMS(pipelineStart))
      a.delayedHideIf(autoHide, 2500)
      return
   }
   logger.Info(
      "PERF text_output_done id=%d result=success direct_inserted=%t output_ms=%d text_runes=%d since_start_ms=%d",
      perfID,
      output.DirectInserted,
      perfDurationMS(time.Since(outputStart)),
      textRunes,
      perfSinceMS(pipelineStart),
   )
   a.indicator.SetStatus(overlay.StatusDone, "完成")
   if soundFeedback {
@@ -1111,6 +1450,23 @@
      return "total"
   default:
      return ""
   }
}
func (a *App) emitTextOutputUpdated(result textoutput.Result) {
   if a == nil || a.wailsApp == nil {
      return
   }
   a.wailsApp.Event.Emit(textOutputUpdated, textOutputUpdatePayload(result))
}
func textOutputUpdatePayload(result textoutput.Result) map[string]interface{} {
   return map[string]interface{}{
      "ok":                result.OK,
      "needsPermission":   result.NeedsPermission,
      "directInserted":    result.DirectInserted,
      "fallbackAvailable": result.FallbackAvailable,
      "hasText":           result.Text != "",
   }
}
@@ -1357,6 +1713,9 @@
}
func hotkeyReadyText(keyName, mode string) string {
   if keyName == "" {
      return "触发键未设置"
   }
   if mode == config.HotkeyModeTap {
      return "点按" + keyName + "说话"
   }