note taking and transcription

This commit is contained in:
kiri committed 2026-08-30 12:34:51 +02:00
1 parent 2985b91127
commit de7dd264bf
11 files changed
+618 -32

No files matched your search

+120
View File
@@ -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))"
+3 -1
View File
@@ -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
];
};
}
@@ -79,6 +79,7 @@
gimp
dbeaver-bin
onlyoffice-desktopeditors
obsidian
];
};
+2 -2
View File
@@ -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;
+80
View File
@@ -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 ];
};
}
+1
View File
@@ -39,6 +39,7 @@ in
path = "${config.home.homeDirectory}/sync";
label = "sync";
devices = builtins.attrNames syncDevices;
ignorePatterns = [ "**/.zk" ];
};
bruno = {
path =
+124
View File
@@ -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 ];
};
}
+1
View File
@@ -36,6 +36,7 @@ in
imports = [
nixosModules.workstation-base
nixosModules.laptop-power
nixosModules.transcription
{
hardware.enableRedistributableFirmware = true;
services.fwupd.enable = true;
+1
View File
@@ -16,6 +16,7 @@ in
nixosModules.network-manager
nixosModules.nftables-firewall
nixosModules.niri
nixosModules.notes
nixosModules.plymouth
nixosModules.printing
nixosModules.sddm