589 lines
16 KiB
Go
589 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/leonardotrapani/hyprvoice/internal/bus"
|
|
"github.com/leonardotrapani/hyprvoice/internal/config"
|
|
"github.com/leonardotrapani/hyprvoice/internal/daemon"
|
|
"github.com/leonardotrapani/hyprvoice/internal/models/whisper"
|
|
"github.com/leonardotrapani/hyprvoice/internal/provider"
|
|
"github.com/leonardotrapani/hyprvoice/internal/tui"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
func main() {
|
|
_ = rootCmd.Execute()
|
|
}
|
|
|
|
var rootCmd = &cobra.Command{
|
|
Use: "hyprvoice",
|
|
Short: "Voice-powered typing for Wayland/Hyprland",
|
|
}
|
|
|
|
func init() {
|
|
rootCmd.AddCommand(
|
|
serveCmd(),
|
|
toggleCmd(),
|
|
cancelCmd(),
|
|
statusCmd(),
|
|
versionCmd(),
|
|
stopCmd(),
|
|
configureCmd(),
|
|
modelCmd(),
|
|
)
|
|
}
|
|
|
|
func serveCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "serve",
|
|
Short: "Run the daemon",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
d, err := daemon.New()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create daemon: %w", err)
|
|
}
|
|
return d.Run()
|
|
},
|
|
}
|
|
}
|
|
|
|
func toggleCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "toggle",
|
|
Short: "Toggle recording on/off",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
resp, err := bus.SendCommand('t')
|
|
if err != nil {
|
|
return fmt.Errorf("failed to toggle recording: %w", err)
|
|
}
|
|
fmt.Print(resp)
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func statusCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "status",
|
|
Short: "Get current recording status",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
resp, err := bus.SendCommand('s')
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get status: %w", err)
|
|
}
|
|
fmt.Print(resp)
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func versionCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "version",
|
|
Short: "Get protocol version",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
resp, err := bus.SendCommand('v')
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get version: %w", err)
|
|
}
|
|
fmt.Print(resp)
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func stopCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "stop",
|
|
Short: "Stop the daemon",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
resp, err := bus.SendCommand('q')
|
|
if err != nil {
|
|
return fmt.Errorf("failed to stop daemon: %w", err)
|
|
}
|
|
fmt.Print(resp)
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func cancelCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "cancel",
|
|
Short: "Cancel current operation",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
resp, err := bus.SendCommand('c')
|
|
if err != nil {
|
|
return fmt.Errorf("failed to cancel operation: %w", err)
|
|
}
|
|
fmt.Print(resp)
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func configureCmd() *cobra.Command {
|
|
var onboarding bool
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "configure",
|
|
Short: "Interactive configuration setup",
|
|
Long: `Interactive configuration wizard for hyprvoice.
|
|
This will guide you through setting up:
|
|
- Provider API keys (OpenAI, Groq, Mistral, ElevenLabs)
|
|
- Transcription settings
|
|
- LLM post-processing
|
|
- Text injection and notification preferences`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return runConfigure(onboarding)
|
|
},
|
|
}
|
|
|
|
cmd.Flags().BoolVar(&onboarding, "onboarding", false, "Run the guided onboarding wizard")
|
|
|
|
return cmd
|
|
}
|
|
|
|
func runConfigure(onboarding bool) error {
|
|
// Load existing config or create default
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load config: %w", err)
|
|
}
|
|
|
|
// Run TUI wizard
|
|
result, err := tui.Run(cfg, onboarding)
|
|
if err != nil {
|
|
return fmt.Errorf("configuration wizard error: %w", err)
|
|
}
|
|
|
|
if result.Cancelled {
|
|
fmt.Println("Configuration cancelled.")
|
|
return nil
|
|
}
|
|
|
|
// Validate configuration
|
|
if err := result.Config.Validate(); err != nil {
|
|
fmt.Printf("Configuration validation failed: %v\n", err)
|
|
return err
|
|
}
|
|
|
|
// Save configuration
|
|
if err := saveConfig(result.Config); err != nil {
|
|
return fmt.Errorf("failed to save config: %w", err)
|
|
}
|
|
|
|
fmt.Println()
|
|
fmt.Println("Configuration saved successfully!")
|
|
fmt.Println()
|
|
|
|
// Show next steps
|
|
showNextSteps(result.Config)
|
|
|
|
return nil
|
|
}
|
|
|
|
func showNextSteps(cfg *config.Config) {
|
|
// Check if service is running
|
|
serviceRunning := false
|
|
if _, err := exec.Command("systemctl", "--user", "is-active", "--quiet", "hyprvoice.service").CombinedOutput(); err == nil {
|
|
serviceRunning = true
|
|
}
|
|
|
|
// Check if ydotool is in backends
|
|
hasYdotool := false
|
|
for _, b := range cfg.Injection.Backends {
|
|
if b == "ydotool" {
|
|
hasYdotool = true
|
|
break
|
|
}
|
|
}
|
|
|
|
fmt.Println("Next Steps:")
|
|
step := 1
|
|
if hasYdotool {
|
|
fmt.Printf("%d. Ensure ydotoold is running\n", step)
|
|
step++
|
|
}
|
|
if !serviceRunning {
|
|
fmt.Printf("%d. Start the service: systemctl --user start hyprvoice.service\n", step)
|
|
} else {
|
|
fmt.Printf("%d. Restart the service to apply changes: systemctl --user restart hyprvoice.service\n", step)
|
|
}
|
|
step++
|
|
fmt.Printf("%d. Test voice input: hyprvoice toggle\n", step)
|
|
fmt.Println()
|
|
|
|
configPath, _ := config.GetConfigPath()
|
|
fmt.Printf("Config file location: %s\n", configPath)
|
|
}
|
|
|
|
func saveConfig(cfg *config.Config) error {
|
|
configPath, err := config.GetConfigPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
file, err := os.Create(configPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create config file: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
var sb strings.Builder
|
|
|
|
// Header
|
|
sb.WriteString(`# Hyprvoice Configuration
|
|
# Generated by hyprvoice configure
|
|
# Changes are applied immediately without daemon restart.
|
|
|
|
`)
|
|
|
|
// Keywords (must be before any table definitions)
|
|
if len(cfg.Keywords) > 0 {
|
|
sb.WriteString("# Keywords help transcription and LLM spell names/terms correctly\n")
|
|
sb.WriteString("keywords = [")
|
|
for i, kw := range cfg.Keywords {
|
|
if i > 0 {
|
|
sb.WriteString(", ")
|
|
}
|
|
sb.WriteString(fmt.Sprintf("%q", kw))
|
|
}
|
|
sb.WriteString("]\n\n")
|
|
}
|
|
|
|
// Providers section
|
|
if len(cfg.Providers) > 0 {
|
|
sb.WriteString("# API Keys for providers\n")
|
|
for name, pc := range cfg.Providers {
|
|
sb.WriteString(fmt.Sprintf("[providers.%s]\n", name))
|
|
sb.WriteString(fmt.Sprintf(" api_key = %q\n", pc.APIKey))
|
|
sb.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
// Recording
|
|
sb.WriteString(`# Audio Recording Configuration
|
|
[recording]
|
|
`)
|
|
sb.WriteString(fmt.Sprintf(" sample_rate = %d\n", cfg.Recording.SampleRate))
|
|
sb.WriteString(fmt.Sprintf(" channels = %d\n", cfg.Recording.Channels))
|
|
sb.WriteString(fmt.Sprintf(" format = %q\n", cfg.Recording.Format))
|
|
sb.WriteString(fmt.Sprintf(" buffer_size = %d\n", cfg.Recording.BufferSize))
|
|
sb.WriteString(fmt.Sprintf(" device = %q\n", cfg.Recording.Device))
|
|
sb.WriteString(fmt.Sprintf(" channel_buffer_size = %d\n", cfg.Recording.ChannelBufferSize))
|
|
sb.WriteString(fmt.Sprintf(" timeout = %q\n", cfg.Recording.Timeout.String()))
|
|
sb.WriteString("\n")
|
|
|
|
// Transcription
|
|
sb.WriteString(`# Speech Transcription Configuration
|
|
[transcription]
|
|
`)
|
|
sb.WriteString(fmt.Sprintf(" provider = %q\n", cfg.Transcription.Provider))
|
|
sb.WriteString(fmt.Sprintf(" language = %q\n", cfg.Transcription.Language))
|
|
sb.WriteString(fmt.Sprintf(" model = %q\n", cfg.Transcription.Model))
|
|
sb.WriteString("\n")
|
|
|
|
// LLM
|
|
sb.WriteString(`# LLM Post-Processing Configuration
|
|
[llm]
|
|
`)
|
|
sb.WriteString(fmt.Sprintf(" enabled = %v\n", cfg.LLM.Enabled))
|
|
if cfg.LLM.Provider != "" {
|
|
sb.WriteString(fmt.Sprintf(" provider = %q\n", cfg.LLM.Provider))
|
|
}
|
|
if cfg.LLM.Model != "" {
|
|
sb.WriteString(fmt.Sprintf(" model = %q\n", cfg.LLM.Model))
|
|
}
|
|
sb.WriteString("\n")
|
|
|
|
sb.WriteString(" [llm.post_processing]\n")
|
|
sb.WriteString(fmt.Sprintf(" remove_stutters = %v\n", cfg.LLM.PostProcessing.RemoveStutters))
|
|
sb.WriteString(fmt.Sprintf(" add_punctuation = %v\n", cfg.LLM.PostProcessing.AddPunctuation))
|
|
sb.WriteString(fmt.Sprintf(" fix_grammar = %v\n", cfg.LLM.PostProcessing.FixGrammar))
|
|
sb.WriteString(fmt.Sprintf(" remove_filler_words = %v\n", cfg.LLM.PostProcessing.RemoveFillerWords))
|
|
sb.WriteString("\n")
|
|
|
|
sb.WriteString(" [llm.custom_prompt]\n")
|
|
sb.WriteString(fmt.Sprintf(" enabled = %v\n", cfg.LLM.CustomPrompt.Enabled))
|
|
if cfg.LLM.CustomPrompt.Prompt != "" {
|
|
sb.WriteString(fmt.Sprintf(" prompt = %q\n", cfg.LLM.CustomPrompt.Prompt))
|
|
}
|
|
sb.WriteString("\n")
|
|
|
|
// Injection
|
|
sb.WriteString(`# Text Injection Configuration
|
|
[injection]
|
|
`)
|
|
sb.WriteString(" backends = [")
|
|
for i, b := range cfg.Injection.Backends {
|
|
if i > 0 {
|
|
sb.WriteString(", ")
|
|
}
|
|
sb.WriteString(fmt.Sprintf("%q", b))
|
|
}
|
|
sb.WriteString("]\n")
|
|
sb.WriteString(fmt.Sprintf(" ydotool_timeout = %q\n", cfg.Injection.YdotoolTimeout.String()))
|
|
sb.WriteString(fmt.Sprintf(" wtype_timeout = %q\n", cfg.Injection.WtypeTimeout.String()))
|
|
sb.WriteString(fmt.Sprintf(" clipboard_timeout = %q\n", cfg.Injection.ClipboardTimeout.String()))
|
|
sb.WriteString("\n")
|
|
|
|
// Notifications
|
|
sb.WriteString(`# Desktop Notification Configuration
|
|
[notifications]
|
|
`)
|
|
sb.WriteString(fmt.Sprintf(" enabled = %v\n", cfg.Notifications.Enabled))
|
|
sb.WriteString(fmt.Sprintf(" type = %q\n", cfg.Notifications.Type))
|
|
|
|
// Write custom messages if any
|
|
msgs := cfg.Notifications.Messages
|
|
if hasCustomMessages(msgs) {
|
|
sb.WriteString("\n [notifications.messages]\n")
|
|
if msgs.RecordingStarted.Title != "" || msgs.RecordingStarted.Body != "" {
|
|
sb.WriteString(" [notifications.messages.recording_started]\n")
|
|
sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.RecordingStarted.Title))
|
|
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.RecordingStarted.Body))
|
|
}
|
|
if msgs.Transcribing.Title != "" || msgs.Transcribing.Body != "" {
|
|
sb.WriteString(" [notifications.messages.transcribing]\n")
|
|
sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.Transcribing.Title))
|
|
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.Transcribing.Body))
|
|
}
|
|
if msgs.LLMProcessing.Title != "" || msgs.LLMProcessing.Body != "" {
|
|
sb.WriteString(" [notifications.messages.llm_processing]\n")
|
|
sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.LLMProcessing.Title))
|
|
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.LLMProcessing.Body))
|
|
}
|
|
if msgs.ConfigReloaded.Title != "" || msgs.ConfigReloaded.Body != "" {
|
|
sb.WriteString(" [notifications.messages.config_reloaded]\n")
|
|
sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.ConfigReloaded.Title))
|
|
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.ConfigReloaded.Body))
|
|
}
|
|
if msgs.OperationCancelled.Title != "" || msgs.OperationCancelled.Body != "" {
|
|
sb.WriteString(" [notifications.messages.operation_cancelled]\n")
|
|
sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.OperationCancelled.Title))
|
|
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.OperationCancelled.Body))
|
|
}
|
|
if msgs.RecordingAborted.Body != "" {
|
|
sb.WriteString(" [notifications.messages.recording_aborted]\n")
|
|
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.RecordingAborted.Body))
|
|
}
|
|
if msgs.InjectionAborted.Body != "" {
|
|
sb.WriteString(" [notifications.messages.injection_aborted]\n")
|
|
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.InjectionAborted.Body))
|
|
}
|
|
}
|
|
|
|
if _, err := file.WriteString(sb.String()); err != nil {
|
|
return fmt.Errorf("failed to write config content: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func hasCustomMessages(msgs config.MessagesConfig) bool {
|
|
return msgs.RecordingStarted.Title != "" || msgs.RecordingStarted.Body != "" ||
|
|
msgs.Transcribing.Title != "" || msgs.Transcribing.Body != "" ||
|
|
msgs.LLMProcessing.Title != "" || msgs.LLMProcessing.Body != "" ||
|
|
msgs.ConfigReloaded.Title != "" || msgs.ConfigReloaded.Body != "" ||
|
|
msgs.OperationCancelled.Title != "" || msgs.OperationCancelled.Body != "" ||
|
|
msgs.RecordingAborted.Body != "" ||
|
|
msgs.InjectionAborted.Body != ""
|
|
}
|
|
|
|
func modelCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "model",
|
|
Short: "Manage transcription models",
|
|
}
|
|
|
|
cmd.AddCommand(modelListCmd())
|
|
cmd.AddCommand(modelDownloadCmd())
|
|
|
|
return cmd
|
|
}
|
|
|
|
func modelListCmd() *cobra.Command {
|
|
var providerFilter string
|
|
var typeFilter string
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "list",
|
|
Short: "List available transcription and LLM models",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return runModelList(providerFilter, typeFilter)
|
|
},
|
|
}
|
|
|
|
cmd.Flags().StringVar(&providerFilter, "provider", "", "filter by provider name")
|
|
cmd.Flags().StringVar(&typeFilter, "type", "", "filter by type: transcription, llm")
|
|
|
|
return cmd
|
|
}
|
|
|
|
func runModelList(providerFilter, typeFilter string) error {
|
|
// parse type filter
|
|
var filterType *provider.ModelType
|
|
if typeFilter != "" {
|
|
switch strings.ToLower(typeFilter) {
|
|
case "transcription":
|
|
t := provider.Transcription
|
|
filterType = &t
|
|
case "llm":
|
|
t := provider.LLM
|
|
filterType = &t
|
|
default:
|
|
return fmt.Errorf("invalid type: %s (use 'transcription' or 'llm')", typeFilter)
|
|
}
|
|
}
|
|
|
|
// get providers to iterate
|
|
providerNames := provider.ListProviders()
|
|
sort.Strings(providerNames)
|
|
|
|
// filter by provider if specified
|
|
if providerFilter != "" {
|
|
found := false
|
|
for _, name := range providerNames {
|
|
if name == providerFilter {
|
|
providerNames = []string{name}
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return fmt.Errorf("unknown provider: %s", providerFilter)
|
|
}
|
|
}
|
|
|
|
for _, providerName := range providerNames {
|
|
p := provider.GetProvider(providerName)
|
|
if p == nil {
|
|
continue
|
|
}
|
|
|
|
models := p.Models()
|
|
if filterType != nil {
|
|
models = provider.ModelsOfType(p, *filterType)
|
|
}
|
|
|
|
if len(models) == 0 {
|
|
continue
|
|
}
|
|
|
|
// print provider header
|
|
fmt.Printf("\n%s:\n", providerName)
|
|
|
|
for _, m := range models {
|
|
printModelLine(m)
|
|
}
|
|
}
|
|
|
|
fmt.Println()
|
|
return nil
|
|
}
|
|
|
|
func printModelLine(m provider.Model) {
|
|
// build prefix: checkmark for installed local models
|
|
prefix := " "
|
|
if m.Local {
|
|
if whisper.IsInstalled(m.ID) {
|
|
prefix = " [x]"
|
|
} else {
|
|
prefix = " [ ]"
|
|
}
|
|
}
|
|
|
|
// build suffix parts
|
|
var parts []string
|
|
|
|
// type indicator
|
|
if m.Type == provider.LLM {
|
|
parts = append(parts, "llm")
|
|
}
|
|
|
|
// streaming indicator
|
|
if m.Streaming {
|
|
parts = append(parts, "streaming")
|
|
}
|
|
|
|
// size for local models
|
|
if m.LocalInfo != nil && m.LocalInfo.Size != "" {
|
|
parts = append(parts, m.LocalInfo.Size)
|
|
}
|
|
|
|
// build line
|
|
line := fmt.Sprintf("%s %s", prefix, m.ID)
|
|
if m.Description != "" {
|
|
line += fmt.Sprintf(" - %s", m.Description)
|
|
}
|
|
if len(parts) > 0 {
|
|
line += fmt.Sprintf(" [%s]", strings.Join(parts, ", "))
|
|
}
|
|
|
|
fmt.Println(line)
|
|
}
|
|
|
|
func modelDownloadCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "download <model-name>",
|
|
Short: "Download a local model (e.g. whisper models)",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return runModelDownload(cmd.Context(), args[0])
|
|
},
|
|
}
|
|
}
|
|
|
|
func runModelDownload(ctx context.Context, modelName string) error {
|
|
// find the model across all providers
|
|
model, _, err := provider.FindModelByID(modelName)
|
|
if err != nil {
|
|
return fmt.Errorf("unknown model: %s", modelName)
|
|
}
|
|
|
|
// check if it needs download (local model)
|
|
if !model.NeedsDownload() {
|
|
fmt.Printf("model '%s' is a cloud model and does not require download\n", modelName)
|
|
return nil
|
|
}
|
|
|
|
// check if already installed
|
|
if whisper.IsInstalled(modelName) {
|
|
path := whisper.GetModelPath(modelName)
|
|
fmt.Printf("model '%s' is already installed at %s\n", modelName, path)
|
|
return nil
|
|
}
|
|
|
|
// download with progress
|
|
fmt.Printf("downloading %s", modelName)
|
|
if model.LocalInfo != nil && model.LocalInfo.Size != "" {
|
|
fmt.Printf(" (%s)", model.LocalInfo.Size)
|
|
}
|
|
fmt.Println("...")
|
|
|
|
var lastPercent int
|
|
err = whisper.Download(ctx, modelName, func(downloaded, total int64) {
|
|
if total > 0 {
|
|
percent := int(downloaded * 100 / total)
|
|
if percent >= lastPercent+10 {
|
|
fmt.Printf("%d%% ", percent)
|
|
lastPercent = percent
|
|
}
|
|
}
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("download failed: %w", err)
|
|
}
|
|
|
|
path := whisper.GetModelPath(modelName)
|
|
fmt.Printf("\ndownload complete: %s\n", path)
|
|
return nil
|
|
}
|