note taking and transcription
This commit is contained in:
1 parent
2985b91127
commit
de7dd264bf
11 files changed
+618
-32
No files matched your search
Generated
+29
-29
@@ -79,11 +79,11 @@
|
|||||||
"nixpkgs-lib": "nixpkgs-lib"
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785627969,
|
"lastModified": 1787559586,
|
||||||
"narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=",
|
"narHash": "sha256-onL0VLf9vPllmT0H/OlURIU5r5t5WIEl7t4tVNKT0Nw=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a",
|
"rev": "9d0d87172c374f89da73c1cfe6d81ae62feac1f1",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -139,11 +139,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1787379775,
|
"lastModified": 1787540599,
|
||||||
"narHash": "sha256-XL2qfqmocfsvuEGyomZg4nOZfkuSe8vZtrOX99I+b6I=",
|
"narHash": "sha256-MHwk52ty3NsLhq9a1IbIfBJZlCUJIdiUYFo52JLK9hY=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "home-manager",
|
"repo": "home-manager",
|
||||||
"rev": "ec1a8fdf74ed3f276148ee106299a2ba0e65d51f",
|
"rev": "03f4cd46bc1dd4f3a96da778d2ce9f7ce39dd450",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -178,11 +178,11 @@
|
|||||||
"treefmt-nix": "treefmt-nix"
|
"treefmt-nix": "treefmt-nix"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1787372739,
|
"lastModified": 1787551962,
|
||||||
"narHash": "sha256-0o/VXqH7ENNuDZ9YOL2JMUo0wkhZc6FUKLUwGOwGq1A=",
|
"narHash": "sha256-zfumhlJTGDKSILebSBa8nTKG76ByIPlLkGPAKjxaiwE=",
|
||||||
"owner": "numtide",
|
"owner": "numtide",
|
||||||
"repo": "llm-agents.nix",
|
"repo": "llm-agents.nix",
|
||||||
"rev": "304ada966596829e870cacc580e6b8bf27186744",
|
"rev": "46e6a013fd49779cd2def2c98fa447f197da3fe7",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -198,11 +198,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1786852476,
|
"lastModified": 1787457452,
|
||||||
"narHash": "sha256-IM5CYtf86W4w8eUPpKcY/LpdHElmVBtJhaKnoTKxZEA=",
|
"narHash": "sha256-FJh4esFS3zqNNuKwvN3t6wrJGewqp1AUF9DAEvoKPD8=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nix-index-database",
|
"repo": "nix-index-database",
|
||||||
"rev": "c7962dc97b45129df8d751bedaf37beb5a17706e",
|
"rev": "c51d5c2ba69c907a34e90c9b6b80cd2b93811745",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -218,11 +218,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1787399276,
|
"lastModified": 1787579952,
|
||||||
"narHash": "sha256-g+OahR7f3bQYMQu5/3HeEO5hUbizcbrKGsO4TdIJEqA=",
|
"narHash": "sha256-Dn6IPaLyltx8hBTyC4hgNWDb7T7WHRYUG6wWh6KBHXA=",
|
||||||
"owner": "BirdeeHub",
|
"owner": "BirdeeHub",
|
||||||
"repo": "nix-wrapper-modules",
|
"repo": "nix-wrapper-modules",
|
||||||
"rev": "fcbc7ed975df6e79f295e3062c722f246af2a7fd",
|
"rev": "b56e973dd2bd62244b44d6542fb52182bc3a3cda",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -283,11 +283,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-stable": {
|
"nixpkgs-stable": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1787204541,
|
"lastModified": 1787414105,
|
||||||
"narHash": "sha256-OURZPknrTjQrlNyxPdqzyqmU/81Wes1CUP/Ft1Rv/YI=",
|
"narHash": "sha256-WncT27+3BOkgTaJZLnCsf3LcYf9RXMuR9ONSN4rzQ7s=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "5880666fd9eb563038431edb35c2d0aa595884e6",
|
"rev": "a9e6d84f9c2f9012f5fe7d964a7851352300e61a",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -312,11 +312,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_3": {
|
"nixpkgs_3": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1787209939,
|
"lastModified": 1787394516,
|
||||||
"narHash": "sha256-8Ou9WyfCXV3MdC/pQcawzeR8EnxRsPMhLoRDZG1AWik=",
|
"narHash": "sha256-wS+SNrj0wobAXQONggzrpetnZ9E0FL7l0WVWDRQ77Sc=",
|
||||||
"rev": "391b592eb44808b3bd0cb80bb71b63a5a118b8bb",
|
"rev": "c8f90650c15282fa8656a041bfbbd2403997a9a7",
|
||||||
"type": "tarball",
|
"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": {
|
"original": {
|
||||||
"type": "tarball",
|
"type": "tarball",
|
||||||
@@ -325,11 +325,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_4": {
|
"nixpkgs_4": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1786599213,
|
"lastModified": 1787360063,
|
||||||
"narHash": "sha256-yjGG65gA1jORt0hWxjzrnw3AYkIn8EWolp8vezwXZ3I=",
|
"narHash": "sha256-95aJfHyQTLWslCXFVNB0odgmVkpdmDezQwTg9mhqV1E=",
|
||||||
"rev": "0e251e24a4f24e036a084b6b4b2d2491af4167f4",
|
"rev": "2c423e03bbafcff28bfadc6781a4a8257f205cb5",
|
||||||
"type": "tarball",
|
"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": {
|
"original": {
|
||||||
"type": "tarball",
|
"type": "tarball",
|
||||||
@@ -341,11 +341,11 @@
|
|||||||
"nixpkgs": "nixpkgs_4"
|
"nixpkgs": "nixpkgs_4"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1787369379,
|
"lastModified": 1787575682,
|
||||||
"narHash": "sha256-1WUdzP3Y9l95gRhn8muREP0UJu8PmvzEHmdIyfaYRac=",
|
"narHash": "sha256-qSjBgNe40+DxiMV+obDrJ+WCI/zynUbOtcXQDAXv5Fk=",
|
||||||
"owner": "noctalia-dev",
|
"owner": "noctalia-dev",
|
||||||
"repo": "noctalia",
|
"repo": "noctalia",
|
||||||
"rev": "7fd9f7bfa9604022b9de311b30a3da0c2c4415de",
|
"rev": "0f61b0ae07607739189d07a0a7617ef0d8f3796c",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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))"
|
||||||
@@ -63,8 +63,10 @@ in
|
|||||||
|
|
||||||
home.packages = [
|
home.packages = [
|
||||||
pkgs.llm-agents.codex
|
pkgs.llm-agents.codex
|
||||||
pkgs.ctx7
|
|
||||||
pkgs.llm-agents.pi
|
pkgs.llm-agents.pi
|
||||||
|
pkgs.llm-agents.claude-code
|
||||||
|
pkgs.llm-agents.herdr
|
||||||
|
pkgs.ctx7
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -79,6 +79,7 @@
|
|||||||
gimp
|
gimp
|
||||||
dbeaver-bin
|
dbeaver-bin
|
||||||
onlyoffice-desktopeditors
|
onlyoffice-desktopeditors
|
||||||
|
obsidian
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
config,
|
config,
|
||||||
inputs,
|
inputs,
|
||||||
lib,
|
lib,
|
||||||
|
pkgs,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
@@ -11,12 +12,11 @@
|
|||||||
wallpaper = "${homeDir}/media/images/wallpapers/green-leaves.png";
|
wallpaper = "${homeDir}/media/images/wallpapers/green-leaves.png";
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
imports = [ inputs.noctalia.homeModules.default ];
|
|
||||||
|
|
||||||
desktop.autostart = [ [ (lib.getExe config.programs.noctalia.package) ] ];
|
desktop.autostart = [ [ (lib.getExe config.programs.noctalia.package) ] ];
|
||||||
|
|
||||||
programs.noctalia = {
|
programs.noctalia = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
package = inputs.noctalia.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||||
settings = {
|
settings = {
|
||||||
audio = {
|
audio = {
|
||||||
enable_sounds = true;
|
enable_sounds = true;
|
||||||
|
|||||||
@@ -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 ];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ in
|
|||||||
path = "${config.home.homeDirectory}/sync";
|
path = "${config.home.homeDirectory}/sync";
|
||||||
label = "sync";
|
label = "sync";
|
||||||
devices = builtins.attrNames syncDevices;
|
devices = builtins.attrNames syncDevices;
|
||||||
|
ignorePatterns = [ "**/.zk" ];
|
||||||
};
|
};
|
||||||
bruno = {
|
bruno = {
|
||||||
path =
|
path =
|
||||||
|
|||||||
@@ -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 ];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ in
|
|||||||
imports = [
|
imports = [
|
||||||
nixosModules.workstation-base
|
nixosModules.workstation-base
|
||||||
nixosModules.laptop-power
|
nixosModules.laptop-power
|
||||||
|
nixosModules.transcription
|
||||||
{
|
{
|
||||||
hardware.enableRedistributableFirmware = true;
|
hardware.enableRedistributableFirmware = true;
|
||||||
services.fwupd.enable = true;
|
services.fwupd.enable = true;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ in
|
|||||||
nixosModules.network-manager
|
nixosModules.network-manager
|
||||||
nixosModules.nftables-firewall
|
nixosModules.nftables-firewall
|
||||||
nixosModules.niri
|
nixosModules.niri
|
||||||
|
nixosModules.notes
|
||||||
nixosModules.plymouth
|
nixosModules.plymouth
|
||||||
nixosModules.printing
|
nixosModules.printing
|
||||||
nixosModules.sddm
|
nixosModules.sddm
|
||||||
|
|||||||
Reference in new issue
Block a user