From de7dd264bffef07f1bd9196ba731e161c2e46f71 Mon Sep 17 00:00:00 2001 From: Jelle Spreeuwenberg Date: Sun, 30 Aug 2026 12:34:51 +0200 Subject: [PATCH] note taking and transcription --- flake.lock | 58 ++-- modules/capabilities/_diarize-transcript.py | 120 ++++++++ .../capabilities/_transcribe-recordings.sh | 256 ++++++++++++++++++ modules/capabilities/ai.nix | 4 +- modules/capabilities/desktop-defaults.nix | 1 + modules/capabilities/noctalia.nix | 4 +- modules/capabilities/notes.nix | 80 ++++++ modules/capabilities/syncthing.nix | 1 + modules/capabilities/transcription.nix | 124 +++++++++ modules/hosts/zenith/default.nix | 1 + modules/profiles/workstation-base.nix | 1 + 11 files changed, 618 insertions(+), 32 deletions(-) create mode 100644 modules/capabilities/_diarize-transcript.py create mode 100644 modules/capabilities/_transcribe-recordings.sh create mode 100644 modules/capabilities/notes.nix create mode 100644 modules/capabilities/transcription.nix diff --git a/flake.lock b/flake.lock index 749161f..51e4d5f 100644 --- a/flake.lock +++ b/flake.lock @@ -79,11 +79,11 @@ "nixpkgs-lib": "nixpkgs-lib" }, "locked": { - "lastModified": 1785627969, - "narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=", + "lastModified": 1787559586, + "narHash": "sha256-onL0VLf9vPllmT0H/OlURIU5r5t5WIEl7t4tVNKT0Nw=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a", + "rev": "9d0d87172c374f89da73c1cfe6d81ae62feac1f1", "type": "github" }, "original": { @@ -139,11 +139,11 @@ ] }, "locked": { - "lastModified": 1787379775, - "narHash": "sha256-XL2qfqmocfsvuEGyomZg4nOZfkuSe8vZtrOX99I+b6I=", + "lastModified": 1787540599, + "narHash": "sha256-MHwk52ty3NsLhq9a1IbIfBJZlCUJIdiUYFo52JLK9hY=", "owner": "nix-community", "repo": "home-manager", - "rev": "ec1a8fdf74ed3f276148ee106299a2ba0e65d51f", + "rev": "03f4cd46bc1dd4f3a96da778d2ce9f7ce39dd450", "type": "github" }, "original": { @@ -178,11 +178,11 @@ "treefmt-nix": "treefmt-nix" }, "locked": { - "lastModified": 1787372739, - "narHash": "sha256-0o/VXqH7ENNuDZ9YOL2JMUo0wkhZc6FUKLUwGOwGq1A=", + "lastModified": 1787551962, + "narHash": "sha256-zfumhlJTGDKSILebSBa8nTKG76ByIPlLkGPAKjxaiwE=", "owner": "numtide", "repo": "llm-agents.nix", - "rev": "304ada966596829e870cacc580e6b8bf27186744", + "rev": "46e6a013fd49779cd2def2c98fa447f197da3fe7", "type": "github" }, "original": { @@ -198,11 +198,11 @@ ] }, "locked": { - "lastModified": 1786852476, - "narHash": "sha256-IM5CYtf86W4w8eUPpKcY/LpdHElmVBtJhaKnoTKxZEA=", + "lastModified": 1787457452, + "narHash": "sha256-FJh4esFS3zqNNuKwvN3t6wrJGewqp1AUF9DAEvoKPD8=", "owner": "nix-community", "repo": "nix-index-database", - "rev": "c7962dc97b45129df8d751bedaf37beb5a17706e", + "rev": "c51d5c2ba69c907a34e90c9b6b80cd2b93811745", "type": "github" }, "original": { @@ -218,11 +218,11 @@ ] }, "locked": { - "lastModified": 1787399276, - "narHash": "sha256-g+OahR7f3bQYMQu5/3HeEO5hUbizcbrKGsO4TdIJEqA=", + "lastModified": 1787579952, + "narHash": "sha256-Dn6IPaLyltx8hBTyC4hgNWDb7T7WHRYUG6wWh6KBHXA=", "owner": "BirdeeHub", "repo": "nix-wrapper-modules", - "rev": "fcbc7ed975df6e79f295e3062c722f246af2a7fd", + "rev": "b56e973dd2bd62244b44d6542fb52182bc3a3cda", "type": "github" }, "original": { @@ -283,11 +283,11 @@ }, "nixpkgs-stable": { "locked": { - "lastModified": 1787204541, - "narHash": "sha256-OURZPknrTjQrlNyxPdqzyqmU/81Wes1CUP/Ft1Rv/YI=", + "lastModified": 1787414105, + "narHash": "sha256-WncT27+3BOkgTaJZLnCsf3LcYf9RXMuR9ONSN4rzQ7s=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "5880666fd9eb563038431edb35c2d0aa595884e6", + "rev": "a9e6d84f9c2f9012f5fe7d964a7851352300e61a", "type": "github" }, "original": { @@ -312,11 +312,11 @@ }, "nixpkgs_3": { "locked": { - "lastModified": 1787209939, - "narHash": "sha256-8Ou9WyfCXV3MdC/pQcawzeR8EnxRsPMhLoRDZG1AWik=", - "rev": "391b592eb44808b3bd0cb80bb71b63a5a118b8bb", + "lastModified": 1787394516, + "narHash": "sha256-wS+SNrj0wobAXQONggzrpetnZ9E0FL7l0WVWDRQ77Sc=", + "rev": "c8f90650c15282fa8656a041bfbbd2403997a9a7", "type": "tarball", - "url": "https://releases.nixos.org/nixpkgs/nixpkgs-26.11pre1058520.391b592eb448/nixexprs.tar.xz" + "url": "https://releases.nixos.org/nixpkgs/nixpkgs-26.11pre1059707.c8f90650c152/nixexprs.tar.xz" }, "original": { "type": "tarball", @@ -325,11 +325,11 @@ }, "nixpkgs_4": { "locked": { - "lastModified": 1786599213, - "narHash": "sha256-yjGG65gA1jORt0hWxjzrnw3AYkIn8EWolp8vezwXZ3I=", - "rev": "0e251e24a4f24e036a084b6b4b2d2491af4167f4", + "lastModified": 1787360063, + "narHash": "sha256-95aJfHyQTLWslCXFVNB0odgmVkpdmDezQwTg9mhqV1E=", + "rev": "2c423e03bbafcff28bfadc6781a4a8257f205cb5", "type": "tarball", - "url": "https://releases.nixos.org/nixos/unstable/nixos-26.11pre1053317.0e251e24a4f2/nixexprs.tar.zst" + "url": "https://releases.nixos.org/nixos/unstable/nixos-26.11pre1059570.2c423e03bbaf/nixexprs.tar.zst" }, "original": { "type": "tarball", @@ -341,11 +341,11 @@ "nixpkgs": "nixpkgs_4" }, "locked": { - "lastModified": 1787369379, - "narHash": "sha256-1WUdzP3Y9l95gRhn8muREP0UJu8PmvzEHmdIyfaYRac=", + "lastModified": 1787575682, + "narHash": "sha256-qSjBgNe40+DxiMV+obDrJ+WCI/zynUbOtcXQDAXv5Fk=", "owner": "noctalia-dev", "repo": "noctalia", - "rev": "7fd9f7bfa9604022b9de311b30a3da0c2c4415de", + "rev": "0f61b0ae07607739189d07a0a7617ef0d8f3796c", "type": "github" }, "original": { diff --git a/modules/capabilities/_diarize-transcript.py b/modules/capabilities/_diarize-transcript.py new file mode 100644 index 0000000..84728b9 --- /dev/null +++ b/modules/capabilities/_diarize-transcript.py @@ -0,0 +1,120 @@ +import argparse +import json +import re +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class Turn: + start: float + end: float + speaker: str + + +@dataclass +class Token: + start: float + end: float + text: str + + +def load_tokens(path: Path) -> list[Token]: + with path.open(encoding="utf-8") as file: + transcript = json.load(file) + + tokens: list[Token] = [] + pending = "" + + for segment in transcript["transcription"]: + for raw_token in segment["tokens"]: + text = raw_token["text"] + offsets = raw_token.get("offsets") + + if not text: + continue + if offsets is None: + if tokens: + tokens[-1].text += text + else: + pending += text + continue + + tokens.append( + Token(offsets["from"] / 1000, offsets["to"] / 1000, pending + text) + ) + pending = "" + + if not tokens: + raise ValueError("Whisper produced no timed text") + + return tokens + + +def load_turns(audio: Path, model: Path) -> list[Turn]: + from pyannote.audio import Pipeline + + pipeline = Pipeline.from_pretrained(model) + result = pipeline(str(audio)) + turns = [ + Turn(segment.start, segment.end, speaker) + for segment, _, speaker in result.exclusive_speaker_diarization.itertracks( + yield_label=True + ) + ] + if not turns: + raise RuntimeError("Pyannote detected no speech") + + return turns + + +def token_speaker(token: Token, turns: list[Turn]) -> str: + midpoint = (token.start + token.end) / 2 + + def rank(turn: Turn) -> tuple[float, float]: + overlap = max(0.0, min(token.end, turn.end) - max(token.start, turn.start)) + distance = max(turn.start - midpoint, midpoint - turn.end, 0.0) + return overlap, -distance + + return max(turns, key=rank).speaker + + +def render(tokens: list[Token], turns: list[Turn]) -> str: + speaker_numbers: dict[str, int] = {} + blocks: list[tuple[int, str]] = [] + + for token in tokens: + speaker = token_speaker(token, turns) + number = speaker_numbers.setdefault(speaker, len(speaker_numbers) + 1) + + if blocks and blocks[-1][0] == number: + blocks[-1] = number, blocks[-1][1] + token.text + else: + blocks.append((number, token.text)) + + rendered = [] + for number, text in blocks: + text = re.sub(r"\s+", " ", text).strip() + if text: + rendered.append(f"**Speaker {number}:**\n\n{text}") + + if not rendered: + raise ValueError("Whisper produced no transcript text") + return "\n\n".join(rendered) + "\n" + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--audio", required=True, type=Path) + parser.add_argument("--model", required=True, type=Path) + parser.add_argument("--transcript", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + args = parser.parse_args() + + tokens = load_tokens(args.transcript) + turns = load_turns(args.audio, args.model) + args.output.write_text(render(tokens, turns), encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/modules/capabilities/_transcribe-recordings.sh b/modules/capabilities/_transcribe-recordings.sh new file mode 100644 index 0000000..87109b8 --- /dev/null +++ b/modules/capabilities/_transcribe-recordings.sh @@ -0,0 +1,256 @@ +path_exists() { + [[ -e "$1" || -L "$1" ]] +} + +is_recording() { + case "${1,,}" in + *.wav | *.flac | *.mp3 | *.ogg | *.opus | *.aac | *.m4a) return 0 ;; + *) return 1 ;; + esac +} + +move_no_clobber() { + local source=$1 + local destination=$2 + + if path_exists "$destination"; then + return 1 + fi + + mv -T --no-clobber -- "$source" "$destination" + ! path_exists "$source" && path_exists "$destination" +} + +fail_recording() { + local source=$1 + local failed_dir=$2 + local message=$3 + local details=${4:-} + local base target error_tmp suffix=0 + + base=$(basename "$source") + target="$failed_dir/$base" + rm -f -- "${source}.transcribe-state" + + while path_exists "$target" || path_exists "${target}.error.txt"; do + ((suffix += 1)) + target="$failed_dir/$base.failed-$suffix" + done + + if ! move_no_clobber "$source" "$target"; then + printf 'Could not quarantine %s: destination collision or move failure\n' "$source" >&2 + return 1 + fi + + error_tmp=$(mktemp "$failed_dir/.transcription-error.XXXXXX") + { + printf '%s\n' "$message" + if [[ -n "$details" && -s "$details" ]]; then + printf '\n' + cat -- "$details" + fi + } >"$error_tmp" + + if ! ln -- "$error_tmp" "${target}.error.txt"; then + printf 'Could not publish error sidecar for %s\n' "$target" >&2 + rm -f -- "$error_tmp" + return 1 + fi + rm -f -- "$error_tmp" + printf 'Moved failed recording to %s\n' "$target" >&2 +} + +recover_completed_recording() { + local source=$1 + local transcript=$2 + local archive=$3 + local state_file="${source}.transcribe-state" + + if [[ ! -f "$state_file" ]]; then + return 1 + fi + + if ! path_exists "$transcript"; then + rm -f -- "$state_file" + return 1 + fi + + if ! move_no_clobber "$source" "$archive"; then + return 2 + fi + + rm -f -- "$state_file" + printf 'Recovered completed transcription and archived %s\n' "$archive" + return 0 +} + +process_recording() ( + set -o errexit + + local source=$1 + local inbox_dir=$2 + local transcript_dir=$3 + local archive_dir=$4 + local failed_dir=$5 + local base stem transcript archive original_path recorded_at transcribed_at + local work_dir log_file raw_output rendered_output draft state_file recovery_status + + base=$(basename "$source") + stem=${base%.*} + transcript="$transcript_dir/$stem.md" + archive="$archive_dir/$base" + original_path="$inbox_dir/$base" + state_file="${source}.transcribe-state" + + if recover_completed_recording "$source" "$transcript" "$archive"; then + return 0 + else + recovery_status=$? + fi + if [[ $recovery_status -eq 2 ]]; then + fail_recording "$source" "$failed_dir" "Archive collision after transcription: $archive" + return 1 + fi + + if path_exists "$transcript"; then + fail_recording "$source" "$failed_dir" "Transcript already exists: $transcript" + return 1 + fi + if path_exists "$archive"; then + fail_recording "$source" "$failed_dir" "Archived recording already exists: $archive" + return 1 + fi + + recorded_at=$(date --date="@$(stat -c %Y -- "$source")" --iso-8601=seconds) + transcribed_at=$(date --utc --iso-8601=seconds) + work_dir=$(mktemp -d "$runtime_dir/work.XXXXXX") + trap 'rm -r -- "$work_dir"' EXIT + log_file="$work_dir/transcription.log" + raw_output="$work_dir/transcript" + rendered_output="$work_dir/transcript.md" + + if ! ffmpeg \ + -nostdin \ + -hide_banner \ + -loglevel error \ + -i "$source" \ + -vn \ + -ac 1 \ + -ar 16000 \ + -c:a pcm_s16le \ + "$work_dir/audio.wav" >"$log_file" 2>&1; then + fail_recording "$source" "$failed_dir" "FFmpeg conversion failed" "$log_file" + return 1 + fi + + if ! whisper-cli \ + --model "$TRANSCRIBE_MODEL" \ + --file "$work_dir/audio.wav" \ + --language auto \ + --output-json-full \ + --output-file "$raw_output" >>"$log_file" 2>&1 || [[ ! -s "${raw_output}.json" ]]; then + fail_recording "$source" "$failed_dir" "Whisper transcription failed" "$log_file" + return 1 + fi + + if ! diarize-transcript \ + --audio "$work_dir/audio.wav" \ + --model "$TRANSCRIBE_DIARIZATION_MODEL" \ + --transcript "${raw_output}.json" \ + --output "$rendered_output" >>"$log_file" 2>&1 || [[ ! -s "$rendered_output" ]]; then + fail_recording "$source" "$failed_dir" "Speaker diarization failed" "$log_file" + return 1 + fi + + draft=$(mktemp "$transcript_dir/.transcript.XXXXXX") + { + printf '# %s\n\n' "$stem" + printf -- '- Original recording: %s\n' "$original_path" + printf -- '- Recording timestamp: %s\n' "$recorded_at" + printf -- '- Transcription timestamp: %s\n' "$transcribed_at" + printf -- '- Engine: whisper.cpp %s (Vulkan)\n' "$TRANSCRIBE_ENGINE_VERSION" + printf -- '- Model: %s\n' "$TRANSCRIBE_MODEL_NAME" + printf -- '- Model SHA-256: %s\n' "$TRANSCRIBE_MODEL_SHA256" + printf -- '- Diarization engine: pyannote.audio %s (CPU)\n' "$TRANSCRIBE_DIARIZATION_ENGINE_VERSION" + printf -- '- Diarization model: %s\n' "$TRANSCRIBE_DIARIZATION_MODEL_NAME" + printf -- '- Diarization model revision: %s\n\n' "$TRANSCRIBE_DIARIZATION_MODEL_REVISION" + cat -- "$rendered_output" + } >"$draft" + chmod 0644 "$draft" + + touch -- "$state_file" + + if ! ln -- "$draft" "$transcript"; then + rm -f -- "$draft" "$state_file" + fail_recording "$source" "$failed_dir" "Transcript appeared while transcription was running: $transcript" "$log_file" + return 1 + fi + rm -f -- "$draft" + + if ! move_no_clobber "$source" "$archive"; then + printf 'Could not archive %s; leaving recoverable state in processing\n' "$source" >&2 + return 1 + fi + + rm -f -- "$state_file" + printf 'Transcribed %s to %s\n' "$archive" "$transcript" +) + +process_scope() { + local recordings_dir=$1 + local transcript_dir=$2 + local inbox_dir="$recordings_dir/inbox" + local processing_dir="$recordings_dir/processing" + local archive_dir="$recordings_dir/archive" + local failed_dir="$recordings_dir/failed" + local source processing_target recording_status scope_status=0 + + if ! mkdir -p "$inbox_dir" "$processing_dir" "$archive_dir" "$failed_dir" "$transcript_dir"; then + return 1 + fi + + while IFS= read -r -d '' source; do + if is_recording "$source"; then + process_recording "$source" "$inbox_dir" "$transcript_dir" "$archive_dir" "$failed_dir" + recording_status=$? + if [[ $recording_status -ne 0 ]]; then + scope_status=1 + fi + fi + done < <(find "$processing_dir" -maxdepth 1 -type f -print0 | sort -z) + + while IFS= read -r -d '' source; do + if ! is_recording "$source"; then + continue + fi + + processing_target="$processing_dir/$(basename "$source")" + if ! move_no_clobber "$source" "$processing_target"; then + fail_recording "$source" "$failed_dir" "Processing filename already exists: $processing_target" + scope_status=1 + continue + fi + process_recording "$processing_target" "$inbox_dir" "$transcript_dir" "$archive_dir" "$failed_dir" + recording_status=$? + if [[ $recording_status -ne 0 ]]; then + scope_status=1 + fi + done < <(find "$inbox_dir" -maxdepth 1 -type f -print0 | sort -z) + + return "$scope_status" +} + +runtime_dir="${XDG_RUNTIME_DIR:?}/transcribe-recordings" +mkdir -p "$runtime_dir" + +if [[ ! -f "$TRANSCRIBE_DIARIZATION_MODEL/.complete" || ! -f "$TRANSCRIBE_DIARIZATION_MODEL/config.yaml" ]]; then + printf 'Speaker diarization model is not installed; run prepare-transcription-diarization-model\n' >&2 + exit 1 +fi + +set +o errexit +process_scope "$TRANSCRIBE_SYNC_ROOT/recordings" "$TRANSCRIBE_SYNC_ROOT/notes/transcripts" +personal_status=$? +process_scope "$TRANSCRIBE_SYNC_ROOT/work/recordings" "$TRANSCRIBE_SYNC_ROOT/work/notes/transcripts" +work_status=$? +exit "$((personal_status != 0 || work_status != 0))" diff --git a/modules/capabilities/ai.nix b/modules/capabilities/ai.nix index 35a14b6..94ceb7d 100644 --- a/modules/capabilities/ai.nix +++ b/modules/capabilities/ai.nix @@ -63,8 +63,10 @@ in home.packages = [ pkgs.llm-agents.codex - pkgs.ctx7 pkgs.llm-agents.pi + pkgs.llm-agents.claude-code + pkgs.llm-agents.herdr + pkgs.ctx7 ]; }; } diff --git a/modules/capabilities/desktop-defaults.nix b/modules/capabilities/desktop-defaults.nix index a924c27..91c8ef9 100644 --- a/modules/capabilities/desktop-defaults.nix +++ b/modules/capabilities/desktop-defaults.nix @@ -79,6 +79,7 @@ gimp dbeaver-bin onlyoffice-desktopeditors + obsidian ]; }; diff --git a/modules/capabilities/noctalia.nix b/modules/capabilities/noctalia.nix index 8bc2480..01ccc19 100644 --- a/modules/capabilities/noctalia.nix +++ b/modules/capabilities/noctalia.nix @@ -4,6 +4,7 @@ config, inputs, lib, + pkgs, ... }: let @@ -11,12 +12,11 @@ wallpaper = "${homeDir}/media/images/wallpapers/green-leaves.png"; in { - imports = [ inputs.noctalia.homeModules.default ]; - desktop.autostart = [ [ (lib.getExe config.programs.noctalia.package) ] ]; programs.noctalia = { enable = true; + package = inputs.noctalia.packages.${pkgs.stdenv.hostPlatform.system}.default; settings = { audio = { enable_sounds = true; diff --git a/modules/capabilities/notes.nix b/modules/capabilities/notes.nix new file mode 100644 index 0000000..97ce568 --- /dev/null +++ b/modules/capabilities/notes.nix @@ -0,0 +1,80 @@ +{ config, ... }: +let + homeModules = config.flake.modules.homeManager; +in +{ + flake.modules.homeManager.notes = + { + config, + lib, + pkgs, + ... + }: + let + home = config.home.homeDirectory; + zkConfig = '' + [note] + filename = "{{format-date now 'timestamp'}}-{{slug title}}" + template = "default.md" + + [tool] + fzf-preview = "bat --plain --color=always {-1}" + ''; + noteTemplate = '' + --- + date: {{format-date now}} + --- + + # {{title}} + ''; + in + { + home.packages = with pkgs; [ + ripgrep-all + zk + ]; + + home.file = { + "sync/notes/.zk/config.toml".text = zkConfig; + "sync/notes/.zk/templates/default.md".text = noteTemplate; + "sync/work/notes/.zk/config.toml".text = zkConfig; + "sync/work/notes/.zk/templates/default.md".text = noteTemplate; + }; + + home.activation.createNotesLayout = lib.hm.dag.entryAfter [ "writeBoundary" ] '' + ${pkgs.coreutils}/bin/mkdir -p \ + "${home}/sync/notes/transcripts" \ + "${home}/sync/recordings/inbox" \ + "${home}/sync/recordings/processing" \ + "${home}/sync/recordings/archive" \ + "${home}/sync/recordings/failed" \ + "${home}/sync/work/notes/meetings" \ + "${home}/sync/work/notes/transcripts" \ + "${home}/sync/work/recordings/inbox" \ + "${home}/sync/work/recordings/processing" \ + "${home}/sync/work/recordings/archive" \ + "${home}/sync/work/recordings/failed" + ''; + + programs.helix.languages = { + language-server.zk = { + command = lib.getExe pkgs.zk; + args = [ "lsp" ]; + }; + + language = [ + { + name = "markdown"; + roots = [ ".zk" ]; + language-servers = [ "zk" ]; + } + ]; + }; + }; + + flake.modules.nixos.notes = + { ... }: + { + home-manager.sharedModules = [ homeModules.notes ]; + }; +} diff --git a/modules/capabilities/syncthing.nix b/modules/capabilities/syncthing.nix index 2f6c00e..b2eaba4 100644 --- a/modules/capabilities/syncthing.nix +++ b/modules/capabilities/syncthing.nix @@ -39,6 +39,7 @@ in path = "${config.home.homeDirectory}/sync"; label = "sync"; devices = builtins.attrNames syncDevices; + ignorePatterns = [ "**/.zk" ]; }; bruno = { path = diff --git a/modules/capabilities/transcription.nix b/modules/capabilities/transcription.nix new file mode 100644 index 0000000..6662d40 --- /dev/null +++ b/modules/capabilities/transcription.nix @@ -0,0 +1,124 @@ +{ config, ... }: +let + homeModules = config.flake.modules.homeManager; +in +{ + flake.modules.homeManager.transcription = + { + config, + lib, + pkgs, + ... + }: + let + modelName = "large-v3-turbo-q5_0"; + modelHash = "sha256-OUIhcJzVrR9AxG5gMcphvOiJMebgiMGIKUxtWlX/p+I="; + modelSha256 = builtins.convertHash { + hash = modelHash; + toHashFormat = "base16"; + }; + diarizationModelName = "pyannote/speaker-diarization-community-1"; + diarizationModelRevision = "3533c8cf8e369892e6b79ff1bf80f7b0286a54ee"; + diarizationModel = "${config.xdg.dataHome}/transcribe-recordings/speaker-diarization-community-1"; + model = pkgs.fetchurl { + name = "ggml-${modelName}.bin"; + url = "https://huggingface.co/ggerganov/whisper.cpp/resolve/98aa99a0a9db05ae2342309f5096248665f7cba3/ggml-${modelName}.bin"; + hash = modelHash; + }; + diarizationPython = pkgs.python3.withPackages (pythonPackages: [ + pythonPackages.pyannote-audio + ]); + diarizeTranscript = pkgs.writeTextFile { + name = "diarize-transcript"; + destination = "/bin/diarize-transcript"; + executable = true; + text = '' + #!${diarizationPython}/bin/python3 + ${builtins.readFile ./_diarize-transcript.py} + ''; + }; + prepareDiarizationModel = pkgs.writeShellApplication { + name = "prepare-transcription-diarization-model"; + runtimeInputs = with pkgs; [ + coreutils + python3Packages.huggingface-hub + ]; + text = '' + model_dir=${lib.escapeShellArg diarizationModel} + + if [[ -f "$model_dir/.complete" ]]; then + printf 'Speaker diarization model is already installed at %s\n' "$model_dir" + exit 0 + fi + + printf 'Accept the model terms at https://huggingface.co/${diarizationModelName}\n' + if ! hf auth whoami >/dev/null 2>&1; then + hf auth login + fi + + mkdir -p "$model_dir" + hf download \ + ${lib.escapeShellArg diarizationModelName} \ + --revision ${lib.escapeShellArg diarizationModelRevision} \ + --local-dir "$model_dir" + touch "$model_dir/.complete" + printf 'Installed speaker diarization model at %s\n' "$model_dir" + ''; + }; + transcribeRecordings = pkgs.writeShellApplication { + name = "transcribe-recordings"; + runtimeInputs = with pkgs; [ + coreutils + diarizeTranscript + ffmpeg + findutils + whisper-cpp-vulkan + ]; + text = builtins.readFile ./_transcribe-recordings.sh; + }; + in + { + home.packages = with pkgs; [ + ffmpeg + prepareDiarizationModel + whisper-cpp-vulkan + ]; + + systemd.user.services.transcribe-recordings = { + Unit.Description = "Transcribe synchronized recordings with whisper.cpp"; + + Service = { + Type = "oneshot"; + ExecStart = lib.getExe transcribeRecordings; + Environment = [ + "TRANSCRIBE_ENGINE_VERSION=${pkgs.whisper-cpp-vulkan.version}" + "TRANSCRIBE_MODEL=${model}" + ''"TRANSCRIBE_MODEL_NAME=${modelName} multilingual"'' + "TRANSCRIBE_MODEL_SHA256=${modelSha256}" + "TRANSCRIBE_DIARIZATION_ENGINE_VERSION=${pkgs.python3Packages.pyannote-audio.version}" + "TRANSCRIBE_DIARIZATION_MODEL=${diarizationModel}" + "TRANSCRIBE_DIARIZATION_MODEL_NAME=${diarizationModelName}" + "TRANSCRIBE_DIARIZATION_MODEL_REVISION=${diarizationModelRevision}" + "TRANSCRIBE_SYNC_ROOT=${config.home.homeDirectory}/sync" + ]; + }; + }; + + systemd.user.timers.transcribe-recordings = { + Unit.Description = "Transcribe new recordings every two minutes"; + + Timer = { + OnCalendar = "*:0/2"; + Persistent = true; + }; + + Install.WantedBy = [ "timers.target" ]; + }; + }; + + flake.modules.nixos.transcription = + { ... }: + { + home-manager.sharedModules = [ homeModules.transcription ]; + }; +} diff --git a/modules/hosts/zenith/default.nix b/modules/hosts/zenith/default.nix index 2eb9ef4..1866b2e 100644 --- a/modules/hosts/zenith/default.nix +++ b/modules/hosts/zenith/default.nix @@ -36,6 +36,7 @@ in imports = [ nixosModules.workstation-base nixosModules.laptop-power + nixosModules.transcription { hardware.enableRedistributableFirmware = true; services.fwupd.enable = true; diff --git a/modules/profiles/workstation-base.nix b/modules/profiles/workstation-base.nix index b5c84db..a909d81 100644 --- a/modules/profiles/workstation-base.nix +++ b/modules/profiles/workstation-base.nix @@ -16,6 +16,7 @@ in nixosModules.network-manager nixosModules.nftables-firewall nixosModules.niri + nixosModules.notes nixosModules.plymouth nixosModules.printing nixosModules.sddm