Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
| 2026-11-05 | v3.97 | **남은 UI 부채 두 개(0.5.4 화면 검토의 3·5번).** (1) 안내 말풍선: 자리가 다른 것에 덮여 있으면(`elementFromPoint`로 자리 가운데의 맨 위 요소가 그 자리가 아니면, 안내 상자 자신은 `data-coachmark`로 뺀다) 띄우지 않는다: AI 어시스턴트 서랍이 내보내기 단추를 덮은 채 안내가 서랍 위에 뜨던 것. 대화 내용 안내(`transcript`)는 첫 시각 칩 **위**에 칩 왼쪽에 맞춰(`align="start"`) 띄워 대화 줄을 가리지 않는다(잠깐 제목 줄을 가린다). (2) 빈 요약: 회색 한 줄 대신 점선 상자(아이콘·`아직 요약이 없습니다`·`위에서 … ‘요약 만들기’`)로 위의 주 동작을 가리킨다. |
| 2026-11-05 | v3.98 | **사전: 고친 낱말 제안(PR6, 소유자 선택: 자동 바꾸기 없이 제안만).** Rust `meetings::corrections`가 녹음 노트(메모·예시 노트 제외)에서 `corrected_text`가 원문과 다른 줄을 [원문, 고친 문장]으로 모아(5000쌍까지) `glossary_corrections`로 넘기고, 화면은 기존 `vocab.termFromEdit`(낱말 1~2개·소리가 비슷함·용어처럼 보임)로 낱말을 골라 `dictionary.suggestFromCorrections`가 세고 거른다(사전에 있음·무시함 제외, 많이 고친 순 8개). 사전 화면에 `고쳐 쓴 낱말` 칩: 누르면 추가, ×는 무시(localStorage). 글자를 저절로 바꾸지 않아 측정이 필요 없다. 규칙이 화면 한 곳(vocab.ts)에만 있도록 코어 명령 대신 Rust가 원문만 읽어 넘긴다. |
| 2026-11-05 | v3.99 | **의존성 줄이기: onnxruntime을 뺐다(소유자 선택: 지금 모델을 MLX로).** 목소리 프로필의 CAM++ ONNX를 `onnx_mlx`가 그대로 실행한다: protobuf에서 그래프·가중치·메타데이터를 직접 읽고(필드 몇 개뿐이라 `onnx` 패키지 없이), 이 모델이 쓰는 26개 연산만 구현한다(모양 계산은 numpy, 텐서는 MLX, 모르는 연산은 실행 전 오류). 받는 파일·sha256·`MODEL_ID`는 그대로라 **등록한 목소리를 다시 등록하지 않는다**. onnxruntime 1.30과 비교: 무작위 입력 5가지 길이에서 cosine 1.0·최대 차이 약 1e-5, `bench/voice_eval.py`(AI Hub 4세션 40명 1427발화, 지표만) 결과가 RTF(0.0085 → 0.0082) 말고 모두 같다. 검사: `tests/test_onnx_mlx.py`(테스트 안에서 만든 작은 ONNX로 protobuf·연산을 numpy와 비교, slow는 실제 모델을 onnxruntime으로 미리 뽑아 둔 임베딩과 비교). 후보였던 mlx-community CAM++는 출력 512차원·가중치 6MB로 우리 모델(192차원·28MB)과 달라 쓰지 않았다. 번들 감소량은 다음 번들 빌드에서 잰다(개발 환경의 onnxruntime 폴더는 75MB). |
| 2026-11-06 | v3.100 | **⌘K 넓히기: 이 노트의 동작과 폴더·태그로 가기(브랜치 `feat/palette-note-actions`, 앱만).** 노트를 열었을 때 맨 앞 `이 노트` 묶음: 즐겨찾기(`org.setFavorite`)·태그 편집(`org.requestTagFocus`, 사이드바 줄 메뉴와 같은 통로)·형식별 내보내기·Obsidian. 내보내기는 `useNoteExport`가 MeetingView 안에 있어 App이 `exportReq {id, format, nonce}`를 내려 주고 MeetingView가 노트를 다 읽은 뒤 한 번 `exp.exportAs`/`toObsidian`을 부른다(노트 머리 메뉴와 같은 함수: Markdown·Word는 넣을 내용 고르기 창). 목록이 길어지지 않게 `PaletteAction.typed`(빈 입력에서는 숨기고 찾을 때만)를 두어 빈 입력에서는 Markdown·Word만 보인다. 입력이 있으면 `폴더·태그` 묶음: 고르면 사이드바 거르기를 그 폴더(태그) 하나로 바꾸고(`clearFilters` → `selectFolder`/`toggleTag`) 노트 목록을 펼친다. 폴더로 옮기기·인쇄는 고르는 창·쓰던 메모 저장이 끼어 넣지 않았다. WORDS §4.30. |
| 2026-10-30 | v3.78 | **한국어 파인튜닝 엔진을 8bit 변환본으로 교체(브랜치 `feat/univa-8bit`).** 0.5.2에 올린 `asr-ko-univa`가 원본 bf16(4.7GB)이었다(변환·업로드 위치를 정하지 않고 허브 원본을 그대로 등록했다). `mlx_audio.convert -q --q-bits 8 --q-group-size 64 --q-mode affine`로 변환해(표준 엔진과 같은 설정, `model.safetensors` 2,463,307,541바이트로 공식 Qwen3-ASR 1.7B 8bit와 같다) 소유자 계정에 `voyagar/Qwen3-ASR-1.7B-ko-mlx-8bit`(공개, Apache-2.0, 커밋 `8fcc32c4`, 모델 카드에 원 제작자·원본 revision·변환 명령)로 올리고 등록 목록을 바꿨다. ID `asr-ko-univa`·`level_note`는 그대로라 사용자가 고른 엔진 설정은 유지되고, 새 폴더(`qwen3-asr-1.7b-ko-8bit`)를 받을 때까지는 받아 둔 다른 엔진으로 변환한다. 0.5.2로 bf16을 받은 폴더(`qwen3-asr-1.7b-ko-bf16`)는 등록 밖이 되어 `models prune`으로 지운다. 간이 점검: 원본과 출력 차이 CER 2.5, 처리 시간 약 30% 단축(docs/design/asr-engines.md). |
| 2026-10-30 | v3.77 | **0.5.2 릴리스 준비(브랜치 `fix/mic-config-change-crash`).** 버전 0.5.2를 다섯 매니페스트와 잠금 파일에 맞췄다. 0.5.1 이후: 한국어 파인튜닝 엔진 `asr-ko-univa` 등록과 모델 화면 변환 엔진 라디오 목록(v3.75), 장치 전환 중 녹음 프로그램 크래시 수정(v3.76, 이슈 #57). 릴리스 노트 `docs/releases/v0.5.2.md`, 로드맵 항목. |
| 2026-10-30 | v3.76 | **녹음 중 입력 장치가 바뀌면 `tractus-rec`가 죽던 것(이슈 #57, 브랜치 `fix/mic-config-change-crash`).** `AVAudioEngineConfigurationChange` → `Recorder.restart` → `MicSource.open`의 `installTap`이 Objective-C 예외(NSException)를 던져 Swift `do/catch`를 지나 프로세스가 abort됐다(66분 녹음 중 크래시, `recording.json`의 `duration`이 null로 남음). ① `RecorderKit.micFormatProblem`: tap 전에 채널·표본 속도 0과 하드웨어 포맷 불일치를 오류로 던진다(`restart`의 경고와 watchdog 2초 재시도로 넘어감). 음성 처리(에코 제거) 중에는 두 포맷이 다를 수 있어 표본 속도 일치는 보지 않는다. ② `MicSource.stop()`마다 새 `AVAudioEngine`으로 바꾸고 관찰자를 다시 단다(장치가 바뀐 뒤 같은 엔진의 입력 노드가 낡은 포맷을 쥐는 것을 피함). ③ 새 SwiftPM 타깃 `ObjCCatch`(Objective-C, `TractusObjC.catching { }`)로 `installTap`을 감싸 NSException을 Swift 오류로 바꾼다(`recorder/`의 tools-version 5.10·플랫폼은 그대로). 이 Mac의 실제 마이크로 일반·에코 제거 녹음이 정상임은 확인했고, **실제 장치 전환(블루투스 연결·해제)은 재현하지 못했다.** 비정상 종료로 `duration`이 null인 녹음은 앱의 `변환 대기 중인 녹음`에 보이고 코어가 그대로 변환 전 노트로 저장한다는 것을 테스트로 고정했다. |
Expand Down
4 changes: 3 additions & 1 deletion WORDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -880,7 +880,9 @@ LLM 프롬프트(요약·AI 질문)도 이름 없는 참석자를 `참석자 N`
| --- | --- |
| 창 이름 (`aria-label`) | `빠른 실행` |
| 입력 칸 | placeholder·`aria-label` `노트·동작 찾기` |
| 묶음 제목 | 빈 입력 `최근 노트`(최근에 고친 5개) · `동작`, 입력이 있으면 `노트`(제목 일치, 사이드바 검색과 같은 규칙) · `동작`(이름에 낱말이 모두 든 것) · `찾기` |
| 묶음 제목 | 노트를 열었으면 맨 앞에 `이 노트`. 빈 입력 `최근 노트`(최근에 고친 5개) · `동작`, 입력이 있으면 `노트`(제목 일치, 사이드바 검색과 같은 규칙) · `동작`(이름에 낱말이 모두 든 것) · `폴더·태그` · `찾기` |
| 이 노트 (2026-11-06) | `즐겨찾기에 추가`/`즐겨찾기에서 빼기` · `태그 편집…`(사이드바 줄 메뉴와 같은 말, 노트 머리의 태그 칸에 초점) · `내보내기: {format}`(형식 이름은 내보내기 메뉴와 같다. 빈 입력에서는 Markdown·Word만, 나머지는 `내보내기`나 확장자로 찾을 때) · `Obsidian으로 보내기`. 노트 머리의 메뉴와 같은 함수다 |
| 폴더·태그 (2026-11-06) | 폴더는 경로(`상위/하위`), 태그는 `#태그`. 고르면 사이드바 거르기가 그 폴더(태그) 하나로 바뀌고 노트 목록이 접혀 있으면 펼친다 |
| 동작 | `녹음 시작` · `글쓰기`(⌘N) · `파일 가져오기` · `AI 어시스턴트`(⌘J) · `홈` · `사전` · `설정` · `모델` · `시작 점검` · `노트 목록 접기`/`노트 목록 펼치기`(⌘B). 새 노트 메뉴와 같은 이유로 막힌 동작은 흐리게 하고 오른쪽에 이유(`이미 녹음 중입니다` 등)를 보이며 고를 수 없다. 모델이 없는 것 같은 기능 잠금은 실행하면 그 단추와 똑같이 알린다 |
| 내용 검색 | `내용에서 ‘{q}’ 찾기` (스마트 검색 화면으로 간다) |
| 결과 없음 | `맞는 것이 없습니다` |
Expand Down
35 changes: 29 additions & 6 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@ import { ErrorDetail } from "./ErrorDetail";
import { t } from "./i18n";
import { bindIndexer, indexer } from "./indexer";
import { baseName, errorView, isImeKey, isRecLive, IDLE_JOB, JOB_ERROR, IMPORT_EXTS, isImportable, isTypingTarget, recMode, REC_MODES, type RecMode } from "./lib";
import { MeetingView } from "./MeetingView";
import { MeetingView, type ExportReq } from "./MeetingView";
import type { NewNoteAction } from "./menu";
import { NewNoteMenu } from "./NewNoteMenu";
import { dropMemo, flushMemos, isMemo, settleMemo } from "./memo";
import { dropMemo, exportFormatsFor, flushMemos, isMemo, settleMemo } from "./memo";
import { RecordView } from "./RecordView";
import { deleteDialogText, deletedText, deleteFailure, deleteReduce, IDLE_DELETE, type DeleteTarget } from "./remove";
import { SearchView } from "./SearchView";
Expand All @@ -45,17 +45,17 @@ import { AfterRecordDialog } from "./AfterRecordDialog";
import { afterRecord, ASK_BLOCKS_RECORD, effectiveChoice, rememberedChoice, stopAction, type AskChoice } from "./afterRecord";
import { LIVE_BLOCKS_RECORD, recordBlock } from "./recordBusy";
import { recButton } from "./pause";
import { BULK_MIN, CONVERT_ALL, CONVERT_ALL_TITLE, WAIT_OTHER, WAIT_RECORDING, type ConvertControls } from "./recorded";
import { BULK_MIN, CONVERT_ALL, CONVERT_ALL_TITLE, isRecorded, WAIT_OTHER, WAIT_RECORDING, type ConvertControls } from "./recorded";
import { NoteLibrary } from "./NoteLibrary";
import { QuickFind } from "./QuickFind";
import { CommandPalette } from "./CommandPalette";
import type { PaletteAction } from "./palette";
import { touchNote as touchNoteIn, type Folder } from "./organize";
import { folderPath, touchNote as touchNoteIn, type Folder } from "./organize";
import { OrganizeContext, useOrganizeState } from "./useOrganize";
import { PanelResizer, PanelToggle, usePanels } from "./PanelToggle";
import { PANEL_TEXT, RAIL_REC_LABEL, railPendingLabel, railRecState, toggleLabel } from "./panels";
import { check } from "@tauri-apps/plugin-updater";
import { AudioLines, BookA, Box, House, Mic, PanelLeft, Settings, Sparkles, SquarePen, Stethoscope, Upload, X } from "lucide-react";
import { AudioLines, BookA, Box, Download, Folder as FolderIcon, House, Mic, PanelLeft, Settings, Sparkles, SquarePen, Star, Stethoscope, Tag, Upload, X } from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button";
import { Kbd } from "@/components/ui/kbd";
import { cn } from "@/components/ui/utils";
Expand Down Expand Up @@ -95,6 +95,7 @@ export function App() {
// 전체 회의에 질문 (6.3): 오른쪽 서랍. 닫아도 대화는 남는다(앱을 끄기 전까지)
const [askOpen, setAskOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
const [exportReq, setExportReq] = useState<ExportReq | null>(null); // ⌘K의 노트 내보내기 요청
const askInput = useRef<HTMLTextAreaElement>(null);
const askButton = useRef<HTMLButtonElement>(null);
const closeAsk = useCallback(() => {
Expand Down Expand Up @@ -656,6 +657,27 @@ export function App() {
{ id: "checkup", label: t("시작 점검"), icon: <Stethoscope />, run: caps.openCheckup },
{ id: "left", label: leftToggle.label, icon: <PanelLeft />, kbd: PANEL_TEXT.left.key, run: () => void panels.toggle("left") },
];
const COMMON_EXPORTS = ["md", "docx"]; // 빈 입력에서도 보이는 내보내기 형식. 나머지는 `내보내기`로 찾는다
// 지금 연 노트의 동작: 즐겨찾기·태그는 사이드바 줄 메뉴와, 내보내기는 노트 머리의 내보내기 메뉴와 같은 함수다 (내보내기는 MeetingView가 exportReq를 받아 한다)
const openNote = view.kind === "meeting" ? meetings.find((m) => m.id === view.id) : undefined;
const noteActions: PaletteAction[] = openNote
? [
{ id: "favorite", label: openNote.favorite ? t("즐겨찾기에서 빼기") : t("즐겨찾기에 추가"), icon: <Star />, run: () => void org.setFavorite(openNote.id, !openNote.favorite) },
{ id: "tags", label: t("태그 편집…"), icon: <Tag />, run: () => org.requestTagFocus(openNote.id) },
...exportFormatsFor(isMemo(openNote), isRecorded(openNote)).map((f) => ({ id: `export-${f.format}`, label: t("내보내기: {format}", { format: f.label }), keywords: f.ext, icon: <Download />, typed: !COMMON_EXPORTS.includes(f.format), run: () => setExportReq({ id: openNote.id, format: f.format, nonce: Date.now() }) })),
{ id: "obsidian", label: t("Obsidian으로 보내기"), keywords: t("내보내기"), icon: <Download />, run: () => setExportReq({ id: openNote.id, format: "obsidian", nonce: Date.now() }) },
]
: [];
// 폴더·태그로 바로 가기: 사이드바 거르기를 그 폴더(태그) 하나로 바꾸고 노트 목록을 펼친다
const showFiltered = (apply: () => void) => {
org.clearFilters();
apply();
if (panels.state.left) void panels.toggle("left");
};
const jumps: PaletteAction[] = [
...folders.map((f) => ({ id: `folder-${f.id}`, label: folderPath(folders, f.id), keywords: t("폴더"), icon: <FolderIcon />, run: () => showFiltered(() => org.selectFolder(f.id)) })),
...org.tags.map(({ tag }) => ({ id: `tag-${tag}`, label: `#${tag}`, keywords: t("태그"), icon: <Tag />, run: () => showFiltered(() => org.toggleTag(tag)) })),
];

const waitReason = recording && liveRec ? WAIT_RECORDING : WAIT_OTHER;
const convertControls: ConvertControls | undefined = view.kind === "meeting" ? { slot: convert.slotOf(view.id), waitReason, gate: convertGate, onConvert: convertNote } : undefined;
Expand Down Expand Up @@ -859,6 +881,7 @@ export function App() {
attach={attachControls}
convert={convertControls}
summaryStale={noteEpoch.stale && noteEpoch.id === view.id}
exportReq={exportReq?.id === view.id ? exportReq : undefined}
/>
)}
{view.kind === "search" && <SearchView key={view.q} query={view.q} onOpen={openMeeting} memoIds={memoIds} />}
Expand Down Expand Up @@ -905,7 +928,7 @@ export function App() {
/>
)}
{afterAsk && <AfterRecordDialog seconds={afterAsk.seconds} convert={convertGate} onChoose={chooseAfter} />}
{paletteOpen && <CommandPalette notes={meetings} actions={paletteActions} onOpenNote={openMeeting} onSearch={(q) => void go({ kind: "search", q })} onClose={() => setPaletteOpen(false)} />}
{paletteOpen && <CommandPalette notes={meetings} actions={paletteActions} noteActions={noteActions} jumps={jumps} onOpenNote={openMeeting} onSearch={(q) => void go({ kind: "search", q })} onClose={() => setPaletteOpen(false)} />}
{dropAsk && (
<ConfirmDialog
title={DROP_CONFIRM.title(dropAsk.target.title)}
Expand Down
6 changes: 4 additions & 2 deletions app/src/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import { DIALOG } from "@/components/ui/dialog";
import { Kbd } from "@/components/ui/kbd";
import { cn } from "@/components/ui/utils";

export function CommandPalette({ notes, actions, onOpenNote, onSearch, onClose }: { notes: readonly MeetingInfo[]; actions: readonly PaletteAction[]; onOpenNote: (id: string) => void; onSearch: (q: string) => void; onClose: () => void }) {
const NONE: readonly PaletteAction[] = [];

export function CommandPalette({ notes, actions, noteActions = NONE, jumps = NONE, onOpenNote, onSearch, onClose }: { notes: readonly MeetingInfo[]; actions: readonly PaletteAction[]; noteActions?: readonly PaletteAction[]; jumps?: readonly PaletteAction[]; onOpenNote: (id: string) => void; onSearch: (q: string) => void; onClose: () => void }) {
const dialog = useRef<HTMLDialogElement>(null);
const [q, setQ] = useState("");
const sections = useMemo(() => paletteSections(notes, actions, q), [notes, actions, q]);
const sections = useMemo(() => paletteSections(notes, actions, q, noteActions, jumps), [notes, actions, q, noteActions, jumps]);
const items = useMemo(() => sections.flatMap((s) => s.items), [sections]);
const [pick, setPick] = useState(0);
const id = useId();
Expand Down
16 changes: 15 additions & 1 deletion app/src/MeetingView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import { LOCK_NOTICE, type AttachControls } from "./attach";
import { ConvertPanel } from "./ConvertPanel";
import { useConvertJob } from "./convertJobs";
import { CONVERT_FIRST, deleteBlockedWhileConverting, isRecorded, keptNotice, recordedStats, type ConvertControls } from "./recorded";
import { isMemo, memoLineNumbers, memoLineSegments, segmentSpans, type SaveState } from "./memo";
import { exportFormatsFor, isMemo, memoLineNumbers, memoLineSegments, segmentSpans, type SaveState } from "./memo";
import { Button } from "@/components/ui/button";
import { cn } from "@/components/ui/utils";
import { PolishBar } from "./PolishControls";
Expand All @@ -59,6 +59,9 @@ import { SegmentRow } from "@/components/note/SegmentRow";
import { MemoRow } from "@/components/note/MemoRow";
import { livePolish, nextSuggestion, pendingIds, suggestionMarks } from "./polish";

/** 노트 머리의 내보내기 메뉴와 같은 일을 밖에서 부탁한다: format은 EXPORT_FORMATS의 형식 또는 "obsidian" */
export type ExportReq = { id: string; format: string; nonce: number };

export function MeetingView({
id,
seek,
Expand All @@ -69,6 +72,7 @@ export function MeetingView({
attach,
convert,
summaryStale = false,
exportReq,
}: {
id: string;
seek?: Seek;
Expand All @@ -83,6 +87,8 @@ export function MeetingView({
convert?: ConvertControls;
/** 녹음을 방금 추가해 열었다: 요약이 있어도 아직 새 내용이 반영되지 않았다 */
summaryStale?: boolean;
/** ⌘K 빠른 실행의 내보내기 요청 (nonce가 바뀌면 새 요청) */
exportReq?: ExportReq;
}) {
const note = useNote({ id, summaryStale, notify, onEdited, onChanged });
const { detail, commit, summaryRev, refresh: refreshNote } = note.data;
Expand Down Expand Up @@ -184,6 +190,14 @@ export function MeetingView({
const active = segmentIndexAt(segments, pos);

const exp = useNoteExport({ id, detail, memoNote, selectedSummary, serial, notify });
const doneReq = useRef(0);
useEffect(() => {
if (!exportReq || !detail || exportReq.nonce === doneReq.current) return; // 노트를 다 읽은 뒤에 한 번만
doneReq.current = exportReq.nonce;
if (exportReq.format === "obsidian") return void exp.toObsidian();
const f = exportFormatsFor(memoNote, isRecorded(detail.info)).find((x) => x.format === exportReq.format);
if (f) void exp.exportAs(f);
}, [exportReq, detail]); // exp는 렌더마다 새로 만들어 넣지 않는다

const railState = useRail(memoNote, memoRef, setMemoPick);
const { railCollapsed, openMemoLine } = railState;
Expand Down
3 changes: 3 additions & 0 deletions app/src/en/palette.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@ export default {
"내용에서 ‘{q}’ 찾기": "Search contents for “{q}”",
"맞는 것이 없습니다": "Nothing matches",
"새 메모": "new memo",
"이 노트": "This note",
"폴더·태그": "Folders & tags",
"내보내기: {format}": "Export: {format}",
} satisfies Dict;
Loading
Loading