From 656f144267907d2f3314a491e2b046877713158f Mon Sep 17 00:00:00 2001 From: Jelle Spreeuwenberg Date: Sun, 6 Sep 2026 01:04:41 +0200 Subject: [PATCH] feat: prepare Orion transcription pilot --- .../capabilities/_transcribe-recordings.sh | 114 ++++++++++++------ modules/capabilities/notes.nix | 6 +- modules/capabilities/transcription.nix | 46 +++++-- modules/hosts/orion/default.nix | 1 + 4 files changed, 117 insertions(+), 50 deletions(-) diff --git a/modules/capabilities/_transcribe-recordings.sh b/modules/capabilities/_transcribe-recordings.sh index 87109b8..8307fe5 100644 --- a/modules/capabilities/_transcribe-recordings.sh +++ b/modules/capabilities/_transcribe-recordings.sh @@ -92,8 +92,9 @@ process_recording() ( local transcript_dir=$3 local archive_dir=$4 local failed_dir=$5 - local base stem transcript archive original_path recorded_at transcribed_at + local base stem transcript archive original_path recorded_at transcribed_at duration local work_dir log_file raw_output rendered_output draft state_file recovery_status + local diarization_succeeded=false base=$(basename "$source") stem=${base%.*} @@ -129,6 +130,20 @@ process_recording() ( raw_output="$work_dir/transcript" rendered_output="$work_dir/transcript.md" + if ! duration=$(ffprobe \ + -v error \ + -show_entries format=duration \ + -of default=noprint_wrappers=1:nokey=1 \ + "$source" 2>"$log_file"); then + fail_recording "$source" "$failed_dir" "Media validation failed" "$log_file" + return 1 + fi + if [[ ! $duration =~ ^[0-9]+([.][0-9]+)?$ || -z ${duration//[.0]/} ]]; then + printf 'Invalid media duration: %s\n' "$duration" >"$log_file" + fail_recording "$source" "$failed_dir" "Media validation failed" "$log_file" + return 1 + fi + if ! ffmpeg \ -nostdin \ -hide_banner \ @@ -148,18 +163,24 @@ process_recording() ( --file "$work_dir/audio.wav" \ --language auto \ --output-json-full \ - --output-file "$raw_output" >>"$log_file" 2>&1 || [[ ! -s "${raw_output}.json" ]]; then + --output-txt \ + --output-file "$raw_output" >>"$log_file" 2>&1 \ + || [[ ! -s "${raw_output}.json" || ! -s "${raw_output}.txt" ]]; 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 + if [[ -f "$TRANSCRIBE_DIARIZATION_MODEL/.complete" && -f "$TRANSCRIBE_DIARIZATION_MODEL/config.yaml" ]] \ + && 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 + diarization_succeeded=true + else + printf 'Speaker diarization unavailable; publishing unlabeled transcript\n' >&2 + cp -- "${raw_output}.txt" "$rendered_output" fi draft=$(mktemp "$transcript_dir/.transcript.XXXXXX") @@ -168,12 +189,17 @@ process_recording() ( 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 -- '- Duration: %s seconds\n' "$duration" + printf -- '- Engine: whisper.cpp %s (%s)\n' "$TRANSCRIBE_ENGINE_VERSION" "$TRANSCRIBE_ENGINE_ACCELERATION" 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" + if [[ $diarization_succeeded == true ]]; then + 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" + else + printf -- '- Diarization: unavailable; unlabeled fallback published\n\n' + fi cat -- "$rendered_output" } >"$draft" chmod 0644 "$draft" @@ -203,7 +229,8 @@ process_scope() { 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 + local source processing_target recording_status signature index scope_status=0 + local -a sources signatures if ! mkdir -p "$inbox_dir" "$processing_dir" "$archive_dir" "$failed_dir" "$transcript_dir"; then return 1 @@ -219,23 +246,42 @@ process_scope() { 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 + while true; do + sources=() + signatures=() + + while IFS= read -r -d '' source; do + if is_recording "$source" && signature=$(stat -c '%s:%y:%i' -- "$source"); then + sources+=("$source") + signatures+=("$signature") + fi + done < <(find "$inbox_dir" -maxdepth 1 -type f -print0 | sort -z) + + if [[ ${#sources[@]} -eq 0 ]]; then + break 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) + sleep "${TRANSCRIBE_SETTLE_SECONDS:-300}" + + for index in "${!sources[@]}"; do + source=${sources[$index]} + if [[ ! -f "$source" ]] || [[ $(stat -c '%s:%y:%i' -- "$source") != "${signatures[$index]}" ]]; 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 + done return "$scope_status" } @@ -243,14 +289,6 @@ process_scope() { 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))" +exit $? diff --git a/modules/capabilities/notes.nix b/modules/capabilities/notes.nix index 5a296ae..2889bd1 100644 --- a/modules/capabilities/notes.nix +++ b/modules/capabilities/notes.nix @@ -64,11 +64,7 @@ in ${pkgs.coreutils}/bin/mkdir -p \ "${home}/sync/notes/daily" \ "${home}/sync/notes/meetings" \ - "${home}/sync/notes/transcripts" \ - "${home}/sync/recordings/inbox" \ - "${home}/sync/recordings/processing" \ - "${home}/sync/recordings/archive" \ - "${home}/sync/recordings/failed" + "${home}/sync/notes/transcripts" ''; }; diff --git a/modules/capabilities/transcription.nix b/modules/capabilities/transcription.nix index 6662d40..8b26d84 100644 --- a/modules/capabilities/transcription.nix +++ b/modules/capabilities/transcription.nix @@ -11,6 +11,7 @@ in ... }: let + isOrion = config.facts.machine.name == "orion"; modelName = "large-v3-turbo-q5_0"; modelHash = "sha256-OUIhcJzVrR9AxG5gMcphvOiJMebgiMGIKUxtWlX/p+I="; modelSha256 = builtins.convertHash { @@ -37,6 +38,8 @@ in ${builtins.readFile ./_diarize-transcript.py} ''; }; + whisper = if isOrion then pkgs.whisper-cpp else pkgs.whisper-cpp-vulkan; + acceleration = if isOrion then "CPU" else "Vulkan"; prepareDiarizationModel = pkgs.writeShellApplication { name = "prepare-transcription-diarization-model"; runtimeInputs = with pkgs; [ @@ -72,18 +75,28 @@ in diarizeTranscript ffmpeg findutils - whisper-cpp-vulkan + whisper ]; text = builtins.readFile ./_transcribe-recordings.sh; }; + home = config.home.homeDirectory; in { - home.packages = with pkgs; [ - ffmpeg + home.packages = [ + pkgs.ffmpeg prepareDiarizationModel - whisper-cpp-vulkan + whisper ]; + home.activation.createTranscriptionLayout = 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" + ''; + systemd.user.services.transcribe-recordings = { Unit.Description = "Transcribe synchronized recordings with whisper.cpp"; @@ -91,7 +104,8 @@ in Type = "oneshot"; ExecStart = lib.getExe transcribeRecordings; Environment = [ - "TRANSCRIBE_ENGINE_VERSION=${pkgs.whisper-cpp-vulkan.version}" + "TRANSCRIBE_ENGINE_ACCELERATION=${acceleration}" + "TRANSCRIBE_ENGINE_VERSION=${whisper.version}" "TRANSCRIBE_MODEL=${model}" ''"TRANSCRIBE_MODEL_NAME=${modelName} multilingual"'' "TRANSCRIBE_MODEL_SHA256=${modelSha256}" @@ -99,12 +113,30 @@ in "TRANSCRIBE_DIARIZATION_MODEL=${diarizationModel}" "TRANSCRIBE_DIARIZATION_MODEL_NAME=${diarizationModelName}" "TRANSCRIBE_DIARIZATION_MODEL_REVISION=${diarizationModelRevision}" - "TRANSCRIBE_SYNC_ROOT=${config.home.homeDirectory}/sync" + "TRANSCRIBE_SETTLE_SECONDS=300" + "TRANSCRIBE_SYNC_ROOT=${home}/sync" ]; + } + // lib.optionalAttrs isOrion { + Restart = "on-failure"; + RestartSec = 60; }; + + Install.WantedBy = lib.optionals isOrion [ "default.target" ]; }; - systemd.user.timers.transcribe-recordings = { + systemd.user.paths.transcribe-recordings = lib.mkIf isOrion { + Unit.Description = "Watch for synchronized recordings"; + + Path = { + PathChanged = "${home}/sync/recordings/inbox"; + Unit = "transcribe-recordings.service"; + }; + + Install.WantedBy = [ "paths.target" ]; + }; + + systemd.user.timers.transcribe-recordings = lib.mkIf (!isOrion) { Unit.Description = "Transcribe new recordings every two minutes"; Timer = { diff --git a/modules/hosts/orion/default.nix b/modules/hosts/orion/default.nix index 3d1ae76..a0c6d6b 100644 --- a/modules/hosts/orion/default.nix +++ b/modules/hosts/orion/default.nix @@ -27,6 +27,7 @@ in nixosModules.radicale nixosModules.actual nixosModules.gitea + nixosModules.transcription ./_hardware.nix ./_disk.nix ];