package main
|
|
import (
|
"context"
|
"fmt"
|
"sync"
|
"sync/atomic"
|
"time"
|
|
"voicesnap/internal/audio"
|
"voicesnap/internal/config"
|
"voicesnap/internal/dock"
|
"voicesnap/internal/engine"
|
"voicesnap/internal/history"
|
"voicesnap/internal/hotkey"
|
"voicesnap/internal/input"
|
"voicesnap/internal/logger"
|
"voicesnap/internal/overlay"
|
"voicesnap/internal/sound"
|
"voicesnap/internal/textproc"
|
"voicesnap/internal/userdict"
|
"voicesnap/services"
|
|
"github.com/wailsapp/wails/v3/pkg/application"
|
"github.com/wailsapp/wails/v3/pkg/events"
|
)
|
|
const (
|
appVersion = "2.1.1"
|
appBuild = "20260518.1604"
|
appDisplayVersion = appVersion + " (build " + appBuild + ")"
|
appName = "VoiceSnap"
|
|
silenceThreshold = 0.05 // RMS below this = silence (matches HasVoiceActivity)
|
silenceTimeoutDuration = 3 * time.Second // auto-stop after this much silence in free-talk
|
silenceGracePeriod = 2 * time.Second // don't auto-stop within first 2s of free-talk
|
)
|
|
// App holds all application state and orchestration logic.
|
type App struct {
|
ctx context.Context
|
cancel context.CancelFunc
|
wg sync.WaitGroup
|
|
cfg *config.Config
|
recorder *audio.Recorder
|
eng engine.Engine
|
hk hotkey.Listener
|
paster input.Paster
|
history *history.Store
|
userdict *userdict.Store
|
|
wailsApp *application.App
|
settingsWindow *application.WebviewWindow
|
indicator overlay.Overlay
|
|
// State
|
mu sync.Mutex
|
isRecording bool
|
isFreetalking bool
|
hotkeyActive bool
|
hotkeyPressTime time.Time
|
isCombination bool
|
isRecordingHotkey bool
|
hideGen atomic.Uint64
|
lastStopTime time.Time
|
hotkeyPollSeen bool
|
lastHotkeyDown bool
|
lastHotkeyBlocked time.Time
|
silenceSince time.Time // when continuous silence started (free-talk only)
|
freeTalkStart time.Time // when free-talk mode started
|
}
|
|
func RunApp() error {
|
ctx, cancel := context.WithCancel(context.Background())
|
defer cancel()
|
|
app := &App{
|
ctx: ctx,
|
cancel: cancel,
|
}
|
|
// Load config
|
cfg, err := config.Load()
|
if err != nil {
|
logger.Error("Failed to load config: %v", err)
|
cfg = config.Default()
|
}
|
app.cfg = cfg
|
|
// Initialize audio recorder
|
app.recorder = audio.NewRecorder()
|
|
// Initialize platform-specific hotkey listener
|
app.hk = hotkey.New()
|
|
// Initialize platform-specific paster
|
app.paster = input.NewPaster()
|
|
// Initialize history store
|
app.history = history.New()
|
|
// Initialize user dictionary store
|
app.userdict = userdict.New()
|
|
// Create services for Wails bindings
|
appService := services.NewAppService(app.cfg, appDisplayVersion)
|
configService := services.NewConfigService(app.cfg)
|
engineService := services.NewEngineService()
|
hotkeyService := services.NewHotkeyService(app.cfg)
|
permissionService := services.NewPermissionService()
|
updaterService := services.NewUpdaterService(appVersion)
|
audioService := services.NewAudioService(app.recorder, app.cfg)
|
historyService := services.NewHistoryService(app.history)
|
userDictService := services.NewUserDictService(app.userdict)
|
|
// Create Wails application
|
wailsApp := application.New(application.Options{
|
Name: appName,
|
Icon: appIcon,
|
Services: []application.Service{
|
application.NewService(appService),
|
application.NewService(configService),
|
application.NewService(engineService),
|
application.NewService(hotkeyService),
|
application.NewService(permissionService),
|
application.NewService(updaterService),
|
application.NewService(audioService),
|
application.NewService(historyService),
|
application.NewService(userDictService),
|
},
|
Assets: application.AssetOptions{
|
Handler: application.AssetFileServerFS(assets),
|
},
|
OnShutdown: func() {
|
app.cleanup()
|
},
|
})
|
|
app.wailsApp = wailsApp
|
if app.cfg.HideDockIcon {
|
dock.SetHidden(true)
|
}
|
|
// Create settings window (visible on startup, close → hide to tray)
|
app.settingsWindow = wailsApp.Window.NewWithOptions(application.WebviewWindowOptions{
|
Name: "settings",
|
Title: "VoiceSnap",
|
Width: 820,
|
Height: 580,
|
URL: "/",
|
Hidden: false,
|
BackgroundColour: application.NewRGB(242, 242, 247), // #F2F2F7 Apple grouped bg
|
Windows: application.WindowsWindow{
|
Theme: application.SystemDefault,
|
},
|
})
|
|
// Intercept window close: hide to tray instead of closing
|
app.settingsWindow.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
|
e.Cancel()
|
app.settingsWindow.Hide()
|
})
|
userDictService.SetApp(wailsApp, app.settingsWindow)
|
|
// Create overlay indicator (native Win32 on Windows, native AppKit on macOS)
|
app.indicator = overlay.New()
|
app.indicator.OnDragged(func(x, y int) {
|
app.cfg.IndicatorX = x
|
app.cfg.IndicatorY = y
|
config.Save(app.cfg)
|
})
|
|
// Create system tray
|
tray := wailsApp.SystemTray.New()
|
tray.SetTemplateIcon(trayIcon)
|
|
showLabel, exitLabel := "Show Settings", "Exit"
|
if isChinese() {
|
showLabel, exitLabel = "显示设置", "退出"
|
}
|
|
trayMenu := wailsApp.Menu.New()
|
trayMenu.Add(showLabel).OnClick(func(data *application.Context) {
|
app.showSettings()
|
})
|
trayMenu.AddSeparator()
|
trayMenu.Add(exitLabel).OnClick(func(data *application.Context) {
|
app.cleanup()
|
wailsApp.Quit()
|
})
|
tray.SetMenu(trayMenu)
|
|
tray.OnClick(func() {
|
app.toggleSettings()
|
})
|
|
// Start background goroutines
|
app.wg.Add(1)
|
go app.hotkeyLoop()
|
|
// Initialize engine asynchronously
|
go app.initEngine()
|
|
// Volume callback → native overlay + silence auto-stop
|
app.recorder.OnVolume(func(vol float64) {
|
app.indicator.SetVolume(vol)
|
app.checkSilenceTimeout(vol)
|
})
|
|
// Device change callback
|
app.recorder.OnDeviceChange(func(name string) {
|
wailsApp.Event.Emit("device:changed", map[string]string{"name": name})
|
})
|
|
// Store services references for orchestration callbacks
|
appService.SetApp(app)
|
hotkeyService.SetApp(app)
|
engineService.SetApp(wailsApp)
|
engineService.SetInitCallback(app.initEngine)
|
|
logger.Info("Application initialized, starting Wails")
|
return wailsApp.Run()
|
}
|
|
// hotkeyLoop polls the hotkey state at 30ms intervals.
|
func (a *App) hotkeyLoop() {
|
defer a.wg.Done()
|
|
ticker := time.NewTicker(30 * time.Millisecond)
|
defer ticker.Stop()
|
|
for {
|
select {
|
case <-a.ctx.Done():
|
return
|
case <-ticker.C:
|
a.pollHotkey()
|
}
|
}
|
}
|
|
func (a *App) pollHotkey() {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
|
isDown := a.hk.IsKeyDown(a.cfg.HotkeyVK)
|
if !a.hotkeyPollSeen || a.lastHotkeyDown != isDown {
|
a.hotkeyPollSeen = true
|
a.lastHotkeyDown = isDown
|
logger.Info(
|
"Hotkey poll state: key=%s vk=0x%X down=%t active=%t recording=%t freetalk=%t engineReady=%t recordingHotkey=%t",
|
hotkey.GetKeyName(a.cfg.HotkeyVK),
|
a.cfg.HotkeyVK,
|
isDown,
|
a.hotkeyActive,
|
a.isRecording,
|
a.isFreetalking,
|
a.eng != nil,
|
a.isRecordingHotkey,
|
)
|
}
|
|
if a.eng == nil || a.isRecordingHotkey {
|
if isDown && time.Since(a.lastHotkeyBlocked) > time.Second {
|
a.lastHotkeyBlocked = time.Now()
|
logger.Info("Hotkey ignored: key=%s engineReady=%t recordingHotkey=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), a.eng != nil, a.isRecordingHotkey)
|
}
|
return
|
}
|
|
// Escape cancels any active recording
|
if a.cfg.HotkeyVK != 0x1B && (a.isRecording || a.isFreetalking) && a.hk.IsKeyDown(0x1B) {
|
a.isFreetalking = false
|
a.isRecording = false
|
a.lastStopTime = time.Now()
|
a.recorder.Stop()
|
logger.Info("Recording cancelled (Escape)")
|
a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
|
if a.cfg.SoundFeedback {
|
sound.PlayCancel()
|
}
|
a.delayedHide(1000)
|
return
|
}
|
|
if isDown {
|
if !a.hotkeyActive {
|
// Key just pressed
|
a.hotkeyActive = true
|
a.isCombination = false
|
a.hotkeyPressTime = time.Now()
|
logger.Info("Hotkey accepted: key=%s vk=0x%X freetalk=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), a.cfg.HotkeyVK, a.isFreetalking)
|
|
// If in free talk mode, stop on next press
|
if a.isFreetalking {
|
logger.Info("Hotkey action: stop free talk")
|
a.stopFreetalkLocked()
|
return
|
}
|
} else {
|
// Key held down - check for combination keys
|
if !a.isCombination && a.hk.IsAnyOtherKeyPressedSince(a.cfg.HotkeyVK, a.hotkeyPressTime) {
|
a.isCombination = true
|
logger.Info("Hotkey marked as combination: key=%s", hotkey.GetKeyName(a.cfg.HotkeyVK))
|
if a.isRecording {
|
a.stopRecordingLocked(true)
|
}
|
}
|
|
// If held > 300ms without combo, start hold-to-talk recording
|
if !a.isRecording && !a.isCombination && time.Since(a.hotkeyPressTime) > 300*time.Millisecond && time.Since(a.lastStopTime) > 500*time.Millisecond {
|
logger.Info("Hotkey action: start hold-to-talk after %dms", time.Since(a.hotkeyPressTime).Milliseconds())
|
a.startRecordingLocked()
|
}
|
}
|
} else if a.hotkeyActive {
|
// Key released
|
pressDuration := time.Since(a.hotkeyPressTime)
|
logger.Info("Hotkey released: key=%s duration=%dms recording=%t combination=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), pressDuration.Milliseconds(), a.isRecording, a.isCombination)
|
a.hotkeyActive = false
|
|
if a.isRecording {
|
// Hold-to-talk: release stops recording
|
a.stopRecordingLocked(a.isCombination)
|
} else if !a.isCombination && pressDuration < 300*time.Millisecond && time.Since(a.lastStopTime) > 500*time.Millisecond {
|
// Short tap (<300ms): start free talk mode
|
logger.Info("Hotkey action: start free talk after short tap")
|
a.startFreetalkLocked()
|
}
|
}
|
}
|
|
func (a *App) startRecordingLocked() {
|
if a.isRecording {
|
return
|
}
|
a.isRecording = true
|
a.hideGen.Add(1) // cancel any pending delayed hide
|
|
logger.Info("Recording started")
|
a.positionIndicator()
|
a.indicator.SetStatus(overlay.StatusRecording, "0:00")
|
a.indicator.Show()
|
if a.cfg.SoundFeedback {
|
sound.PlayStart()
|
}
|
|
if err := a.recorder.Start(); err != nil {
|
logger.Error("Failed to start recording: %v", err)
|
a.isRecording = false
|
return
|
}
|
a.startRecordingTimer(overlay.StatusRecording)
|
}
|
|
func (a *App) stopRecordingLocked(cancel bool) {
|
if !a.isRecording {
|
return
|
}
|
a.isRecording = false
|
a.lastStopTime = time.Now()
|
|
if cancel {
|
a.recorder.Stop()
|
logger.Info("Recording cancelled (combination key)")
|
a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
|
if a.cfg.SoundFeedback {
|
sound.PlayCancel()
|
}
|
a.delayedHide(1000)
|
return
|
}
|
|
hasVoice := a.recorder.HasVoiceActivity()
|
samples := a.recorder.StopAndGetSamples()
|
|
a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
|
go a.recognizeAndPaste(hasVoice, samples)
|
}
|
|
func (a *App) startFreetalkLocked() {
|
if a.isRecording || a.isFreetalking {
|
return
|
}
|
a.isFreetalking = true
|
a.isRecording = true
|
a.freeTalkStart = time.Now()
|
a.silenceSince = time.Time{}
|
a.hideGen.Add(1)
|
|
logger.Info("Free talk started")
|
a.positionIndicator()
|
a.indicator.SetStatus(overlay.StatusFreetalking, "0:00")
|
a.indicator.Show()
|
if a.cfg.SoundFeedback {
|
sound.PlayStart()
|
}
|
|
if err := a.recorder.Start(); err != nil {
|
logger.Error("Failed to start free talk recording: %v", err)
|
a.isFreetalking = false
|
a.isRecording = false
|
return
|
}
|
a.startRecordingTimer(overlay.StatusFreetalking)
|
}
|
|
// startRecordingTimer updates the indicator text with elapsed time every second.
|
// It exits automatically when a.isRecording becomes false.
|
func (a *App) startRecordingTimer(status overlay.Status) {
|
start := time.Now()
|
go func() {
|
ticker := time.NewTicker(time.Second)
|
defer ticker.Stop()
|
for {
|
select {
|
case <-a.ctx.Done():
|
return
|
case <-ticker.C:
|
a.mu.Lock()
|
recording := a.isRecording
|
a.mu.Unlock()
|
if !recording {
|
return
|
}
|
d := time.Since(start)
|
a.indicator.SetStatus(status, fmt.Sprintf("%d:%02d", int(d.Minutes()), int(d.Seconds())%60))
|
}
|
}
|
}()
|
}
|
|
func (a *App) stopFreetalkLocked() {
|
if !a.isFreetalking {
|
return
|
}
|
a.isFreetalking = false
|
a.isRecording = false
|
a.lastStopTime = time.Now()
|
|
hasVoice := a.recorder.HasVoiceActivity()
|
samples := a.recorder.StopAndGetSamples()
|
|
logger.Info("Free talk stopped")
|
a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
|
go a.recognizeAndPaste(hasVoice, samples)
|
}
|
|
// checkSilenceTimeout monitors volume during free-talk mode and auto-stops
|
// recording if silence persists for silenceTimeoutDuration.
|
// Called from the audio volume callback (~every 50ms).
|
func (a *App) checkSilenceTimeout(vol float64) {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
|
if !a.isFreetalking {
|
a.silenceSince = time.Time{}
|
return
|
}
|
|
// Grace period: don't auto-stop in the first 2 seconds
|
if time.Since(a.freeTalkStart) < silenceGracePeriod {
|
return
|
}
|
|
if vol > silenceThreshold {
|
a.silenceSince = time.Time{} // voice detected, reset
|
return
|
}
|
|
// Silence detected
|
if a.silenceSince.IsZero() {
|
a.silenceSince = time.Now()
|
return
|
}
|
|
if time.Since(a.silenceSince) >= silenceTimeoutDuration {
|
logger.Info("Silence timeout in free talk mode, auto-stopping")
|
a.silenceSince = time.Time{}
|
// Mark state immediately to prevent re-triggering from subsequent callbacks
|
// and to block pollHotkey from starting a new recording before the device stops.
|
a.isFreetalking = false
|
a.isRecording = false
|
a.lastStopTime = time.Now()
|
// 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.
|
go a.silenceAutoStop()
|
}
|
}
|
|
// silenceAutoStop performs the actual device stop and ASR after a silence timeout.
|
// Runs in its own goroutine to avoid deadlocking the audio callback thread.
|
func (a *App) silenceAutoStop() {
|
hasVoice := a.recorder.HasVoiceActivity()
|
samples := a.recorder.StopAndGetSamples()
|
|
logger.Info("Free talk stopped (silence auto-stop)")
|
a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
|
a.recognizeAndPaste(hasVoice, samples)
|
}
|
|
// recognizeAndPaste runs ASR on the recorded samples and pastes the result.
|
// Shared by both hold-to-talk and free-talk modes.
|
func (a *App) recognizeAndPaste(hasVoice bool, samples []float32) {
|
// Snapshot config values under lock to avoid data races
|
a.mu.Lock()
|
soundFeedback := a.cfg.SoundFeedback
|
hotkeyVK := a.cfg.HotkeyVK
|
autoHide := a.cfg.AutoHide
|
copyToClipboard := a.cfg.CopyToClipboard
|
a.mu.Unlock()
|
|
if !hasVoice {
|
logger.Info("No voice activity detected")
|
a.indicator.SetStatus(overlay.StatusNoVoice, "无语音")
|
a.delayedHideIf(autoHide, 1500)
|
return
|
}
|
|
if len(samples) == 0 {
|
a.indicator.SetStatus(overlay.StatusNoContent, "无内容")
|
a.delayedHideIf(autoHide, 1500)
|
return
|
}
|
|
text, err := a.eng.Recognize(samples)
|
if err != nil {
|
logger.Error("Recognition failed: %v", err)
|
a.indicator.SetStatus(overlay.StatusError, "错误")
|
a.delayedHideIf(autoHide, 2000)
|
return
|
}
|
|
text = textproc.PostProcess(text)
|
text = a.userdict.Apply(text)
|
|
if text == "" {
|
a.indicator.SetStatus(overlay.StatusNoContent, "无内容")
|
a.delayedHideIf(autoHide, 1500)
|
return
|
}
|
|
logger.Info("Recognized: %s", text)
|
a.history.Add(text)
|
|
a.waitForHotkeyRelease(hotkeyVK)
|
|
if err := a.paster.Paste(text, copyToClipboard); err != nil {
|
logger.Error("Paste failed, trying fallback: %v", err)
|
if err := a.paster.TypeText(text); err != nil {
|
logger.Error("Fallback type also failed: %v", err)
|
a.indicator.SetStatus(overlay.StatusError, "需辅助权限")
|
a.delayedHideIf(autoHide, 2500)
|
return
|
}
|
}
|
|
a.indicator.SetStatus(overlay.StatusDone, "完成")
|
if soundFeedback {
|
sound.PlayDone()
|
}
|
a.delayedHideIf(autoHide, 2000)
|
}
|
|
// waitForHotkeyRelease polls until the hotkey is released (max 500ms),
|
// then waits an additional 50ms settling delay.
|
func (a *App) waitForHotkeyRelease(hotkeyVK int) {
|
for i := 0; i < 50; i++ {
|
if !a.hk.IsKeyDown(hotkeyVK) {
|
break
|
}
|
time.Sleep(10 * time.Millisecond)
|
}
|
time.Sleep(50 * time.Millisecond)
|
}
|
|
// delayedHide hides the indicator after a delay if AutoHide is enabled.
|
// Used from contexts where a.mu is held (reads a.cfg.AutoHide safely).
|
func (a *App) delayedHide(delayMs int) {
|
if a.cfg.AutoHide {
|
a.scheduleHide(delayMs)
|
}
|
}
|
|
// delayedHideIf hides the indicator after a delay if autoHide is true.
|
// Used from goroutines with a pre-snapshotted config value.
|
func (a *App) delayedHideIf(autoHide bool, delayMs int) {
|
if autoHide {
|
a.scheduleHide(delayMs)
|
}
|
}
|
|
func (a *App) scheduleHide(delayMs int) {
|
gen := a.hideGen.Load()
|
go func() {
|
time.Sleep(time.Duration(delayMs) * time.Millisecond)
|
if a.hideGen.Load() == gen {
|
a.indicator.Hide()
|
}
|
}()
|
}
|
|
func (a *App) initEngine() {
|
logger.Info("Initializing ASR engine...")
|
a.wailsApp.Event.Emit("engine:status", map[string]interface{}{
|
"status": "loading",
|
})
|
|
eng, err := engine.New()
|
if err != nil {
|
logger.Error("Engine initialization failed: %v", err)
|
a.wailsApp.Event.Emit("engine:status", map[string]interface{}{
|
"status": "need_model",
|
"error": err.Error(),
|
})
|
return
|
}
|
|
a.mu.Lock()
|
a.eng = eng
|
a.mu.Unlock()
|
|
logger.Info("ASR engine ready: %s", eng.HardwareInfo())
|
a.wailsApp.Event.Emit("engine:status", map[string]interface{}{
|
"status": "ready",
|
"hardwareInfo": eng.HardwareInfo(),
|
})
|
|
// Show indicator briefly — snapshot config under lock
|
a.mu.Lock()
|
hotkeyVK := a.cfg.HotkeyVK
|
autoHide := a.cfg.AutoHide
|
indX := a.cfg.IndicatorX
|
indY := a.cfg.IndicatorY
|
a.mu.Unlock()
|
|
keyName := hotkey.GetKeyName(hotkeyVK)
|
a.positionIndicatorAt(indX, indY)
|
a.indicator.SetStatus(overlay.StatusReady, "按住"+keyName+"说话")
|
a.indicator.Show()
|
a.delayedHideIf(autoHide, 2000)
|
}
|
|
// positionIndicator places the indicator at the saved position, or bottom-center if not saved.
|
// Caller must hold a.mu (reads a.cfg).
|
func (a *App) positionIndicator() {
|
a.positionIndicatorAt(a.cfg.IndicatorX, a.cfg.IndicatorY)
|
}
|
|
// positionIndicatorAt places the indicator at (x, y), or bottom-center if both are 0.
|
func (a *App) positionIndicatorAt(x, y int) {
|
if x != 0 || y != 0 {
|
a.indicator.SetPosition(x, y)
|
return
|
}
|
// Try Screen.GetPrimary(); on macOS this may return nil during early startup.
|
// The darwin overlay has its own autoPosition fallback in Show().
|
screen := a.wailsApp.Screen.GetPrimary()
|
if screen == nil {
|
return
|
}
|
wa := screen.WorkArea
|
cx := wa.X + (wa.Width-170)/2
|
cy := wa.Y + wa.Height - 48 - 100
|
a.indicator.SetPosition(cx, cy)
|
}
|
|
func (a *App) showSettings() {
|
if a.settingsWindow != nil {
|
a.settingsWindow.Show()
|
a.settingsWindow.Focus()
|
}
|
}
|
|
func (a *App) toggleSettings() {
|
if a.settingsWindow != nil {
|
if a.settingsWindow.IsVisible() {
|
a.settingsWindow.Hide()
|
} else {
|
a.settingsWindow.Show()
|
a.settingsWindow.Focus()
|
}
|
}
|
}
|
|
func (a *App) cleanup() {
|
logger.Info("Cleaning up...")
|
a.cancel()
|
if a.indicator != nil {
|
a.indicator.Close()
|
}
|
if a.recorder != nil {
|
a.recorder.Close()
|
}
|
if a.eng != nil {
|
a.eng.Close()
|
}
|
a.wg.Wait()
|
logger.Info("Cleanup complete")
|
}
|
|
// SetRecordingHotkey is called by the hotkey service when entering hotkey recording mode.
|
func (a *App) SetRecordingHotkey(recording bool) {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
a.isRecordingHotkey = recording
|
if !recording {
|
a.hotkeyActive = false
|
a.lastStopTime = time.Now()
|
}
|
}
|
|
// UpdateHotkeyVK updates the hotkey virtual key code.
|
func (a *App) UpdateHotkeyVK(vk int) {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
a.cfg.HotkeyVK = vk
|
config.Save(a.cfg)
|
}
|
|
// GetEngine returns the current engine (may be nil).
|
func (a *App) GetEngine() engine.Engine {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
return a.eng
|
}
|
|
// SetEngine sets the engine after model download.
|
func (a *App) SetEngine(eng engine.Engine) {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
a.eng = eng
|
}
|