diff --git a/flake.lock b/flake.lock index 4b8f455..74172c5 100644 --- a/flake.lock +++ b/flake.lock @@ -675,6 +675,7 @@ "tonearm": "tonearm", "turny": "turny", "voidauth": "voidauth", + "voxtype": "voxtype", "workout-sync": "workout-sync" } }, @@ -872,6 +873,27 @@ "type": "github" } }, + "voxtype": { + "inputs": { + "flake-utils": "flake-utils_7", + "nixpkgs": [ + "nixpkgs-unstable" + ] + }, + "locked": { + "lastModified": 1772443545, + "narHash": "sha256-oD3lameQXilKcgxQORR2l0+iDbnCO61+mjYD3MEVbuQ=", + "owner": "peteonrails", + "repo": "voxtype", + "rev": "d011f3ff074a6a14c14e75fefb375a408e9e8887", + "type": "github" + }, + "original": { + "owner": "peteonrails", + "repo": "voxtype", + "type": "github" + } + }, "workout-sync": { "inputs": { "nixpkgs": [ diff --git a/flake.nix b/flake.nix index e4efae6..ffa5b50 100644 --- a/flake.nix +++ b/flake.nix @@ -56,6 +56,10 @@ inputs.nixpkgs.follows = "nixpkgs-unstable"; inputs.flake-parts.follows = "flake-parts"; }; + voxtype = { + url = "github:peteonrails/voxtype"; + inputs.nixpkgs.follows = "nixpkgs-unstable"; + }; }; outputs = { diff --git a/home/common/default.nix b/home/common/default.nix index 4171108..6117a29 100644 --- a/home/common/default.nix +++ b/home/common/default.nix @@ -12,9 +12,11 @@ ../gnome ./dnote.nix ../modules/zed + ../modules/voxtype ./secrets.nix inputs.hastebin.nixosModules.hm inputs.agenix.homeManagerModules.default + inputs.voxtype.homeManagerModules.default ]; # This value determines the Home Manager release that your configuration is @@ -94,6 +96,18 @@ '') pkgs-unstable.tidal-hifi inputs.tonearm.packages.${pkgs.stdenv.hostPlatform.system}.tonearm + (writeShellScriptBin "voxtoggle" '' + status=$(${lib.getExe config.programs.voxtype.package} status) + pid=$(cat ''${XDG_RUNTIME_DIR}/voxtype/pid) + + if [[ "$status" == "stopped" ]]; then + exit 1 + elif [[ "$status" == "recording" ]]; then + kill -SIGUSR2 "$pid" + else + kill -SIGUSR1 "$pid" + fi + '') ]; programs = { @@ -427,6 +441,51 @@ enable = true; defaultEditor = true; }; + + voxtype = { + enable = true; + package = inputs.voxtype.packages.${pkgs.stdenv.hostPlatform.system}.vulkan; + model.name = "large-v3-turbo"; + service.enable = true; + settings = { + hotkey.enabled = false; + whisper.language = "auto"; + output.notification = { + on_recording_start = false; + on_recording_stop = false; + on_transcription = false; + }; + }; + postProcessing = { + enable = true; + settings = { + model = "qwen3:4b-instruct"; + commonInstructions = "no quotes, no emojis, no explanations"; + prompts = [ + { + title = "Clean up"; + instructions = "Clean up this dictation. Remove filler words, fix grammar and punctuation. Output ONLY the cleaned text"; + } + { + title = "Make a title"; + instructions = "Make a concise and descriptive title for this dictation. Output ONLY the title"; + } + { + title = "Summarize"; + instructions = "Summarize this dictation in a few sentences. Output ONLY the summary"; + } + { + title = "Commit message"; + instructions = "Write a concise and descriptive git commit message for this dictation. Output ONLY the commit message"; + } + { + title = "Translate to English"; + instructions = "Translate this dictation to English. Remove filler words, fix grammar and punctuation. Output ONLY the translation"; + } + ]; + }; + }; + }; }; gnome = { diff --git a/home/modules/voxtype/default.nix b/home/modules/voxtype/default.nix new file mode 100644 index 0000000..ab7e776 --- /dev/null +++ b/home/modules/voxtype/default.nix @@ -0,0 +1,92 @@ +{ + config, + osConfig, + lib, + pkgs, + pkgs-unstable, + ... +}: +let + cfg = config.programs.voxtype; + postProcessUnwrapped = + pkgs.runCommand "voxtype-post-process-unwrapped" + { + code = ./post-process.cr; + nativeBuildInputs = [ + pkgs-unstable.crystal + ]; + } + '' + mkdir -p $out/bin + crystal build $code -o $out/bin/voxtype-post-process + ''; + + postProcess = pkgs.symlinkJoin { + name = "voxtype-post-process"; + paths = [ postProcessUnwrapped ]; + nativeBuildInputs = [ pkgs.makeBinaryWrapper ]; + postBuild = '' + wrapProgram $out/bin/voxtype-post-process \ + --set OLLAMA_PORT ${toString osConfig.services.ollama.port} \ + --set WALKER_BIN ${lib.getExe config.services.walker.package} + ''; + meta.mainProgram = "voxtype-post-process"; + }; +in +{ + options.programs.voxtype = { + postProcessing = lib.mkOption { + type = lib.types.submodule { + options = { + enable = lib.mkEnableOption "Enable post-processing of transcriptions"; + settings = lib.mkOption { + type = lib.types.submodule { + options = { + model = lib.mkOption { + type = lib.types.str; + description = "The ollama model to use for post-processing"; + }; + commonInstructions = lib.mkOption { + type = lib.types.str; + default = "no quotes, no emojis, no explanations"; + description = "Instructions to include in every post-processing prompt"; + }; + prompts = lib.mkOption { + type = lib.types.listOf ( + lib.types.submodule { + options = { + title = lib.mkOption { + type = lib.types.str; + description = "A title for this prompt, used in the selector"; + }; + instructions = lib.mkOption { + type = lib.types.str; + description = "Instructions to include in the post-processing prompt, in addition to the common instructions"; + }; + }; + } + ); + default = [ + { + title = "Clean up"; + instructions = "Clean up this dictation. Remove filler words, fix grammar and punctuation. Output ONLY the cleaned text"; + } + ]; + }; + }; + }; + default = { }; + }; + }; + }; + }; + }; + + config = lib.mkIf cfg.postProcessing.enable { + xdg.configFile."voxtype/post-processing.json".text = builtins.toJSON cfg.postProcessing.settings; + programs.voxtype.settings.output.post_process = { + command = lib.getExe postProcess; + timeout_ms = 5 * 60 * 1000; # 5 minutes + }; + }; +} diff --git a/home/modules/voxtype/post-process.cr b/home/modules/voxtype/post-process.cr new file mode 100644 index 0000000..e693112 --- /dev/null +++ b/home/modules/voxtype/post-process.cr @@ -0,0 +1,54 @@ +require "json" +require "http/client" + +struct OllamaResponse + include JSON::Serializable + + getter response : String +end + +struct Prompt + include JSON::Serializable + + getter title : String + getter instructions : String +end + +struct Config + include JSON::Serializable + + getter model : String + getter prompts : Array(Prompt) + @[JSON::Field(key: "commonInstructions")] + getter common_instructions : String +end + +config_path = "#{ENV.fetch("XDG_CONFIG_HOME", "~/.config")}/voxtype/post-processing.json" +config = File.open(config_path) { |file| Config.from_json(file) } +client = HTTP::Client.new("localhost", ENV.fetch("OLLAMA_PORT", "11434").to_i) + +prompt_selection = Process.run(ENV["WALKER_BIN"], ["--dmenu"]) do |process| + config.prompts.each do |prompt| + process.input.puts prompt.title + end + + process.input.close + process.output.gets_to_end.chomp +end + +instructions = config.prompts.find { |prompt| prompt.title == prompt_selection }.try(&.instructions) || prompt_selection + +payload = { + model: config.model, + prompt: "#{instructions} - #{config.common_instructions}:\n\n#{STDIN.gets_to_end.chomp}", + think: false, + stream: false, +} + +client.post("/api/generate", body: payload.to_json) do |response| + if response.status_code == 200 + puts OllamaResponse.from_json(response.body_io).response.strip + else + abort "Ollama API error: #{response.status_code} #{response.body}" + end +end diff --git a/hosts/radish/packages.nix b/hosts/radish/packages.nix index 4a73c59..64b9170 100644 --- a/hosts/radish/packages.nix +++ b/hosts/radish/packages.nix @@ -51,10 +51,12 @@ in ollama = { enable = true; - acceleration = "rocm"; - environmentVariables = { - HSA_OVERRIDE_GFX_VERSION = "11.0.3"; - }; + package = pkgs-unstable.ollama-vulkan; + syncModels = true; + loadModels = [ + "qwen3:4b-instruct" + "qwen3:8b" + ]; }; borgbackup.jobs.root = { @@ -139,4 +141,31 @@ in environment.etc."1password/custom_allowed_browsers".text = '' vivaldi ''; + + systemd.services.ollama-keep-alive = + let + ollamaURL = "http://localhost:${toString config.services.ollama.port}/api/generate"; + payload = { + model = lib.elemAt config.services.ollama.loadModels 0; + keep_alive = -1; + }; + in + { + enable = true; + description = "Keep Ollama primary model loaded by pinging it"; + after = [ + "ollama.service" + "network-online.target" + ]; + wants = [ "network-online.target" ]; + bindsTo = [ "ollama.service" ]; + wantedBy = [ + "multi-user.target" + "ollama.service" + ]; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${pkgs.curl}/bin/curl -s '${ollamaURL}' -d '${builtins.toJSON payload}'"; + }; + }; }