This commit is contained in:
jahruz67
2026-03-26 09:56:19 -07:00
commit 2d36041f6a
50 changed files with 6563 additions and 0 deletions
+560
View File
@@ -0,0 +1,560 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"wis-free-v3/internal/audio/media"
"wis-free-v3/internal/audio/recorder"
"wis-free-v3/internal/config"
"wis-free-v3/internal/hotkey"
"wis-free-v3/internal/logger"
"wis-free-v3/internal/services/transcriber"
"wis-free-v3/internal/services/whisper"
"wis-free-v3/internal/system/startup"
"wis-free-v3/internal/ui/overlay"
"wis-free-v3/internal/ui/tray"
"github.com/go-vgo/robotgo"
"github.com/wailsapp/wails/v2/pkg/runtime"
"golang.design/x/clipboard"
)
// App struct
type App struct {
ctx context.Context
audioRecorder *recorder.AudioRecorder
hotkeyListener *hotkey.Listener
transcriber *transcriber.Client
config *config.Config
overlay *overlay.Overlay
recordingPath string
isQuitting bool
wasMediaPlaying bool
whisperManager *whisper.Manager
}
// NewApp creates a new App application struct
func NewApp() *App {
return &App{}
}
// GetConfig returns the app configuration
func (a *App) GetConfig() *config.Config {
return a.config
}
// ShowSettings shows the settings window
func (a *App) ShowSettings() {
if a.ctx != nil {
runtime.WindowShow(a.ctx)
}
}
// startup is called when the app starts
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
// Initialize components
a.startupHeadless()
// Start system tray in a goroutine
go tray.Start(a)
}
// beforeClose is called when the window is about to close
func (a *App) beforeClose(ctx context.Context) (prevent bool) {
if a.isQuitting {
return false
}
runtime.WindowHide(ctx)
return true
}
// Quit handles application exit from tray
func (a *App) Quit() {
a.isQuitting = true
logger.Info("Application quitting...")
logger.Close()
runtime.Quit(a.ctx)
}
// StartRecording starts the audio recording
func (a *App) StartRecording() {
logger.Info("StartRecording triggered")
// Pause media if playing
a.wasMediaPlaying = media.PauseMedia()
if a.wasMediaPlaying {
logger.Info("Media paused for recording")
}
tray.UpdateStatus("Recording...")
if a.overlay != nil {
a.overlay.Show("Recording...")
}
if a.audioRecorder == nil {
logger.Error("Recorder not initialized")
tray.UpdateStatus("Ready")
if a.overlay != nil {
a.overlay.Hide()
}
return
}
// Save to a temporary file
tempDir := os.TempDir()
timestamp := time.Now().Format("20060102_150405")
a.recordingPath = filepath.Join(tempDir, fmt.Sprintf("wis_recording_%s.wav", timestamp))
err := a.audioRecorder.Start(a.recordingPath)
if err != nil {
logger.Error("Failed to start recording with primary device: %v", err)
// IDIOT-PROOFING: Fallback to default microphone if the selected one fails
if a.config.MicrophoneDevice != nil {
logger.Info("Attempting fallback to default microphone...")
a.audioRecorder.SetDevice("") // Reset to default
err = a.audioRecorder.Start(a.recordingPath)
}
if err != nil {
logger.Error("Recording completely failed: %v", err)
tray.UpdateStatus("Ready")
if a.overlay != nil {
a.overlay.Hide()
}
// Resume media if we paused it
media.ResumeMedia(a.wasMediaPlaying)
return
}
logger.Info("Fallback successful - using default microphone")
}
}
// StopRecording stops the audio recording and triggers transcription
func (a *App) StopRecording() {
logger.Info("StopRecording triggered")
// Resume media if it was playing before
media.ResumeMedia(a.wasMediaPlaying)
if a.wasMediaPlaying {
logger.Info("Media resumed after recording")
}
if a.audioRecorder == nil {
return
}
err := a.audioRecorder.Stop()
if err != nil {
logger.Error("Failed to stop recording: %v", err)
return
}
// Transcribe in a goroutine to avoid blocking
go a.processRecording()
}
// processRecording handles transcription and pasting
func (a *App) processRecording() {
if a.recordingPath == "" {
logger.Error("No recording path set")
tray.UpdateStatus("Ready")
if a.overlay != nil {
a.overlay.Hide()
}
return
}
logger.Info("Transcribing audio...")
tray.UpdateStatus("Transcribing...")
if a.overlay != nil {
a.overlay.Show("Transcribing...")
}
var text string
var err error
// Check if using local whisper
isLocal := strings.HasPrefix(a.config.WhisperModel, "local-")
// IDIOT-PROOFING: Validate API key before attempting cloud transcription
if !isLocal && !strings.HasPrefix(a.config.APIKey, "gsk_") {
err = fmt.Errorf("invalid API key - must start with gsk_")
} else if isLocal {
// Use local whisper.cpp
if a.whisperManager == nil {
var mgrErr error
a.whisperManager, mgrErr = whisper.NewManager()
if mgrErr != nil {
logger.Error("Failed to create whisper manager: %v", mgrErr)
err = mgrErr
}
}
if a.whisperManager != nil {
text, err = a.whisperManager.Transcribe(a.recordingPath)
}
} else {
// Use cloud API
text, err = a.transcriber.TranscribeAudio(a.recordingPath, a.config.Language)
}
if err != nil {
logger.Error("Transcription failed: %v", err)
tray.UpdateStatus("Ready")
if a.overlay != nil {
a.overlay.Hide()
}
return
}
logger.Info("Transcribed: %s", text)
// Refine text (optional)
refinedText, err := a.transcriber.RefineText(text)
if err != nil {
logger.Error("Refinement failed: %v", err)
// Fallback to original text
refinedText = text
} else {
logger.Info("Refined: %s", refinedText)
}
// Save to history
historyItem := config.HistoryItem{
Text: refinedText,
Timestamp: time.Now().Format(time.RFC3339),
}
// Prepend to history
a.config.History = append([]config.HistoryItem{historyItem}, a.config.History...)
// Keep only last 50 items
if len(a.config.History) > 50 {
a.config.History = a.config.History[:50]
}
config.Save(a.config, "")
// Copy to clipboard
clipboard.Write(clipboard.FmtText, []byte(refinedText))
// Paste
a.pasteText()
// Clean up the recording file
os.Remove(a.recordingPath)
logger.Info("Processing complete!")
tray.UpdateStatus("Ready")
// Hide overlay after a short delay
time.Sleep(1 * time.Second)
if a.overlay != nil {
a.overlay.Hide()
}
}
// pasteText simulates Ctrl+V to paste from clipboard
func (a *App) pasteText() {
// Give a small delay for clipboard to update
time.Sleep(100 * time.Millisecond)
// Simulate Ctrl+V
robotgo.KeyToggle("control", "down")
robotgo.KeyTap("v")
robotgo.KeyToggle("control", "up")
}
// GetSettings returns the current configuration
func (a *App) GetSettings() map[string]interface{} {
conf := make(map[string]interface{})
conf["api_key"] = a.config.APIKey
conf["shortcut"] = a.config.Shortcut
conf["whisper_model"] = a.config.WhisperModel
conf["ai_model"] = a.config.AIModel
conf["ai_prompt"] = a.config.AIPrompt
conf["language"] = a.config.Language
conf["microphone_device"] = a.config.MicrophoneDevice
conf["history"] = a.config.History
conf["startup"] = startup.IsInStartup()
return conf
}
// SaveSettings updates the configuration
func (a *App) SaveSettings(settings map[string]interface{}) string {
if val, ok := settings["api_key"].(string); ok {
a.config.APIKey = val
}
if val, ok := settings["shortcut"].(string); ok {
// Validate shortcut before applying
trigger, _ := hotkey.ParseShortcut(val)
if len(trigger) == 0 {
logger.Error("Invalid shortcut: %s (rejected)", val)
return "Invalid shortcut - must have at least one modifier and a regular key"
}
a.config.Shortcut = val
// Update existing listener with new shortcut (hot-swap)
if a.hotkeyListener != nil {
a.hotkeyListener.UpdateShortcut(val)
} else {
// Should not happen if app started correctly, but just in case
a.hotkeyListener = hotkey.NewListener(val, a.StartRecording, a.StopRecording)
a.hotkeyListener.Start()
}
}
if val, ok := settings["whisper_model"].(string); ok {
a.config.WhisperModel = val
}
if val, ok := settings["ai_model"].(string); ok {
a.config.AIModel = val
}
if val, ok := settings["ai_prompt"].(string); ok {
a.config.AIPrompt = val
}
if val, ok := settings["language"].(string); ok {
a.config.Language = val
}
if val, ok := settings["microphone_device"]; ok {
if val == nil {
a.config.MicrophoneDevice = nil
} else {
// Handle float64 from JSON/JS
if f, ok := val.(float64); ok {
i := int(f)
a.config.MicrophoneDevice = &i
}
}
}
// Save to file
config.Save(a.config, "")
// Re-init transcriber with new settings
a.transcriber = transcriber.NewClient(
a.config.APIKey,
a.config.WhisperModel,
a.config.AIModel,
a.config.AIPrompt,
)
logger.Info("Settings saved")
return "Settings saved successfully"
}
// GetMicrophones returns available microphone devices
func (a *App) GetMicrophones() []map[string]interface{} {
var result []map[string]interface{}
if a.audioRecorder != nil {
mics, err := a.audioRecorder.GetMicrophones()
if err != nil {
logger.Error("Failed to enumerate microphones: %v", err)
} else {
for i, mic := range mics {
result = append(result, map[string]interface{}{
"index": i - 1, // -1 for default, 0+ for specific devices
"name": mic.Name,
"id": mic.ID,
})
}
return result
}
}
// Fallback to just default
return []map[string]interface{}{
{"index": -1, "name": "System Default", "id": ""},
}
}
// ToggleStartup toggles Windows startup status
func (a *App) ToggleStartup(enable bool) string {
var err error
if enable {
err = startup.AddToStartup()
} else {
err = startup.RemoveFromStartup()
}
if err != nil {
logger.Error("Startup toggle error: %v", err)
return fmt.Sprintf("Error: %v", err)
}
return "Success"
}
// ClearHistory clears the transcription history
func (a *App) ClearHistory() {
a.config.History = []config.HistoryItem{}
config.Save(a.config, "")
logger.Info("History cleared")
}
// startupHeadless initializes the app without Wails context
func (a *App) startupHeadless() {
// Initialize Logger
err := logger.Init()
if err != nil {
fmt.Printf("Failed to initialize logger: %v\n", err)
}
// Initialize clipboard
err = clipboard.Init()
if err != nil {
logger.Error("Failed to initialize clipboard: %v", err)
}
// Load configuration
configPath, err := config.GetConfigPath()
if err != nil {
logger.Error("Failed to get config path: %v", err)
a.config = config.DefaultConfig()
} else {
a.config, err = config.Load(configPath)
if err != nil {
logger.Error("Failed to load config: %v", err)
a.config = config.DefaultConfig()
}
}
// Initialize heavy components in background for instant startup
go func() {
// Initialize transcriber
a.transcriber = transcriber.NewClient(
a.config.APIKey,
a.config.WhisperModel,
a.config.AIModel,
a.config.AIPrompt,
)
// Initialize whisper manager
a.whisperManager, _ = whisper.NewManager()
// Initialize Audio Recorder
rec, err := recorder.NewRecorder()
if err != nil {
logger.Error("Error initializing recorder: %v", err)
} else {
a.audioRecorder = rec
}
// Initialize Overlay
a.overlay = overlay.NewOverlay()
// Connect volume feedback from recorder to overlay
if a.audioRecorder != nil && a.overlay != nil {
a.audioRecorder.OnVolume = func(level float64) {
a.overlay.SetVolume(level)
}
}
// Initialize Hotkey Listener
a.hotkeyListener = hotkey.NewListener(a.config.Shortcut, a.StartRecording, a.StopRecording)
a.hotkeyListener.Start()
logger.Info("Background components initialized successfully!")
}()
logger.Info("Basic app components loaded, continuing startup...")
}
// Shutdown cleans up resources
func (a *App) Shutdown(ctx context.Context) {
if a.hotkeyListener != nil {
a.hotkeyListener.Stop()
}
if a.audioRecorder != nil {
a.audioRecorder.Cleanup()
}
if a.overlay != nil {
a.overlay.Close()
}
logger.Close()
}
// Greet returns a greeting for the given name (for frontend testing)
func (a *App) Greet(name string) string {
return fmt.Sprintf("Hello %s, It's show time!", name)
}
// CheckOnline checks if internet connection is available
func (a *App) CheckOnline() bool {
return whisper.CheckOnline()
}
// IsWhisperInstalled checks if offline whisper is installed
func (a *App) IsWhisperInstalled() bool {
mgr, err := whisper.NewManager()
if err != nil {
return false
}
return mgr.IsInstalled()
}
// GetWhisperInfo returns information about installed whisper
func (a *App) GetWhisperInfo() map[string]interface{} {
mgr, err := whisper.NewManager()
if err != nil {
return map[string]interface{}{"installed": false}
}
if !mgr.IsInstalled() {
return map[string]interface{}{"installed": false}
}
info, err := mgr.GetInstalledInfo()
if err != nil {
return map[string]interface{}{"installed": false}
}
return map[string]interface{}{
"installed": true,
"model": info.Model,
"version": info.Version,
}
}
// InstallWhisper starts the whisper installation process
func (a *App) InstallWhisper(model string) string {
mgr, err := whisper.NewManager()
if err != nil {
return fmt.Sprintf("Error: %v", err)
}
if err := mgr.Install(model); err != nil {
return fmt.Sprintf("Error: %v", err)
}
return "Installation started - check the terminal window"
}
// UninstallWhisper removes the whisper installation
func (a *App) UninstallWhisper() string {
mgr, err := whisper.NewManager()
if err != nil {
return fmt.Sprintf("Error: %v", err)
}
if err := mgr.Uninstall(); err != nil {
return fmt.Sprintf("Error: %v", err)
}
return "Whisper uninstalled successfully"
}
// GetAvailableWhisperModels returns list of available whisper models
func (a *App) GetAvailableWhisperModels() []map[string]string {
var models []map[string]string
for name, info := range whisper.Models {
models = append(models, map[string]string{
"name": name,
"size": info.Size,
})
}
return models
}