From 70ea06a83e267056fcad54cec1c695b9bceeaba3 Mon Sep 17 00:00:00 2001 From: serenolux <59664950+sjoon21@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:03:39 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EB=85=B9=EC=9D=8C=20=EC=A4=91=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EC=9E=A5=EC=B9=98=EA=B0=80=20=EB=B0=94?= =?UTF-8?q?=EB=80=94=20=EB=95=8C=20tractus-rec=EA=B0=80=20=EC=A3=BD?= =?UTF-8?q?=EB=8D=98=20=EA=B2=83=20=EC=88=98=EC=A0=95=20(#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - installTap 전에 입력 포맷(채널·표본 속도·하드웨어 포맷)을 검사해 오류로 던지고, 기존 재시도(watchdog)로 넘김 - 마이크 소스를 멈출 때마다 새 AVAudioEngine으로 바꿈 - Objective-C 예외를 Swift 오류로 바꾸는 ObjCCatch 타깃으로 installTap을 감쌈 - 비정상 종료로 duration이 null인 녹음도 변환 전 노트로 저장되는지 테스트 추가 Co-Authored-By: Claude Sonnet 5.5 --- ARCHITECTURE.md | 1 + recorder/Package.swift | 6 ++-- recorder/Sources/ObjCCatch/ObjCCatch.m | 18 +++++++++++ .../Sources/ObjCCatch/include/ObjCCatch.h | 13 ++++++++ recorder/Sources/RecorderKit/MicFormat.swift | 15 +++++++++ recorder/Sources/tractus-rec/Sources.swift | 31 ++++++++++++++++--- .../RecorderKitTests/RecorderKitTests.swift | 24 ++++++++++++++ tests/test_deferred.py | 10 ++++++ 8 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 recorder/Sources/ObjCCatch/ObjCCatch.m create mode 100644 recorder/Sources/ObjCCatch/include/ObjCCatch.h create mode 100644 recorder/Sources/RecorderKit/MicFormat.swift diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 211f7fc..9d27811 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -11,6 +11,7 @@ | 날짜 | 버전 | 변경 | | --- | --- | --- | +| 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인 녹음은 앱의 `변환 대기 중인 녹음`에 보이고 코어가 그대로 변환 전 노트로 저장한다는 것을 테스트로 고정했다. | | 2026-10-30 | v3.75 | **변환 엔진 목록(브랜치 `feat/engine-list-univa`).** 소유자 불만: 0.5.1의 드롭다운에는 받아 둔 엔진만 있었고, 설치한 앱의 모델 폴더(`~/Library/Application Support/dev.tractus.app/models`)에는 개발 Mac의 `models/custom-models.json`에 넣은 TeamUNIVA 모델이 없었다. ① `asr-ko-univa`(`TeamUNIVA/qwen3_asr_1.7b_ko` 3e66f586, Apache-2.0, bf16 4.7GB, 계열 `qwen3`)를 등록 목록에 올렸다(`level_note: one_of`, 벤치마크 면제: CLAUDE.md 기록). `--preset all`은 약 12GB. ② 모델 화면 디자인: 변환 엔진 선택(드롭다운)·받기/삭제(평평한 모델 목록 속)·직접 추가(맨 아래)가 세 곳에 흩어져 있었고, 모델 10개가 같은 무게로 줄줄이 늘어서 한 줄이 5줄·오른쪽 열이 두 줄이었다. 이제 `변환 엔진` 구역 하나에 라디오 목록(자동 + 엔진 넷, 받지 않은 엔진도 고르면 설정에 저장하고 받기 시작, 받기 전에는 받아 둔 다른 엔진으로 변환)과 직접 추가를 모았다. 줄 제목은 모델 이름(`model_name`), 크기·상태·삭제·받기는 한 줄에 같은 자리. 엔진이 아닌 모델은 `다른 기능의 모델` 구역(컴포넌트 `EngineChoice`, `renderActions`). | | 2026-10-30 | v3.74 | **0.5.1 릴리스 준비(브랜치 `release/0.5.1`).** 버전 0.5.1을 다섯 매니페스트와 잠금 파일에 맞췄다. ① 사이드바 노트 목록: 날짜별 묶음을 달 머리글(접고 펼침, `SidebarFold.months`에 직접 정한 값만 저장, 기본은 이번 달·지난달·열어 둔 노트가 든 달만 펼침, 거르면 모두 펼침)로 묶고 검색창과 달 머리글을 `sticky`로 붙였다. 검색창(`QuickFind`)은 입력하는 동안 제목이 맞는 노트를 바로 열 수 있게 하고(↑↓·Enter, `quickMatch.ts`), 고르지 않은 Enter는 예전처럼 내용 검색(SearchView)이다. ② 모델 화면 맨 위 `변환 엔진` 드롭다운으로 기본 엔진을 고른다(줄마다 있던 `기본으로 쓰기`·`자동으로 고르기` 단추를 없앴다, 받아 둔 엔진만 고를 수 있음). ③ 코어 `asr.Transcriber._transcribe`: Qwen3 계열이 자동 감지에서 토큰 0개(영어로 오감지)를 내면 그 청크만 한국어로 다시 변환한다(직접 추가한 TeamUNIVA/qwen3_asr_1.7b_ko 간이 점검에서 30초 청크의 27%가 빔: docs/design/asr-engines.md). 직접 추가한 엔진이 있는 개발 Mac에서 실패하던 `test_convert_needs_any_one_transcription_engine`을 빈 모델 폴더 기준으로 고쳤다. 릴리스 노트 `docs/releases/v0.5.1.md`, 로드맵 항목. | | 2026-10-29 | v3.73 | **0.5.0 릴리스 준비(브랜치 `release/0.5.0`).** 버전 0.5.0을 다섯 매니페스트(`tauri.conf.json`·`Cargo.toml`·`app/package.json`·`pyproject.toml`·플러그인 `plugin.json`)와 잠금 파일에 맞췄다. 0.4.4 이후: 변환 중에도 녹음(v3.71), 내보내기에서 넣을 내용 고르기(v3.72), 시작 때 새 버전을 큰 창으로 알림, 글만 있는 노트의 떠 있는 녹음 추가 단추, 새 앱 아이콘과 사이드바 로고. 이름은 Tractus 그대로(Oat로 바꾸기는 소유자가 취소). README 스크린샷·데모 GIF를 새 아이콘으로 다시 찍었다. 릴리스 노트 `docs/releases/v0.5.0.md`, 로드맵 항목. | diff --git a/recorder/Package.swift b/recorder/Package.swift index 48e318a..5fe94cb 100644 --- a/recorder/Package.swift +++ b/recorder/Package.swift @@ -15,15 +15,17 @@ let package = Package( ], targets: [ .target(name: "RecorderKit"), + // Swift do/catch가 못 잡는 Objective-C 예외(AVAudioNode.installTap 등)를 NSError로 바꾸는 작은 C 계열 타깃 + .target(name: "ObjCCatch"), .executableTarget( name: "tractus-rec", - dependencies: ["RecorderKit"], + dependencies: ["RecorderKit", "ObjCCatch"], linkerSettings: [ .unsafeFlags(["-Xlinker", "-sectcreate", "-Xlinker", "__TEXT", "-Xlinker", "__info_plist", "-Xlinker", infoPlist]), ] ), // tractus-rec에 의존해 swift test가 실행 파일도 빌드하게 한다(fake 모드 E2E 테스트용). - .testTarget(name: "RecorderKitTests", dependencies: ["RecorderKit", "tractus-rec"]), + .testTarget(name: "RecorderKitTests", dependencies: ["RecorderKit", "ObjCCatch", "tractus-rec"]), ], swiftLanguageVersions: [.v5] ) diff --git a/recorder/Sources/ObjCCatch/ObjCCatch.m b/recorder/Sources/ObjCCatch/ObjCCatch.m new file mode 100644 index 0000000..3aeade3 --- /dev/null +++ b/recorder/Sources/ObjCCatch/ObjCCatch.m @@ -0,0 +1,18 @@ +#import "ObjCCatch.h" + +@implementation TractusObjC + ++ (BOOL)catching:(NS_NOESCAPE void (^)(void))block error:(NSError *_Nullable *_Nullable)error { + @try { + block(); + return YES; + } @catch (NSException *exception) { + if (error) { + NSString *text = [NSString stringWithFormat:@"%@: %@", exception.name, exception.reason ?: @""]; + *error = [NSError errorWithDomain:@"tractus.objc" code:1 userInfo:@{NSLocalizedDescriptionKey: text}]; + } + return NO; + } +} + +@end diff --git a/recorder/Sources/ObjCCatch/include/ObjCCatch.h b/recorder/Sources/ObjCCatch/include/ObjCCatch.h new file mode 100644 index 0000000..95abf8f --- /dev/null +++ b/recorder/Sources/ObjCCatch/include/ObjCCatch.h @@ -0,0 +1,13 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface TractusObjC : NSObject + +/// block 안에서 던진 Objective-C 예외를 NSError로 바꾼다. Swift의 do/catch는 NSException을 잡지 못해 프로세스가 abort되므로 +/// AVFAudio처럼 예외를 던지는 호출(installTap 등)을 감싸 쓴다. Swift에서는 `try TractusObjC.catching { … }`. ++ (BOOL)catching:(NS_NOESCAPE void (^)(void))block error:(NSError *_Nullable *_Nullable)error; + +@end + +NS_ASSUME_NONNULL_END diff --git a/recorder/Sources/RecorderKit/MicFormat.swift b/recorder/Sources/RecorderKit/MicFormat.swift new file mode 100644 index 0000000..470f37d --- /dev/null +++ b/recorder/Sources/RecorderKit/MicFormat.swift @@ -0,0 +1,15 @@ +/// 마이크 입력 노드에 tap을 걸어도 되는지 본다. 걸어도 되면 nil, 아니면 이유. +/// 장치를 막 바꾼 뒤에는 채널 0이나 낡은 표본 속도가 남아 있을 수 있고, 그때 installTap은 NSException을 던진다(2026-10-30 이슈 #57). +/// 음성 처리(에코 제거)를 켜면 tap 포맷과 하드웨어 포맷이 다를 수 있어 표본 속도 일치는 음성 처리가 꺼져 있을 때만 본다. +public func micFormatProblem(tapRate: Double, tapChannels: Int, hardwareRate: Double, hardwareChannels: Int, voiceProcessing: Bool) -> String? { + if tapRate <= 0 || tapChannels <= 0 { + return "입력 장치 포맷이 아직 준비되지 않았습니다 (\(Int(tapRate))Hz, \(tapChannels)채널)" + } + if hardwareRate <= 0 || hardwareChannels <= 0 { + return "입력 장치가 아직 준비되지 않았습니다 (\(Int(hardwareRate))Hz, \(hardwareChannels)채널)" + } + if !voiceProcessing && tapRate != hardwareRate { + return "입력 장치 포맷이 바뀌는 중입니다 (\(Int(tapRate))Hz ≠ \(Int(hardwareRate))Hz)" + } + return nil +} diff --git a/recorder/Sources/tractus-rec/Sources.swift b/recorder/Sources/tractus-rec/Sources.swift index aedf3ab..bd4b8b6 100644 --- a/recorder/Sources/tractus-rec/Sources.swift +++ b/recorder/Sources/tractus-rec/Sources.swift @@ -1,6 +1,7 @@ import AVFoundation import CoreAudio import Foundation +import ObjCCatch import RecorderKit typealias SampleSink = ([Int16]) -> Void @@ -48,12 +49,16 @@ final class MonoConverter { /// 입력 장치가 바뀌면(AirPods 연결 등) 엔진이 스스로 멈추므로 `onChange`로 알려 다시 열게 한다. /// `echoCancel`이면 macOS 음성 처리(에코 제거 + AGC·잡음 억제)를 켠다. 스피커로 나간 상대방 목소리가 마이크 트랙에 다시 들어가지 않게 한다. final class MicSource: AudioSource { - private let engine = AVAudioEngine() + /// 장치가 바뀌면 같은 엔진의 입력 노드가 낡은 포맷을 쥔 채 남을 수 있어, `stop()`마다 새 엔진으로 바꾼다. + private var engine = AVAudioEngine() + private var observer: NSObjectProtocol? + private let onChange: () -> Void /// 실제로 켜졌는지. 켜지 못하면 경고 후 false로 녹음한다. private(set) var echoCancel: Bool init(echoCancel: Bool, onChange: @escaping () -> Void) { self.echoCancel = echoCancel + self.onChange = onChange // 음성 처리를 켜면 입력 구성이 바뀌므로 관찰자를 걸기 전에 켠다. if echoCancel { do { @@ -63,8 +68,13 @@ final class MicSource: AudioSource { warning("echo_cancel_failed", "에코 제거를 켜지 못해 에코 제거 없이 녹음합니다: \(error)") } } - // ponytail: 관찰자는 프로세스가 끝날 때까지 둔다(녹음 1회 = 프로세스 1개). - NotificationCenter.default.addObserver(forName: .AVAudioEngineConfigurationChange, object: engine, queue: nil) { _ in onChange() } + attach() + } + + /// 지금 엔진의 구성 변경 알림을 받는다 (엔진을 바꿀 때 이전 엔진의 관찰자는 뗀다). + private func attach() { + if let observer { NotificationCenter.default.removeObserver(observer) } + observer = NotificationCenter.default.addObserver(forName: .AVAudioEngineConfigurationChange, object: engine, queue: nil) { [onChange] _ in onChange() } } /// 엔진이 멈춘 상태에서만 부른다. 덕킹 설정 변경도 구성 변경 알림을 부를 수 있으므로 켤 때만 넣는다. @@ -98,10 +108,19 @@ final class MicSource: AudioSource { let input = engine.inputNode // 음성 처리는 입력 포맷(채널 수 등)을 바꾸므로 켠 뒤에 포맷을 읽는다. let format = input.outputFormat(forBus: 0) - guard format.sampleRate > 0, let converter = MonoConverter(from: format, firstChannelOnly: input.isVoiceProcessingEnabled) else { + let hardware = input.inputFormat(forBus: 0) + if let problem = micFormatProblem(tapRate: format.sampleRate, tapChannels: Int(format.channelCount), hardwareRate: hardware.sampleRate, hardwareChannels: Int(hardware.channelCount), voiceProcessing: input.isVoiceProcessingEnabled) { + throw WavError.io(problem) // 던지면 Recorder.restart가 경고를 내고 watchdog이 2초 뒤 다시 연다 + } + guard let converter = MonoConverter(from: format, firstChannelOnly: input.isVoiceProcessingEnabled) else { throw WavError.io("입력 장치가 없거나 포맷을 변환할 수 없습니다 (\(format))") } - input.installTap(onBus: 0, bufferSize: 4800, format: format) { buffer, _ in sink(converter.convert(buffer)) } + do { + // 위 검사로 못 거른 조건에서도 AVFAudio가 NSException을 던질 수 있다: 녹음 프로세스가 죽지 않게 오류로 바꾼다 + try TractusObjC.catching { input.installTap(onBus: 0, bufferSize: 4800, format: format) { buffer, _ in sink(converter.convert(buffer)) } } + } catch { + throw WavError.io("마이크 입력을 열지 못했습니다: \(error.localizedDescription)") + } do { try engine.start() } catch { @@ -113,6 +132,8 @@ final class MicSource: AudioSource { func stop() { engine.inputNode.removeTap(onBus: 0) engine.stop() + engine = AVAudioEngine() + attach() } } diff --git a/recorder/Tests/RecorderKitTests/RecorderKitTests.swift b/recorder/Tests/RecorderKitTests/RecorderKitTests.swift index 7143779..25fa851 100644 --- a/recorder/Tests/RecorderKitTests/RecorderKitTests.swift +++ b/recorder/Tests/RecorderKitTests/RecorderKitTests.swift @@ -1,5 +1,6 @@ import Foundation @testable import RecorderKit +import ObjCCatch import XCTest /// WAV 헤더를 읽어 테스트에서 확인한다. @@ -234,3 +235,26 @@ final class CalendarPickTests: XCTestCase { XCTAssertThrowsError(try NextOptions.parse(["--x"])) } } + +final class MicFormatTests: XCTestCase { + func testReadyFormatsPass() { + XCTAssertNil(micFormatProblem(tapRate: 48000, tapChannels: 1, hardwareRate: 48000, hardwareChannels: 1, voiceProcessing: false)) + XCTAssertNil(micFormatProblem(tapRate: 24000, tapChannels: 1, hardwareRate: 48000, hardwareChannels: 2, voiceProcessing: true), "음성 처리 중에는 포맷이 달라도 된다") + } + + func testTransitionalFormatsAreRejectedInsteadOfCrashing() { + XCTAssertNotNil(micFormatProblem(tapRate: 48000, tapChannels: 0, hardwareRate: 48000, hardwareChannels: 1, voiceProcessing: false), "채널 0") + XCTAssertNotNil(micFormatProblem(tapRate: 0, tapChannels: 1, hardwareRate: 48000, hardwareChannels: 1, voiceProcessing: false), "표본 속도 0") + XCTAssertNotNil(micFormatProblem(tapRate: 48000, tapChannels: 1, hardwareRate: 0, hardwareChannels: 0, voiceProcessing: true), "하드웨어 포맷이 아직 없다") + XCTAssertNotNil(micFormatProblem(tapRate: 44100, tapChannels: 1, hardwareRate: 48000, hardwareChannels: 1, voiceProcessing: false), "낡은 표본 속도") + } +} + +final class ObjCCatchTests: XCTestCase { + func testObjectiveCExceptionBecomesASwiftError() { + XCTAssertThrowsError(try TractusObjC.catching { NSException(name: .invalidArgumentException, reason: "required condition is false: format.sampleRate == hwFormat.sampleRate", userInfo: nil).raise() }) { error in + XCTAssertTrue(error.localizedDescription.contains("required condition is false")) + } + XCTAssertNoThrow(try TractusObjC.catching {}) + } +} diff --git a/tests/test_deferred.py b/tests/test_deferred.py index 194edc4..4a3b103 100644 --- a/tests/test_deferred.py +++ b/tests/test_deferred.py @@ -47,6 +47,16 @@ def saved(tmp_path, marks=None, *extra): return d, read_transcript(d) +def test_recording_that_crashed_with_null_duration_still_finishes(tmp_path): + """녹음 프로그램이 비정상 종료하면 recording.json의 duration이 null로 남는다(이슈 #57). 트랙(WAV)이 살아 있으면 그대로 변환 전 노트로 저장된다.""" + rec = make_recording(tmp_path) + info = json.loads((rec / "recording.json").read_text()) + info["duration"] = None + (rec / "recording.json").write_text(json.dumps(info)) + d, t = saved_from(rec) + assert t["audio"]["duration_sec"] > 0 and t["segments"] == [] + + def saved_from(rec: Path) -> tuple[Path, dict]: r = finish(rec) assert r.exit_code == 0, r.output From 6b1f61166a4aa33a415eababe4a7ef33b6e6875e Mon Sep 17 00:00:00 2001 From: serenolux <59664950+sjoon21@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:09:12 +0900 Subject: [PATCH 2/2] =?UTF-8?q?chore:=200.5.2=20=EB=A6=B4=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=20=EC=A4=80=EB=B9=84=20=E2=80=94=20=EB=B2=84=EC=A0=84?= =?UTF-8?q?,=20=EB=A6=B4=EB=A6=AC=EC=8A=A4=20=EB=85=B8=ED=8A=B8,=20?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=EB=A7=B5,=20ARCHITECTURE=20v3.77?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- ARCHITECTURE.md | 1 + app/package.json | 2 +- app/src-tauri/Cargo.lock | 2 +- app/src-tauri/Cargo.toml | 2 +- app/src-tauri/tauri.conf.json | 2 +- docs/releases/v0.5.2.md | 55 ++++++++++++++++++++++ docs/roadmap.md | 1 + plugins/tractus/.claude-plugin/plugin.json | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 10 files changed, 64 insertions(+), 7 deletions(-) create mode 100644 docs/releases/v0.5.2.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9d27811..c53d7e8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -11,6 +11,7 @@ | 날짜 | 버전 | 변경 | | --- | --- | --- | +| 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인 녹음은 앱의 `변환 대기 중인 녹음`에 보이고 코어가 그대로 변환 전 노트로 저장한다는 것을 테스트로 고정했다. | | 2026-10-30 | v3.75 | **변환 엔진 목록(브랜치 `feat/engine-list-univa`).** 소유자 불만: 0.5.1의 드롭다운에는 받아 둔 엔진만 있었고, 설치한 앱의 모델 폴더(`~/Library/Application Support/dev.tractus.app/models`)에는 개발 Mac의 `models/custom-models.json`에 넣은 TeamUNIVA 모델이 없었다. ① `asr-ko-univa`(`TeamUNIVA/qwen3_asr_1.7b_ko` 3e66f586, Apache-2.0, bf16 4.7GB, 계열 `qwen3`)를 등록 목록에 올렸다(`level_note: one_of`, 벤치마크 면제: CLAUDE.md 기록). `--preset all`은 약 12GB. ② 모델 화면 디자인: 변환 엔진 선택(드롭다운)·받기/삭제(평평한 모델 목록 속)·직접 추가(맨 아래)가 세 곳에 흩어져 있었고, 모델 10개가 같은 무게로 줄줄이 늘어서 한 줄이 5줄·오른쪽 열이 두 줄이었다. 이제 `변환 엔진` 구역 하나에 라디오 목록(자동 + 엔진 넷, 받지 않은 엔진도 고르면 설정에 저장하고 받기 시작, 받기 전에는 받아 둔 다른 엔진으로 변환)과 직접 추가를 모았다. 줄 제목은 모델 이름(`model_name`), 크기·상태·삭제·받기는 한 줄에 같은 자리. 엔진이 아닌 모델은 `다른 기능의 모델` 구역(컴포넌트 `EngineChoice`, `renderActions`). | | 2026-10-30 | v3.74 | **0.5.1 릴리스 준비(브랜치 `release/0.5.1`).** 버전 0.5.1을 다섯 매니페스트와 잠금 파일에 맞췄다. ① 사이드바 노트 목록: 날짜별 묶음을 달 머리글(접고 펼침, `SidebarFold.months`에 직접 정한 값만 저장, 기본은 이번 달·지난달·열어 둔 노트가 든 달만 펼침, 거르면 모두 펼침)로 묶고 검색창과 달 머리글을 `sticky`로 붙였다. 검색창(`QuickFind`)은 입력하는 동안 제목이 맞는 노트를 바로 열 수 있게 하고(↑↓·Enter, `quickMatch.ts`), 고르지 않은 Enter는 예전처럼 내용 검색(SearchView)이다. ② 모델 화면 맨 위 `변환 엔진` 드롭다운으로 기본 엔진을 고른다(줄마다 있던 `기본으로 쓰기`·`자동으로 고르기` 단추를 없앴다, 받아 둔 엔진만 고를 수 있음). ③ 코어 `asr.Transcriber._transcribe`: Qwen3 계열이 자동 감지에서 토큰 0개(영어로 오감지)를 내면 그 청크만 한국어로 다시 변환한다(직접 추가한 TeamUNIVA/qwen3_asr_1.7b_ko 간이 점검에서 30초 청크의 27%가 빔: docs/design/asr-engines.md). 직접 추가한 엔진이 있는 개발 Mac에서 실패하던 `test_convert_needs_any_one_transcription_engine`을 빈 모델 폴더 기준으로 고쳤다. 릴리스 노트 `docs/releases/v0.5.1.md`, 로드맵 항목. | diff --git a/app/package.json b/app/package.json index 79c8995..071c57c 100644 --- a/app/package.json +++ b/app/package.json @@ -1,7 +1,7 @@ { "name": "tractus-app", "private": true, - "version": "0.5.1", + "version": "0.5.2", "license": "Apache-2.0", "type": "module", "scripts": { diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 75c0d80..e9afab7 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -4426,7 +4426,7 @@ dependencies = [ [[package]] name = "tractus-app" -version = "0.5.1" +version = "0.5.2" dependencies = [ "base64 0.22.1", "minisign-verify", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index f938286..14b766b 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tractus-app" -version = "0.5.1" +version = "0.5.2" description = "Tractus 데스크톱 앱" license = "Apache-2.0" edition = "2021" diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index 90fb294..37398c7 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Tractus", - "version": "0.5.1", + "version": "0.5.2", "identifier": "dev.tractus.app", "build": { "beforeDevCommand": "pnpm dev", diff --git a/docs/releases/v0.5.2.md b/docs/releases/v0.5.2.md new file mode 100644 index 0000000..79533d4 --- /dev/null +++ b/docs/releases/v0.5.2.md @@ -0,0 +1,55 @@ +--- +version: 0.5.2 +date: 2026-10-30 +summary: 설정 › 모델에서 변환 엔진을 목록에서 바로 고르고 받을 수 있습니다. 한국어 파인튜닝 엔진을 목록에 더했고, 녹음 중 입력 장치가 바뀌면 녹음이 멈추던 문제를 고쳤습니다. +summary_en: Pick and download a transcription engine straight from a list in Settings › Models. A Korean fine-tuned engine is now listed, and recording no longer stops when the input device changes mid-recording. +--- +# Tractus v0.5.2 + +녹음과 메모를 내 Mac 안에서 텍스트로 바꾸고, 요약·검색·AI 질문까지 가능한 앱입니다. 이번 버전은 변환 엔진을 고르는 화면을 다시 만들고, 녹음 중 장치가 바뀔 때 녹음이 죽던 문제를 고쳤습니다. (English below) + +## 이번 버전에서 바뀐 것 + +- **변환 엔진을 한 목록에서 고릅니다.** 설정 › 모델의 `변환 엔진`에 `자동`과 엔진들이 라디오 목록으로 모였습니다. + - 받지 않은 엔진도 고를 수 있습니다. 고르면 바로 받기 시작하고, 다 받기 전에는 받아 둔 다른 엔진으로 변환합니다. + - 각 줄에서 모델 이름, 만든 곳, 크기, 상태, 받기·삭제를 한눈에 봅니다. `변환 엔진 직접 추가`도 같은 구역으로 옮겼습니다. + - 변환 엔진이 아닌 모델(목소리 구간 감지, 참석자 구분 등)은 아래 `다른 기능의 모델`에 따로 묶었습니다. +- **한국어 파인튜닝 엔진을 목록에 더했습니다.** `Qwen3-ASR 1.7B 한국어 (bf16)`(TeamUNIVA, 4.7GB, Apache-2.0)입니다. 자주 쓰는 단어를 쓸 수 있습니다. + - 아직 간이 점검만 했습니다(회의 5분 구간에서 반복 루프 없음). 한·영 혼용 정확도는 재지 않았습니다. 직접 써 보고 판단해 주세요. + - 이 엔진은 언어 자동 감지가 영어로 잘못 보고 구간을 비우는 일이 있어, 빈 글이 나오면 그 구간만 한국어로 다시 변환합니다. +- **녹음 중 입력 장치가 바뀌어도 녹음이 이어집니다.** AirPods 같은 블루투스 장치를 연결하거나 끊을 때 녹음 프로그램이 통째로 멈추던 문제(이슈 #57)를 고쳤습니다. 장치가 아직 준비되지 않았으면 오류로 알리고 2초 뒤 다시 연결합니다. 이 Mac의 마이크와 에코 제거 녹음이 정상인 것은 확인했지만, 실제 장치를 바꾸는 상황은 재현해 확인하지 못했습니다. 같은 문제가 또 생기면 알려 주세요. + - 멈춘 녹음이 있다면 `recordings` 폴더의 소리 파일은 남아 있고, 사이드바 `변환 대기 중인 녹음`에서 변환할 수 있습니다. + +## 업데이트·설치 + +- **v0.1.1 이상을 쓰고 있다면:** 설정 › 업데이트에서 `업데이트 확인` → `업데이트 설치` → `다시 열기`. +- **새로 설치한다면:** DMG를 열고 `Tractus.app`을 `Applications`로 끌어다 놓습니다. + - 처음 열 때 "확인되지 않은 개발자" 경고가 나오면: 시스템 설정 → 개인정보 보호 및 보안 → "그래도 열기" (또는 `xattr -dr com.apple.quarantine /Applications/Tractus.app`) + - **ffmpeg가 필요합니다:** `brew install ffmpeg` +- 노트·모델·설정 파일은 그대로입니다. + +## 받을 파일 + +| 파일 | 설명 | +| --- | --- | +| `Tractus-0.5.2-arm64.dmg` | 설치 파일 (Apple Silicon 전용) | +| `Tractus-0.5.2-arm64.dmg.sha256` | 체크섬. `shasum -a 256 -c Tractus-0.5.2-arm64.dmg.sha256` | +| `Tractus.app.tar.gz`, `.sig`, `latest.json` | 앱 안 업데이트가 쓰는 파일입니다. 직접 받을 필요는 없습니다. | + +## 요구 사항 + +- Apple Silicon(M1 이상), 메모리 16GB 이상 권장(8GB는 저사양 구성) +- macOS 14.4 이상(설계상 최소) + +--- + +## English + +Tractus turns recordings and memos into text on your Mac, then summarizes, searches and answers questions about them. **New in 0.5.2: a simpler way to choose the transcription engine, and a fix for recording stopping when the input device changes.** + +- **Choose the engine from one list.** In Settings › Models, `Transcription engine` is now a radio list with `Automatic` and each engine. You can pick an engine you haven't downloaded: it starts downloading right away, and until it finishes the engines you already have are used. Each row shows the model name, publisher, size, status and download/delete; `Add a transcription engine` moved into the same section. Models that aren't transcription engines sit below under `Models for other features`. +- **A Korean fine-tuned engine is listed.** `Qwen3-ASR 1.7B Korean (bf16)` (TeamUNIVA, 4.7GB, Apache-2.0) supports custom vocabulary. It has only had a quick check (no repetition loops over a 5-minute stretch of a meeting); mixed Korean–English accuracy wasn't measured. When its language auto-detection returns nothing for a chunk, that chunk is transcribed again as Korean. +- **Recording survives an input-device change.** Connecting or disconnecting a Bluetooth device such as AirPods during a recording used to abort the recorder (issue #57). If the device isn't ready, it now reports an error and reconnects after 2 seconds. Normal and echo-cancelled recording on a Mac were checked, but a real device switch could not be reproduced here — please report it if it happens again. If a recording was cut short, its audio is still in the `recordings` folder and can be transcribed from `Recordings waiting to transcribe` in the sidebar. +- Install: open the DMG and drag `Tractus.app` to `Applications`; you need `brew install ffmpeg`. The build isn't notarized yet — use System Settings › Privacy & Security › Open Anyway on first launch. Apple Silicon, macOS 14.4+. + +Guides: [Setup](https://github.com/sjoon21/Tractus/blob/main/docs/en/setup.md) · [Usage](https://github.com/sjoon21/Tractus/blob/main/docs/en/usage.md) · [Agents (MCP)](https://github.com/sjoon21/Tractus/blob/main/docs/en/mcp.md) diff --git a/docs/roadmap.md b/docs/roadmap.md index e0f301f..ab792da 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -32,6 +32,7 @@ - [x] 0.4.4: 스마트 교정 강화(군말·반복·문장부호·띄어쓰기·맞춤법까지, 녹취록 전체 문맥, 받은 용어를 한 번에 자주 쓰는 단어로), 앱 경량화(설치 698 → 538MB, 업데이트 198 → 154MB, 앱 반응·변환 시작 빨라짐), AI 프롬프트를 md 파일로 (v3.67~v3.69) - [x] 0.5.0: 변환 중에도 녹음, 내보내기에서 넣을 요약·내용 고르기, 시작 때 새 버전 안내 창, 글만 있는 노트의 떠 있는 녹음 추가 단추, 새 아이콘 (v3.71~v3.73) - [x] 0.5.1: 사이드바 노트 목록이 길어져도 쓰기 쉽게(달 묶음 접기·위에 붙는 검색창과 달 머리글·제목 바로가기), 모델 화면의 변환 엔진 드롭다운, 자동 감지가 빈 글을 낼 때 한국어로 다시 변환 (v3.74) +- [x] 0.5.2: 모델 화면 변환 엔진을 라디오 목록 하나로(받지 않은 엔진도 고르면 받기 시작), 한국어 파인튜닝 엔진(TeamUNIVA) 목록에 추가, 녹음 중 입력 장치가 바뀌면 녹음이 죽던 것 수정 (v3.75~v3.77, 이슈 #57) - [ ] Windows 지원 (MLX를 대신할 런타임, Windows 녹음 프로그램, 설치·서명). 2026-10-22 소유자 결정: macOS에서 충분히 다듬은 뒤로 최대한 미룬다(1.0일 수 있다) - [ ] 영어 화면에서 실제 AI 요약·노트 완성 품질 확인 - [ ] 깨끗한 Mac(새 계정)과 macOS 14.4~15에서 미리 만든 앱 실행, 내려받은 `.dmg`의 Gatekeeper 동작과 권한 이름 확인 diff --git a/plugins/tractus/.claude-plugin/plugin.json b/plugins/tractus/.claude-plugin/plugin.json index f445347..4af8e42 100644 --- a/plugins/tractus/.claude-plugin/plugin.json +++ b/plugins/tractus/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "tractus", "displayName": "Tractus", - "version": "0.5.1", + "version": "0.5.2", "description": "Your meetings as local memory for Claude Code: search notes, transcripts, decisions and action items recorded on this Mac with Tractus. Nothing leaves your Mac except what Claude reads.", "author": { "name": "sjoon21", "url": "https://github.com/sjoon21" }, "homepage": "https://github.com/sjoon21/Tractus", diff --git a/pyproject.toml b/pyproject.toml index 62a75e6..4a01a77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tractus" -version = "0.5.1" +version = "0.5.2" description = "Local-first 노트·녹음 도구" readme = "README.md" license = "Apache-2.0" diff --git a/uv.lock b/uv.lock index e42c601..c27583e 100644 --- a/uv.lock +++ b/uv.lock @@ -1767,7 +1767,7 @@ wheels = [ [[package]] name = "tractus" -version = "0.5.1" +version = "0.5.2" source = { editable = "." } dependencies = [ { name = "kaldi-native-fbank" },