diff --git a/internal/config/config.go b/internal/config/config.go index 83d12fa..08b1bca 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,7 +18,6 @@ import ( "fmt" "os" "path/filepath" - "strings" "time" "github.com/kami/maven/internal/delivery/ntfysink" @@ -435,104 +434,6 @@ type VoiceConfig struct { ToolTimeout Duration `json:"tool_timeout,omitempty"` } -// CaptureConfig — the meeting recorder (internal/capture, -// docs/plans/08-hearing.md). -// -// Absent, or enabled=false, ⇒ the recorder is not wired and the capture methods -// return "unknown method", so no client can start a recording however it asks. -// A media block is required too: audio is never held only in memory. -// -// There is deliberately no "auto", no keyword trigger and no duration default -// long enough to be forgotten about. Recording other people is an explicit act -// with a start, a stop, and a cap. -type CaptureConfig struct { - // Enabled — may she record a meeting when asked. Default false. - Enabled bool `json:"enabled,omitempty"` - - // MaxMinutes — hard cap on one session; it stops itself there. 0 ⇒ - // capture.DefaultMaxDuration (120 minutes). - MaxMinutes int `json:"max_minutes,omitempty"` - - // STTWindow — audio handed to whisper per call. 0 ⇒ - // capture.DefaultSTTWindow (5m). Larger windows transcribe slightly better - // and block the STT worker for longer. - STTWindow Duration `json:"stt_window,omitempty"` - - // ChunkRunes — transcript runes per summarisation prompt. 0 ⇒ - // capture.DefaultChunkRunes (3000), sized for the resident model's n_ctx of - // 4096. Raise this only if the resident model's context grows. - ChunkRunes int `json:"chunk_runes,omitempty"` - - // MaxChunks — how many windows one meeting may be summarised in before the - // transcript is truncated and the summary says so. 0 ⇒ - // capture.DefaultMaxChunks (40). - MaxChunks int `json:"max_chunks,omitempty"` - - // SaveTranscript — write the full transcript as a note alongside the - // summary. Default false, and the cost is not disk: a note is embedded and - // becomes recall corpus, so every later question can surface verbatim words - // other people said in a room. That is the reason it takes a deliberate yes. - // The audio blob is pruned by media.retention either way; the notes are not. - // - // A meeting with no summary writes its transcript regardless. The choice - // here is transcript IN ADDITION to a summary, not whether the meeting is - // remembered at all. - SaveTranscript bool `json:"save_transcript,omitempty"` -} - -// Records reports whether the recorder should be wired. Safe on a nil receiver. -func (c *CaptureConfig) Records() bool { - return c != nil && c.Enabled -} - -// MaxDuration is the configured session cap as a duration, or 0 for the -// package default. Safe on a nil receiver. -func (c *CaptureConfig) MaxDuration() time.Duration { - if c == nil || c.MaxMinutes <= 0 { - return 0 - } - return time.Duration(c.MaxMinutes) * time.Minute -} - -// SpeakerConfig — voice identification (internal/speaker, -// docs/plans/10-speaker-recognition.md). -// -// Absent, or enabled=false, ⇒ no voiceprint is computed for any turn, the -// enrolment methods do not exist, and nobody can be enrolled. A voiceprint is -// biometric data about a person, so this one is off until someone typed a model -// path on purpose. -// -// It cannot currently be turned on: there is no speaker-embedding model on this -// box. See the plan document for what to download. -type SpeakerConfig struct { - // Enabled — may she work out who is speaking. Default false. - Enabled bool `json:"enabled,omitempty"` - - // ModelPath — an ECAPA-TDNN (or equivalent) speaker-embedding ONNX model. - // Required; without it the recognizer runs disabled and says so once. - ModelPath string `json:"model_path,omitempty"` - - // LibPath — onnxruntime shared library, as for the text embedder. Empty ⇒ - // the same default the embedder block uses. - LibPath string `json:"lib_path,omitempty"` - - // Threshold — cosine similarity a match must beat. 0 ⇒ - // speaker.DefaultThreshold (0.7). Lower it and she starts calling guests by - // his name, which is the expensive direction of this error. - Threshold float64 `json:"threshold,omitempty"` - - // MinSeconds — least speech an identification will look at. 0 ⇒ - // speaker.DefaultMinSeconds (2s). - MinSeconds float64 `json:"min_seconds,omitempty"` -} - -// Recognizes reports whether voice identification should be wired. Safe on a -// nil receiver, and false without a model path — enabled with nothing to embed -// with is a misconfiguration, not a capability. -func (s *SpeakerConfig) Recognizes() bool { - return s != nil && s.Enabled && strings.TrimSpace(s.ModelPath) != "" -} - // WeatherConfig configures the weather provider for voice queries. type WeatherConfig struct { Provider string `json:"provider,omitempty"` // "open-meteo" or "" → stub @@ -988,8 +889,8 @@ func (c *Config) validate() error { if err := c.validateVision(); err != nil { return err } - if c.Capture.Records() && c.Media.StoreDir() == "" { - return errors.New("capture.enabled set but there is no media block to keep the audio in") + if err := c.validateCapture(); err != nil { + return err } if len(c.MorningRoutines) > 0 { if err := morning.Validate(morningRoutinesFromConfig(c.MorningRoutines)); err != nil { diff --git a/internal/config/senses.go b/internal/config/senses.go index 47736da..c9fc117 100644 --- a/internal/config/senses.go +++ b/internal/config/senses.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "strings" + "time" "github.com/kami/maven/internal/vision" ) @@ -127,3 +128,113 @@ func (c *Config) validateVision() error { } return nil } + +// CaptureConfig — the meeting recorder (internal/capture, +// docs/plans/08-hearing.md). +// +// Absent, or enabled=false, ⇒ the recorder is not wired and the capture methods +// return "unknown method", so no client can start a recording however it asks. +// A media block is required too: audio is never held only in memory. +// +// There is deliberately no "auto", no keyword trigger and no duration default +// long enough to be forgotten about. Recording other people is an explicit act +// with a start, a stop, and a cap. +type CaptureConfig struct { + // Enabled — may she record a meeting when asked. Default false. + Enabled bool `json:"enabled,omitempty"` + + // MaxMinutes — hard cap on one session; it stops itself there. 0 ⇒ + // capture.DefaultMaxDuration (120 minutes). + MaxMinutes int `json:"max_minutes,omitempty"` + + // STTWindow — audio handed to whisper per call. 0 ⇒ + // capture.DefaultSTTWindow (5m). Larger windows transcribe slightly better + // and block the STT worker for longer. + STTWindow Duration `json:"stt_window,omitempty"` + + // ChunkRunes — transcript runes per summarisation prompt. 0 ⇒ + // capture.DefaultChunkRunes (3000), sized for the resident model's n_ctx of + // 4096. Raise this only if the resident model's context grows. + ChunkRunes int `json:"chunk_runes,omitempty"` + + // MaxChunks — how many windows one meeting may be summarised in before the + // transcript is truncated and the summary says so. 0 ⇒ + // capture.DefaultMaxChunks (40). + MaxChunks int `json:"max_chunks,omitempty"` + + // SaveTranscript — write the full transcript as a note alongside the + // summary. Default false, and the cost is not disk: a note is embedded and + // becomes recall corpus, so every later question can surface verbatim words + // other people said in a room. That is the reason it takes a deliberate yes. + // The audio blob is pruned by media.retention either way; the notes are not. + // + // A meeting with no summary writes its transcript regardless. The choice + // here is transcript IN ADDITION to a summary, not whether the meeting is + // remembered at all. + SaveTranscript bool `json:"save_transcript,omitempty"` +} + +// Records reports whether the recorder should be wired. Safe on a nil receiver. +func (c *CaptureConfig) Records() bool { + return c != nil && c.Enabled +} + +// MaxDuration is the configured session cap as a duration, or 0 for the +// package default. Safe on a nil receiver. +func (c *CaptureConfig) MaxDuration() time.Duration { + if c == nil || c.MaxMinutes <= 0 { + return 0 + } + return time.Duration(c.MaxMinutes) * time.Minute +} + +// validateCapture refuses a recorder with nowhere to keep the audio. +func (c *Config) validateCapture() error { + if c.Capture.Records() && c.Media.StoreDir() == "" { + return errors.New("capture.enabled set but there is no media block to keep the audio in") + } + return nil +} + +// SpeakerConfig — voice identification (internal/speaker, +// docs/plans/10-speaker-recognition.md). +// +// Absent, or enabled=false, ⇒ no voiceprint is computed for any turn, the +// enrolment methods do not exist, and nobody can be enrolled. A voiceprint is +// biometric data about a person, so this one is off until someone typed a model +// path on purpose. +// +// It cannot currently be turned on: there is no speaker-embedding model on this +// box. See the plan document for what to download. +type SpeakerConfig struct { + // Enabled — may she work out who is speaking. Default false. + Enabled bool `json:"enabled,omitempty"` + + // ModelPath — an ECAPA-TDNN (or equivalent) speaker-embedding ONNX model. + // Required; without it the recognizer runs disabled and says so once. + ModelPath string `json:"model_path,omitempty"` + + // LibPath — onnxruntime shared library, as for the text embedder. Empty ⇒ + // the same default the embedder block uses. + // + // Nothing reads it yet: cmd/mavend's newSpeakerEmbedder discards the whole + // block, because there is no speaker model on this box to load. It stays + // declared so the block a reader writes matches the plan document. + LibPath string `json:"lib_path,omitempty"` + + // Threshold — cosine similarity a match must beat. 0 ⇒ + // speaker.DefaultThreshold (0.7). Lower it and she starts calling guests by + // his name, which is the expensive direction of this error. + Threshold float64 `json:"threshold,omitempty"` + + // MinSeconds — least speech an identification will look at. 0 ⇒ + // speaker.DefaultMinSeconds (2s). + MinSeconds float64 `json:"min_seconds,omitempty"` +} + +// Recognizes reports whether voice identification should be wired. Safe on a +// nil receiver, and false without a model path — enabled with nothing to embed +// with is a misconfiguration, not a capability. +func (s *SpeakerConfig) Recognizes() bool { + return s != nil && s.Enabled && strings.TrimSpace(s.ModelPath) != "" +}