updates
This commit is contained in:
1 parent
f164d8b76e
commit
5748b3409b
60 files changed
+1346
-590
No files matched your search
@@ -0,0 +1,132 @@
|
||||
{ config, ... }:
|
||||
{
|
||||
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}
|
||||
'';
|
||||
};
|
||||
whisper = pkgs.whisper-cpp;
|
||||
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
|
||||
];
|
||||
text = builtins.readFile ./transcribe-recordings.sh;
|
||||
};
|
||||
home = config.home.homeDirectory;
|
||||
in
|
||||
{
|
||||
home.packages = [
|
||||
pkgs.ffmpeg
|
||||
prepareDiarizationModel
|
||||
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";
|
||||
|
||||
Service = {
|
||||
Type = "oneshot";
|
||||
ExecStart = lib.getExe transcribeRecordings;
|
||||
Environment = [
|
||||
"TRANSCRIBE_ENGINE_ACCELERATION=CPU"
|
||||
"TRANSCRIBE_ENGINE_VERSION=${whisper.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=${home}/sync"
|
||||
];
|
||||
Restart = "on-failure";
|
||||
RestartSec = 60;
|
||||
};
|
||||
|
||||
Install.WantedBy = [ "default.target" ];
|
||||
};
|
||||
|
||||
systemd.user.paths.transcribe-recordings = {
|
||||
Unit.Description = "Watch for synchronized recordings";
|
||||
|
||||
Path.PathChanged = "${home}/sync/recordings/inbox";
|
||||
|
||||
Install.WantedBy = [ "paths.target" ];
|
||||
};
|
||||
};
|
||||
|
||||
flake.modules.nixos.transcription = {
|
||||
home-manager.sharedModules = [ config.flake.modules.homeManager.transcription ];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from itertools import groupby
|
||||
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 = ""
|
||||
# Whisper reserves all IDs from end-of-text onward for control tokens.
|
||||
end_of_text = 50256 + int(transcript["model"]["multilingual"])
|
||||
|
||||
for segment in transcript["transcription"]:
|
||||
for raw_token in segment["tokens"]:
|
||||
if raw_token["id"] >= end_of_text:
|
||||
continue
|
||||
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] = {}
|
||||
rendered = []
|
||||
for speaker, group in groupby(
|
||||
tokens, key=lambda token: token_speaker(token, turns)
|
||||
):
|
||||
number = speaker_numbers.setdefault(speaker, len(speaker_numbers) + 1)
|
||||
text = "".join(token.text for token in group)
|
||||
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,319 @@
|
||||
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" || return 1
|
||||
! 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=${source##*/}
|
||||
target="$failed_dir/$base"
|
||||
|
||||
while path_exists "$target" || path_exists "${target}.error.txt"; do
|
||||
((suffix += 1))
|
||||
target="$failed_dir/$base.failed-$suffix"
|
||||
done
|
||||
|
||||
error_tmp=$(mktemp "$failed_dir/.transcription-error.XXXXXX") || return 1
|
||||
trap 'rm -f -- "$error_tmp"' EXIT
|
||||
{
|
||||
printf '%s\n' "$message" || return 1
|
||||
if [[ -n "$details" && -s "$details" ]]; then
|
||||
printf '\n' || return 1
|
||||
cat -- "$details" || return 1
|
||||
fi
|
||||
} >"$error_tmp" || return 1
|
||||
|
||||
if ! move_no_clobber "$source" "$target"; then
|
||||
printf 'Could not quarantine %s: destination collision or move failure\n' "$source" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! ln -- "$error_tmp" "${target}.error.txt"; then
|
||||
printf 'Could not publish error sidecar for %s\n' "$target" >&2
|
||||
return 1
|
||||
fi
|
||||
rm -f -- "${source}.transcribe-state" || return 1
|
||||
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 3
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! move_no_clobber "$source" "$archive"; then
|
||||
if path_exists "$archive"; then
|
||||
return 2
|
||||
fi
|
||||
return 3
|
||||
fi
|
||||
|
||||
rm -f -- "$state_file" || return 3
|
||||
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 duration
|
||||
local work_dir log_file raw_output rendered_output state_file recovery_status
|
||||
local draft="" diarization_log
|
||||
local diarization_succeeded=false
|
||||
|
||||
base=${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 [[ $recovery_status -ne 1 ]]; then
|
||||
printf 'Could not recover %s; leaving state for retry\n' "$source" >&2
|
||||
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=$(stat -c %Y -- "$source")
|
||||
recorded_at=$(date --date="@$recorded_at" --iso-8601=seconds)
|
||||
transcribed_at=$(date --utc --iso-8601=seconds)
|
||||
work_dir=$(mktemp -d "$runtime_dir/work.XXXXXX")
|
||||
trap 'if [[ -n "$draft" ]]; then rm -f -- "$draft"; fi; rm -r -- "$work_dir"' EXIT
|
||||
log_file="$work_dir/transcription.log"
|
||||
diarization_log="$work_dir/diarization.log"
|
||||
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 \
|
||||
-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-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 [[ -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" >"$diarization_log" 2>&1 \
|
||||
&& [[ -s "$rendered_output" ]]; then
|
||||
diarization_succeeded=true
|
||||
else
|
||||
printf 'Speaker diarization unavailable; publishing unlabeled transcript\n' >&2
|
||||
if [[ -s "$diarization_log" ]]; then
|
||||
cat -- "$diarization_log" >&2
|
||||
fi
|
||||
cp -- "${raw_output}.txt" "$rendered_output"
|
||||
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 -- '- 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"
|
||||
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"
|
||||
|
||||
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 signature index listing scope_status=0
|
||||
local -a sources signatures
|
||||
|
||||
mkdir -p "$inbox_dir" "$processing_dir" "$archive_dir" "$failed_dir" "$transcript_dir" || return 1
|
||||
listing=$(mktemp "$runtime_dir/recordings.XXXXXX") || return 1
|
||||
trap 'rm -f -- "$listing"' EXIT
|
||||
|
||||
find "$processing_dir" -maxdepth 1 -type f -print0 | sort -z >"$listing" || return 1
|
||||
while IFS= read -r -d '' source; do
|
||||
if is_recording "$source"; then
|
||||
# Keep this call outside conditionals so its subshell's errexit stays active.
|
||||
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 <"$listing"
|
||||
|
||||
while true; do
|
||||
sources=()
|
||||
signatures=()
|
||||
|
||||
find "$inbox_dir" -maxdepth 1 -type f -print0 | sort -z >"$listing" || return 1
|
||||
while IFS= read -r -d '' source; do
|
||||
if is_recording "$source"; then
|
||||
if ! signature=$(stat -c '%s:%y:%i' -- "$source"); then
|
||||
if path_exists "$source"; then return 1; fi
|
||||
continue
|
||||
fi
|
||||
sources+=("$source")
|
||||
signatures+=("$signature")
|
||||
fi
|
||||
done <"$listing"
|
||||
|
||||
if [[ ${#sources[@]} -eq 0 ]]; then
|
||||
break
|
||||
fi
|
||||
|
||||
sleep "${TRANSCRIBE_SETTLE_SECONDS:-300}" || return 1
|
||||
|
||||
for index in "${!sources[@]}"; do
|
||||
source=${sources[$index]}
|
||||
if [[ ! -f "$source" ]]; then
|
||||
continue
|
||||
fi
|
||||
if ! signature=$(stat -c '%s:%y:%i' -- "$source"); then
|
||||
if path_exists "$source"; then return 1; fi
|
||||
continue
|
||||
fi
|
||||
if [[ $signature != "${signatures[$index]}" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
processing_target="$processing_dir/${source##*/}"
|
||||
if ! move_no_clobber "$source" "$processing_target"; then
|
||||
fail_recording "$source" "$failed_dir" "Could not claim recording: destination collision or move failure: $processing_target" || return 1
|
||||
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"
|
||||
)
|
||||
|
||||
runtime_dir="${XDG_RUNTIME_DIR:?}/transcribe-recordings"
|
||||
mkdir -p "$runtime_dir"
|
||||
|
||||
set +o errexit
|
||||
process_scope "$TRANSCRIBE_SYNC_ROOT/recordings" "$TRANSCRIBE_SYNC_ROOT/notes/transcripts"
|
||||
Reference in new issue
Block a user