config/config.default.json
Rama main · réplica de lectura del repositorio original.
{
"_comment": "Speech-to-Text Configuration File",
"_usage": "Edit these values to configure your transcription settings. Command-line arguments will override these defaults.",
"model": {
"type": "whisper",
"_type_comment": "Model type: 'whisper' (OpenAI), 'huggingface' (Transformers), or 'custom' (local path)",
"_type_options": [
"whisper",
"huggingface",
"custom"
],
"whisper": {
"model": "",
"_model_options": [
"tiny",
"base",
"small",
"medium",
"large",
"large-v1",
"large-v2",
"large-v3"
],
"_model_comment": "Whisper model size. Use .en suffix for English-only (e.g., small.en). tiny/base for real-time, medium/large for accuracy. Ships empty on purpose: a model is several GB and which one to run depends on the machine, so a fresh install picks and downloads one in the Model Manager rather than inheriting a guess. Until then the Start button stays disabled with that hint."
},
"backend": "faster-whisper",
"_backend_comment": "Whisper backend: 'faster-whisper' (CTranslate2, 4-10x faster, default) or 'whisper' (OpenAI)",
"huggingface": {
"model_id": "",
"_model_id_comment": "Hugging Face model ID, used only when type is 'huggingface'. Ships empty so nothing is downloaded until one is chosen; popular options below.",
"_popular_models": {
"whisper": [
"openai/whisper-tiny",
"openai/whisper-base",
"openai/whisper-small",
"openai/whisper-medium",
"openai/whisper-large-v3"
],
"distil_whisper": [
"distil-whisper/distil-small.en",
"distil-whisper/distil-medium.en",
"distil-whisper/distil-large-v2",
"distil-whisper/distil-large-v3"
],
"wav2vec2": [
"facebook/wav2vec2-base-960h",
"facebook/wav2vec2-large-960h-lv60-self"
],
"other": [
"facebook/s2t-small-librispeech-asr",
"nvidia/stt_en_conformer_ctc_large"
]
},
"use_flash_attention": false,
"_use_flash_attention_comment": "Enable Flash Attention 2 for faster inference (requires compatible GPU)"
},
"custom": {
"model_path": "",
"_model_path_comment": "Path to custom model directory or file",
"model_type": "whisper",
"_model_type_comment": "Architecture type: 'whisper', 'wav2vec2', 'speech2text', etc."
}
},
"audio": {
"backend": "ffmpeg",
"_backend_comment": "Audio capture backend: 'ffmpeg' (recommended, more reliable) or 'pyaudio' (legacy, may hang on some systems)",
"_backend_options": [
"ffmpeg",
"pyaudio"
],
"default_microphone": "default",
"_default_microphone_comment": "Audio device name. For ffmpeg: 'default', 'plughw:0,0', 'plughw:1,0', etc. Use plughw for better format compatibility.",
"default_microphone_name": "",
"_default_microphone_name_comment": "Stable card name substring (e.g. 'UR22mkII', 'Steinberg') used to re-find the device after a reboot, even if its ALSA card index changes. Takes priority over default_microphone if set and matched. Auto-populated when you save a device selection in Settings.",
"microphone_selected": false,
"_microphone_selected_comment": "Set true once the user saves an audio device in Settings (including the Default device). Drives the getting-started checklist / greyed-out Start button; value-based checks can't tell 'user chose Default' from 'never touched'.",
"deprioritize_device_markers": [],
"_deprioritize_device_markers_comment": "Optional list of lowercase substrings (e.g. 'nvidia', 'hdmi') matched against card name/description to exclude those cards from being auto-flagged as the default device. Leave empty to disable this filtering.",
"device_index": null,
"_device_index_comment": "Optional numeric device index override. null = resolve device from default_microphone / default_microphone_name instead.",
"energy_threshold": 100,
"_energy_threshold_comment": "Energy level for mic to detect speech. Lower = more sensitive, Higher = less sensitive",
"phrase_timeout": 2,
"_phrase_timeout_comment": "Seconds of silence before considering it a new phrase. Higher = fewer false phrase breaks, Lower = more responsive to pauses",
"language": "auto",
"_language_comment": "Language code for transcription (auto, en, es, fr, de, etc.)",
"autostart": false,
"_autostart_comment": "Automatically begin live microphone transcription as soon as the server starts, without needing the UI Start button. The worker still loads the model and self-selects the default device.",
"same_output_threshold": 7,
"_same_output_threshold_comment": "Number of repeated outputs before text is finalized. Lower = faster finalization (3-5), Higher = more stable but slower (10-25). Default: 7",
"fuzzy_duplicate_threshold": 0.85,
"_fuzzy_duplicate_threshold_comment": "Similarity threshold (0.0-1.0) for detecting duplicate sentences in live transcription. Higher = stricter matching. Default: 0.85",
"min_words": 0,
"_min_words_comment": "Minimum word count to save a segment. 0 = save all segments, 5 = skip short fragments. Default: 0",
"pending_buffer": {
"enabled": true,
"_enabled_comment": "Hold incomplete sentence fragments until the sentence completes, so transcript rows (and translation units) are whole sentences. false = save fragments immediately (old behavior)",
"max_words": 30,
"_max_words_comment": "Safety valve for run-on speech: flush a held fragment once it exceeds this many words",
"max_age_seconds": 10,
"_max_age_seconds_comment": "Safety valve: flush a held fragment older than this many seconds"
},
"stabilize_live_text": true,
"_stabilize_live_text_comment": "LocalAgreement: reveal a live in-progress word only once two consecutive hypotheses agree, so the live caption stops rewriting itself (jitter). Adds a small, self-correcting lag on the newest word(s). false = show the raw hypothesis each pass (old behavior).",
"listen_stream": {
"enabled": true,
"_enabled_comment": "Offer a full-quality copy of the input (Socket.IO room 'audio_stream_hq', event 'audio_chunk_hq', interleaved s16le PCM) for loudness meters and listeners. It opens the device a second time, only while transcription runs and someone has joined. The 16 kHz mono 'audio_stream' feed Whisper and SongListener use is unaffected.",
"sample_rate": 48000,
"_sample_rate_comment": "Sample rate of the high-quality stream in Hz.",
"channels": 2,
"_channels_comment": "Channels of the high-quality stream. A mono input is duplicated to both channels. About 1.5 Mbps per listener at 48 kHz stereo."
},
"loudness_normalization": {
"enabled": false,
"_enabled_comment": "Boost quiet audio before transcription (single gain per pass, never clips). Only affects the audio sent to Whisper — energy threshold and calibration always see raw levels",
"target_rms_dbfs": -20,
"_target_rms_dbfs_comment": "Target loudness in dBFS. Audio quieter than this is boosted toward it; louder audio is left untouched",
"max_gain": 10,
"_max_gain_comment": "Maximum boost factor, caps amplification of very quiet audio (and its background noise)"
}
},
"vad": {
"enabled": true,
"_enabled_comment": "Enable Voice Activity Detection to filter out music and background noise",
"threshold": 0.5,
"_threshold_comment": "VAD confidence threshold (0.0-1.0). Examples: 0.3=sensitive, 0.5=balanced, 0.7=strict, 0.9=very strict",
"_threshold_recommendations": {
"quiet_environments": 0.3,
"normal_speech": 0.5,
"noisy_with_music": 0.7,
"very_noisy": 0.9
}
},
"database": {
"path": "",
"_path_comment": "Custom database base path. Empty = use default (_AUTOMATIC_BACKUP). Can be absolute or relative path.",
"path_format": "%Y/%m",
"_path_format_comment": "Python strftime format for database subdirectories. Default: %Y/%m (2025/01)",
"filename_format": "%Y-%m-%d_%H%M%S",
"_filename_format_comment": "Python strftime format for database filenames. MUST include time (%H%M%S) for unique databases per session. Default: %Y-%m-%d_%H%M%S (2025-01-08_143527). Use %Y-%m-%d_%H for hourly databases.",
"filename_prefix": "",
"_filename_prefix_comment": "Optional prefix for session files. Empty = date only. Example: 'Church' creates '{format}_Church.db'",
"max_entries_to_send": 100,
"_max_entries_comment": "Number of recent transcriptions to send to web UI. Lower = faster, Higher = more history shown",
"srt_enabled": true,
"_srt_enabled_comment": "Automatically generate SRT subtitle files alongside database",
"html_enabled": true,
"_html_enabled_comment": "Automatically generate HTML transcript files alongside database",
"record_partials": true,
"_record_partials_comment": "Persist in-progress (partial) transcription snapshots as is_final=0 rows for offline replay/tuning. Partials are hidden from the web UI, corrections, and exports",
"partials_min_interval_ms": 1000,
"_partials_min_interval_ms_comment": "Minimum milliseconds between partial snapshots of the same segment. Lower = finer replay fidelity, Higher = smaller databases",
"partials_store_words": false,
"_partials_store_words_comment": "Store per-word timings (words_json) on partial rows too. Off by default: nothing reads words_json off an is_final=0 row \u2014 not this server (every reference is an INSERT; there is no SELECT) and not the ChurchPresenter BLE replay \u2014 while each snapshot re-serialises the whole growing word list and the final row supersedes all of them. On a real 167-minute service that was 4.18 MB of a 9.1 MB database, 46% of the file. Turn on only if something starts consuming per-word data from partials.",
"partials_retention_days": 0,
"_partials_retention_days_comment": "Delete is_final=0 rows from session databases older than N days at server startup. 0 = keep partials forever. Final rows are never touched"
},
"session_meta": {
"enabled": true,
"_enabled_comment": "Record which transcription and translation models, decode settings, and filters produced each session into a session_meta table in the session database. Without it, a transcript reviewed later can't be attributed to a model or setting once the server is restarted or retuned",
"_history_comment": "Settings changed mid-session (e.g. a hot language switch) are appended as '<key>@<timestamp>' rows; the unsuffixed key always holds the value at session start"
},
"web_server": {
"host": "0.0.0.0",
"_host_comment": "Server host. 0.0.0.0 = accessible from network, 127.0.0.1 = localhost only",
"port": 8080,
"_port_comment": "Server port. Port 80 requires admin/root privileges. Use 8080 for non-privileged access",
"update_interval": 0.5,
"_update_interval_comment": "Seconds between web UI updates. Lower = more responsive, Higher = less network traffic",
"settings_ip_whitelist": [
"127.0.0.1",
"::1"
],
"_settings_ip_whitelist_comment": "IP addresses or CIDR ranges allowed to access settings. Empty array = allow all. Example: ['127.0.0.1', '192.168.1.0/24']",
"cloudflare_tunnel": {
"binary": "cloudflared",
"_binary_comment": "Path to the cloudflared executable. Bare name = look in our own bin/ dir, then PATH, then the usual install locations. Set a full path only to force a specific build.",
"auto_download": true,
"_auto_download_comment": "Fetch the official cloudflared build (~40MB, from github.com/cloudflare/cloudflared releases) into bin/ the first time the tunnel is started, if it isn't already installed. cloudflared is a system binary, so the normal dependency sync — which only installs Python packages — can never deliver it. Cloudflare publishes no checksums for these assets, so HTTPS to github.com is the trust anchor; set this to false if you would rather install it yourself via apt/brew.",
"protocol": "auto",
"_protocol_comment": "Transport cloudflared uses: 'auto' (it picks, normally QUIC), 'http2' or 'quic'. Set 'http2' on a host whose kernel will not give QUIC the UDP receive buffer it wants — cloudflared warns about that at startup and the tunnel card repeats the warning. Anything unrecognised is treated as 'auto'.",
"auto_stop_enabled": true,
"_auto_stop_enabled_comment": "true = the tunnel closes itself after transcription ends (see auto_stop_seconds). false = it stays open until you press Stop. Leaving it on is the safer default: a tunnel nobody remembered to close is a server left reachable from the internet.",
"auto_stop_seconds": 60,
"_auto_stop_seconds_comment": "Seconds after transcription ends before the tunnel closes itself. Ignored when auto_stop_enabled is false. Restarting transcription within the window cancels the pending stop. A tunnel opened before any transcription has run stays up until you press Stop — there is no 'end' to measure from yet.",
"_security_comment": "Requests arriving through the tunnel are identified by Cloudflare's CF-Connecting-IP header (which the edge refuses to accept from clients, so it cannot be forged) and are NOT treated as local: they get neither the 127.0.0.1 shortcut nor an empty whitelist, and ?key= is not accepted from them. They must log in with the web_server.password_auth password — the same one the UI uses. The caption display at / is deliberately left open, so ANYONE WITH THE URL CAN READ THE LIVE TRANSCRIPT without logging in; everything that changes state or shows settings needs the password. State-changing websocket events are gated the same way. Tunnelled requests appear in /logs with origin=tunnel and the real visitor IP. Note that Cloudflare terminates TLS and can read caption traffic in plaintext."
},
"access_token": "",
"_access_token_comment": "Optional token to embed auth in a URL: append ?key=TOKEN (e.g. http://host:8080/?key=TOKEN) so a non-whitelisted device or an OBS/display source that can't use the login page gets in. Opening such a URL sets a session cookie for the rest of that browser session. Empty = disabled. Generate/rotate it on the Server Settings page. It appears in URLs/logs, so rotate it if leaked.",
"password_auth": {
"enabled": true,
"_enabled_comment": "Enable password authentication as fallback for non-whitelisted IPs",
"password": "admin",
"_password_comment": "Password for temporary access, and a known default on purpose so an operator can reach a fresh install from another machine on the network without first reading a log file. Change it in Settings. Setting it empty is supported and stronger: the server then generates a random 12-character password at startup, saves it here, and prints it to the console and logs/stt.log. Note the whitelist already admits localhost unconditionally, so this password only governs access from other machines.",
"session_timeout_minutes": 60,
"_session_timeout_comment": "How long password-authenticated sessions remain valid (in minutes)"
}
},
"access_log": {
"enabled": true,
"_enabled_comment": "Log all HTTP requests, WebSocket connections, and SocketIO actions (tagged web vs api). View at /logs.",
"max_rows": 50000,
"_max_rows_comment": "Maximum log rows to retain in APP_DIR/logs/access_log.db. Oldest rows are pruned past this cap.",
"skip_polling_paths": true,
"_skip_polling_paths_comment": "Do not log the dashboard polling endpoints (/api/health, /api/transcription/status, /api/translation/status, /api/logs, ...). The logs page already hides them by default, so writing and fsyncing a row only to filter it out again costs disk for nothing \u2014 an operator with the health and settings pages open produces about 150 such rows a minute, and they displace real requests inside max_rows. Set false when diagnosing the polling itself."
},
"performance": {
"use_gpu": true,
"_use_gpu_comment": "Automatically use GPU (CUDA) if available for faster transcription"
},
"transcription": {
"start_stall_seconds": 600,
"_start_stall_seconds_comment": "How long a single startup step may make no progress before the start is reported as failed instead of showing STARTING for ever. Measured per step, not per start, so a slow model load is left alone as long as it keeps moving. 0 disables the check."
},
"audio_backup": {
"wav_enabled": true,
"_wav_enabled_comment": "Save final session audio as .wav file (created when transcription stops)",
"ts_enabled": true,
"_ts_enabled_comment": "Save continuous MPEG-TS backup (power-fail safe, created during recording)",
"base_directory": "",
"_base_directory_comment": "Base directory for audio backups. Empty = use default (_AUTOMATIC_BACKUP). Can be absolute or relative path.",
"path_format": "%Y/%m",
"_path_format_comment": "Python strftime format for audio backup subdirectories. Default: %Y/%m (2025/01)",
"filename_format": "%Y-%m-%d_%H%M%S",
"_filename_format_comment": "Python strftime format for audio filenames. Default: %Y-%m-%d_%H%M%S (2025-01-08_143527)",
"filename_prefix": "",
"_filename_prefix_comment": "Optional custom prefix for audio files. Empty = no prefix. Example: 'Recording' creates '{format}_Recording.wav'",
"format": "wav",
"_format_comment": "Audio format for backups. WAV recommended to prevent file corruption"
},
"file_transcription": {
"model": {
"type": "whisper",
"whisper": {
"model": "",
"_model_comment": "Whisper model for batch file transcription. Empty on a fresh install; pick a downloaded one in the Model Manager. A file can afford a larger model than the live path, since nothing is waiting on it in real time."
},
"backend": "",
"_backend_comment": "Backend for file transcription. Empty = use same as main model."
},
"use_gpu": true,
"_use_gpu_comment": "Use GPU for file transcription if available",
"language": "auto",
"_language_comment": "Language code for transcription (auto, en, es, fr, de, etc.)",
"translate_enabled": false,
"_translate_enabled_comment": "Translate a transcribed file after transcription. Off by default: translation loads a separate multi-GB model, and a file upload should not pull one down unasked. This is also what the code has always assumed when the key was missing.",
"translate_to": "en",
"translation_model": "",
"_translation_model_comment": "NMT model for file translation. Empty means none is chosen: the file is transcribed and left untranslated rather than downloading several GB mid-upload. Pick one in the Model Manager."
},
"whisper_decoding": {
"_comment": "Advanced Whisper decoding parameters. Only modify if you understand Whisper internals. Defaults are optimized for each use case.",
"live_transcription": {
"beam_size": 3,
"_beam_size_comment": "Search breadth (1=greedy/fast, 5=thorough/slow). Default 3 balances accuracy and speed for live.",
"best_of": 1,
"_best_of_comment": "Number of sampling runs (1=single pass, higher=more options to choose from).",
"temperature": 0,
"_temperature_comment": "Must stay 0 for live: nonzero output varies between passes so segments never finalize and no rows save. Enforced at save and at runtime.",
"condition_on_previous_text": false,
"_condition_comment": "Use previous text as context. False for live because short chunks lack context.",
"compression_ratio_threshold": 2.4,
"_compression_ratio_threshold_comment": "Reject transcriptions with too much repetition. 2.4 is Whisper's standard; lower values (e.g. 1.8) reject legitimately repetitive speech.",
"logprob_threshold": -1.0,
"_logprob_threshold_comment": "Reject transcriptions with low confidence scores. -1.0 is Whisper's standard; higher values (e.g. -0.5) silently drop quiet/accented real speech.",
"no_speech_threshold": 0.6,
"_no_speech_threshold_comment": "Probability threshold for detecting silence (0.0-1.0)."
},
"file_transcription": {
"beam_size": 5,
"_beam_size_comment": "Search breadth (1=greedy/fast, 5=thorough/slow). Default 5 for file prioritizes quality.",
"temperature": [
0.0,
0.2,
0.4,
0.6,
0.8
],
"_temperature_comment": "Temperature fallback sequence. Tries deterministic first, then increases creativity if fails.",
"condition_on_previous_text": true,
"_condition_comment": "Use previous text as context. True for file because 30s chunks have context.",
"compression_ratio_threshold": 2.4,
"logprob_threshold": -1.0,
"no_speech_threshold": 0.6
}
},
"file_manager": {
"root_visible_items": [
"_AUTOMATIC_BACKUP"
],
"_root_visible_items_comment": "At the app root, only these items are shown unless 'Show hidden items' is on. Empty list = show everything. Deeper folders are unaffected. A custom database.path inside the app dir is always shown too.",
"hidden_items": [],
"_hidden_items_comment": "List of specific file/folder paths to hide in file manager. Paths are relative to the working directory. Example: ['templates', 'config.json', 'models/cached']",
"file_mover": {
"move_on_transcription_stop": false,
"_move_on_transcription_stop_comment": "Automatically move files when transcription stops",
"destination_path": "",
"_destination_path_comment": "Destination path for files (can be SMB path like //server/share/folder or local path)",
"smb_username": "",
"_smb_username_comment": "Username for SMB/network share authentication (leave empty for local paths)",
"smb_password": "",
"_smb_password_comment": "Password for SMB/network share authentication (stored in plain text - use with caution)",
"smb_domain": "",
"_smb_domain_comment": "Domain for SMB authentication (optional, can be empty)",
"source_patterns": [
"_AUTOMATIC_BACKUP/**/*"
],
"_source_patterns_comment": "File patterns to move (glob patterns relative to working directory)",
"delete_source": true,
"_delete_source_comment": "Delete source file after successful move (false = copy instead). In copy mode a file already present at the destination with the same size is skipped, so a run only sends what is new — without that, every run re-sent the whole accumulated history, since the patterns match what exists rather than what changed.",
"preserve_structure": true,
"_preserve_structure_comment": "Preserve directory structure when moving files",
"strip_source_base": false,
"_strip_source_base_comment": "Take each file's path relative to the pattern's own base directory instead of the data dir. Everything swept lives under _AUTOMATIC_BACKUP, so with this off a destination folder of that name receives _AUTOMATIC_BACKUP/_AUTOMATIC_BACKUP/<year>/<month>. Turn it on to land sessions directly in an existing archive folder. Off by default: it changes where files go, and an established archive's layout must not shift because the app updated."
}
},
"url_builder_defaults": {
"fontSize": "30",
"bgColor": "000000",
"textColor": "ffffff",
"alignment": "center",
"verticalAlign": "bottom",
"fontFamily": "Arial",
"fontWeight": "400",
"lineHeight": "1",
"paddingTop": "10",
"paddingBottom": "20",
"paddingLeft": "20",
"paddingRight": "20",
"maxWidth": "100",
"inProgressOpacity": "0.9",
"dripSpeed": "30",
"layout": "side_by_side",
"inverse": "true"
},
"hallucination_filter": {
"enabled": true,
"_enabled_comment": "Filter out known Whisper hallucinations (phantom subtitles from training data)",
"phrases": [
"DimaTorzok",
"Редактор субтитров",
"Корректор субтитров",
"Субтитры подогнал",
"Субтитры создавал",
"Субтитры сделал",
"Субтитры делал",
"Субтитры подготовил",
"Субтитры подготовлены",
"Продолжение следует",
"Subtitle Editor",
"Subtitles by",
"Subtitles created by",
"Thank you for watching",
"Thanks for watching",
"Please subscribe",
"Like and subscribe",
"Don't forget to subscribe"
],
"_phrases_comment": "Phrases to filter, matched as substrings of the transcribed text (case- and punctuation-insensitive). Prefer a STEM over a whole observed line: Whisper invents a different credited name each time ('Субтитры сделал DimaTorzok', 'Субтитры подогнал «Симон»', 'Редактор субтитров А.Семкин'), so 'Субтитры сделал' catches every name that follows it while a full string only ever catches the one you saw. Avoid a stem so short it could match real speech (e.g. bare 'Субтитры').",
"_phrases_english_comment": "The English entries matter because this filter also runs on translated output, so a credit that slipped through in the source cannot be published in translation either.",
"cjk_filter_enabled": true,
"_cjk_filter_enabled_comment": "Filter out hallucinated CJK characters when transcribing Latin-based languages"
},
"profanity_filter": {
"enabled": true,
"_enabled_comment": "Replace broadcast-forbidden words with **** in all transcription output",
"replacement": "****",
"_replacement_comment": "String substituted for each matched word",
"words": [
"fuck",
"fucking",
"fucked",
"fucker",
"shit",
"shitting",
"bullshit",
"cunt",
"cocksucker",
"cock",
"motherfucker",
"piss",
"tits",
"ass",
"asshole",
"asses",
"bitch",
"bitches",
"bastard",
"damn",
"goddamn",
"dick",
"pussy",
"nigger",
"nigga",
"faggot",
"fag"
],
"_words_comment": "List of words to replace. Case-insensitive, word-boundary matched (so 'ass' won't match 'bass' or 'classic')"
},
"live_translation": {
"enabled": false,
"_enabled_comment": "Enable real-time translation of live transcription. Off by default: turning it on loads a separate multi-GB NMT model, which a fresh install should download deliberately rather than discover on the first Start.",
"target_language": "en",
"_target_language_comment": "Target language code (e.g., 'en', 'es', 'fr', 'de', 'ru')",
"source_language": "auto",
"_source_language_comment": "Source language (auto uses configured audio.language)",
"in_order_hold_seconds": 15,
"_in_order_hold_seconds_comment": "Seconds a finished caption waits for an earlier one still being translated, so displays only ever gain lines at the end. A caption not translated in time is left off the live display rather than inserted later (the SRT still gets it). 0 sends each as soon as it is ready",
"translate_in_progress": false,
"_translate_in_progress_comment": "Also translate in-progress text (may cause flicker)",
"display_mode": "translated_only",
"_display_mode_comment": "Display mode: translated_only, side_by_side, stacked",
"_display_mode_options": [
"translated_only",
"side_by_side",
"stacked"
],
"translation_model": "",
"_translation_model_comment": "HuggingFace model ID for translation. Ships empty so no weights are fetched until one is chosen in the Model Manager. Empty means no model: on nllb/madlad that leaves captions untranslated, and on an LLM session it means no fallback for the captions the LLM declines — several GB not held in memory, which on a memory-bound machine is the room a larger LLM needs. Selectable as \"None\" on the Translations and Model Manager pages.",
"use_gpu": true,
"_use_gpu_comment": "Use GPU for translation model. Disable to run NLLB on CPU and free VRAM for Whisper",
"warmup": true,
"_warmup_comment": "Run one throwaway translation right after the model loads to pre-compile MPS/CUDA kernels, so the first real request isn't slow. No effect on CPU.",
"use_fp16": false,
"_use_fp16_comment": "Load NLLB in half precision on GPU (MPS/CUDA only; ignored on CPU). Faster and lower memory, but MPS fp16 seq2seq can occasionally produce garbled output — turn OFF if translations look wrong. Requires a model reload to take effect.",
"max_entries_to_send": 100,
"_max_entries_to_send_comment": "Number of recent translations to send to web UI. Lower = faster, Higher = more history shown",
"srt_enabled": true,
"_srt_enabled_comment": "Generate SRT subtitle files for translations",
"html_enabled": true,
"_html_enabled_comment": "Generate HTML transcript files for translations",
"translation_method": "nllb",
"_translation_method_comment": "Translation engine: 'nllb' (Facebook NLLB-200) or 'madlad' (Google MADLAD-400). Set translation_model to a matching model id.",
"use_ctranslate2": false,
"_use_ctranslate2_comment": "Run the translation model with the CTranslate2 int8 backend (CPU) instead of transformers. Much smaller/faster (e.g. MADLAD-3B in ~3 GB) — recommended for low-RAM machines like a 16 GB Mac. Converts the model to int8 once on first use. Works for NLLB and MADLAD; no Apple Metal (runs CPU int8 there).",
"ct2_compute_type": "auto",
"_ct2_compute_type_comment": "CTranslate2 quantization: 'auto' -> int8 on CPU, int8_float16 on CUDA. Or set explicitly (int8, int8_float16, float16, float32).",
"ct2_intra_threads": 4,
"_ct2_intra_threads_comment": "CTranslate2 CPU compute threads. ~number of physical performance cores (e.g. 4 P-cores on Apple Silicon). 0 = auto-detect. Only affects the CPU/int8 path.",
"ct2_inter_threads": 1,
"_ct2_inter_threads_comment": "CTranslate2 parallel translation batches. Keep 1 unless parallelizing across independent documents.",
"generation_params": {
"num_beams": 2,
"_num_beams_comment": "Beam search width. Higher = better quality but slower; 1 = greedy (fastest). 2 balances quality against latency for real-time captioning.",
"length_penalty": 1.0,
"_length_penalty_comment": "Exponential penalty on sequence length. >1.0 favors longer output, <1.0 shorter.",
"no_repeat_ngram_size": 0,
"_no_repeat_ngram_size_comment": "Block repeated n-grams of this size. 0 = disabled.",
"repetition_penalty": 1.0,
"_repetition_penalty_comment": "Penalty applied to repeated tokens. 1.0 = disabled."
},
"context_window": 2,
"_context_window_comment": "Number of segments combined for translation context. 1 = translate each segment in isolation; 2-3 = include preceding sentence(s) for pronoun/gender/tense accuracy",
"debug_logging": false,
"_debug_logging_comment": "Log a per-cycle [TRANS-DBG] trace of the translation loop (which segments are cached/re-translated/persisted) to diagnose repeating-phrase issues. Hot-reloaded — no restart needed. Env var STT_TRANSLATION_DEBUG=1 also enables it.",
"remote": {
"enabled": false,
"_enabled_comment": "Offload translation to another machine running this app",
"endpoint": "",
"_endpoint_comment": "URL of remote machine (e.g. http://192.168.1.200:80). Leave empty to use local translation.",
"fallback": "skip",
"_fallback_comment": "What to do when the remote machine is unreachable: 'skip' shows the original untranslated text, 'local' falls back to local translation",
"timeout_seconds": 15,
"_timeout_seconds_comment": "How long to wait for one offloaded caption before giving up on it. Not a latency budget for the other machine's model \u2014 a healthy pair answers in about 1.5s \u2014 but how long a caption may hold up the translation loop, which handles up to three captions serially per cycle. Raising it turns one slow peer into a multi-minute stall of every caption including the cached ones, so raise it only for a peer that is genuinely slower, and prefer finding out why it is slow.",
"connect_timeout_seconds": 5,
"_connect_timeout_seconds_comment": "How long to wait to reach the other machine at all. Split from the read timeout so an unplugged or rebooting peer fails in seconds instead of spending the full read budget on every caption.",
"server_cache_enabled": true,
"_server_cache_enabled_comment": "On the machine SERVING offloaded translations, cache results by (text, langs, beams) so repeated phrases skip the model. Independent of the segment cache.",
"server_cache_size": 512,
"_server_cache_size_comment": "Max entries in the server-side text translation cache (LRU).",
"max_text_chars": 8000,
"_max_text_chars_comment": "Largest text /api/translate will accept from a paired machine, in characters. Over this it returns 413 rather than passing it to the model. Captions are short — the longest seen over 70k real captions was ~1100 chars — so this only catches a malformed or runaway payload, which would also be held whole as a server cache key.",
"sync_dictionary_on_edit": true,
"_sync_dictionary_on_edit_comment": "When offloading, push glossary/dictionary edits to the remote server immediately so it doesn't keep translating with a stale glossary.",
"token": "",
"_token_comment": "Bearer token this machine presents to the paired remote (Machine B). Claimed automatically at pairing and stored here; empty until then. Delete it to force a fresh claim. Treat it as a secret — it is what proves this machine is the paired one."
},
"trusted_clients": [],
"trusted_client_tokens": {},
"_trusted_client_tokens_comment": "Tokens issued to paired clients, as {sha256-of-token: ip}. Only the fingerprint is kept, so this file cannot hand out anyone's token. An entry here means that client must present its token — its IP address alone no longer grants access. Unpairing deletes the entry, which revokes it.",
"_trusted_client_ports_comment": "Web UI port of each paired client, keyed by IP (learned at pairing, refreshed by the heartbeat). Lets this machine link back to the machines it translates for. Managed automatically.",
"trusted_client_ports": {},
"_trusted_clients_comment": "IPs allowed to use this machine for remote translation (managed automatically via pairing)",
"tts": {
"enabled": false,
"_enabled_comment": "Enable text-to-speech for translated output",
"backend": "edge",
"_backend_comment": "TTS backend: 'edge' (Microsoft Edge cloud, 400+ voices, requires internet), 'piper' (local/offline, one downloaded model per language) or 'supertonic' (local/offline, one 385MB model covering 30 languages — but not Chinese, which only piper has locally). Download local models from Model Manager.",
"edge_voice": "en-US-AriaNeural",
"_edge_voice_comment": "Edge TTS voice name. Browse voices in Translation Settings page.",
"piper_model": "",
"_piper_model_comment": "Piper model ID (e.g. 'en_US-lessac-medium'). Download from Model Manager.",
"supertonic_voice": "F1",
"_supertonic_voice_comment": "Supertonic voice preset: F1-F5 (female) or M1-M5 (male). Presets are language-independent — the same voice speaks every supported language.",
"supertonic_steps": 8,
"_supertonic_steps_comment": "Supertonic denoising steps, 5-12. Higher is better quality and slower; 8 is the model's default.",
"edge_voice_preferences": {},
"_edge_voice_preferences_comment": "Edge voice remembered per target language, as {lang: voice}. Written automatically when you pick a voice or switch language, so switching back restores your choice.",
"piper_model_preferences": {},
"_piper_model_preferences_comment": "Piper model remembered per target language, as {lang: model_id}. Managed automatically.",
"supertonic_voice_preferences": {},
"_supertonic_voice_preferences_comment": "Supertonic preset remembered per target language, as {lang: voice}. Managed automatically.",
"speed": 1.0,
"_speed_comment": "Speech speed multiplier (0.5 = half speed, 2.0 = double speed). Honoured by edge and supertonic; piper's pace is fixed by the downloaded voice."
},
"llm": {
"context_window": 1,
"_context_window_comment": "Captions sent together when the LLM translates. Separate from the top-level context_window, which only the NLLB/MADLAD models use. 1 measured best: more context made no caption better and every caption slower.",
"provider": "endpoint",
"_provider_comment": "'local' runs a GGUF in-process via llama-cpp-python: no server, no extra installer, no port — the option that works on a fresh Windows/Linux/macOS install. 'endpoint' talks to any OpenAI-compatible server you already run (Ollama, LM Studio, llama-server, vLLM, hosted).",
"endpoint": "",
"_endpoint_comment": "Full chat URL of an OpenAI-compatible server. Empty = LLM translation off. Works with any of them: Ollama 'http://127.0.0.1:11434/api/chat', llama-server / LM Studio / vLLM / hosted 'https://host/v1/chat/completions'. No Ollama dependency.",
"model": "",
"_model_comment": "Model name as the server knows it. Must NOT be a reasoning model: those emit their chain of thought into the reply (and some ignore every switch for disabling it), which consumes the token budget and is not separable from the translation.",
"api_key": "",
"_api_key_comment": "Optional bearer token for a hosted endpoint. Empty = no auth header.",
"system_prompt": "",
"_system_prompt_comment": "Overrides the built-in prompt. This is the tuning surface: naming a convention here fixes a whole class of term at once (e.g. stating that вечеря is communion fixed it everywhere), which is why this is config and not code. Only sent under prompt_style 'chat': a TranslateGemma reads four fields and no instructions, so under that style this field is not sent at all.",
"prompt_style": "auto",
"_prompt_style_comment": "How the model is asked to translate. 'chat' sends the system prompt above plus the caption — a general instruction model (Gemma 3/4, Qwen2.5, Aya), where the prompt is the tuning surface for terminology. 'translategemma' sends Google's TranslateGemma field format (type/source_lang_code/target_lang_code/text): it scores higher on translation — the 12B beats the Gemma 3 27B baseline on WMT24++ — but Google states it does not support system prompts or instructions at all, so the prompt above, the terminology conventions in it, and the corrective retry are all inert under it. 'auto' (the default) picks by model name and is right for both.",
"_prompt_style_options": ["auto", "chat", "translategemma"],
"max_tokens": 160,
"_max_tokens_comment": "Cap on generated tokens. Real captions measured p50 19 tokens, p90 41, max 80, so 160 is headroom; it also bounds a runaway generation.",
"keep_alive": -1,
"_keep_alive_comment": "How long the server keeps the model loaded; -1 = forever. Leave at -1: an unpinned model measured p90 4.89s against p50 0.29s purely because it was unloaded between captions. Ignored by servers that don't support it.",
"timeout_ms": 8000,
"_timeout_ms_comment": "Per-caption timeout. On timeout the caption falls back to the NMT model rather than being dropped.",
"fallback": "nmt",
"_fallback_comment": "What a caption does when the LLM declines it. 'nmt' translates it with the NMT model, which means those weights load on the first declined caption and stay resident for the session — several GB to serve roughly one caption in a hundred. 'skip' shows the source text untranslated and never loads them, which on a memory-bound machine is the room a larger and better LLM needs. Same vocabulary as remote.fallback.",
"retry_on_reject": true,
"_retry_on_reject_comment": "When a caption fails validation, ask the model once more with the broken rule named, before falling back to the NMT model. Without this a rejection is a downgrade rather than a fix: the LLM usually had the caption nearly right and broke one rule, and the NMT model is not the better answer for it. Costs a second call only on the ~1% of captions that are rejected, is never attempted after a timeout or a transport error, and is skipped when too little of the caption's budget remains to finish. Inert under prompt_style 'translategemma', which has no system prompt to name the broken rule in.",
"warmup_timeout_ms": 180000,
"_warmup_timeout_ms_comment": "Timeout for the one-off warm-up call that loads and pins the model at start. Much longer than timeout_ms because a cold load of a multi-GB model takes far longer than a caption's budget; if the warm-up times out the NMT fallback loads instead and then occupies the VRAM the LLM needed.",
"gguf_repo": "",
"_gguf_repo_comment": "provider=local: HuggingFace repo holding the GGUF, e.g. 'bartowski/Qwen2.5-7B-Instruct-GGUF'. Only the single file named in gguf_file is downloaded — these repos publish a dozen quantisations and fetching them all would pull 40+ GB.",
"gguf_file": "",
"_gguf_file_comment": "The exact quant filename, e.g. 'Qwen2.5-7B-Instruct-Q4_K_M.gguf'. A 7B at Q4 is ~4.7 GB; a 4B is ~2.5 GB and coexists more comfortably with the Whisper model.",
"gguf_path": "",
"_gguf_path_comment": "Absolute path to a GGUF you supplied yourself. Overrides gguf_repo/gguf_file.",
"n_gpu_layers": "auto",
"_n_gpu_layers_comment": "provider=local: layers offloaded to the GPU. 'auto' = all when an accelerator is present, none otherwise; -1 = all; 0 = CPU only. CPU works everywhere and is the safe default — a caption is short (measured p50 19 output tokens), so CPU is slow but viable.",
"n_ctx": 2048,
"_n_ctx_comment": "provider=local: context window. Captions are short; 2048 is ample and keeps memory down. Clamped to a minimum of 1024: below that the system prompt plus the reserved output leaves no room for the caption itself, so every caption declines for not fitting and LLM translation is silently off."
}
},
"corrections": {
"enabled": true,
"_enabled_comment": "Enable correction and confidence features",
"confidence_threshold": 0.7,
"_confidence_threshold_comment": "Minimum confidence score (0.0-1.0) to accept transcription without review",
"confidence_highlighting": true,
"_confidence_highlighting_comment": "Highlight low-confidence words in the UI",
"show_review_queue": true,
"n_best_alternatives": {
"translation_count": 3,
"_translation_count_comment": "Number of alternative translations to generate"
},
"output_delay": {
"enabled": false,
"_enabled_comment": "Delay output to allow corrections before publishing",
"delay_seconds": 7,
"auto_publish": true
}
},
"custom_dictionary": {
"file": "custom_dictionary.json",
"_file_comment": "Path to custom dictionary JSON file for domain-specific terms",
"nllb_glossary_enabled": true,
"_nllb_glossary_enabled_comment": "Use dictionary for NLLB translation glossary (forced term translations)"
},
"speech_type_detection": {
"_comment": "Automatic audio type detection per segment (Speaking / Music / Quiet)",
"enabled": true,
"_enabled_comment": "Tag each transcription segment with its detected audio type",
"music_threshold": 0.5,
"_music_threshold_comment": "Whisper no_speech_prob above this value = non-speech audio (0.0-1.0)",
"quiet_db_threshold": -40,
"_quiet_db_threshold_comment": "Audio level (dB) below this = Quiet; above = Music (when non-speech detected)",
"method": "panns",
"_method_comment": "Detector: 'panns' (dedicated audio tagger, accurate) or 'whisper' (legacy no_speech_prob heuristic). Falls back to 'whisper' if panns-inference / checkpoint is unavailable.",
"music_prob_threshold": 0.5,
"_music_prob_threshold_comment": "PANNs music/singing probability (0.0-1.0) above which a segment is tagged Music. Lower = more sensitive to music.",
"smoothing_window": 4,
"_smoothing_window_comment": "Number of recent detections averaged to stop the label flip-flopping (1 = no smoothing).",
"device": "cpu",
"_device_comment": "Where to run the PANNs tagger: 'cpu' (default, spares GPU VRAM for Whisper) or 'cuda'.",
"checkpoint_path": "",
"_checkpoint_path_comment": "Optional path to the CNN14 checkpoint. Empty = default ~/.stt/panns_data/Cnn14_mAP=0.431.pth",
"transcribe_detected_music": false,
"speech_overrides_music": true,
"music_override_backoff": true,
"music_override_trip_after": 3,
"music_override_cooldown_seconds": 30,
"_music_override_backoff_comment": "Detected music is sent to Whisper even where VAD hears no speech, so sung lines can be captured. An instrumental gets only subtitle credits back, repeatedly. After music_override_trip_after credits in a row with no real text, the override stops for music_override_cooldown_seconds (doubling on each credit-only probe, up to 4 minutes). Real text, lyrics included, reopens it. Speech VAD hears is never affected.",
"_speech_overrides_music_comment": "When PANNs' dominant tag is Speech, ignore the music score. Speech over quiet background music (a reading under keyboards) scores high on Music and used to be hidden as music. Singing is dominated by Music/Singing/Choir and is unaffected. Set false to go back to the raw music score.",
"_transcribe_detected_music_comment": "Music is always transcribed (even when VAD would drop it). When true, its rows show in the transcript. When false, they are saved hidden with denied_reason 'music' and can be restored on /corrections."
},
"service_phase": {
"_comment": "Proof of concept: track which part of a service is running (songs / speaking / quiet blocks, with a tentative name) from the PANNs audio labels already stored on each row. Detection is deterministic and needs no LLM. Review and correct it on /service-phase.",
"enabled": true,
"_enabled_comment": "Run the detector during live transcription and save its output to the session database.",
"profile": "",
"_profile_comment": "Force one service profile instead of picking by day and time. Empty is the normal setting. A profile is this installation's description of one kind of service \u2014 a file in the config directory beside this one, named service_phases.<profile>.json \u2014 holding that service's rules, its thresholds under \"service\", and how many of a phase it has under \"limits\". Your file is never overwritten by an update, and nothing about any particular church ships in this app.",
"profiles": [],
"_profiles_comment": "Pick the profile by when a service starts, first match wins: [{\"profile\": \"sunday-morning\", \"weekdays\": [6], \"from\": \"08:00\", \"to\": \"13:00\"}, {\"profile\": \"midweek\", \"weekdays\": [2], \"from\": \"17:00\", \"to\": \"22:00\"}]. Weekdays are Monday=0 through Sunday=6; from/to are local 24-hour times and may be omitted to match the whole day. Empty means every service uses service_phases.json, which is how this behaved before profiles existed. A Sunday morning and a midweek evening are different shapes \u2014 different sermon counts, different lengths \u2014 and one setting cannot fit both.",
"interval_seconds": 20,
"_interval_seconds_comment": "How often the detector re-runs. It re-reads the whole session each tick (~180 bins for a 3-hour service), so this is cheap; lower values only shorten the lag on the display, not the detection lag.",
"bin_seconds": 60,
"_bin_seconds_comment": "Width of one analysis bucket in seconds. One minute matches the dwell settings below; changing it invalidates their tuning.",
"use_audio_tag": true,
"_use_audio_tag_comment": "Consult the PANNs top tag (audio_tag) for rows the pipeline labelled Quiet. speech_type is a derivation, not the classifier's answer: it calls a row Music only above music_prob_threshold and otherwise decides Quiet or Speaking purely on whether audio_db clears speech_type_detection.quiet_db_threshold. On a quiet feed that gate silences almost everything — measured on one service running at about -40 dB, the threshold itself, 27 rows tagged 'Speech' and 14 tagged 'Music' were all recorded as Quiet and nine minutes of the service became a Quiet block. Only rows already labelled Quiet are reconsidered, so where speech_type is confident it stays authoritative and the dwell settings keep the meaning they were tuned with. If the detector is misreading your services, check quiet_db_threshold against your actual input level before changing this.",
"dominance": 0.35,
"_dominance_comment": "Share (0.0-1.0) of a bucket's rows that must be Music or Speaking for the bucket to count as that. A share, not a majority: a minute of preaching still contains pauses tagged Quiet.",
"enter_minutes": 2,
"_enter_minutes_comment": "Consecutive minutes of a new type before the block switches. Measured over 10 real services, 2 recovers 89% of major blocks at a 1-minute median lag with ~1 spurious flip per service; 3 gives 81% at 2 minutes with a quarter of the flips.",
"exit_minutes": 3,
"_exit_minutes_comment": "Same, for switching into Quiet. Deliberately longer than enter_minutes: a lull inside a sermon is common and must not end it.",
"sermon_min_minutes": 8,
"_sermon_min_minutes_comment": "A speaking block at least this long is named 'Sermon N'; a shorter one before the first sermon is named 'Opening'.",
"songs_min_minutes": 3,
"_songs_min_minutes_comment": "A music block at least this long is named 'Songs N'; shorter ones are just 'Music'.",
"closing_max_minutes": 6,
"_closing_max_minutes_comment": "A short final speaking block no longer than this is named 'Closing'.",
"typical_music_max_minutes": 30,
"_typical_music_max_minutes_comment": "A music block longer than this is flagged for review — a song set does not usually run this long. STARTING POINT, not a measurement of your services: it came from one congregation's archive, where the longest was 27 minutes. Correct a few services and let the learner propose your own. It is only ever a flag: the block keeps the type the audio gave it and only its name drops to low confidence. Music services, plays and carol nights are real and happen a few times a year, and none of them is in the sample these bands came from, so a hard rule would mislabel exactly the service most worth labelling.",
"typical_speaking_max_minutes": 60,
"_typical_speaking_max_minutes_comment": "Same, for a speaking block. Another starting point from that same one archive, where the longest was 54 minutes.",
"typical_music_share_min": 0.20,
"_typical_music_share_min_comment": "Music as a share of a service's non-quiet time. Outside the min/max the whole service is noted as atypical on the review page. The shipped band is wide on purpose because it describes ten services at ONE church, where the share ran 27%-61%; a liturgical parish and a worship-led congregation sit at opposite ends of it.",
"typical_music_share_max": 0.70,
"_typical_music_share_max_comment": "Upper end of the same band. Above it the page says the service looks more musical than usual — which is what a musical evening or a Christmas play looks like.",
"communion_min_hits": 12,
"_communion_min_hits_comment": "The WEAKER of the two communion signals, kept for services where the institution formula is not read aloud — see cue_fragments for the stronger one, which is what normally names a communion. Keyword hits within one speaking block before it is named 'Communion' instead of a sermon. Separation is by volume, not position — a sermon can discuss communion at length without being one. Counting transcript and translation together, the archive's one real communion block scored 17 while the highest non-communion block scored 6, so 12 sits mid-gap. On the transcript alone the peak is only 12, i.e. exactly on the threshold: if you clear cue_phrases_translated, lower this to about 9.",
"cue_phrases": {
"_comment": "Regex fragments counted per bucket against the TRANSCRIPT, and shown on the review page. Cues annotate and help name blocks; they never move a boundary. Defaults are Russian because that is what these services are in — replace them for another language.",
"amen": ["амин[ья]"],
"communion": ["причасти\\w*", "преломлен\\w*", "чаш[аиуе]", "хлеб\\w*", "тело христово", "кров[ьи] христ\\w*"],
"opening": ["добро пожаловать", "приветству\\w*", "дорогие брать\\w*", "мир вам"]
},
"cue_phrases_translated": {
"_comment": "The same cues against the TRANSLATION, in the target language. Either side can miss what the other catches: the transcript may spell a word the list does not anticipate, while the translation normalises the variants but sometimes paraphrases the term away. Measured over the archive, using both raised communion hits 17% and opening-phrase hits 28% over the transcript alone. A cue is counted once per row (the larger of the two counts), never summed, so translated rows are not weighted above untranslated ones. Leave empty to use the transcript only.",
"amen": ["amen"],
"communion": ["communion", "lord'?s supper", "breaking of bread", "the bread", "the cup", "body of christ", "blood of christ"],
"opening": ["welcome", "dear brothers", "peace be (?:with|unto) you", "greet\\w*"]
},
"cue_fragments": {
"_comment": "Set phrases whose separate LINES matter, grouped as {group: {fragment: [regex, ...]}}. A rule can then ask how many DIFFERENT fragments of a group appeared, which is what tells a formula being read aloud apart from a topic being discussed: a sermon about communion says 'the cup' many times but never reads the whole institution. Fragments are counted exactly like cue_phrases and stored the same way, under the name 'group:fragment'.",
"_communion_verse_comment": "The words of institution (1 Cor 11:23-26), which open communion in most traditions. Write them as the RECOGNISER hears them, not as the printed text: word order moves, and the model normalises toward modern speech — a recogniser will not reproduce the Synodal wording, and the exact form it does produce differs by model and by language. Short distinctive fragments survive that; long verbatim quotes do not. Replace these for your language and tradition, or correct a few services and let the learner propose them.",
"communion_verse": {
"night_betrayed": ["в ту ночь", "котор[уюый]{1,2} предан", "предан был"],
"took_bread": ["взял хлеб", "преломил", "благословив"],
"this_is_my_body": ["сие есть тело", "это мо[её] тело", "тело мо[её]"],
"cup_after_supper": ["после вечери", "взял чашу", "также и чашу"],
"new_covenant": ["новый завет в мо[её]й крови", "чаша есть новый завет"],
"until_he_comes": ["доколе он придет", "возвещаете смерть"]
}
},
"cue_fragments_translated": {
"_comment": "The same fragment groups against the TRANSLATION. Same shape, same counting.",
"communion_verse": {
"night_betrayed": ["the night (?:in which |when )?he was betrayed", "was betrayed"],
"took_bread": ["took bread", "broke it", "had given thanks"],
"this_is_my_body": ["this is my body"],
"cup_after_supper": ["after supper", "took the cup"],
"new_covenant": ["new (?:covenant|testament) in my blood"],
"until_he_comes": ["until he comes?", "proclaim the lord'?s death"]
}
}
},
"sermon_summary": {
"_comment": "Summary and chapter markers for each sermon, written a few minutes after the sermon ends and reviewable on /sermon-summary. Uses the same local LLM as translation, so it needs live_translation.llm configured; the sermon is summarised in its own language. Everything is stored in the session database beside the transcript it came from and is deleted with it.",
"enabled": false,
"_enabled_comment": "Summarise sermons automatically once they end. Off by default: it shares the translation model, and an installation that has not configured an LLM has nothing to run it on. This governs the automatic trigger only — 'Summarise now' and 'Regenerate' on /sermon-summary work either way, because asking for a summary is asking for one. Toggle it from that page.",
"settle_seconds": 180,
"_settle_seconds_comment": "How long a sermon block must stay closed before it is summarised. The detector back-dates a closing block's end to where the next run began, so a block that just closed is still moving; summarising immediately writes a summary of a range that then changes. Waiting costs nothing — the service has moved on to closing songs.",
"min_minutes": 8,
"_min_minutes_comment": "Shortest speaking block worth summarising. Matches service_phase.sermon_min_minutes, which is what decides a block is called 'Sermon' in the first place.",
"chunk_tokens": 900,
"_chunk_tokens_comment": "Target size of one map call, in tokens. A sermon does not fit one context window, so it is summarised in parts and the parts are then reduced to one summary. This is also the live-caption contention knob: a caption waits behind at most one chunk, so lower values cost quality and buy responsiveness. Capped by whatever live_translation.llm.n_ctx actually leaves free.",
"max_chapters": 12,
"_max_chapters_comment": "Hard ceiling on chapter markers, whatever the sermon's length. Past a dozen it is a table of contents nobody scrubs through, which is the thing the cap exists to prevent.",
"min_minutes_per_chapter": 4,
"_min_minutes_per_chapter_comment": "Closest two chapter markers may sit, which sets the most a sermon may have. Chapter density follows the sermon rather than a fixed number: one preacher works through eight points in twenty minutes and another develops three across forty, and a single fixed range makes the model split a movement to reach a floor or merge two to stay under a ceiling that is not that sermon's.",
"max_minutes_per_chapter": 10,
"_max_minutes_per_chapter_comment": "Furthest apart two markers may sit, which sets the fewest a sermon may have. Raise it for a speaker who develops one idea at length; lower it for one who moves quickly.",
"map_max_tokens": 220,
"_map_max_tokens_comment": "Reply reservation for one part summary. Short on purpose: these are intermediate notes, not output.",
"reduce_max_tokens": 700,
"_reduce_max_tokens_comment": "Reply reservation for the final summary and chapter list, which is the only generated text anyone reads.",
"defer_while_live": true,
"_defer_while_live_comment": "Never summarise while a service is being transcribed \u2014 on this machine, or on a paired machine that offloads to it. Summarising shares the translation model, and one summary part takes long enough that a caption queued behind it misses its timeout and is shown untranslated. What a service owes is summarised after it ends, and a summary still outstanding when the next service starts stands aside again until that one ends: the work waits, it is never dropped. Turning this off restores per-chunk interleaving, which costs live caption latency.",
"peer_quiet_seconds": 45,
"_peer_quiet_seconds_comment": "How long the machine holding the model must have heard nothing from a paired machine before it accepts an offloaded summary part. It is measured against the transcription heartbeat, not caption traffic: a service is silent for minutes at a time during music and prayer, and any window short enough to be responsive would be crossed mid-service. Must stay comfortably above the ~20s heartbeat interval, so it is clamped to 25s at the low end.",
"pending_stale_seconds": 60,
"_pending_stale_seconds_comment": "How long this machine's own caption backlog count stays believable. The translation loop publishes it once per cycle and re-stamps it as it goes; a window shorter than the slowest cycle would read as \u2018the loop is idle\u2019 at exactly the moment captions are most backed up.",
"finalise_pause_max_seconds": 21600,
"_finalise_pause_max_seconds_comment": "Longest a finished session may be held back in total, including time the summariser spends standing aside for a later service. finalise_max_seconds bounds the run\u2019s own working time; this bounds the wall clock, so a day of back-to-back services cannot hold a session\u2019s files forever. Past it the session is delivered without its summary \u2014 a late file is recoverable, a lost one is not.",
"pause_on_backlog": true,
"_pause_on_backlog_comment": "Hold the summariser while this machine's own translation loop has captions queued, so live output always wins the shared model. The narrower of the two switches: defer_while_live covers a whole service including a paired machine's, this one covers a backlog on this machine. Turning it off makes summaries finish sooner at the cost of caption latency during the run.",
"finalise_max_seconds": 1800,
"_finalise_max_seconds_comment": "Longest the end-of-session run may hold up delivery of the finished session. When a service stops with a sermon still unsummarised, the models are kept loaded and the file mover waits, so the summary ships inside the session database rather than being lost with the local copy. Measured at roughly 6-7 minutes per sermon on an idle machine and longer while captions are still being translated. It is a bound, not a target: past it the session is delivered regardless, so a stuck run delays a delivery and never prevents one.",
"translate": true,
"_translate_comment": "Also produce the summary and the chapter titles in live_translation.target_language, which is the version a media team publishes. One extra model call after the summary is finished, made separately rather than asking for both languages at once: by then the chapter timestamps are settled and snapped to real transcript rows, so translating cannot disturb them. Titles are matched back by position and the translation is discarded whole if it does not line up, because a translated title against the wrong timestamp is worse than none. Does nothing when no target language is set.",
"peer_wait_seconds": 15,
"_peer_wait_seconds_comment": "When summarising is offloaded to a paired machine, how long to wait before retrying a part that machine declined because it was translating captions. Live captions always win; a part that waits several minutes has cost nothing anyone was watching.",
"archive_scan_limit": 200,
"_archive_scan_limit_comment": "How many past session databases /sermon-summary looks back through when listing services to review. They are opened read-only, one at a time, newest first."
},
"timezone": {
"_comment": "Timezone that transcript rows are stamped with. Affects the human-readable `timestamp` column, and therefore SRT/HTML exports and the UI. The immutable `ts_ms` column is always epoch milliseconds and is unaffected.",
"mode": "auto",
"_mode_comment": "'auto' follows the machine's own timezone. Any other value uses `value` below.",
"value": "",
"_value_comment": "IANA timezone name, e.g. 'America/New_York' or 'Europe/Kyiv'. Only used when mode is not 'auto'. A name this machine cannot load falls back to the system zone with a warning rather than failing, because a typo must not be able to stop a service recording. Takes effect on restart."
},
"watchdog": {
"update_channel": "main",
"_update_channel_comment": "Auto-update channel: 'main' = track the latest code on GitHub (default), 'stable' = tagged releases only.",
"show_control_window": true,
"_show_control_window_comment": "macOS/Windows: open the desktop control window at login (the daemon still runs in the background). Set false to run the daemon fully headless with no window."
},
"crash_reporting": {
"enabled": true,
"_enabled_comment": "Write local crash dumps to logs/crashes/ when the app crashes.",
"sentry_enabled": true,
"_sentry_enabled_comment": "Report errors, logs, traces, and profiles to Sentry (on by default). Set false for fully local crash reporting — nothing leaves the machine.",
"sentry_dsn": "",
"_sentry_dsn_comment": "Custom Sentry DSN. Empty = the project's built-in default.",
"sentry_send_pii_optin": false,
"_sentry_send_pii_optin_comment": "Include request headers and client IPs in Sentry events. Off by default — reports carry no identifying network data. Replaces the old sentry_send_pii key, which is ignored so installs that materialized its true default are healed. Request bodies and frame locals are never sent regardless of this setting.",
"sentry_enable_logs": true,
"_sentry_enable_logs_comment": "Forward Python logging records to Sentry Logs.",
"sentry_traces_sample_rate": 0.1,
"_sentry_traces_sample_rate_comment": "Fraction of requests traced for performance (0-1). Keep low: the web UI polls ~2x/second, so 1.0 would produce a huge transaction volume.",
"sentry_profiling_enabled": false,
"_sentry_profiling_enabled_comment": "Continuous profiling (off by default): the Sentry SDK profiler thread has caused server segfaults while sampling thread stacks. Enable at your own risk.",
"sentry_profile_session_sample_rate": 1.0,
"_sentry_profile_session_sample_rate_comment": "Fraction of profile sessions collected (0-1). Only applies when sentry_profiling_enabled is true.",
"sentry_profile_lifecycle": "trace",
"_sentry_profile_lifecycle_comment": "'trace' = profile automatically while a sampled transaction is active; 'manual' = only when started explicitly."
},
"analytics": {
"endpoint": "https://stt.churchpresenter.org/api/ping",
"_endpoint_comment": "Live-map ping URL, hit twice: once when the server starts (event=app_start) and once when a transcription starts (event=transcription_start). The two are separate signals — an install left running all week is invisible to a ping that only fires on Start. Each ping reports the OS and its release, CPU architecture, GPU model, app version and commit, and the names of the configured transcription and translation models; a transcription ping adds the languages. When translation is offloaded to a paired machine, the translation model reported is that machine's, marked 'remote:' — the local one never runs. It never sends an endpoint URL, an API key, or any filesystem path. Change only if self-hosting the collector; leave empty to disable both.",
"install_id": "",
"_install_id_comment": "Anonymous per-install UUID, generated automatically on first ping. Used only to dedupe the map so one machine doesn't spam it. No IP is stored; location is derived server-side and fuzzed."
},
"auto_update": {
"enabled": true,
"_enabled_comment": "When running the server directly (not under the watchdog), fast-forward the git checkout to its upstream branch and restart. Skipped whenever the working tree is dirty, so uncommitted work is never discarded. Ignored under the watchdog, which has its own updater.",
"reset_on_diverged_upstream": true,
"_reset_on_diverged_upstream_comment": "When the checkout has diverged from its upstream (normally because the remote branch was force-pushed or rebased), hard-reset onto the upstream instead of skipping the update. Default true: without it one force push freezes a box on its current version until someone resets it by hand, and a machine that silently stops updating is the worse failure. A dirty working tree is still never touched, and the pre-reset commit is logged (and kept in the reflog) so unpushed work can be restored. Set false on a developer checkout that carries local commits on the same branch.",
"idle_minutes": 10,
"_idle_minutes_comment": "Minutes the server must sit with no transcription running before it applies an update; the checkout is checked for one hourly. Replaced the fixed update_hour (1am), which a box switched on only for services never reached. Updates never apply while transcription is running."
}
}