Merge dev into main (v0.37.2)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 28s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 28s
This commit is contained in:
commit
7ddebb8a94
13 files changed
+131
-18
No files matched your search
@@ -149,3 +149,5 @@ STT_MAX_PARALLEL=1
|
|||||||
STT_PER_USER_PER_HOUR=60
|
STT_PER_USER_PER_HOUR=60
|
||||||
# Höchstdauer einer Erkennung in Sekunden, Warten in der Schlange eingeschlossen.
|
# Höchstdauer einer Erkennung in Sekunden, Warten in der Schlange eingeschlossen.
|
||||||
STT_TIMEOUT_SECONDS=120
|
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
|
||||||
+10
-1
@@ -6,6 +6,14 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.37.1] - 2026-11-01
|
||||||
|
|
||||||
### Fixed
|
### 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.
|
- 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.
|
- 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.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.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
|
[0.36.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.35.0...v0.36.0
|
||||||
|
|||||||
@@ -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_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_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_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_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`, `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. |
|
| `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. |
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](#-schnellstart)
|
[](#-schnellstart)
|
||||||
[](#-deine-daten-gehören-dir)
|
[](#-deine-daten-gehören-dir)
|
||||||
[](CHANGELOG.md)
|
[](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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ services:
|
|||||||
- STT_MAX_PARALLEL=${STT_MAX_PARALLEL:-1}
|
- STT_MAX_PARALLEL=${STT_MAX_PARALLEL:-1}
|
||||||
- STT_PER_USER_PER_HOUR=${STT_PER_USER_PER_HOUR:-60}
|
- STT_PER_USER_PER_HOUR=${STT_PER_USER_PER_HOUR:-60}
|
||||||
- STT_TIMEOUT_SECONDS=${STT_TIMEOUT_SECONDS:-120}
|
- STT_TIMEOUT_SECONDS=${STT_TIMEOUT_SECONDS:-120}
|
||||||
|
- STT_VAD_FILTER=${STT_VAD_FILTER:-true}
|
||||||
|
|
||||||
# Optional: eigene Spracherkennung (Whisper). Zum Einschalten:
|
# Optional: eigene Spracherkennung (Whisper). Zum Einschalten:
|
||||||
# 1. die Rauten vor dem Block "whisper" unten entfernen
|
# 1. die Rauten vor dem Block "whisper" unten entfernen
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.37.1",
|
"version": "0.37.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.37.1",
|
"version": "0.37.2",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "5.3.0",
|
"@fontsource-variable/inter": "5.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.37.1",
|
"version": "0.37.2",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -507,7 +507,7 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
|||||||
aria-label="Fälligkeitsdatum"
|
aria-label="Fälligkeitsdatum"
|
||||||
value={dueParts.date}
|
value={dueParts.date}
|
||||||
onChange={(e) => onUpdateDueDate(task, joinDue(e.target.value, dueParts.time))}
|
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"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
@@ -516,7 +516,7 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
|||||||
value={dueParts.time}
|
value={dueParts.time}
|
||||||
disabled={!dueParts.date}
|
disabled={!dueParts.date}
|
||||||
onChange={(e) => onUpdateDueDate(task, joinDue(dueParts.date, e.target.value))}
|
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 && (
|
{task.dueDate && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -355,6 +355,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
return globalColumns;
|
return globalColumns;
|
||||||
}, [currentSelectedList, globalColumns]);
|
}, [currentSelectedList, globalColumns]);
|
||||||
|
|
||||||
|
// The columns of the list a task belongs to: in "Alle Aufgaben" every task keeps its own list's columns
|
||||||
|
const columnsFor = (task: Thought): KanbanColumn[] => {
|
||||||
|
const own = safeTaskLists.find((l) => l.id === task.taskListId)?.columns;
|
||||||
|
return Array.isArray(own) && own.length > 0 ? own : activeColumns;
|
||||||
|
};
|
||||||
|
|
||||||
const safeTasks = useMemo(() => {
|
const safeTasks = useMemo(() => {
|
||||||
if (!Array.isArray(tasks)) return [];
|
if (!Array.isArray(tasks)) return [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -501,8 +507,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
const handleToggleDone = async (task: Thought) => {
|
const handleToggleDone = async (task: Thought) => {
|
||||||
triggerHaptic(15);
|
triggerHaptic(15);
|
||||||
const nextState = !task.isCompleted;
|
const nextState = !task.isCompleted;
|
||||||
const doneCol = activeColumns.find((c) => c.isDone) || activeColumns[activeColumns.length - 1];
|
const cols = columnsFor(task);
|
||||||
const todoCol = activeColumns.find((c) => !c.isDone) || activeColumns[0];
|
const doneCol = cols.find((c) => c.isDone) || cols[cols.length - 1];
|
||||||
|
const todoCol = cols.find((c) => !c.isDone) || cols[0];
|
||||||
const nextKanban = nextState ? (doneCol ? doneCol.id : 'done') : (todoCol ? todoCol.id : 'todo');
|
const nextKanban = nextState ? (doneCol ? doneCol.id : 'done') : (todoCol ? todoCol.id : 'todo');
|
||||||
|
|
||||||
setTasks((prev) =>
|
setTasks((prev) =>
|
||||||
@@ -516,7 +523,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
|
|
||||||
const handleSetKanbanStatus = async (task: Thought, nextStatus: string) => {
|
const handleSetKanbanStatus = async (task: Thought, nextStatus: string) => {
|
||||||
triggerHaptic(15);
|
triggerHaptic(15);
|
||||||
const targetCol = activeColumns.find((c) => c.id === nextStatus);
|
const targetCol = columnsFor(task).find((c) => c.id === nextStatus);
|
||||||
const isCompleted = targetCol ? !!targetCol.isDone : nextStatus === 'done';
|
const isCompleted = targetCol ? !!targetCol.isDone : nextStatus === 'done';
|
||||||
|
|
||||||
setTasks((prev) =>
|
setTasks((prev) =>
|
||||||
@@ -664,6 +671,11 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
|
|
||||||
const handleConvertToDoc = async (task: Thought) => {
|
const handleConvertToDoc = async (task: Thought) => {
|
||||||
triggerHaptic(20);
|
triggerHaptic(20);
|
||||||
|
// Already linked to a note: open it instead of creating another one
|
||||||
|
if (task.docId) {
|
||||||
|
onOpenDocument(task.docId, task.blockId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const updated = await api.triageAction(task.id, 'keep', { convertTo: 'document' });
|
const updated = await api.triageAction(task.id, 'keep', { convertTo: 'document' });
|
||||||
onRefreshStats();
|
onRefreshStats();
|
||||||
// The task stays in its list, linked to the note's first checkbox (docId); open that note
|
// The task stays in its list, linked to the note's first checkbox (docId); open that note
|
||||||
@@ -1339,7 +1351,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
{/* Status: dot in the column colour; a selector on desktop, plain text on phones (changed in TaskDetailModal) */}
|
{/* Status: dot in the column colour; a selector on desktop, plain text on phones (changed in TaskDetailModal) */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const statusId = task.kanbanStatus || (isCompleted ? 'done' : 'todo');
|
const statusId = task.kanbanStatus || (isCompleted ? 'done' : 'todo');
|
||||||
const statusCol = activeColumns.find((c) => c.id === statusId);
|
const taskColumns = columnsFor(task);
|
||||||
|
const statusCol = taskColumns.find((c) => c.id === statusId);
|
||||||
const dot = <span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${columnDotClass(statusCol)}`} aria-hidden="true" />;
|
const dot = <span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${columnDotClass(statusCol)}`} aria-hidden="true" />;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1352,7 +1365,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
className="h-5 px-1.5 text-[10px] rounded-btn border border-border bg-panel text-muted hover:text-main cursor-pointer"
|
className="h-5 px-1.5 text-[10px] rounded-btn border border-border bg-panel text-muted hover:text-main cursor-pointer"
|
||||||
title="Status / Zustand ändern"
|
title="Status / Zustand ändern"
|
||||||
>
|
>
|
||||||
{activeColumns.map((c) => (
|
{taskColumns.map((c) => (
|
||||||
<option key={c.id} value={c.id}>
|
<option key={c.id} value={c.id}>
|
||||||
{c.name}
|
{c.name}
|
||||||
</option>
|
</option>
|
||||||
@@ -1469,8 +1482,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
onClick={() => handleConvertToDoc(task)}
|
onClick={() => handleConvertToDoc(task)}
|
||||||
title="Als Notiz vertiefen"
|
title={task.docId ? 'Verknüpfte Notiz öffnen' : 'Als Notiz vertiefen'}
|
||||||
className="text-faint hover:text-accent p-1.5 transition-colors hidden sm:block"
|
className={`${task.docId ? 'text-accent' : 'text-faint'} hover:text-accent p-1.5 transition-colors hidden sm:block`}
|
||||||
>
|
>
|
||||||
<FileText className="w-3.5 h-3.5" />
|
<FileText className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -2292,7 +2305,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
setShowDetailModal(false);
|
setShowDetailModal(false);
|
||||||
setDetailTaskId(null);
|
setDetailTaskId(null);
|
||||||
}}
|
}}
|
||||||
activeColumns={activeColumns}
|
activeColumns={detailTask ? columnsFor(detailTask) : activeColumns}
|
||||||
taskLists={safeTaskLists}
|
taskLists={safeTaskLists}
|
||||||
onOpenDocument={onOpenDocument}
|
onOpenDocument={onOpenDocument}
|
||||||
onUpdateTitle={handleUpdateTaskTitle}
|
onUpdateTitle={handleUpdateTaskTitle}
|
||||||
@@ -2412,8 +2425,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
<div className="hidden sm:flex items-center gap-1 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
<div className="hidden sm:flex items-center gap-1 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleConvertToDoc(task)}
|
onClick={() => handleConvertToDoc(task)}
|
||||||
title="Als Notiz vertiefen"
|
title={task.docId ? 'Verknüpfte Notiz öffnen' : 'Als Notiz vertiefen'}
|
||||||
className="text-faint hover:text-accent p-0.5"
|
className={`${task.docId ? 'text-accent' : 'text-faint'} hover:text-accent p-0.5`}
|
||||||
>
|
>
|
||||||
<FileText className="w-3.5 h-3.5" />
|
<FileText className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+60
-1
@@ -12,6 +12,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
"path"
|
"path"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -173,6 +174,9 @@ func (s *Server) transcribe(ctx context.Context, audio []byte, filename, content
|
|||||||
_ = mw.WriteField("language", lang)
|
_ = mw.WriteField("language", lang)
|
||||||
}
|
}
|
||||||
_ = mw.WriteField("response_format", "json")
|
_ = mw.WriteField("response_format", "json")
|
||||||
|
if s.cfg.Speech.VADFilter {
|
||||||
|
_ = mw.WriteField("vad_filter", "true")
|
||||||
|
}
|
||||||
if err := mw.Close(); err != nil {
|
if err := mw.Close(); err != nil {
|
||||||
return "", err
|
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 {
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
return "", fmt.Errorf("unerwartete Antwort: %s", strings.TrimSpace(string(raw)))
|
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.
|
// checkVoice asks the Whisper server whether it is up and has the model.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,6 +91,7 @@ type SpeechConfig struct {
|
|||||||
MaxParallel int // transcriptions running at the same time; more wait in line
|
MaxParallel int // transcriptions running at the same time; more wait in line
|
||||||
PerUserPerHour int
|
PerUserPerHour int
|
||||||
TimeoutSeconds 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
|
// 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),
|
MaxParallel: envInt("STT_MAX_PARALLEL", 1, 1, 32),
|
||||||
PerUserPerHour: envInt("STT_PER_USER_PER_HOUR", 60, 1, 100000),
|
PerUserPerHour: envInt("STT_PER_USER_PER_HOUR", 60, 1, 100000),
|
||||||
TimeoutSeconds: envInt("STT_TIMEOUT_SECONDS", 120, 5, 3600),
|
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),
|
NotifyDigestMinutes: envInt("NOTIFY_DIGEST_MINUTES", 5, 1, 1440),
|
||||||
VAPIDPublicKey: strings.TrimSpace(os.Getenv("VAPID_PUBLIC_KEY")),
|
VAPIDPublicKey: strings.TrimSpace(os.Getenv("VAPID_PUBLIC_KEY")),
|
||||||
|
|||||||
@@ -311,6 +311,12 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
|
|||||||
convertedType = "task"
|
convertedType = "task"
|
||||||
} else if convertTo == "document" {
|
} else if convertTo == "document" {
|
||||||
isTask := thought.Status == domain.StatusKept || (thought.Status == domain.StatusConverted && thought.ConvertedType == "task")
|
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
|
newStatus = domain.StatusConverted
|
||||||
convertedType = "document"
|
convertedType = "document"
|
||||||
title := thought.Content
|
title := thought.Content
|
||||||
|
|||||||
Reference in new issue
Block a user