7c7bd8ceeb
Maven can now be told who someone is. She cannot yet tell who is speaking, and this commit is careful to say so rather than pretend otherwise. What works: profiles are enrolled from several deliberately recorded samples, listed, and deleted. They live in the existing memory_vectors table under a "speaker:" id prefix, so there is no migration; what that needed was a wider interface than memory.Store, hence memory.Catalog with ByPrefix and Delete. Delete is the load-bearing half — a voiceprint someone asked to be rid of has to actually go, and a search-only store cannot do that. InMemoryStore.Insert became an upsert by id to match what the persistent store already did. What does not work, and why it is not faked: there is no speaker-embedding model on this box. Sixteen ggufs in /mnt/hdd1/llms, all text; no ECAPA, no x-vector, no titanet, no wespeaker, no .onnx anywhere under /mnt/hdd1. So newSpeakerEmbedder returns nil, internal/speaker falls back to speaker.Disabled, Identify answers ErrDisabled, and the daemon logs which half is off at startup. The plan's "simple MFCC + GMM" floor is refused in the package comment: MFCC cosine distance detects channel and loudness as much as voice, and a biometric that is confidently wrong writes false claims about named people into his memory. A bad floor is worse than none here. Refused as well, and the reason is in enroll.go's doc comment: the plan asked for unknown speakers to be enrolled on first interaction with a TTS "кто это?". There is no request shape in the protocol that could express that. Taking a biometric of whoever walks past the microphone does it to guests who are not party to the exchange, and a synthesised question into a room is not consent from whoever answers. Authority: enrolment is AuthStepUp, because it is a deliberate sit-down act that writes a biometric of a named person and never something done by voice mid-conversation. Deletion is one rung lower at AuthWrite, deliberately inverting the usual pattern — getting rid of a biometric must never be the harder half. Listing is AuthRead and never returns the vectors themselves. Off unless configured: no speaker block means the three methods answer ErrUnknownMethod, so a default box has no wire path that takes a voiceprint. make build and make test pass. Vikunja #255 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
224 lines
7.0 KiB
Go
224 lines
7.0 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// Absent blocks must read as off on a nil receiver: the daemon calls these
|
|
// helpers before it knows whether the operator configured anything.
|
|
func TestSensesOffByDefault(t *testing.T) {
|
|
var cfg Config
|
|
if cfg.Media.StoreDir() != "" {
|
|
t.Error("media store dir is set with no media block")
|
|
}
|
|
if cfg.Vision.LooksAtImages() {
|
|
t.Error("vision is on with no vision block")
|
|
}
|
|
if cfg.Capture.Records() {
|
|
t.Error("the recorder is on with no capture block")
|
|
}
|
|
if cfg.Capture.MaxDuration() != 0 {
|
|
t.Error("a nil capture block invented a duration")
|
|
}
|
|
if cfg.Speaker.Recognizes() {
|
|
t.Error("speaker recognition is on with no speaker block")
|
|
}
|
|
}
|
|
|
|
// The recorder is the capability that most needs its default to be off, so it
|
|
// gets its own test rather than a line in the one above.
|
|
func TestCaptureIsOffUntilExplicitlyEnabled(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
c *CaptureConfig
|
|
want bool
|
|
}{
|
|
{"absent", nil, false},
|
|
{"present but not enabled", &CaptureConfig{MaxMinutes: 60}, false},
|
|
{"enabled", &CaptureConfig{Enabled: true}, true},
|
|
}
|
|
for _, c := range cases {
|
|
if got := c.c.Records(); got != c.want {
|
|
t.Errorf("%s: Records() = %v, want %v", c.name, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCaptureBlockParsesFromJSON(t *testing.T) {
|
|
raw := `{"capture":{"enabled":true,"max_minutes":45,"stt_window":"2m",
|
|
"chunk_runes":2000,"max_chunks":10,"save_transcript":true}}`
|
|
var cfg Config
|
|
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if !cfg.Capture.Records() {
|
|
t.Fatal("capture did not parse as enabled")
|
|
}
|
|
if cfg.Capture.MaxDuration() != 45*time.Minute {
|
|
t.Errorf("max duration = %v", cfg.Capture.MaxDuration())
|
|
}
|
|
if time.Duration(cfg.Capture.STTWindow) != 2*time.Minute {
|
|
t.Errorf("stt window = %v", time.Duration(cfg.Capture.STTWindow))
|
|
}
|
|
if cfg.Capture.ChunkRunes != 2000 || cfg.Capture.MaxChunks != 10 {
|
|
t.Errorf("summariser limits = %+v", cfg.Capture)
|
|
}
|
|
if !cfg.Capture.SaveTranscript {
|
|
t.Error("save_transcript did not parse")
|
|
}
|
|
}
|
|
|
|
// Keeping the verbatim record of what other people said is the heavier act, so
|
|
// it is separately opt-in from recording at all.
|
|
func TestTranscriptIsNotSavedByDefault(t *testing.T) {
|
|
var cfg Config
|
|
if err := json.Unmarshal([]byte(`{"capture":{"enabled":true}}`), &cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.Capture.SaveTranscript {
|
|
t.Error("transcripts are saved without anyone asking")
|
|
}
|
|
if cfg.Capture.MaxDuration() != 0 {
|
|
t.Error("max_minutes defaulted in config instead of in the package")
|
|
}
|
|
}
|
|
|
|
// enabled with nothing to talk to is a misconfiguration, not a capability.
|
|
func TestVisionNeedsBothEnabledAndEndpoint(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
v *VisionConfig
|
|
want bool
|
|
}{
|
|
{"absent", nil, false},
|
|
{"endpoint but not enabled", &VisionConfig{Endpoint: "http://127.0.0.1:8081"}, false},
|
|
{"enabled but no endpoint", &VisionConfig{Enabled: true}, false},
|
|
{"enabled, blank endpoint", &VisionConfig{Enabled: true, Endpoint: " "}, false},
|
|
{"both", &VisionConfig{Enabled: true, Endpoint: "http://127.0.0.1:8081"}, true},
|
|
}
|
|
for _, c := range cases {
|
|
if got := c.v.LooksAtImages(); got != c.want {
|
|
t.Errorf("%s: LooksAtImages() = %v, want %v", c.name, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSensesBlocksParseFromJSON(t *testing.T) {
|
|
raw := `{
|
|
"db_path": "/tmp/x.db",
|
|
"socket_path": "/tmp/x.sock",
|
|
"media": {"dir": "media", "retention": "48h", "max_bytes": 1048576},
|
|
"vision": {
|
|
"enabled": true,
|
|
"endpoint": "http://127.0.0.1:8081",
|
|
"model": "qwen2.5-vl",
|
|
"max_dim": 640,
|
|
"max_tokens": 200,
|
|
"timeout": "45s",
|
|
"prompt": "Что тут?"
|
|
}
|
|
}`
|
|
var cfg Config
|
|
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if cfg.Media.StoreDir() != "media" {
|
|
t.Errorf("media dir = %q", cfg.Media.StoreDir())
|
|
}
|
|
if time.Duration(cfg.Media.Retention) != 48*time.Hour {
|
|
t.Errorf("retention = %v", time.Duration(cfg.Media.Retention))
|
|
}
|
|
if cfg.Media.MaxBytes != 1<<20 {
|
|
t.Errorf("max_bytes = %d", cfg.Media.MaxBytes)
|
|
}
|
|
if !cfg.Vision.LooksAtImages() {
|
|
t.Fatal("vision did not parse as enabled")
|
|
}
|
|
if cfg.Vision.MaxDim != 640 || cfg.Vision.MaxTokens != 200 {
|
|
t.Errorf("vision limits = %+v", cfg.Vision)
|
|
}
|
|
if time.Duration(cfg.Vision.Timeout) != 45*time.Second {
|
|
t.Errorf("vision timeout = %v", time.Duration(cfg.Vision.Timeout))
|
|
}
|
|
if cfg.Vision.Prompt != "Что тут?" {
|
|
t.Errorf("prompt = %q", cfg.Vision.Prompt)
|
|
}
|
|
}
|
|
|
|
// A media dir set with no vision block is a valid state, and the useful one on a
|
|
// box with no vision model: images can be kept, they just cannot be described.
|
|
func TestMediaWithoutVisionIsValid(t *testing.T) {
|
|
var cfg Config
|
|
if err := json.Unmarshal([]byte(`{"media":{"dir":"/srv/media"}}`), &cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.Media.StoreDir() != "/srv/media" {
|
|
t.Errorf("dir = %q", cfg.Media.StoreDir())
|
|
}
|
|
if cfg.Vision.LooksAtImages() {
|
|
t.Error("vision came on by itself")
|
|
}
|
|
}
|
|
|
|
// A voiceprint is a biometric of a named person. Nothing about it turns on by
|
|
// itself: no speaker block means no recognition, and no enrolment either.
|
|
func TestSpeakerIsOffUntilExplicitlyEnabled(t *testing.T) {
|
|
var cfg Config
|
|
if err := json.Unmarshal([]byte(`{}`), &cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.Speaker.Recognizes() {
|
|
t.Error("speaker recognition came on with no config at all")
|
|
}
|
|
var empty Config
|
|
if err := json.Unmarshal([]byte(`{"speaker":{}}`), &empty); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if empty.Speaker.Recognizes() {
|
|
t.Error("an empty speaker block enabled recognition")
|
|
}
|
|
}
|
|
|
|
// Enabled alone is not enough: recognition needs a model, and on this box there
|
|
// is none. Recognizes() must stay false so the daemon reports the honest state
|
|
// instead of claiming a capability it cannot perform.
|
|
func TestSpeakerNeedsBothEnabledAndAModel(t *testing.T) {
|
|
var cfg Config
|
|
if err := json.Unmarshal([]byte(`{"speaker":{"enabled":true}}`), &cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.Speaker.Recognizes() {
|
|
t.Error("enabled with no model_path claimed to recognise")
|
|
}
|
|
var only Config
|
|
if err := json.Unmarshal([]byte(`{"speaker":{"model_path":"/opt/x.onnx"}}`), &only); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if only.Speaker.Recognizes() {
|
|
t.Error("a model_path alone enabled recognition")
|
|
}
|
|
}
|
|
|
|
func TestSpeakerBlockParsesFromJSON(t *testing.T) {
|
|
const raw = `{"speaker":{"enabled":true,"model_path":"/opt/maven/models/spk/ecapa.onnx",` +
|
|
`"lib_path":"/opt/maven/lib","threshold":0.62,"min_seconds":1.5}}`
|
|
var cfg Config
|
|
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if !cfg.Speaker.Recognizes() {
|
|
t.Fatal("speaker did not parse as enabled")
|
|
}
|
|
if cfg.Speaker.ModelPath != "/opt/maven/models/spk/ecapa.onnx" {
|
|
t.Errorf("model_path = %q", cfg.Speaker.ModelPath)
|
|
}
|
|
if cfg.Speaker.LibPath != "/opt/maven/lib" {
|
|
t.Errorf("lib_path = %q", cfg.Speaker.LibPath)
|
|
}
|
|
if cfg.Speaker.Threshold != 0.62 || cfg.Speaker.MinSeconds != 1.5 {
|
|
t.Errorf("thresholds = %+v", cfg.Speaker)
|
|
}
|
|
}
|