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 @@ -34,6 +34,7 @@
| 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-11-06 | v3.101 | **일정과 노트 잇기(브랜치 `feat/event-note-link`).** v3.96의 이번 주 띠는 일정과 그 일정으로 녹음한 노트를 따로 보였다(노트에 일정 ID가 없었다). `event.json`에 선택 키 `id`(EventKit 일정 ID, 한 줄 256자 이하)를 더했다: 앱 `calendar::clean_event`와 코어 `calendar_event.clean_event`가 둘 다 남긴다(코어가 녹음 폴더 → 노트 폴더로 옮길 때 다시 검사하므로). 노트 목록 `MeetingInfo.event`에 `{id?, title, start}`만 싣는다(`calendar::note_event`, 목록 캐시 안: `event.json`은 두 저장 경로 모두 transcript.json보다 먼저 쓰인다). 화면 `week.linked`: 같은 순간에 시작하고 ID가 같으면(반복 일정은 회차마다 ID가 같아 시각으로 가른다), ID가 없는 옛 노트는 제목이 같으면 잇는다. `groupWeek`가 이어진 노트를 일정(`DayEvent.notes`) 아래로 옮기고 그날 노트에서는 뺀다(`dayNotes`는 둘 다 센다). 미리보기는 세 번째 노트를 화요일 스탠드업에 이었다. 실제 EventKit ID로 녹음해 잇는 것은 이 터미널에 캘린더 권한이 없어 확인하지 않았다(릴리스 번들 확인 때 함께). |
| 2026-11-06 | v3.102 | **⌘K에 폴더로 옮기기·PDF(브랜치 `feat/palette-move-print`, 앱만).** v3.100에서 뺀 둘을 더했다. 폴더 고르는 창을 띄우는 대신 폴더마다 `폴더로 이동: {경로}` 한 줄과 `폴더에서 빼기`(`org.moveNote`, 사이드바 줄 메뉴와 같다, 둘 다 `typed`라 찾을 때만). PDF는 `exportReq`의 `format: "print"`: MeetingView가 노트 머리 PDF와 똑같이 `flushMemos` 뒤 `setPrintNonce`. |
| 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
2 changes: 1 addition & 1 deletion WORDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -881,7 +881,7 @@ LLM 프롬프트(요약·AI 질문)도 이름 없는 참석자를 `참석자 N`
| 창 이름 (`aria-label`) | `빠른 실행` |
| 입력 칸 | placeholder·`aria-label` `노트·동작 찾기` |
| 묶음 제목 | 노트를 열었으면 맨 앞에 `이 노트`. 빈 입력 `최근 노트`(최근에 고친 5개) · `동작`, 입력이 있으면 `노트`(제목 일치, 사이드바 검색과 같은 규칙) · `동작`(이름에 낱말이 모두 든 것) · `폴더·태그` · `찾기` |
| 이 노트 (2026-11-06) | `즐겨찾기에 추가`/`즐겨찾기에서 빼기` · `태그 편집…`(사이드바 줄 메뉴와 같은 말, 노트 머리의 태그 칸에 초점) · `내보내기: {format}`(형식 이름은 내보내기 메뉴와 같다. 빈 입력에서는 Markdown·Word만, 나머지는 `내보내기`나 확장자로 찾을 때) · `Obsidian으로 보내기`. 노트 머리의 메뉴와 같은 함수다 |
| 이 노트 (2026-11-06) | `즐겨찾기에 추가`/`즐겨찾기에서 빼기` · `태그 편집…`(사이드바 줄 메뉴와 같은 말, 노트 머리의 태그 칸에 초점) · `내보내기: {format}`(형식 이름은 내보내기 메뉴와 같다. 빈 입력에서는 Markdown·Word만, 나머지는 `내보내기`나 확장자로 찾을 때. PDF는 `내보내기: PDF`, 찾기 말 `인쇄`) · `폴더로 이동: {folder}`(폴더 경로, 지금 폴더는 빼고)·`폴더에서 빼기`(폴더에 있을 때. 둘 다 찾을 때만) · `Obsidian으로 보내기`. 노트 머리의 메뉴·사이드바 줄 메뉴와 같은 함수다 |
| 폴더·태그 (2026-11-06) | 폴더는 경로(`상위/하위`), 태그는 `#태그`. 고르면 사이드바 거르기가 그 폴더(태그) 하나로 바뀌고 노트 목록이 접혀 있으면 펼친다 |
| 동작 | `녹음 시작` · `글쓰기`(⌘N) · `파일 가져오기` · `AI 어시스턴트`(⌘J) · `홈` · `사전` · `설정` · `모델` · `시작 점검` · `노트 목록 접기`/`노트 목록 펼치기`(⌘B). 새 노트 메뉴와 같은 이유로 막힌 동작은 흐리게 하고 오른쪽에 이유(`이미 녹음 중입니다` 등)를 보이며 고를 수 없다. 모델이 없는 것 같은 기능 잠금은 실행하면 그 단추와 똑같이 알린다 |
| 내용 검색 | `내용에서 ‘{q}’ 찾기` (스마트 검색 화면으로 간다) |
Expand Down
4 changes: 4 additions & 0 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,10 @@ export function App() {
{ 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: "print", label: t("내보내기: {format}", { format: "PDF" }), keywords: t("인쇄"), icon: <Download />, typed: true, run: () => setExportReq({ id: openNote.id, format: "print", nonce: Date.now() }) },
// 폴더로 옮기기: 사이드바 줄 메뉴의 고르는 창 대신 폴더마다 한 줄 (찾을 때만)
...folders.filter((f) => f.id !== openNote.folder).map((f) => ({ id: `move-${f.id}`, label: t("폴더로 이동: {folder}", { folder: folderPath(folders, f.id) }), icon: <FolderIcon />, typed: true, run: () => void org.moveNote(openNote.id, f.id) })),
...(openNote.folder ? [{ id: "move-out", label: t("폴더에서 빼기"), keywords: t("폴더"), icon: <FolderIcon />, typed: true, run: () => void org.moveNote(openNote.id, null) }] : []),
{ id: "obsidian", label: t("Obsidian으로 보내기"), keywords: t("내보내기"), icon: <Download />, run: () => setExportReq({ id: openNote.id, format: "obsidian", nonce: Date.now() }) },
]
: [];
Expand Down
5 changes: 3 additions & 2 deletions 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 { exportFormatsFor, isMemo, memoLineNumbers, memoLineSegments, segmentSpans, type SaveState } from "./memo";
import { exportFormatsFor, flushMemos, 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,7 +59,7 @@ import { SegmentRow } from "@/components/note/SegmentRow";
import { MemoRow } from "@/components/note/MemoRow";
import { livePolish, nextSuggestion, pendingIds, suggestionMarks } from "./polish";

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

export function MeetingView({
Expand Down Expand Up @@ -195,6 +195,7 @@ export function MeetingView({
if (!exportReq || !detail || exportReq.nonce === doneReq.current) return; // 노트를 다 읽은 뒤에 한 번만
doneReq.current = exportReq.nonce;
if (exportReq.format === "obsidian") return void exp.toObsidian();
if (exportReq.format === "print") return void flushMemos().then((ok) => ok && exp.setPrintNonce((n) => n + 1)); // 노트 머리의 PDF와 같다: 쓰던 글까지
const f = exportFormatsFor(memoNote, isRecorded(detail.info)).find((x) => x.format === exportReq.format);
if (f) void exp.exportAs(f);
}, [exportReq, detail]); // exp는 렌더마다 새로 만들어 넣지 않는다
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 @@ -11,4 +11,7 @@ export default {
"이 노트": "This note",
"폴더·태그": "Folders & tags",
"내보내기: {format}": "Export: {format}",
"폴더로 이동: {folder}": "Move to folder: {folder}",
"폴더에서 빼기": "Remove from folder",
인쇄: "print",
} satisfies Dict;
Loading