diff --git a/.env.example b/.env.example index 4cccaf2..79cae6d 100644 --- a/.env.example +++ b/.env.example @@ -149,3 +149,5 @@ STT_MAX_PARALLEL=1 STT_PER_USER_PER_HOUR=60 # Höchstdauer einer Erkennung in Sekunden, Warten in der Schlange eingeschlossen. STT_TIMEOUT_SECONDS=120 +# Bittet den Whisper-Server, Stille zu überspringen (weniger erfundene Wiederholungen); false, falls der Server das Feld ablehnt. +STT_VAD_FILTER=true diff --git a/CHANGELOG.md b/CHANGELOG.md index 095c960..033d9bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.37.2] - 2026-11-01 + +### Fixed +- „Als Notiz vertiefen“ an Aufgabenzeile und Kanban-Karte legte bei einer schon verknüpften Aufgabe jedes Mal eine neue Notiz an. Jetzt öffnet der Knopf die verknüpfte Notiz und ist dafür dezent in der Akzentfarbe markiert; auch der Server legt keine zweite Notiz mehr an. +- „Alle Aufgaben“ zeigte bei jeder Aufgabe die Standard-Spalten („Wartet auf andere“) statt der Spalten ihrer Liste. Status-Auswahl, Statuspunkt und Detailansicht nutzen jetzt die Spalten der eigenen Liste, umbenannte Spalten erscheinen also auch dort. +- Spracheingabe mit eigenem Whisper-Server: Bei längeren Aufnahmen hängte Whisper manchmal schon gesagte Sätze noch einmal an. MindSift bittet den Server jetzt, Stille zu überspringen (`STT_VAD_FILTER`, Standard an), und entfernt eine wörtlich wiederholte Schlusspassage. +- Detailansicht am PC: Das Datumsfeld war zu schmal (das Kalender-Symbol lag über dem Datum), das Uhrzeitfeld unnötig breit. + ## [0.37.1] - 2026-11-01 ### Fixed @@ -622,7 +630,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. -[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.1...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.2...dev +[0.37.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.1...v0.37.2 [0.37.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.0...v0.37.1 [0.37.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.36.0...v0.37.0 [0.36.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.35.0...v0.36.0 diff --git a/Doku/architecture.md b/Doku/architecture.md index 8c25189..46329a2 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -83,6 +83,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/ | `STT_DISABLED` | `false` | `true` blendet die Spracheingabe überall aus | | `STT_MAX_SECONDS`, `STT_MAX_UPLOAD_MB` | `600` / `25` | Längste Aufnahme (stoppt im Browser), größte Aufnahme | | `STT_MAX_PARALLEL`, `STT_PER_USER_PER_HOUR`, `STT_TIMEOUT_SECONDS` | `1` / `60` / `120` | Gleichzeitige Erkennungen (weitere warten), Erkennungen pro Person und Stunde, Höchstdauer inkl. Warten | +| `STT_VAD_FILTER` | `true` | Bittet den Whisper-Server, Stille zu überspringen (`vad_filter`, speaches); weniger erfundene Wiederholungen. `false`, falls ein Server das Feld ablehnt. Unabhängig davon entfernt MindSift eine wörtlich wiederholte Schlusspassage. | | `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) | | `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster | | `LEGAL_IMPRINT_CONTENT_FILE`, `LEGAL_PRIVACY_CONTENT_FILE` | leer | Pfad zu einer Datei im Container mit Markdown-Inhalt für Impressum bzw. Datenschutz. Ohne URL und Text bleibt der Link unsichtbar. | diff --git a/README.md b/README.md index 7ced83e..763284d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.37.1-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.37.2-16a34a?style=flat-square)](CHANGELOG.md) **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. diff --git a/docker-compose.yml b/docker-compose.yml index 6a78121..a7dd544 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,6 +61,7 @@ services: - STT_MAX_PARALLEL=${STT_MAX_PARALLEL:-1} - STT_PER_USER_PER_HOUR=${STT_PER_USER_PER_HOUR:-60} - STT_TIMEOUT_SECONDS=${STT_TIMEOUT_SECONDS:-120} + - STT_VAD_FILTER=${STT_VAD_FILTER:-true} # Optional: eigene Spracherkennung (Whisper). Zum Einschalten: # 1. die Rauten vor dem Block "whisper" unten entfernen diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7ea1b7d..3513d9a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.37.1", + "version": "0.37.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.37.1", + "version": "0.37.2", "license": "Unlicense", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index d72b9f7..50dfc82 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.37.1", + "version": "0.37.2", "license": "Unlicense", "type": "module", "scripts": { diff --git a/frontend/src/features/triage/TaskDetailModal.tsx b/frontend/src/features/triage/TaskDetailModal.tsx index ffade94..c587b3f 100644 --- a/frontend/src/features/triage/TaskDetailModal.tsx +++ b/frontend/src/features/triage/TaskDetailModal.tsx @@ -507,7 +507,7 @@ const TaskDetailModalContent: React.FC aria-label="Fälligkeitsdatum" value={dueParts.date} onChange={(e) => onUpdateDueDate(task, joinDue(e.target.value, dueParts.time))} - className="flex-1 min-w-0 h-11 sm:h-8 input-field px-2 text-base sm:text-xs bg-panel" + className="flex-1 min-w-0 h-11 sm:h-8 input-field px-2 lg:px-1.5 text-base sm:text-xs bg-panel" /> value={dueParts.time} disabled={!dueParts.date} onChange={(e) => onUpdateDueDate(task, joinDue(dueParts.date, e.target.value))} - className="w-[7.5rem] shrink-0 h-11 sm:h-8 input-field px-2 text-base sm:text-xs bg-panel disabled:opacity-50" + className="w-[7.5rem] lg:w-[5.25rem] shrink-0 h-11 sm:h-8 input-field px-2 lg:px-1.5 text-base sm:text-xs bg-panel disabled:opacity-50" /> {task.dueDate && ( @@ -2292,7 +2305,7 @@ export const TasksView: React.FC = ({ setShowDetailModal(false); setDetailTaskId(null); }} - activeColumns={activeColumns} + activeColumns={detailTask ? columnsFor(detailTask) : activeColumns} taskLists={safeTaskLists} onOpenDocument={onOpenDocument} onUpdateTitle={handleUpdateTaskTitle} @@ -2412,8 +2425,8 @@ export const TasksView: React.FC = ({
e.stopPropagation()}> diff --git a/internal/api/voice.go b/internal/api/voice.go index 1dec38f..b605c2d 100644 --- a/internal/api/voice.go +++ b/internal/api/voice.go @@ -12,6 +12,7 @@ import ( "net/http" "net/textproto" "path" + "regexp" "strings" "sync" "time" @@ -173,6 +174,9 @@ func (s *Server) transcribe(ctx context.Context, audio []byte, filename, content _ = mw.WriteField("language", lang) } _ = mw.WriteField("response_format", "json") + if s.cfg.Speech.VADFilter { + _ = mw.WriteField("vad_filter", "true") + } if err := mw.Close(); err != nil { return "", err } @@ -192,7 +196,62 @@ func (s *Server) transcribe(ctx context.Context, audio []byte, filename, content if err := json.Unmarshal(raw, &out); err != nil { return "", fmt.Errorf("unerwartete Antwort: %s", strings.TrimSpace(string(raw))) } - return strings.TrimSpace(out.Text), nil + return dropRepeatedTail(strings.TrimSpace(out.Text)), nil +} + +var sentenceRe = regexp.MustCompile(`[^.!?…]+[.!?…]*`) + +func normalizeSpeech(s string) string { + return strings.Trim(strings.ToLower(strings.Join(strings.Fields(s), " ")), " .,!?…;:-–") +} + +// dropRepeatedTail removes what Whisper sometimes makes up at the end of longer recordings: sentences +// it already wrote, repeated once more, or the last words echoed right after themselves. +func dropRepeatedTail(text string) string { + for { + trimmed := dropOnce(text) + if trimmed == text { + return text + } + text = trimmed + } +} + +func dropOnce(text string) string { + // Sentence level: the last k sentences occur in the same order earlier on + sentences := sentenceRe.FindAllStringIndex(text, -1) + norm := make([]string, len(sentences)) + for i, loc := range sentences { + norm[i] = normalizeSpeech(text[loc[0]:loc[1]]) + } + for k := len(norm) / 2; k >= 1; k-- { + tail := norm[len(norm)-k:] + if len(strings.Join(tail, " ")) < 15 { + continue + } + for start := 0; start+k <= len(norm)-k; start++ { + same := true + for j := 0; j < k; j++ { + if norm[start+j] != tail[j] || tail[j] == "" { + same = false + break + } + } + if same { + return strings.TrimSpace(text[:sentences[len(norm)-k][0]]) + } + } + } + // Word level: the last n words (at least 6) directly repeat the n words before them + words := strings.Fields(text) + for n := len(words) / 2; n >= 6; n-- { + a := normalizeSpeech(strings.Join(words[len(words)-2*n:len(words)-n], " ")) + b := normalizeSpeech(strings.Join(words[len(words)-n:], " ")) + if a == b { + return strings.Join(words[:len(words)-n], " ") + } + } + return text } // checkVoice asks the Whisper server whether it is up and has the model. diff --git a/internal/api/voice_tail_test.go b/internal/api/voice_tail_test.go new file mode 100644 index 0000000..0932bd1 --- /dev/null +++ b/internal/api/voice_tail_test.go @@ -0,0 +1,20 @@ +package api + +import "testing" + +// Whisper sometimes repeats earlier sentences or the last words at the end of a longer recording. +func TestDropRepeatedTail(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {"Morgen den Filter wechseln. Dann die Heizung prüfen. Morgen den Filter wechseln.", "Morgen den Filter wechseln. Dann die Heizung prüfen."}, + {"Erst einkaufen. Dann kochen. Danach abwaschen. Dann kochen. Danach abwaschen.", "Erst einkaufen. Dann kochen. Danach abwaschen."}, + {"bitte die neue Version auf dem Server einspielen bitte die neue Version auf dem Server einspielen", "bitte die neue Version auf dem Server einspielen"}, + // Nothing repeated, or only a short word: unchanged + {"Ja. Nein. Ja.", "Ja. Nein. Ja."}, + {"Kauf Milch, Brot und Eier. Und Butter.", "Kauf Milch, Brot und Eier. Und Butter."}, + {"", ""}, + } { + if got := dropRepeatedTail(tc.in); got != tc.want { + t.Errorf("dropRepeatedTail(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index decfcd9..57ef187 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -91,6 +91,7 @@ type SpeechConfig struct { MaxParallel int // transcriptions running at the same time; more wait in line PerUserPerHour int TimeoutSeconds int + VADFilter bool // ask the Whisper server to skip silence (fewer made-up repetitions); speaches understands it } // Mode is "server", "browser" or "off". The demo never uses the server, so visitors cannot keep @@ -270,6 +271,7 @@ func Load() *Config { MaxParallel: envInt("STT_MAX_PARALLEL", 1, 1, 32), PerUserPerHour: envInt("STT_PER_USER_PER_HOUR", 60, 1, 100000), TimeoutSeconds: envInt("STT_TIMEOUT_SECONDS", 120, 5, 3600), + VADFilter: strings.ToLower(getEnv("STT_VAD_FILTER", "true")) != "false", }, NotifyDigestMinutes: envInt("NOTIFY_DIGEST_MINUTES", 5, 1, 1440), VAPIDPublicKey: strings.TrimSpace(os.Getenv("VAPID_PUBLIC_KEY")), diff --git a/internal/storage/thoughts.go b/internal/storage/thoughts.go index 5991910..73c8597 100644 --- a/internal/storage/thoughts.go +++ b/internal/storage/thoughts.go @@ -311,6 +311,12 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target convertedType = "task" } else if convertTo == "document" { isTask := thought.Status == domain.StatusKept || (thought.Status == domain.StatusConverted && thought.ConvertedType == "task") + // A task already linked to a note keeps that note; no second one + if isTask && thought.ConvertedType == "document_block" && thought.DocID != "" { + if doc, err := s.GetDocumentByID(userID, thought.DocID); err == nil && doc != nil { + return thought, nil + } + } newStatus = domain.StatusConverted convertedType = "document" title := thought.Content