package engine import ( "fmt" "time" "voicesnap/internal/config" "voicesnap/internal/language" "voicesnap/internal/logger" "voicesnap/internal/model" "voicesnap/internal/modelselection" "voicesnap/internal/paths" ) // Engine is the interface for ASR engines. type Engine interface { // Recognize takes float32 PCM samples (16kHz mono) and returns the recognized text. Recognize(samples []float32) (string, error) // HardwareInfo returns a human-readable description of the hardware backend being used. HardwareInfo() string // Close releases engine resources. Close() } // StreamingEngine is implemented by engines that can expose partial results // while audio is still being captured. type StreamingEngine interface { NewStreamingSession() (StreamingSession, error) } // ReleaseTailCaptureEngine is implemented by engines that need a short audio // capture grace period after the user releases the recording hotkey. type ReleaseTailCaptureEngine interface { ReleaseTailCaptureDelay() time.Duration } // HoldPreCaptureEngine is implemented by engines that should start capturing // audio immediately on hold-to-talk key down, before the activation delay has // confirmed that the hotkey was not part of a key combination. type HoldPreCaptureEngine interface { HoldPreCaptureEnabled() bool } // 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() if err == nil && resolved.IsUsable() { return resolved.RootDir } return paths.ModelDir() } // ModelPath returns the path to the ONNX model file (prefers int8). func ModelPath() string { resolved, err := resolveCurrentModel() if err == nil && resolved.IsUsable() { return resolved.Files["model"] } return "" } // TokensPath returns the path to the tokens.txt file. func TokensPath() string { resolved, err := resolveCurrentModel() if err == nil && resolved.IsUsable() { return resolved.Files["tokens"] } return "" } // ModelExists checks if both the model and tokens files exist. func ModelExists() bool { resolved, err := resolveCurrentModel() if err != nil { return false } return resolved.IsUsable() } // New creates a new platform-specific ASR engine. // Returns an error if the model files are not found. func New() (Engine, error) { cfg, err := config.Load() if err != nil { logger.Error("Failed to load config for model selection: %v", err) cfg = config.Default() } current := modelselection.Resolve(cfg, language.NewSystemDetector()) return NewWithModelID(current.ModelID) } func NewWithModelID(modelID string) (Engine, error) { resolved, err := model.ResolveModel(model.NormalizeModelID(modelID)) if err != nil { return nil, err } return NewWithResolvedModel(resolved) } func NewWithResolvedModel(resolved model.ResolvedModel) (Engine, error) { if !resolved.IsUsable() { return nil, fmt.Errorf("model %s is not usable: %s missing=%v problems=%v", resolved.ModelID, resolved.Status, resolved.Missing, resolved.Problems) } if !isSupportedBackend(resolved.BackendKind) { return nil, fmt.Errorf("unsupported backend kind: %s", resolved.BackendKind) } logger.Info("Loading model %s backend=%s from %s", resolved.ModelID, resolved.BackendKind, resolved.RootDir) return newPlatformEngine(resolved) } func isSupportedBackend(backend string) bool { switch backend { case model.BackendSenseVoice, model.BackendMoonshine, model.BackendTransducer, model.BackendNemoTransducer, model.BackendQwen3ASR, model.BackendXASRStreaming: return true default: return false } } func resolveCurrentModel() (model.ResolvedModel, error) { cfg, err := config.Load() if err != nil { cfg = config.Default() } current := modelselection.Resolve(cfg, language.NewSystemDetector()) return model.ResolveModel(current.ModelID) }