{t("settings.shortcuts")}{t("settings.shortcutsDescription")}
diff --git a/src/editor/KukuEditor.meeting.test.tsx b/src/editor/KukuEditor.meeting.test.tsx
index 5b2be04..e964b9d 100644
--- a/src/editor/KukuEditor.meeting.test.tsx
+++ b/src/editor/KukuEditor.meeting.test.tsx
@@ -225,6 +225,40 @@ describe("KukuEditor meeting integration", () => {
dispose();
});
+ it("removes provisional third and fourth speakers after final cleanup", async () => {
+ const root = document.createElement("div");
+ document.body.append(root);
+ let handle: KukuEditorHandle | undefined;
+ const dispose = render(
+ () => (
+
{ handle = next; }}
+ onChange={() => undefined}
+ />
+ ),
+ root,
+ );
+ await Promise.resolve();
+
+ handle!.beginMeeting("session-over-split", "미팅 노트");
+ handle!.updateMeeting("session-over-split", "첫 발언", "", 1);
+ handle!.updateMeeting("session-over-split", "첫 발언 임시 둘", "", 2);
+ handle!.updateMeeting("session-over-split", "첫 발언 임시 둘 상대방", "", 3);
+ handle!.updateMeeting("session-over-split", "첫 발언 임시 둘 상대방 임시 넷", "", 4);
+ handle!.finalizeMeeting("session-over-split", [
+ { speaker: 1, text: "첫 발언 임시 둘" },
+ { speaker: 2, text: "상대방 임시 넷" },
+ ]);
+
+ const markdown = handle!.getMarkdown();
+ expect(markdown.match(/\*\*Speaker 1\*\*/g)).toHaveLength(1);
+ expect(markdown.match(/\*\*Speaker 2\*\*/g)).toHaveLength(1);
+ expect(markdown).not.toContain("**Speaker 3**");
+ expect(markdown).not.toContain("**Speaker 4**");
+ dispose();
+ });
+
it("follows the live transcript tail until the user scrolls away", async () => {
const root = document.createElement("div");
document.body.append(root);
diff --git a/src/editor/meeting_document.test.ts b/src/editor/meeting_document.test.ts
index 66e5411..7d0490d 100644
--- a/src/editor/meeting_document.test.ts
+++ b/src/editor/meeting_document.test.ts
@@ -49,6 +49,36 @@ describe("meeting document markdown", () => {
);
});
+ it("trusts final two-speaker cleanup over four provisional live labels", () => {
+ const cleaned = preserveSpeakerBoundaries(
+ [
+ { speaker: 1, text: "첫 발언" },
+ { speaker: 2, text: "두 번째 임시 라벨" },
+ { speaker: 3, text: "상대방 발언" },
+ { speaker: 4, text: "네 번째 임시 라벨" },
+ ],
+ [
+ { speaker: 1, text: "첫 발언 두 번째 임시 라벨" },
+ { speaker: 2, text: "상대방 발언 네 번째 임시 라벨" },
+ ],
+ );
+
+ expect(cleaned.map((segment) => segment.speaker)).toEqual([1, 2]);
+ });
+
+ it("compacts sparse final speaker labels in first-appearance order", () => {
+ const cleaned = preserveSpeakerBoundaries(
+ [{ speaker: 1, text: "실시간 문장" }],
+ [
+ { speaker: 1, text: "첫 화자" },
+ { speaker: 3, text: "둘째 화자" },
+ { speaker: 1, text: "첫 화자 마무리" },
+ ],
+ );
+
+ expect(cleaned.map((segment) => segment.speaker)).toEqual([1, 2, 1]);
+ });
+
it("serializes final speaker turns as editable standard markdown", () => {
const content = createMeetingDocumentNodes("미팅 노트 · 2026-08-04 14:30", [
{ speaker: 1, text: "첫 번째 발화 내용" },
diff --git a/src/editor/meeting_document.ts b/src/editor/meeting_document.ts
index 2499fb1..e2871f4 100644
--- a/src/editor/meeting_document.ts
+++ b/src/editor/meeting_document.ts
@@ -118,15 +118,30 @@ function preserveSpeakerBoundaries(
previousSegments: MeetingTranscriptSegment[],
correctedSegments: MeetingTranscriptSegment[],
): MeetingTranscriptSegment[] {
- const previous = previousSegments.filter((segment) => segment.text.trim());
- const corrected = correctedSegments.filter((segment) => segment.text.trim());
+ const compactSpeakerLabels = (segments: MeetingTranscriptSegment[]) => {
+ const labels = new Map();
+ return segments
+ .filter((segment) => segment.text.trim())
+ .map((segment) => {
+ if (segment.speaker == null) return { ...segment, speaker: null };
+ if (!labels.has(segment.speaker)) labels.set(segment.speaker, labels.size + 1);
+ return { ...segment, speaker: labels.get(segment.speaker) };
+ });
+ };
+ const previous = compactSpeakerLabels(previousSegments);
+ const corrected = compactSpeakerLabels(correctedSegments);
const previousSpeakers = new Set(previous.flatMap((segment) => (
segment.speaker == null ? [] : [segment.speaker]
)));
const correctedSpeakers = new Set(corrected.flatMap((segment) => (
segment.speaker == null ? [] : [segment.speaker]
)));
- if (previousSpeakers.size < 2 || correctedSpeakers.size >= previousSpeakers.size) return corrected;
+ // Live Sortformer labels are provisional and can briefly occupy all four
+ // slots even in a two-person recording. Once the bounded full-file pass
+ // finds two or more speakers, trust it instead of restoring those noisy
+ // live labels. Preserve live boundaries only for the narrow regression we
+ // can defend: exactly two stable live speakers collapsed to zero or one.
+ if (previousSpeakers.size !== 2 || correctedSpeakers.size >= 2) return corrected;
const correctedText = corrected.map((segment) => segment.text.trim()).join(" ");
if (!correctedText) return previous;
diff --git a/src/i18n.ts b/src/i18n.ts
index a503b25..423ebb8 100644
--- a/src/i18n.ts
+++ b/src/i18n.ts
@@ -43,6 +43,13 @@ const en = {
"settings.meetingDescription": "On-device transcription",
"settings.meetingReady": "Local models ready",
"settings.meetingDownload": "Downloads about {size} of AI models on first use",
+ "settings.localData": "Local transcription data",
+ "settings.localDataDescription": "Models and recovery audio left on this Mac",
+ "settings.localDataRemove": "Remove…",
+ "settings.localDataConfirm": "Remove models & audio",
+ "settings.localDataRemoving": "Removing…",
+ "settings.localDataRemoved": "Removed {size} of local transcription data",
+ "settings.localDataRemoveFailed": "Could not remove local transcription data",
"settings.audioPermissions": "Audio permissions",
"settings.microphone": "Microphone",
"settings.microphoneStatus.not-determined": "Permission not requested yet",
@@ -202,6 +209,7 @@ const en = {
type MessageKey = keyof typeof en;
const ko: Partial> = {
+ "settings.localData": "로컬 전사 데이터", "settings.localDataDescription": "앱 삭제 전 모델과 복구 오디오를 함께 정리", "settings.localDataRemove": "삭제…", "settings.localDataConfirm": "모델과 오디오 삭제", "settings.localDataRemoving": "삭제 중…", "settings.localDataRemoved": "로컬 전사 데이터 {size}를 삭제했습니다", "settings.localDataRemoveFailed": "로컬 전사 데이터를 삭제하지 못했습니다",
"settings.microphone": "마이크", "settings.microphoneStatus.not-determined": "아직 권한을 요청하지 않았습니다", "settings.microphoneStatus.authorized": "Ulpaso에 허용됨", "settings.microphoneStatus.denied": "시스템 설정에서 차단됨", "settings.microphoneStatus.restricted": "이 Mac에서 제한됨", "settings.microphoneStatus.unavailable": "Mac 앱에서 사용할 수 있습니다", "settings.microphoneAllow": "허용", "settings.microphoneAllowed": "허용됨", "settings.microphoneOpenSettings": "설정 열기", "settings.microphoneRequesting": "기다리는 중…", "settings.microphoneDenied": "회의 기록을 위해 마이크 접근을 허용해 주세요.",
"settings.editorWidth": "편집기 너비", "settings.editorWidthDescription": "집중형 또는 창 전체로 사용", "settings.editorWidthFocused": "집중형", "settings.editorWidthFull": "전체 너비", "settings.meetingDetection": "회의 감지", "settings.meetingDetectionDescription": "회의 앱이 마이크를 사용하면 기록 여부를 묻습니다",
"meeting.detectedTitle": "이 미팅을 기록할까요?", "meeting.detectedBody": "{app}에서 마이크를 사용 중입니다. 오디오와 전사 내용은 이 Mac에만 남습니다.", "meeting.detectedCancel": "지금은 안 함", "meeting.detectedConfirm": "기록 시작",
@@ -219,6 +227,7 @@ const ko: Partial> = {
};
const ja: Partial> = {
+ "settings.localData": "ローカル文字起こしデータ", "settings.localDataDescription": "アプリ削除前にモデルと復旧音声をまとめて削除", "settings.localDataRemove": "削除…", "settings.localDataConfirm": "モデルと音声を削除", "settings.localDataRemoving": "削除中…", "settings.localDataRemoved": "ローカル文字起こしデータ{size}を削除しました", "settings.localDataRemoveFailed": "ローカル文字起こしデータを削除できませんでした",
"settings.microphone": "マイク", "settings.microphoneStatus.not-determined": "まだ権限を要求していません", "settings.microphoneStatus.authorized": "Ulpasoに許可済み", "settings.microphoneStatus.denied": "システム設定でブロック中", "settings.microphoneStatus.restricted": "このMacで制限されています", "settings.microphoneStatus.unavailable": "Macアプリで利用できます", "settings.microphoneAllow": "許可", "settings.microphoneAllowed": "許可済み", "settings.microphoneOpenSettings": "設定を開く", "settings.microphoneRequesting": "待機中…", "settings.microphoneDenied": "会議メモにはマイクへのアクセスを許可してください。",
"settings.editorWidth": "エディター幅", "settings.editorWidthDescription": "集中表示またはウィンドウ全体を使用", "settings.editorWidthFocused": "集中", "settings.editorWidthFull": "全幅", "settings.meetingDetection": "会議の検出", "settings.meetingDetectionDescription": "会議アプリがマイクを使用すると記録するか確認します",
"meeting.detectedTitle": "この会議を記録しますか?", "meeting.detectedBody": "{app}がマイクを使用しています。音声と文字起こしはこのMac内に残ります。", "meeting.detectedCancel": "今はしない", "meeting.detectedConfirm": "記録を開始",
diff --git a/src/styles.css b/src/styles.css
index 2c95ed6..c880691 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -459,6 +459,9 @@ kbd { min-width: 22px; padding: 3px 6px; border-radius: 4px; border: 1px solid v
.settings-link { height: 29px; flex: 0 0 auto; padding: 0 8px 0 9px; border: 1px solid var(--border); border-radius: 5px; display: flex; align-items: center; gap: 5px; background: transparent; color: var(--text-soft); cursor: pointer; font-size: 10px; transition: background-color .12s ease, border-color .12s ease, color .12s ease; }
.settings-link:hover { background: var(--sidebar-hover); color: var(--text); }
.settings-link:active { background: var(--color-ghost-selected); }
+.settings-link-danger { white-space: nowrap; }
+.settings-link-danger:hover:not(:disabled) { border-color: color-mix(in srgb, #c44 42%, var(--border)); color: #b33; }
+.settings-link-danger:disabled { cursor: default; opacity: .45; }
.confirmation-backdrop { align-items: center; padding-top: 0; }
.confirmation-dialog { width: min(430px, calc(100vw - 40px)); display: grid; grid-template-columns: 34px 1fr; gap: 0 12px; padding: 22px; border: 1px solid var(--border-strong); border-radius: 12px; background: var(--color-bg-elevated); box-shadow: 0 20px 64px rgba(0,0,0,.22), 0 3px 12px rgba(0,0,0,.10); color: var(--text); animation: dialog-in .16s ease-out; }
.confirmation-icon { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 9px; background: var(--color-ghost-selected); color: var(--text-soft); }