package config import ( "fmt" "os" "strconv" "time" ) // UploadConfig holds the settings for audio upload and private transcription // (ADR-033). It is embedded in Config, so its fields read as c.UploadsEnabled etc. type UploadConfig struct { // UploadsEnabled mounts the /uploads routes and starts the worker. Off by // default: the runtime image must contain ffmpeg and ffprobe first (ADR-033). UploadsEnabled bool // UploadMaxBytes caps one upload. UploadMaxDuration caps one recording; 90 // minutes is the longest length MEASURED, not a ceiling that was found, so // raising it is a measurement, not an edit. UploadMaxBytes int64 UploadMaxDuration time.Duration // TranscribeModel is the primary gateway alias; TranscribeFallbackModel is the // fallback, tried on ANY primary failure. The fallback is a cloud endpoint // (berget): set it EMPTY to keep private audio on the homelab. TranscribeModel string TranscribeFallbackModel string // TranscribeTimeout bounds one job (conversion + transcription); a 90 minute // recording took ~7.5 minutes on iguana. TranscribeTimeout time.Duration // UploadTmpDir is where uploads are spooled and converted. Audio is deleted // after transcription, so this holds at most the in-flight files. UploadTmpDir string FFmpegBin string FFprobeBin string // MaxUploadedTranscriptChars bounds the transcript sent to a chat model for an // UPLOADED recording. The 18,000-char default for captions is ~22 minutes of // speech; an hour of meeting is ~50,000 chars. A guess until measured against // the models chat offers. MaxUploadedTranscriptChars int } const ( defaultUploadMaxMB = 200 defaultUploadMaxMinutes = 90 defaultTranscribeModel = "iguana/kb-whisper" defaultTranscribeFallbackModel = "berget/kb-whisper" defaultTranscribeTimeout = 30 * time.Minute defaultMaxUploadedChars = 120000 ) func loadUploadConfig() (UploadConfig, error) { u := UploadConfig{ TranscribeModel: envOr("TAPIR_TRANSCRIBE_MODEL", defaultTranscribeModel), TranscribeFallbackModel: lookupOr("TAPIR_TRANSCRIBE_FALLBACK_MODEL", defaultTranscribeFallbackModel), UploadTmpDir: envOr("TAPIR_UPLOAD_TMP_DIR", os.TempDir()), FFmpegBin: envOr("TAPIR_FFMPEG_BIN", "ffmpeg"), FFprobeBin: envOr("TAPIR_FFPROBE_BIN", "ffprobe"), } if v := os.Getenv("TAPIR_UPLOADS_ENABLED"); v != "" { on, err := strconv.ParseBool(v) if err != nil { return UploadConfig{}, fmt.Errorf("config: TAPIR_UPLOADS_ENABLED=%q: %w", v, err) } u.UploadsEnabled = on } mb, err := positiveInt("TAPIR_UPLOAD_MAX_MB", defaultUploadMaxMB) if err != nil { return UploadConfig{}, err } u.UploadMaxBytes = int64(mb) << 20 mins, err := positiveInt("TAPIR_UPLOAD_MAX_MINUTES", defaultUploadMaxMinutes) if err != nil { return UploadConfig{}, err } u.UploadMaxDuration = time.Duration(mins) * time.Minute timeout, err := durationOr("TAPIR_TRANSCRIBE_TIMEOUT", defaultTranscribeTimeout) if err != nil { return UploadConfig{}, err } if timeout <= 0 { return UploadConfig{}, fmt.Errorf("config: TAPIR_TRANSCRIBE_TIMEOUT must be positive, got %v", timeout) } u.TranscribeTimeout = timeout chars, err := intOr("TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS", defaultMaxUploadedChars) if err != nil { return UploadConfig{}, err } if chars < 0 { chars = 0 } u.MaxUploadedTranscriptChars = chars return u, nil } // positiveInt is intOr that refuses zero and negatives: a zero upload limit would // reject every file, which is a misconfiguration to fail on, not to run with. func positiveInt(key string, fallback int) (int, error) { n, err := intOr(key, fallback) if err != nil { return 0, err } if n <= 0 { return 0, fmt.Errorf("config: %s must be positive, got %d", key, n) } return n, nil }