diff --git a/README.md b/README.md index 97b2d0f..7f47901 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # wis-free-v3 -A high-performance, native Windows voice dictation application built in Go using the Wails framework. **wis-free-v3** provides instant speech-to-text with AI-powered refinement, operating as a background service with global hotkey support. +A high-performance, cross-platform (Windows & Linux) voice dictation application built in Go using the Wails framework. **wis-free-v3** provides instant speech-to-text with AI-powered refinement, operating as a background service with global hotkey support. --- @@ -8,6 +8,7 @@ A high-performance, native Windows voice dictation application built in Go using - **Blazing Fast**: Native implementation ensures zero lag during recording and transcription. - **Global Accessibility**: Trigger from anywhere via configurable global hotkeys. +- **Cross-Platform**: Supports Windows natively and Linux (X11 strongly recommended; Wayland users may need to map desktop shortcut triggers). - **AI-Powered Refinement**: Integrates Groq (Whisper + Llama) for intelligent punctuation and grammar fixing. - **Offline Capability**: Supports local Whisper.cpp for sensitive or offline workflows. - **Micro-Automation**: Automatically pastes transcribed text directly into your active window. diff --git a/app.go b/app.go index 32b0944..3aa0108 100644 --- a/app.go +++ b/app.go @@ -8,15 +8,14 @@ import ( "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/platform" "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" @@ -31,7 +30,7 @@ type App struct { hotkeyListener *hotkey.Listener transcriber *transcriber.Client config *config.Config - overlay *overlay.Overlay + overlay platform.Overlay recordingPath string isQuitting bool wasMediaPlaying bool @@ -131,7 +130,7 @@ func (a *App) StartRecording() { tray.UpdateStatus("Recording...") // Pause media if playing (this is slow due to PowerShell) - a.wasMediaPlaying = media.PauseMedia() + a.wasMediaPlaying = platform.PauseMedia() if a.wasMediaPlaying { logger.Info("Media paused for recording") } @@ -143,7 +142,7 @@ func (a *App) StopRecording() { logger.Info("StopRecording triggered") // Resume media if it was playing before - media.ResumeMedia(a.wasMediaPlaying) + platform.ResumeMedia(a.wasMediaPlaying) if a.wasMediaPlaying { logger.Info("Media resumed after recording") } @@ -281,7 +280,7 @@ func (a *App) GetSettings() map[string]interface{} { conf["language"] = a.config.Language conf["microphone_device"] = a.config.MicrophoneDevice conf["history"] = a.config.History - conf["startup"] = startup.IsInStartup() + conf["startup"] = platform.IsInStartup() return conf } @@ -377,9 +376,9 @@ func (a *App) GetMicrophones() []map[string]interface{} { func (a *App) ToggleStartup(enable bool) string { var err error if enable { - err = startup.AddToStartup() + err = platform.AddToStartup() } else { - err = startup.RemoveFromStartup() + err = platform.RemoveFromStartup() } if err != nil { @@ -445,7 +444,7 @@ func (a *App) startupHeadless() { } // Initialize Overlay - a.overlay = overlay.NewOverlay() + a.overlay = platform.NewOverlay() // Connect volume feedback from recorder to overlay if a.audioRecorder != nil && a.overlay != nil { diff --git a/build/bin/wis-free-v3.exe b/build/bin/wis-free-v3.exe new file mode 100644 index 0000000..fde9cdf Binary files /dev/null and b/build/bin/wis-free-v3.exe differ diff --git a/go.mod b/go.mod index 52ce2a4..beee4a7 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module wis-free-v3 -go 1.24 +go 1.24.0 require ( github.com/gen2brain/malgo v0.11.24 diff --git a/internal/linux/media.go b/internal/linux/media.go new file mode 100644 index 0000000..4287c38 --- /dev/null +++ b/internal/linux/media.go @@ -0,0 +1,42 @@ +//go:build linux +package linux + +import ( + "os/exec" + "wis-free-v3/internal/logger" +) + +// IsPlaying checks if media is currently playing using playerctl +func IsPlaying() bool { + cmd := exec.Command("playerctl", "status") + out, err := cmd.Output() + if err != nil { + return false + } + return string(out) == "Playing\n" +} + +// TogglePlayPause sends the play-pause command via playerctl +func TogglePlayPause() { + cmd := exec.Command("playerctl", "play-pause") + err := cmd.Run() + if err != nil { + logger.Error("Failed to toggle media on Linux: %v", err) + } +} + +// PauseMedia checks if playing, pauses if so, returns whether we paused +func PauseMedia() bool { + wasPlaying := IsPlaying() + if wasPlaying { + TogglePlayPause() + } + return wasPlaying +} + +// ResumeMedia resumes only if we paused it +func ResumeMedia(wasPaused bool) { + if wasPaused { + TogglePlayPause() + } +} diff --git a/internal/linux/overlay.go b/internal/linux/overlay.go new file mode 100644 index 0000000..23754fa --- /dev/null +++ b/internal/linux/overlay.go @@ -0,0 +1,15 @@ +//go:build linux +package linux + +// linuxOverlay provides a simple dummy implementation for Linux. +// It relies on tray icon status updates for visual feedback instead of raw drawing. +type linuxOverlay struct{} + +func NewOverlay() *linuxOverlay { + return &linuxOverlay{} +} + +func (o *linuxOverlay) Show(message string) {} +func (o *linuxOverlay) Hide() {} +func (o *linuxOverlay) SetVolume(level float64) {} +func (o *linuxOverlay) Close() {} diff --git a/internal/linux/process.go b/internal/linux/process.go new file mode 100644 index 0000000..65b70af --- /dev/null +++ b/internal/linux/process.go @@ -0,0 +1,11 @@ +//go:build linux +package linux + +import "syscall" + +// IsProcessRunning checks if a process with the given PID exists on Linux. +func IsProcessRunning(pid int) bool { + // sending signal 0 checks if the process exists and we have permission + err := syscall.Kill(pid, 0) + return err == nil +} diff --git a/internal/linux/startup.go b/internal/linux/startup.go new file mode 100644 index 0000000..8ae03e7 --- /dev/null +++ b/internal/linux/startup.go @@ -0,0 +1,91 @@ +//go:build linux +package linux + +import ( + "fmt" + "os" + "path/filepath" +) + +const ( + appName = "wis-free-v3" + desktopFileContent = `[Desktop Entry] +Type=Application +Name=WIS Free V3 +Exec="%s" +Terminal=false +Categories=Utility; +X-GNOME-Autostart-enabled=true +` +) + +func getAutostartPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + + configDir := os.Getenv("XDG_CONFIG_HOME") + if configDir == "" { + configDir = filepath.Join(home, ".config") + } + autostartDir := filepath.Join(configDir, "autostart") + + if err := os.MkdirAll(autostartDir, 0755); err != nil { + return "", err + } + + return filepath.Join(autostartDir, appName+".desktop"), nil +} + +func getExecutablePath() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", err + } + return filepath.Abs(exe) +} + +func AddToStartup() error { + autostartPath, err := getAutostartPath() + if err != nil { + return fmt.Errorf("failed to determine autostart path: %w", err) + } + + exePath, err := getExecutablePath() + if err != nil { + return fmt.Errorf("failed to get executable path: %w", err) + } + + content := fmt.Sprintf(desktopFileContent, exePath) + if err := os.WriteFile(autostartPath, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to write autostart file: %w", err) + } + + return nil +} + +func RemoveFromStartup() error { + autostartPath, err := getAutostartPath() + if err != nil { + return fmt.Errorf("failed to determine autostart path: %w", err) + } + + if err := os.Remove(autostartPath); err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("failed to remove autostart file: %w", err) + } + } + + return nil +} + +func IsInStartup() bool { + autostartPath, err := getAutostartPath() + if err != nil { + return false + } + + _, err = os.Stat(autostartPath) + return err == nil +} diff --git a/internal/platform/platform.go b/internal/platform/platform.go new file mode 100644 index 0000000..9d4979f --- /dev/null +++ b/internal/platform/platform.go @@ -0,0 +1,9 @@ +package platform + +// Overlay defines the cross-platform interface for screen overlays +type Overlay interface { + Show(message string) + Hide() + SetVolume(level float64) + Close() +} diff --git a/internal/platform/platform_linux.go b/internal/platform/platform_linux.go new file mode 100644 index 0000000..dbd9662 --- /dev/null +++ b/internal/platform/platform_linux.go @@ -0,0 +1,32 @@ +//go:build linux +package platform + +import "wis-free-v3/internal/linux" + +func NewOverlay() Overlay { + return linux.NewOverlay() +} + +func PauseMedia() bool { + return linux.PauseMedia() +} + +func ResumeMedia(wasPaused bool) { + linux.ResumeMedia(wasPaused) +} + +func AddToStartup() error { + return linux.AddToStartup() +} + +func RemoveFromStartup() error { + return linux.RemoveFromStartup() +} + +func IsInStartup() bool { + return linux.IsInStartup() +} + +func IsProcessRunning(pid int) bool { + return linux.IsProcessRunning(pid) +} diff --git a/internal/platform/platform_windows.go b/internal/platform/platform_windows.go new file mode 100644 index 0000000..326f47e --- /dev/null +++ b/internal/platform/platform_windows.go @@ -0,0 +1,32 @@ +//go:build windows +package platform + +import "wis-free-v3/internal/windows" + +func NewOverlay() Overlay { + return windows.NewOverlay() +} + +func PauseMedia() bool { + return windows.PauseMedia() +} + +func ResumeMedia(wasPaused bool) { + windows.ResumeMedia(wasPaused) +} + +func AddToStartup() error { + return windows.AddToStartup() +} + +func RemoveFromStartup() error { + return windows.RemoveFromStartup() +} + +func IsInStartup() bool { + return windows.IsInStartup() +} + +func IsProcessRunning(pid int) bool { + return windows.IsProcessRunning(pid) +} diff --git a/internal/services/whisper/cmd_nonwindows.go b/internal/services/whisper/cmd_nonwindows.go new file mode 100644 index 0000000..888375a --- /dev/null +++ b/internal/services/whisper/cmd_nonwindows.go @@ -0,0 +1,9 @@ +//go:build !windows + +package whisper + +import "os/exec" + +func hideWindowContext(cmd *exec.Cmd) { + // Not needed on non-Windows platforms +} diff --git a/internal/services/whisper/cmd_windows.go b/internal/services/whisper/cmd_windows.go new file mode 100644 index 0000000..b172a99 --- /dev/null +++ b/internal/services/whisper/cmd_windows.go @@ -0,0 +1,15 @@ +//go:build windows + +package whisper + +import ( + "os/exec" + "syscall" +) + +func hideWindowContext(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, // CREATE_NO_WINDOW + } +} diff --git a/internal/services/whisper/whisper.go b/internal/services/whisper/whisper.go index f30e299..ce1219c 100644 --- a/internal/services/whisper/whisper.go +++ b/internal/services/whisper/whisper.go @@ -10,7 +10,6 @@ import ( "os/exec" "path/filepath" "strings" - "syscall" "time" "wis-free-v3/internal/logger" @@ -285,11 +284,8 @@ func (m *Manager) Transcribe(audioPath string) (string, error) { // Set working directory to the binary's location so it can find DLLs cmd.Dir = filepath.Dir(binaryPath) - // Hide the console window - cmd.SysProcAttr = &syscall.SysProcAttr{ - HideWindow: true, - CreationFlags: 0x08000000, // CREATE_NO_WINDOW - } + // Hide the console window on Windows + hideWindowContext(cmd) output, err := cmd.CombinedOutput() outputStr := string(output) diff --git a/internal/ui/tray/tray.go b/internal/ui/tray/tray.go index 1bf9d74..5280f55 100644 --- a/internal/ui/tray/tray.go +++ b/internal/ui/tray/tray.go @@ -8,7 +8,7 @@ import ( "wis-free-v3/internal/config" "wis-free-v3/internal/logger" - "wis-free-v3/internal/system/startup" + "wis-free-v3/internal/platform" "github.com/getlantern/systray" ) @@ -50,9 +50,9 @@ func onReady(app App) { menuSettings := systray.AddMenuItem("Settings", "Open settings window") menuStartup := systray.AddMenuItemCheckbox( - "Start with Windows", - "Automatically start when Windows boots", - startup.IsInStartup(), + "Start with system", + "Automatically start when computer boots", + platform.IsInStartup(), ) systray.AddSeparator() @@ -82,18 +82,18 @@ func handleMenuEvents(app App, settings, startupItem, exit *systray.MenuItem) { // toggleStartup handles the startup toggle menu item. func toggleStartup(item *systray.MenuItem) { if item.Checked() { - if err := startup.RemoveFromStartup(); err != nil { + if err := platform.RemoveFromStartup(); err != nil { logger.Error("Failed to remove from startup: %v", err) } else { item.Uncheck() - logger.Info("Removed from Windows startup") + logger.Info("Removed from system startup") } } else { - if err := startup.AddToStartup(); err != nil { + if err := platform.AddToStartup(); err != nil { logger.Error("Failed to add to startup: %v", err) } else { item.Check() - logger.Info("Added to Windows startup") + logger.Info("Added to system startup") } } } diff --git a/internal/audio/media/MediaCheck.cs b/internal/windows/MediaCheck.cs similarity index 100% rename from internal/audio/media/MediaCheck.cs rename to internal/windows/MediaCheck.cs diff --git a/internal/audio/media/MediaCheck.csproj b/internal/windows/MediaCheck.csproj similarity index 100% rename from internal/audio/media/MediaCheck.csproj rename to internal/windows/MediaCheck.csproj diff --git a/internal/audio/media/check-media.ps1 b/internal/windows/check-media.ps1 similarity index 100% rename from internal/audio/media/check-media.ps1 rename to internal/windows/check-media.ps1 diff --git a/internal/audio/media/media.go b/internal/windows/media.go similarity index 93% rename from internal/audio/media/media.go rename to internal/windows/media.go index 20d64b3..8e869ca 100644 --- a/internal/audio/media/media.go +++ b/internal/windows/media.go @@ -1,4 +1,5 @@ -package media +//go:build windows +package windows import ( _ "embed" @@ -10,8 +11,8 @@ import ( ) var ( - user32 = syscall.NewLazyDLL("user32.dll") - keybd_event = user32.NewProc("keybd_event") + user32Media = syscall.NewLazyDLL("user32.dll") + keybd_event = user32Media.NewProc("keybd_event") ) //go:embed check-media.ps1 diff --git a/internal/ui/overlay/overlay.go b/internal/windows/overlay.go similarity index 97% rename from internal/ui/overlay/overlay.go rename to internal/windows/overlay.go index ff66aff..46d766f 100644 --- a/internal/ui/overlay/overlay.go +++ b/internal/windows/overlay.go @@ -1,4 +1,5 @@ -package overlay +//go:build windows +package windows import ( "math" @@ -255,8 +256,17 @@ func (o *Overlay) run() { procTranslateMessage.Call(uintptr(unsafe.Pointer(&msg))) procDispatchMessage.Call(uintptr(unsafe.Pointer(&msg))) } else { - // Higher resolution sleep for better responsiveness while keeping CPU low - time.Sleep(2 * time.Millisecond) + o.mu.RLock() + isShowing := o.isShowing + o.mu.RUnlock() + + if isShowing { + // Higher resolution sleep for smooth animation when visible + time.Sleep(2 * time.Millisecond) + } else { + // Greatly reduce wakeups to save CPU when hidden (app is idle 99% of the time) + time.Sleep(50 * time.Millisecond) + } } } } diff --git a/internal/windows/process.go b/internal/windows/process.go new file mode 100644 index 0000000..64d384f --- /dev/null +++ b/internal/windows/process.go @@ -0,0 +1,16 @@ +//go:build windows +package windows + +import "syscall" + +// IsProcessRunning checks if a process with the given PID exists on Windows. +func IsProcessRunning(pid int) bool { + const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + + handle, err := syscall.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err != nil { + return false + } + syscall.CloseHandle(handle) + return true +} diff --git a/internal/system/startup/startup.go b/internal/windows/startup.go similarity index 91% rename from internal/system/startup/startup.go rename to internal/windows/startup.go index c05ebb1..1cf6555 100644 --- a/internal/system/startup/startup.go +++ b/internal/windows/startup.go @@ -1,6 +1,5 @@ -// Package startup manages Windows startup registry entries for the application. -// It allows the application to automatically start when the user logs in. -package startup +//go:build windows +package windows import ( "fmt" diff --git a/main.go b/main.go index 6bfac77..b39359c 100644 --- a/main.go +++ b/main.go @@ -7,9 +7,9 @@ import ( "os" "path/filepath" "strconv" - "syscall" "wis-free-v3/internal/logger" + "wis-free-v3/internal/platform" "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/options" @@ -87,7 +87,7 @@ func acquireInstanceLock() bool { // Check for existing lock file if data, err := os.ReadFile(lockPath); err == nil { if pid, err := strconv.Atoi(string(data)); err == nil { - if isProcessRunning(pid) { + if platform.IsProcessRunning(pid) { logger.Info("Another instance is already running (PID: %d)", pid) return false } @@ -136,16 +136,5 @@ func getLockPath() (string, error) { return filepath.Join(homeDir, configDir, lockFile), nil } -// isProcessRunning checks if a process with the given PID exists on Windows. -func isProcessRunning(pid int) bool { - const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 - - handle, err := syscall.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) - if err != nil { - return false - } - syscall.CloseHandle(handle) - return true -} diff --git a/scripts/build-linux.sh b/scripts/build-linux.sh new file mode 100644 index 0000000..3d0dc19 --- /dev/null +++ b/scripts/build-linux.sh @@ -0,0 +1,143 @@ +#!/bin/bash + +# Exit on error +set -e + +# Change to the root directory of the project +cd "$(dirname "$0")/.." + +APP_NAME="wis-free-v3" +BUILD_DIR="build/bin" +EXECUTABLE="$BUILD_DIR/$APP_NAME" + +echo "========================================" +echo " wis-free-v3 - Linux Build Script" +echo "========================================" +echo "" + +# Function to check if a command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# 1. Check for basic tools +if ! command_exists go; then + echo "[ERROR] Go is not installed. Please install Go 1.23+." + exit 1 +fi + +if ! command_exists wails; then + echo "[INFO] Wails CLI not found. Installing..." + go install github.com/wailsapp/wails/v2/cmd/wails@latest + export PATH=$PATH:$(go env GOPATH)/bin +fi + +# 2. Check for Linux dependencies +echo "[1/3] Checking system dependencies..." +MISSING_DEPS=0 +DEPS=("gcc" "pkg-config") + +for dep in "${DEPS[@]}"; do + if ! command_exists $dep; then + echo "[WARNING] Missing basic build tool: $dep" + MISSING_DEPS=1 + fi +done + +# We can't easily check C headers, but we can try to find them with pkg-config +if command_exists pkg-config; then + if ! pkg-config --exists gtk+-3.0 webkit2gtk-4.0; then + echo "[WARNING] Missing Wails dependencies (GTK3 / WebKit2GTK)." + MISSING_DEPS=1 + fi + if ! pkg-config --exists x11 xtst xcb; then + echo "[WARNING] Missing gohook dependencies (X11 / Xtst / Xcb)." + MISSING_DEPS=1 + fi + if ! pkg-config --exists alsa; then + echo "[WARNING] Missing audio dependencies (ALSA)." + MISSING_DEPS=1 + fi + if ! pkg-config --exists ayatana-appindicator3-0.1; then + echo "[WARNING] Missing systray dependencies (ayatana-appindicator3)." + MISSING_DEPS=1 + fi +fi + +if [ $MISSING_DEPS -eq 1 ]; then + echo "" + echo "It looks like you are missing some required libraries." + + DEBIAN_DEPS="build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.0-dev libx11-dev libx11-xcb-dev libxtst-dev libasound2-dev libayatana-appindicator3-dev libxkbcommon-x11-dev" + ARCH_DEPS="base-devel pkgconf gtk3 webkit2gtk libx11 libxtst alsa-lib libayatana-appindicator libxkbcommon-x11" + + echo "The full list of dependencies needed:" + echo " [Ubuntu/Debian]: sudo apt update && sudo apt install -y $DEBIAN_DEPS" + echo " [Arch Linux]: sudo pacman -S $ARCH_DEPS" + echo "" + + if command_exists apt-get; then + read -p "Would you like to automatically install missing dependencies now? (Requires sudo) (y/N): " INSTALL_DEPS + if [[ "$INSTALL_DEPS" == "y" || "$INSTALL_DEPS" == "Y" ]]; then + echo "Installing dependencies..." + sudo apt update && sudo apt install -y $DEBIAN_DEPS + else + echo "Skipping installation." + read -p "Press Enter to attempt build anyway, or Ctrl+C to cancel..." + fi + else + read -p "Press Enter to attempt build anyway, or Ctrl+C to cancel..." + fi +fi + +# 3. Build the application +echo "[2/3] Building with Wails..." +wails build -platform linux/amd64 -clean + +if [ ! -f "$EXECUTABLE" ]; then + echo "[ERROR] Build failed. Binary not found at $EXECUTABLE." + exit 1 +fi + +echo "[3/3] Build successful!" +echo " Output: $EXECUTABLE" +echo "" + +# 4. Optional Installation +read -p "Would you like to install it globally to /usr/local/bin and add a desktop shortcut? (y/n): " INSTALL +if [[ "$INSTALL" == "y" || "$INSTALL" == "Y" ]]; then + echo "Installing..." + + # Needs sudo + sudo cp "$EXECUTABLE" "/usr/local/bin/$APP_NAME" + sudo chmod +x "/usr/local/bin/$APP_NAME" + + # Create Desktop shortcut + DESKTOP_FILE="/usr/share/applications/$APP_NAME.desktop" + + # Try to grab the icon from the Wails build directory if available + ICON_PATH="/usr/share/pixmaps/$APP_NAME.png" + if [ -f "build/appicon.png" ]; then + sudo cp "build/appicon.png" "$ICON_PATH" + fi + + cat << EOF > /tmp/$APP_NAME.desktop +[Desktop Entry] +Type=Application +Name=WIS Free V3 +Comment=Voice Dictation App +Exec=$APP_NAME +Icon=$APP_NAME +Terminal=false +Categories=Utility;Audio; +EOF + + sudo mv /tmp/$APP_NAME.desktop "$DESKTOP_FILE" + sudo chmod 644 "$DESKTOP_FILE" + + echo "" + echo "Installation complete! You can now launch 'WIS Free V3' from your app launcher," + echo "or by typing '$APP_NAME' in your terminal." +else + echo "Skipping installation. You can run the app directly via: ./$EXECUTABLE" +fi diff --git a/tmp.exe b/tmp.exe new file mode 100644 index 0000000..45cac7d Binary files /dev/null and b/tmp.exe differ