From 6da23eb69cc697d1be829043562218713e1c0cba Mon Sep 17 00:00:00 2001 From: Kim yonghae Date: Fri, 26 Jun 2026 21:51:09 +0900 Subject: [PATCH 01/21] =?UTF-8?q?refactor(app):=20=EB=AC=B8=EB=B2=95=20?= =?UTF-8?q?=EA=B5=90=EC=A0=95=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=AA=A9?= =?UTF-8?q?=EC=A0=81=20-=20=EB=B9=8C=EB=93=9C=20=EC=8B=9C=20=EC=83=81?= =?UTF-8?q?=EC=8B=9C=20=EC=B4=88=EA=B8=B0=ED=99=94=20=ED=95=A9=EB=8B=88?= =?UTF-8?q?=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- App/Sources/Debug/DebugSeeder.swift | 32 ++++++++++++++++++++--------- App/Sources/Debug/SeedContent.swift | 12 +++++++++++ 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/App/Sources/Debug/DebugSeeder.swift b/App/Sources/Debug/DebugSeeder.swift index 1b853637..b4983c80 100644 --- a/App/Sources/Debug/DebugSeeder.swift +++ b/App/Sources/Debug/DebugSeeder.swift @@ -12,23 +12,26 @@ let voiceNoteRepository: any VoiceNoteRepository func seedIfNeeded() { - let defaults = UserDefaults.standard - guard !defaults.bool(forKey: Self.didSeedKey) else { - AppLogger.debug("시드 데이터가 이미 존재합니다. 스킵.") - return - } - do { let folders = try folderRepository.fetchAll() guard folders.contains(where: { $0.kind == .default }) else { AppLogger.debug("기본 폴더 미존재. 온보딩 이후 다시 시도합니다.") return } + + // 디버그 빌드 시 매번 실행하여 최신 시드 데이터를 갱신합니다. + // 중복 및 이전 시드를 방지하기 위해 기존 시드 폴더를 먼저 삭제합니다 (Core Data cascade 삭제됨). + let seedFolderNames = ["업무", "개인", "학습", "회의록"] + for folder in folders { + if seedFolderNames.contains(folder.name) { + try folderRepository.delete(id: folder.id) + } + } + try performSeed() - defaults.set(true, forKey: Self.didSeedKey) - AppLogger.info("시드 데이터 생성 완료") + AppLogger.info("시드 데이터 초기화 및 재설정 완료") } catch { - AppLogger.error("시드 데이터 생성 실패: \(error)") + AppLogger.error("시드 데이터 초기화 실패: \(error)") } } @@ -236,6 +239,15 @@ summaryLines: [], keywords: ["장애", "인시던트", "포스트모템"], analysisState: .transcriptionFailed + ), + Spec( + folderID: personalFolder.id, + title: "일정 및 버그 관련 푸념 메모 (문법 교정 테스트용)", + createdAt: now.addingTimeInterval(-h * 2), + texts: SeedContent.grammarCheckTest, + summaryLines: [], + keywords: [], + analysisState: .transcribed ) ] @@ -290,7 +302,7 @@ switch state { case .pending, .transcribing, .transcriptionFailed: return false - case .transcribed, .summarizing, .regenerating, .completed, .summarizationFailed: + case .transcribed, .summarizing, .regenerating, .completed, .summarizationFailed, .grammarCheckFailed, .grammarChecked, .grammarChecking: return true } } diff --git a/App/Sources/Debug/SeedContent.swift b/App/Sources/Debug/SeedContent.swift index 64d32b5a..01287002 100644 --- a/App/Sources/Debug/SeedContent.swift +++ b/App/Sources/Debug/SeedContent.swift @@ -503,5 +503,17 @@ "포스트모템은 이번 주 금요일 오전 11시에 진행 예정이고 회의 기록은 Wiki에 공유합니다.", "포스트모템 전까지 해야 할 작업 리스트입니다." ] + + static let grammarCheckTest: [String] = [ + "아진짜오늘그그미팅할때내가말했던거있자나요그거사실그케파가안맞는건데", + "근데또팀장님이그그냥하라고하니까어쩔수업이알겟다고는했는데말이안댐", + "솔직히 일정이넘후달려가꾸 이거 다끝낼수있으련지모르겟고 디자이너분들도지금바뿐거같은대", + "일단은 대충해보고 마일스토마일스톤을좀미루던지해야될듯여 그치안나여", + "그런데 만약에 저도 한단 1단은 저도 최우선은 그 개발을 먼저 끝내고 나서 디자인을 입혀야 돼는건대", + "지금 막 순서가 디죽박죽 엉켜버려가꼬 아 진자 일할맛 안나내요 어쨋든둥 일정 조정을 해달라고 말을 꺼내긴 해야될듯", + "근데또버그가계속나오구있어가지고아 진짜 답이안나옴 이거언제다 고치고있냐진짜 ㅠㅠ", + "막 서버도 자꾸 터지구 막 디비연결두 끈기고 하는대 왜이러는지 1도 모르겟음 근대 아무도 신경안씀 ㅠㅠ", + "담주 월욜날 싱크회의때 이거 팩트체크 해가지구 다 까발려야겟음 아진자 일하기싫다 퇴사마렵네여" + ] } #endif From c2b411f1f2283049b412b69c5e25c5d397ebfd13 Mon Sep 17 00:00:00 2001 From: Kim yonghae Date: Fri, 26 Jun 2026 21:52:26 +0900 Subject: [PATCH 02/21] =?UTF-8?q?refactor(presentation,app):=20=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?UI=20=EC=88=98=EC=A0=95=20-=20tooltip=EC=9D=84=20=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=A2=8B=EA=B2=8C=20=EB=82=98=EC=98=A4=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95=20-=20mlxGrammarRepository=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- App/Sources/AppDIContainer.swift | 6 +++++- .../Sources/Component/OnBoarding/TimelineGuideLabel.swift | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/App/Sources/AppDIContainer.swift b/App/Sources/AppDIContainer.swift index a419184a..a711ba6a 100644 --- a/App/Sources/AppDIContainer.swift +++ b/App/Sources/AppDIContainer.swift @@ -29,6 +29,9 @@ public final class AppDIContainer { private lazy var mlxSummaryRepository = DefaultMLXSummaryRepository( provider: mlxProvider ) + private lazy var mlxGrammarRepository = DefaultMLXGrammarRepository( + provider: mlxProvider + ) private lazy var whisperProvider = WhisperKitProvider( storageService: storageService, languageRepository: languageRepository @@ -55,7 +58,8 @@ public final class AppDIContainer { voiceNoteRepository: voiceNoteRepository, sttRepository: sttWhisperRepository, summaryRepository: mlxSummaryRepository, - languageRepository: languageRepository + languageRepository: languageRepository, + grammarRepository: mlxGrammarRepository ) /// UseCase diff --git a/Presentation/Sources/Component/OnBoarding/TimelineGuideLabel.swift b/Presentation/Sources/Component/OnBoarding/TimelineGuideLabel.swift index d35bd8df..c6c9cd03 100644 --- a/Presentation/Sources/Component/OnBoarding/TimelineGuideLabel.swift +++ b/Presentation/Sources/Component/OnBoarding/TimelineGuideLabel.swift @@ -108,8 +108,8 @@ extension TimelineGuideLabel { "다운로드가 완료되면 인터넷이 연결되지 않은 비행기 모드나 데이터가 터지지 않는 깊은 산속에서도 음성 인식과 요약 기능을 그대로 사용할 수 있어요.", "길게 녹음된 음성을 처음부터 다 들을 필요 없어요.\n차곡의 AI가 회의나 대화의 핵심 내용과 키워드만 일목요연하게 요약해 줍니다.", "실수로 삭제한 소중한 기록은 휴지통 폴더에 보관돼요.\n완전히 지워지기 전이라면 언제든 터치 한 번으로 복구할 수 있어요.", - "폴더 기능을 활용해 회의록, 아이디어 노트, 강의 녹음 등 주제별로 정리해 보세요.\n정돈된 분류는 나중에 기록을 다시 꺼내볼 때 시간을 절약해 줘요.", - "안정적인 설치를 위해 기기에 약 4GB 이상의 여유 공간이 필요해요.\n다운로드가 원활하지 않다면 기기의 저장 공간을 직접 정리해 주세요." + "폴더 기능을 활용해 회의록, 아이디어 노트, 강의 녹음 등 주제별로 정리해 보세요. 정돈된 분류는 나중에 기록을 다시 꺼내볼 때 시간을 절약해 줘요.", + "안정적인 설치를 위해 기기에 약 4GB 이상의 여유 공간이 필요해요. 다운로드가 원활하지 않다면 기기의 저장 공간을 직접 정리해 주세요." ] } } From 0bc6b744b454613edb3ffa3dd2f70b3eee700d4c Mon Sep 17 00:00:00 2001 From: Kim yonghae Date: Fri, 26 Jun 2026 21:53:54 +0900 Subject: [PATCH 03/21] =?UTF-8?q?feat(data):=20=EB=AC=B8=EB=B2=95=20?= =?UTF-8?q?=EA=B5=90=EC=A0=95=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94=20-=20=EB=AC=B8=EB=B2=95=20=EA=B5=90?= =?UTF-8?q?=EC=A0=95=20=ED=9B=84=20container=EB=A5=BC=20=EC=82=B4=EB=A0=A4?= =?UTF-8?q?=20=EB=8B=A4=EC=8B=9C=20loadModel=EC=9D=84=20=ED=95=98=EC=A7=80?= =?UTF-8?q?=20=EC=95=8A=EB=8F=84=EB=A1=9D=20KVCache=EB=A7=8C=20=EB=B9=84?= =?UTF-8?q?=EC=9B=81=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OnDevice/MLXSupport/MLXModelProvider.swift | 14 ++++++++++---- .../Interfaces/MLXSupport/MLXModelDataSource.swift | 3 +++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXModelProvider.swift b/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXModelProvider.swift index 17f21e77..de27f25b 100644 --- a/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXModelProvider.swift +++ b/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXModelProvider.swift @@ -62,10 +62,16 @@ public actor MLXModelProvider: MLXModelDataSource { /// 메모리에서 모델을 해제합니다. public func clear() { - if container != nil { - MLX.Memory.cacheLimit = 0 - container = nil - } + AppLogger.info("MLXModelProvider clear() 호출됨 - 전체 메모리 해제 시작") + MLX.Memory.cacheLimit = 0 + container = nil + AppLogger.info("MLX model cleared fMemory.clearCache") + } + + /// 메모리의 캐시(KVCache 등)만 해제하고 모델 컨테이너는 유지합니다. + public func clearCache() { + AppLogger.info("MLXModelProvider clearCache 호출됨 - 전체 메모리 해제 시작") + MLX.Memory.cacheLimit = 0 } /// 모델이 설치된 경로를 전달 하기 위한 함수 diff --git a/Data/Sources/Interfaces/MLXSupport/MLXModelDataSource.swift b/Data/Sources/Interfaces/MLXSupport/MLXModelDataSource.swift index 62fec1b2..16b87903 100644 --- a/Data/Sources/Interfaces/MLXSupport/MLXModelDataSource.swift +++ b/Data/Sources/Interfaces/MLXSupport/MLXModelDataSource.swift @@ -15,6 +15,9 @@ public protocol MLXModelDataSource: Sendable { /// 메모리에서 모델을 해제하여 리소스를 반환합니다. func clear() async + /// 메모리의 캐시(KVCache 등)만 해제하고 모델 컨테이너는 유지합니다. + func clearCache() async + /// 다운로드 경로를 전달합니다 func getDownloadPath() async throws(MLXModelDataSourceError) -> URL From bfd9cd0407a4b9b60bb5703efcd97d29bf8042f1 Mon Sep 17 00:00:00 2001 From: Kim yonghae Date: Fri, 26 Jun 2026 21:55:19 +0900 Subject: [PATCH 04/21] =?UTF-8?q?feat(data):=20=EB=AC=B8=EB=B2=95=20?= =?UTF-8?q?=EA=B5=90=EC=A0=95=20=EA=B5=AC=ED=98=84=EC=B2=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=A0=81=EC=9A=A9=20-=20transcript=20-?= =?UTF-8?q?>=20grammarCheck=20->=20=EC=9A=94=EC=95=BD=20=EC=88=9C=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A7=84=ED=96=89=20=EB=90=98=EB=A9=B0=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EC=83=81=ED=85=8C=EC=97=90=20=EB=94=B0=EB=A1=9C=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=EB=90=98=EB=8A=94=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EB=98=90=ED=95=9C=20=EC=88=98=EC=A0=95=ED=95=98=EC=98=80?= =?UTF-8?q?=EC=8A=B5=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DefaultMLXGrammarRepository.swift | 56 +++++++++++++++++ .../Repositories/GrammarRepositoryError.swift | 22 +++++++ .../VoiceNotes/GrammarRepository.swift | 11 ++++ .../VoiceNotes/VoiceNoteAnalysisService.swift | 63 ++++++++++++++++--- 4 files changed, 145 insertions(+), 7 deletions(-) create mode 100644 Data/Sources/Repositories/VoiceNotes/DefaultMLXGrammarRepository.swift create mode 100644 Domain/Sources/Errors/VoiceNotes/Repositories/GrammarRepositoryError.swift create mode 100644 Domain/Sources/Interfaces/VoiceNotes/GrammarRepository.swift diff --git a/Data/Sources/Repositories/VoiceNotes/DefaultMLXGrammarRepository.swift b/Data/Sources/Repositories/VoiceNotes/DefaultMLXGrammarRepository.swift new file mode 100644 index 00000000..4c87c519 --- /dev/null +++ b/Data/Sources/Repositories/VoiceNotes/DefaultMLXGrammarRepository.swift @@ -0,0 +1,56 @@ +import Core +import Domain +import Foundation +import MLXHuggingFace +import MLXLLM +import MLXLMCommon +import Tokenizers + +public struct DefaultMLXGrammarRepository: GrammarRepository { + private let provider: any MLXModelDataSource + + public init(provider: any MLXModelDataSource) { + self.provider = provider + } + + public func correct(transcript: Transcript) async throws(GrammarRepositoryError) -> Transcript { + if Task.isCancelled { throw .cancelled } + AppLogger.info("문법 교정 시작 (ID: \(transcript.id))") + do { + // MLX 모델 로드 + let context: ModelContext = try await provider.loadModel() + let container: ModelContainer = ModelContainer(context: context) + let session = ChatSession(container, instructions: Policy.sttCorrectionPrompt) + + var correctedSections: [TranscriptSection] = [] + for section in transcript.sections { + if Task.isCancelled { break } + let response = try await session.respond(to: Policy.correctionPrompt(text: section.text)) + let trimmed = response.trimmingCharacters(in: .whitespacesAndNewlines) + correctedSections.append(TranscriptSection(timestamp: section.timestamp, text: trimmed)) + } + + if Task.isCancelled { + await provider.clear() + throw GrammarRepositoryError.cancelled + } + + await provider.clearCache() + + AppLogger.info("문법 교정 종료 (ID: \(transcript.id))") + return Transcript( + id: transcript.id, + createdAt: transcript.createdAt, + updatedAt: Date.now, + sections: correctedSections + ) + } catch { + await provider.clear() + AppLogger.error(error) + if let repoError = error as? GrammarRepositoryError { + throw repoError + } + throw .correctionFailed + } + } +} diff --git a/Domain/Sources/Errors/VoiceNotes/Repositories/GrammarRepositoryError.swift b/Domain/Sources/Errors/VoiceNotes/Repositories/GrammarRepositoryError.swift new file mode 100644 index 00000000..75229eb4 --- /dev/null +++ b/Domain/Sources/Errors/VoiceNotes/Repositories/GrammarRepositoryError.swift @@ -0,0 +1,22 @@ +import Foundation + +/// 문법 교정(Grammar) 리포지토리에서 발생할 수 있는 에러. +public enum GrammarRepositoryError: LocalizedError, Sendable { + /// 문법 교정 실패. + case correctionFailed + /// 취소됨. + case cancelled + /// 알 수 없는 에러. + case unknown(any Error) + + public var errorDescription: String? { + switch self { + case .correctionFailed: + return "문법 교정에 실패했습니다." + case .cancelled: + return nil + case .unknown(let error): + return "알 수 없는 에러가 발생했습니다: \(error.localizedDescription)" + } + } +} diff --git a/Domain/Sources/Interfaces/VoiceNotes/GrammarRepository.swift b/Domain/Sources/Interfaces/VoiceNotes/GrammarRepository.swift new file mode 100644 index 00000000..fa787ff2 --- /dev/null +++ b/Domain/Sources/Interfaces/VoiceNotes/GrammarRepository.swift @@ -0,0 +1,11 @@ +import Foundation + +/// 문법 교정(Grammar)을 담당하는 리포지토리 프로토콜. +public protocol GrammarRepository: Sendable { + /// 전사 텍스트의 문법, 철자, 구두점을 교정합니다. + /// - Parameters: + /// - transcript: 교정할 전사 엔티티 + /// - Returns: 문법이 교정된 새로운 전사 엔티티 + /// - Throws: `GrammarRepositoryError` (문법 교정 실패) + func correct(transcript: Transcript) async throws(GrammarRepositoryError) -> Transcript +} diff --git a/Domain/Sources/Services/VoiceNotes/VoiceNoteAnalysisService.swift b/Domain/Sources/Services/VoiceNotes/VoiceNoteAnalysisService.swift index 20b36a54..d9890267 100644 --- a/Domain/Sources/Services/VoiceNotes/VoiceNoteAnalysisService.swift +++ b/Domain/Sources/Services/VoiceNotes/VoiceNoteAnalysisService.swift @@ -37,17 +37,20 @@ public final class DefaultVoiceNoteAnalysisService: VoiceNoteAnalysisService { private let sttRepository: any STTRepository private let summaryRepository: any SummaryRepository private let languageRepository: any LanguageRepository + private let grammarRepository: any GrammarRepository public init( voiceNoteRepository: any VoiceNoteRepository, sttRepository: any STTRepository, summaryRepository: any SummaryRepository, - languageRepository: any LanguageRepository + languageRepository: any LanguageRepository, + grammarRepository: any GrammarRepository ) { self.voiceNoteRepository = voiceNoteRepository self.sttRepository = sttRepository self.summaryRepository = summaryRepository self.languageRepository = languageRepository + self.grammarRepository = grammarRepository } // MARK: - Public API @@ -60,9 +63,10 @@ public final class DefaultVoiceNoteAnalysisService: VoiceNoteAnalysisService { case .pending: startTranscription(for: voiceNote, previousState: .pending) case .transcribed: - startSummarization(for: voiceNote, previousState: .transcribed) + // 문법 교정과 요약을 동일 파이프라인으로 수행합니다. + startGrammarCheckAndSummarization(for: voiceNote, previousState: .transcribed) case .transcribing, .transcriptionFailed, .summarizing, .regenerating, - .completed, .summarizationFailed: + .completed, .summarizationFailed, .grammarChecked, .grammarChecking, .grammarCheckFailed: break } } @@ -79,7 +83,7 @@ public final class DefaultVoiceNoteAnalysisService: VoiceNoteAnalysisService { transientState: .regenerating ) case .pending, .transcribing, .transcriptionFailed, .transcribed, - .summarizing, .regenerating: + .summarizing, .regenerating, .grammarChecked, .grammarChecking, .grammarCheckFailed: break } } @@ -118,7 +122,7 @@ public final class DefaultVoiceNoteAnalysisService: VoiceNoteAnalysisService { persist(voiceNote: withTranscript) if Task.isCancelled { return } entries.removeValue(forKey: voiceNote.id) - startSummarization(for: withTranscript, previousState: .transcribed) + startGrammarCheckAndSummarization(for: withTranscript, previousState: .transcribed) } catch { AppLogger.error(error) if !Task.isCancelled { @@ -130,6 +134,51 @@ public final class DefaultVoiceNoteAnalysisService: VoiceNoteAnalysisService { entries[voiceNote.id] = Entry(task: task, previousState: previousState) } + private func startGrammarCheckAndSummarization(for voiceNote: VoiceNote, previousState: AnalysisState) { + guard let transcript = voiceNote.transcript else { return } + persist(voiceNote: voiceNote, analysisState: .grammarChecking) + + let task = Task { [weak self] in + guard let self else { return } + do { + // 1. 문법 교정 실행 + let correctedTranscript = try await self.grammarRepository.correct(transcript: transcript) + if Task.isCancelled { return } + + let withGrammar = self.makeUpdated( + from: voiceNote, + transcript: correctedTranscript, + analysisState: .grammarChecked + ) + persist(voiceNote: withGrammar) + + // 2. 요약 실행 + persist(voiceNote: withGrammar, analysisState: .summarizing) + let language = self.languageRepository.fetchLanguage() + let (keywords, summary) = try await self.summaryRepository.summarize( + transcript: correctedTranscript, + language: language + ) + if Task.isCancelled { return } + + let completed = self.makeUpdated( + from: withGrammar, + keywords: keywords, + summary: summary, + analysisState: .completed + ) + persist(voiceNote: completed) + } catch { + AppLogger.error(error) + if !Task.isCancelled { + persist(voiceNote: voiceNote, analysisState: .summarizationFailed) + } + } + entries.removeValue(forKey: voiceNote.id) + } + entries[voiceNote.id] = Entry(task: task, previousState: previousState) + } + private func startSummarization( for voiceNote: VoiceNote, previousState: AnalysisState, @@ -188,8 +237,6 @@ public final class DefaultVoiceNoteAnalysisService: VoiceNoteAnalysisService { persist(voiceNote: updated) } - /// 진행 중 취소 시 DB를 이전 상태로 되돌린다. - /// 현재 상태가 전이 상태(`.transcribing` / `.summarizing` / `.regenerating`)일 때만 revert 한다. private func revertState(voiceNoteID: UUID, to previousState: AnalysisState) { guard let current = fetch(voiceNoteID) else { return } switch current.analysisState { @@ -222,3 +269,5 @@ public final class DefaultVoiceNoteAnalysisService: VoiceNoteAnalysisService { ) } } + + From e6cf0c7d55111fdace64bfaf858398f2d5581c74 Mon Sep 17 00:00:00 2001 From: Kim yonghae Date: Fri, 26 Jun 2026 21:56:18 +0900 Subject: [PATCH 05/21] =?UTF-8?q?refactor(data):=20Gemma4=20=EA=B0=80?= =?UTF-8?q?=EC=A4=91=EC=B9=98=20=EB=B6=88=ED=99=95=EC=8B=A4=EC=84=B1=20?= =?UTF-8?q?=EB=B3=B4=EC=95=88=20=EA=B2=80=EC=A6=9D=20-=20=EA=B0=80?= =?UTF-8?q?=EB=81=94=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20json?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A5=BC=20=EC=A3=BC=EB=8A=94=20?= =?UTF-8?q?=EA=B2=BD=EC=9A=B0=EB=A5=BC=20=EB=B0=A9=EC=96=B4=ED=95=98?= =?UTF-8?q?=EA=B3=A0=EC=9E=90=20=EC=B6=94=EA=B0=80=ED=95=98=EC=98=80?= =?UTF-8?q?=EC=8A=B5=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../VoiceNotes/DefaultMLXSummaryRepository.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Data/Sources/Repositories/VoiceNotes/DefaultMLXSummaryRepository.swift b/Data/Sources/Repositories/VoiceNotes/DefaultMLXSummaryRepository.swift index 915adcdd..da2955be 100644 --- a/Data/Sources/Repositories/VoiceNotes/DefaultMLXSummaryRepository.swift +++ b/Data/Sources/Repositories/VoiceNotes/DefaultMLXSummaryRepository.swift @@ -53,6 +53,12 @@ public struct DefaultMLXSummaryRepository: SummaryRepository { .trimmingCharacters(in: .whitespacesAndNewlines) } + // LLM이 반환한 JSON 데이터에서 불필요한 Trailing Comma(배열/객체의 마지막 쉼표)를 정규식으로 제거합니다. + if let regex = try? NSRegularExpression(pattern: ",\\s*(?=[\\}\\]])", options: []) { + let range = NSRange(summaryResponse.startIndex.. Date: Fri, 26 Jun 2026 21:58:08 +0900 Subject: [PATCH 06/21] =?UTF-8?q?refactor(domain):=20=EC=9D=98=EB=8F=84?= =?UTF-8?q?=EB=90=98=EC=A7=80=20=EC=95=8A=EC=9D=80=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20=EC=9D=8C=EC=84=B1=20=EB=85=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=A7=84=EC=9E=85=20=EC=8B=9C=20=EC=9A=94=EC=95=BD?= =?UTF-8?q?,=20=EB=AC=B8=EB=B2=95=20=EA=B5=90=EC=A0=95=EC=9D=84=20?= =?UTF-8?q?=EC=A7=84=ED=96=89=20=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=EB=A5=BC=20=EC=B0=BE=EC=95=98=EC=8A=B5?= =?UTF-8?q?=EB=8B=88=EB=8B=A4.=20=EB=B6=84=EC=84=9D=20Service=EC=9D=98=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=EB=A5=BC=20=ED=81=90=EC=9E=89=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EC=8B=9C=EC=9E=91=ED=95=98=EA=B1=B0=EB=82=98=20?= =?UTF-8?q?=EC=9E=AC=EA=B0=9C=20=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Domain/Sources/UseCases/VoiceNotes/VoiceNoteUseCase.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Domain/Sources/UseCases/VoiceNotes/VoiceNoteUseCase.swift b/Domain/Sources/UseCases/VoiceNotes/VoiceNoteUseCase.swift index 32c6f295..87b931b2 100644 --- a/Domain/Sources/UseCases/VoiceNotes/VoiceNoteUseCase.swift +++ b/Domain/Sources/UseCases/VoiceNotes/VoiceNoteUseCase.swift @@ -28,6 +28,9 @@ public protocol VoiceNoteUseCase: Sendable { /// 완료/실패 상태의 요약을 재생성합니다. func regenerateSummary(id: UUID) + /// 분석 파이프라인에 노트를 큐잉하여 분석을 시작하거나 재개합니다. + func enqueue(id: UUID) + /// 노트를 휴지통으로 단독 이동합니다. 원본 폴더 정보는 `originalFolderID`에 보존됩니다. /// - Parameter noteID: 이동할 노트의 UUID func moveToTrash(noteID: UUID) throws(VoiceNoteUseCaseError) @@ -201,6 +204,10 @@ public struct DefaultVoiceNoteUseCase: VoiceNoteUseCase { analysisService.regenerate(voiceNoteID: id) } + public func enqueue(id: UUID) { + analysisService.enqueue(voiceNoteID: id) + } + // MARK: - Trash public func moveToTrash(noteID: UUID) throws(VoiceNoteUseCaseError) { From 6edc085f0addf462645ea69ace254e89acd680eb Mon Sep 17 00:00:00 2001 From: Kim yonghae Date: Fri, 26 Jun 2026 22:00:03 +0900 Subject: [PATCH 07/21] =?UTF-8?q?refactor(all):=20Presentation=20=EB=B7=B0?= =?UTF-8?q?=EC=97=90=20=EA=B8=B0=EB=8A=A5=20=EC=A0=81=EC=9A=A9=20-=20enque?= =?UTF-8?q?ue=20Preview=20=EC=A0=81=EC=9A=A9=20-=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=B6=94=EA=B0=80=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=EB=B7=B0=20=ED=94=84=EB=A1=9C=ED=8D=BC=ED=8B=B0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Domain/Sources/Entities/VoiceNote.swift | 7 +++++-- .../View/VoiceNote/VoiceNoteScriptViewController.swift | 4 ++-- .../View/VoiceNote/VoiceNoteSummaryViewController.swift | 6 +++--- .../Sources/ViewModel/Folder/FolderDetailViewModel.swift | 2 ++ Presentation/Sources/ViewModel/Main/MainViewModel.swift | 2 ++ Presentation/Sources/ViewModel/Trash/TrashViewModel.swift | 1 + .../ViewModel/VoiceNote/VoiceNoteViewModel+Preview.swift | 2 ++ .../Sources/ViewModel/VoiceNote/VoiceNoteViewModel.swift | 5 +++++ 8 files changed, 22 insertions(+), 7 deletions(-) diff --git a/Domain/Sources/Entities/VoiceNote.swift b/Domain/Sources/Entities/VoiceNote.swift index 6e931e9c..81e9e071 100644 --- a/Domain/Sources/Entities/VoiceNote.swift +++ b/Domain/Sources/Entities/VoiceNote.swift @@ -9,6 +9,9 @@ public enum AnalysisState: String, Sendable, Hashable { case regenerating case completed case summarizationFailed + case grammarChecking + case grammarCheckFailed + case grammarChecked public enum BindingKey { case progress @@ -18,11 +21,11 @@ public enum AnalysisState: String, Sendable, Hashable { public var bindingValue: BindingKey { switch self { - case .pending, .transcribing, .transcribed, .regenerating, .summarizing: + case .pending, .transcribing, .transcribed, .regenerating, .summarizing, .grammarChecked, .grammarChecking: .progress case .completed: .success - case .transcriptionFailed, .summarizationFailed: + case .transcriptionFailed, .summarizationFailed, .grammarCheckFailed: .failed } } diff --git a/Presentation/Sources/View/VoiceNote/VoiceNoteScriptViewController.swift b/Presentation/Sources/View/VoiceNote/VoiceNoteScriptViewController.swift index 09418a9e..cb60a5bc 100644 --- a/Presentation/Sources/View/VoiceNote/VoiceNoteScriptViewController.swift +++ b/Presentation/Sources/View/VoiceNote/VoiceNoteScriptViewController.swift @@ -84,10 +84,10 @@ private extension VoiceNoteScriptViewController { private extension VoiceNoteScriptViewController { var isShowingSkeleton: Bool { switch viewModel.voiceNote.analysisState { - case .pending, .transcribing: + case .pending, .transcribing, .grammarChecked: return true case .transcribed, .summarizing, .regenerating, .completed, - .transcriptionFailed, .summarizationFailed: + .transcriptionFailed, .summarizationFailed, .grammarChecking, .grammarCheckFailed: return false } } diff --git a/Presentation/Sources/View/VoiceNote/VoiceNoteSummaryViewController.swift b/Presentation/Sources/View/VoiceNote/VoiceNoteSummaryViewController.swift index 80638112..934dae84 100644 --- a/Presentation/Sources/View/VoiceNote/VoiceNoteSummaryViewController.swift +++ b/Presentation/Sources/View/VoiceNote/VoiceNoteSummaryViewController.swift @@ -243,7 +243,7 @@ private extension VoiceNoteSummaryViewController { guard !viewModel.isTrashMode else { return nil } switch viewModel.voiceNote.analysisState { // 첫 분석 중에는 요약 섹션이 비어 있어 칩을 숨긴다. - case .pending, .summarizing, .transcribed, .transcribing, .transcriptionFailed: return nil + case .pending, .summarizing, .transcribed, .transcribing, .transcriptionFailed, .grammarChecked, .grammarChecking, .grammarCheckFailed: return nil case .regenerating: return .loading case .completed: return viewModel.isSummaryOutdated ? .outdated : .idle case .summarizationFailed: return .idle @@ -252,9 +252,9 @@ private extension VoiceNoteSummaryViewController { var isShowingSkeleton: Bool { switch viewModel.voiceNote.analysisState { - case .pending, .regenerating, .summarizing, .transcribed, .transcribing: + case .pending, .regenerating, .summarizing, .transcribed, .transcribing, .grammarChecking, .grammarChecked: return true - case .completed, .summarizationFailed, .transcriptionFailed: + case .completed, .summarizationFailed, .transcriptionFailed, .grammarCheckFailed: return false } } diff --git a/Presentation/Sources/ViewModel/Folder/FolderDetailViewModel.swift b/Presentation/Sources/ViewModel/Folder/FolderDetailViewModel.swift index 421f1149..f92623a8 100644 --- a/Presentation/Sources/ViewModel/Folder/FolderDetailViewModel.swift +++ b/Presentation/Sources/ViewModel/Folder/FolderDetailViewModel.swift @@ -335,6 +335,8 @@ extension FolderDetailViewModel { func regenerateSummary(id _: UUID) {} + func enqueue(id _: UUID) {} + func moveToTrash(noteID _: UUID) throws(VoiceNoteUseCaseError) {} func restore(noteID _: UUID) throws(VoiceNoteUseCaseError) {} func delete(noteID _: UUID) throws(VoiceNoteUseCaseError) {} diff --git a/Presentation/Sources/ViewModel/Main/MainViewModel.swift b/Presentation/Sources/ViewModel/Main/MainViewModel.swift index 4243ba01..b2344247 100644 --- a/Presentation/Sources/ViewModel/Main/MainViewModel.swift +++ b/Presentation/Sources/ViewModel/Main/MainViewModel.swift @@ -514,6 +514,8 @@ extension MainViewModel { func regenerateSummary(id _: UUID) {} + func enqueue(id _: UUID) {} + func moveToTrash(noteID _: UUID) throws(VoiceNoteUseCaseError) {} func restore(noteID _: UUID) throws(VoiceNoteUseCaseError) {} func delete(noteID _: UUID) throws(VoiceNoteUseCaseError) {} diff --git a/Presentation/Sources/ViewModel/Trash/TrashViewModel.swift b/Presentation/Sources/ViewModel/Trash/TrashViewModel.swift index a97a9af7..e0a2b003 100644 --- a/Presentation/Sources/ViewModel/Trash/TrashViewModel.swift +++ b/Presentation/Sources/ViewModel/Trash/TrashViewModel.swift @@ -448,6 +448,7 @@ extension TrashViewModel { } func regenerateSummary(id _: UUID) {} + func enqueue(id _: UUID) {} func moveToTrash(noteID _: UUID) throws(VoiceNoteUseCaseError) {} func restore(noteID _: UUID) throws(VoiceNoteUseCaseError) {} func delete(noteID _: UUID) throws(VoiceNoteUseCaseError) {} diff --git a/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel+Preview.swift b/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel+Preview.swift index 3e8733f7..e7937eae 100644 --- a/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel+Preview.swift +++ b/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel+Preview.swift @@ -98,6 +98,8 @@ func regenerateSummary(id _: UUID) {} + func enqueue(id _: UUID) {} + func moveToTrash(noteID _: UUID) throws(VoiceNoteUseCaseError) {} func restore(noteID _: UUID) throws(VoiceNoteUseCaseError) {} func delete(noteID _: UUID) throws(VoiceNoteUseCaseError) {} diff --git a/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel.swift b/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel.swift index 04032e13..0f99b650 100644 --- a/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel.swift +++ b/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel.swift @@ -65,6 +65,11 @@ public final class VoiceNoteViewModel { fetchFolderName() observeVoiceNote() checkMLXSupport() + + // 분석이 미완료 상태(.pending, .transcribed)인 경우 상세 화면 진입 시 자동으로 분석(STT/문법교정/요약)을 재개하도록 큐잉합니다. + if voiceNote.analysisState == .pending || voiceNote.analysisState == .transcribed { + voiceNoteUseCase.enqueue(id: voiceNote.id) + } } private func checkMLXSupport() { From e959448f3c78d261ec072fa9b2570b9b3c808b7d Mon Sep 17 00:00:00 2001 From: Kim yonghae Date: Fri, 26 Jun 2026 22:10:04 +0900 Subject: [PATCH 08/21] =?UTF-8?q?refactor(data):=20=EB=AC=B8=EB=B2=95=20?= =?UTF-8?q?=EA=B5=90=EC=A0=95=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94=20-=20=EA=B0=81=20Section=EC=9D=98=20text?= =?UTF-8?q?=EB=A5=BC=20respond=20=ED=95=98=EC=A7=80=EB=A7=8C=20=EC=9D=B4?= =?UTF-8?q?=EB=9F=B4=20=EA=B2=BD=EC=9A=B0=20=EB=AA=A8=EB=8D=B8=EC=9D=98=20?= =?UTF-8?q?=EC=B6=94=EB=A1=A0=20=EB=8A=A5=EB=A0=A5=EC=9D=B4=20=EB=9B=B0?= =?UTF-8?q?=EC=96=B4=EA=B8=B0=EC=97=90=20respond=20=ED=9B=84=20clearCache?= =?UTF-8?q?=EB=A1=9C=20=EA=B3=84=EC=86=8D=20KVCache=EA=B0=80=20=EB=8A=98?= =?UTF-8?q?=EC=96=B4=EB=82=98=EB=8A=94=EA=B1=B8=20=EB=B0=A9=EC=A7=80?= =?UTF-8?q?=ED=95=A9=EB=8B=88=EB=8B=A4.=20=EA=B2=B0=EA=B3=BC:=20=ED=8F=89?= =?UTF-8?q?=EA=B7=A0=202.7GB=20~=203.1GB=20=EA=B9=8C=EC=A7=80=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94=20=EC=86=8C=EC=9A=94=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=EC=9D=80=20=EB=8A=98=EC=96=B4=EB=82=AC=EC=8A=B5=EB=8B=88?= =?UTF-8?q?=EB=8B=A4..?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repositories/VoiceNotes/DefaultMLXGrammarRepository.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Data/Sources/Repositories/VoiceNotes/DefaultMLXGrammarRepository.swift b/Data/Sources/Repositories/VoiceNotes/DefaultMLXGrammarRepository.swift index 4c87c519..0c3bdf5d 100644 --- a/Data/Sources/Repositories/VoiceNotes/DefaultMLXGrammarRepository.swift +++ b/Data/Sources/Repositories/VoiceNotes/DefaultMLXGrammarRepository.swift @@ -28,6 +28,7 @@ public struct DefaultMLXGrammarRepository: GrammarRepository { let response = try await session.respond(to: Policy.correctionPrompt(text: section.text)) let trimmed = response.trimmingCharacters(in: .whitespacesAndNewlines) correctedSections.append(TranscriptSection(timestamp: section.timestamp, text: trimmed)) + await provider.clearCache() } if Task.isCancelled { From 9b383ceaef6ee18fd4167cc379cbc672dc2bcc1b Mon Sep 17 00:00:00 2001 From: Kim yonghae Date: Sat, 27 Jun 2026 15:45:37 +0900 Subject: [PATCH 09/21] =?UTF-8?q?refactor(domain):=20VoiceNote=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EC=A7=81=EB=A0=AC=20=EB=8C=80=EA=B8=B0=EC=97=B4=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85=20=EB=B0=8F=20OOM=20=EB=B0=A9=EC=A7=80=20-?= =?UTF-8?q?=20=EB=8C=80=EA=B8=B0=20=EC=9E=91=EC=97=85=EC=9D=98=20=EC=9B=90?= =?UTF-8?q?=EB=B3=B5=20=EC=83=81=ED=83=9C=20=EB=B3=B4=EC=A1=B4=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20`QueueJob`=20=EB=8F=84=EC=9E=85=20?= =?UTF-8?q?=EB=B0=8F=20=EB=8C=80=EA=B8=B0=EC=97=B4=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20-=20=ED=99=9C=EC=84=B1=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EC=9E=91=EC=97=85=EC=9D=B4=20=EC=9E=88=EC=9D=84=20?= =?UTF-8?q?=EA=B2=BD=EC=9A=B0=20=EB=8C=80=EA=B8=B0=EC=97=B4=EB=A1=9C=20?= =?UTF-8?q?=EC=A7=84=EC=9E=85=EC=8B=9C=ED=82=A4=EA=B3=A0=20DB=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EB=A5=BC=20`.waiting`=EC=9C=BC=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20-=20=EC=9A=94=EC=95=BD/=EB=AC=B8=EB=B2=95=EA=B5=90?= =?UTF-8?q?=EC=A0=95=20Task=20=EB=8F=84=EC=A4=91=20entries.removeValue?= =?UTF-8?q?=EA=B0=80=20=ED=98=B8=EC=B6=9C=EB=90=98=EC=96=B4=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20=EB=B6=88=EA=B0=80=EB=A5=BC=20=EC=9C=A0=EB=8F=84?= =?UTF-8?q?=ED=95=98=EB=8D=98=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?-=20cancel,=20cancelAll,=20revertState=20=EB=82=B4=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0=20=EC=83=81=ED=83=9C(.waiting)=20=EC=9B=90=EB=B3=B5?= =?UTF-8?q?=20=EB=B0=8F=20=ED=95=B4=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B3=B4=EC=99=84=20-UI(VoiceNoteSummaryViewController)?= =?UTF-8?q?=EC=97=90=20=EB=88=84=EB=9D=BD=EB=90=9C=20.waiting=20switch=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- App/Sources/Debug/DebugSeeder.swift | 2 +- Domain/Sources/Entities/VoiceNote.swift | 4 + .../VoiceNotes/VoiceNoteAnalysisService.swift | 122 ++++++++++++++---- .../VoiceNote/VoiceNoteCardView.swift | 4 +- .../VoiceNoteScriptViewController.swift | 2 +- .../VoiceNoteSummaryViewController.swift | 4 +- 6 files changed, 110 insertions(+), 28 deletions(-) diff --git a/App/Sources/Debug/DebugSeeder.swift b/App/Sources/Debug/DebugSeeder.swift index b4983c80..1aec5f29 100644 --- a/App/Sources/Debug/DebugSeeder.swift +++ b/App/Sources/Debug/DebugSeeder.swift @@ -300,7 +300,7 @@ private func shouldIncludeTranscript(for state: AnalysisState) -> Bool { switch state { - case .pending, .transcribing, .transcriptionFailed: + case .pending, .transcribing, .transcriptionFailed, .waiting: return false case .transcribed, .summarizing, .regenerating, .completed, .summarizationFailed, .grammarCheckFailed, .grammarChecked, .grammarChecking: return true diff --git a/Domain/Sources/Entities/VoiceNote.swift b/Domain/Sources/Entities/VoiceNote.swift index 81e9e071..47f2e477 100644 --- a/Domain/Sources/Entities/VoiceNote.swift +++ b/Domain/Sources/Entities/VoiceNote.swift @@ -2,6 +2,7 @@ import Foundation public enum AnalysisState: String, Sendable, Hashable { case pending + case waiting case transcribing case transcriptionFailed case transcribed @@ -14,6 +15,7 @@ public enum AnalysisState: String, Sendable, Hashable { case grammarChecked public enum BindingKey { + case waiting case progress case success case failed @@ -21,6 +23,8 @@ public enum AnalysisState: String, Sendable, Hashable { public var bindingValue: BindingKey { switch self { + case .waiting: + .waiting case .pending, .transcribing, .transcribed, .regenerating, .summarizing, .grammarChecked, .grammarChecking: .progress case .completed: diff --git a/Domain/Sources/Services/VoiceNotes/VoiceNoteAnalysisService.swift b/Domain/Sources/Services/VoiceNotes/VoiceNoteAnalysisService.swift index d9890267..5c976e05 100644 --- a/Domain/Sources/Services/VoiceNotes/VoiceNoteAnalysisService.swift +++ b/Domain/Sources/Services/VoiceNotes/VoiceNoteAnalysisService.swift @@ -26,12 +26,19 @@ public protocol VoiceNoteAnalysisService: Sendable { func cancelAll() } +@MainActor public final class DefaultVoiceNoteAnalysisService: VoiceNoteAnalysisService { private struct Entry { let task: Task let previousState: AnalysisState } + private enum QueueJob: Sendable { + case analyze(id: UUID, originalState: AnalysisState) + case regenerate(id: UUID, previousState: AnalysisState) + } + + private var pendingQueue: [QueueJob] = [] private var entries: [UUID: Entry] = [:] private let voiceNoteRepository: any VoiceNoteRepository private let sttRepository: any STTRepository @@ -56,45 +63,76 @@ public final class DefaultVoiceNoteAnalysisService: VoiceNoteAnalysisService { // MARK: - Public API public func enqueue(voiceNoteID: UUID) { + guard !pendingQueue.contains(where: { + switch $0 { + case .analyze(let id, _), .regenerate(let id, _): + return id == voiceNoteID + } + }) else { return } guard entries[voiceNoteID] == nil else { return } guard let voiceNote = fetch(voiceNoteID) else { return } - switch voiceNote.analysisState { - case .pending: - startTranscription(for: voiceNote, previousState: .pending) - case .transcribed: - // 문법 교정과 요약을 동일 파이프라인으로 수행합니다. - startGrammarCheckAndSummarization(for: voiceNote, previousState: .transcribed) - case .transcribing, .transcriptionFailed, .summarizing, .regenerating, - .completed, .summarizationFailed, .grammarChecked, .grammarChecking, .grammarCheckFailed: - break + guard entries.isEmpty else { + pendingQueue.append(.analyze(id: voiceNoteID, originalState: voiceNote.analysisState)) + persist(voiceNote: voiceNote, analysisState: .waiting) + return } + + startPipeline(for: voiceNoteID, originalState: voiceNote.analysisState) } public func regenerate(voiceNoteID: UUID) { + guard !pendingQueue.contains(where: { + switch $0 { + case .analyze(let id, _), .regenerate(let id, _): + return id == voiceNoteID + } + }) else { return } guard entries[voiceNoteID] == nil else { return } guard let voiceNote = fetch(voiceNoteID), voiceNote.transcript != nil else { return } switch voiceNote.analysisState { case .completed, .summarizationFailed: - startSummarization( - for: voiceNote, - previousState: voiceNote.analysisState, - transientState: .regenerating - ) - case .pending, .transcribing, .transcriptionFailed, .transcribed, - .summarizing, .regenerating, .grammarChecked, .grammarChecking, .grammarCheckFailed: + guard entries.isEmpty else { + pendingQueue.append(.regenerate(id: voiceNoteID, previousState: voiceNote.analysisState)) + persist(voiceNote: voiceNote, analysisState: .waiting) + return + } + startRegenerationPipeline(for: voiceNoteID, previousState: voiceNote.analysisState) + default: break } } public func cancel(voiceNoteID: UUID) { - guard let entry = entries.removeValue(forKey: voiceNoteID) else { return } + if let index = pendingQueue.firstIndex(where: { + switch $0 { + case .analyze(let id, _), .regenerate(let id, _): + return id == voiceNoteID + } + }) { + let job = pendingQueue.remove(at: index) + let revertTo: AnalysisState = { + switch job { + case .analyze(_, let originalState): + return originalState + case .regenerate(_, let previousState): + return previousState + } + }() + revertState(voiceNoteID: voiceNoteID, to: revertTo) + return + } + guard let entry = entries[voiceNoteID] else { return } entry.task.cancel() revertState(voiceNoteID: voiceNoteID, to: entry.previousState) + finalizeTask(for: voiceNoteID) } public func cancelAll() { + // 대기중인 Queue 전체 비우기 + pendingQueue.removeAll() + let snapshot = entries entries.removeAll() for (id, entry) in snapshot { @@ -121,14 +159,14 @@ public final class DefaultVoiceNoteAnalysisService: VoiceNoteAnalysisService { ) persist(voiceNote: withTranscript) if Task.isCancelled { return } - entries.removeValue(forKey: voiceNote.id) + finalizeTask(for: voiceNote.id) startGrammarCheckAndSummarization(for: withTranscript, previousState: .transcribed) } catch { AppLogger.error(error) if !Task.isCancelled { persist(voiceNote: voiceNote, analysisState: .transcriptionFailed) } - entries.removeValue(forKey: voiceNote.id) + finalizeTask(for: voiceNote.id) } } entries[voiceNote.id] = Entry(task: task, previousState: previousState) @@ -151,7 +189,6 @@ public final class DefaultVoiceNoteAnalysisService: VoiceNoteAnalysisService { analysisState: .grammarChecked ) persist(voiceNote: withGrammar) - // 2. 요약 실행 persist(voiceNote: withGrammar, analysisState: .summarizing) let language = self.languageRepository.fetchLanguage() @@ -168,13 +205,14 @@ public final class DefaultVoiceNoteAnalysisService: VoiceNoteAnalysisService { analysisState: .completed ) persist(voiceNote: completed) + finalizeTask(for: voiceNote.id) } catch { AppLogger.error(error) if !Task.isCancelled { persist(voiceNote: voiceNote, analysisState: .summarizationFailed) } + finalizeTask(for: voiceNote.id) } - entries.removeValue(forKey: voiceNote.id) } entries[voiceNote.id] = Entry(task: task, previousState: previousState) } @@ -208,7 +246,7 @@ public final class DefaultVoiceNoteAnalysisService: VoiceNoteAnalysisService { persist(voiceNote: voiceNote, analysisState: .summarizationFailed) } } - entries.removeValue(forKey: voiceNote.id) + finalizeTask(for: voiceNote.id) } entries[voiceNote.id] = Entry(task: task, previousState: previousState) } @@ -240,13 +278,51 @@ public final class DefaultVoiceNoteAnalysisService: VoiceNoteAnalysisService { private func revertState(voiceNoteID: UUID, to previousState: AnalysisState) { guard let current = fetch(voiceNoteID) else { return } switch current.analysisState { - case .transcribing, .summarizing, .regenerating: + case .transcribing, .summarizing, .regenerating, .waiting: persist(voiceNote: current, analysisState: previousState) default: break } } + private func finalizeTask(for voiceNoteID: UUID) { + // 현재 끝난 작업 제거 + entries.removeValue(forKey: voiceNoteID) + + // 다음 대기 중인 작업 시작 및 종료 + guard !pendingQueue.isEmpty else { return } + + // FIFO 방식으로 대기열에서 다음 작업을 가져와 실행 + let nextJob = pendingQueue.removeFirst() + switch nextJob { + case .analyze(let id, let originalState): + startPipeline(for: id, originalState: originalState) + case .regenerate(let id, let previousState): + startRegenerationPipeline(for: id, previousState: previousState) + } + } + + private func startPipeline(for voiceNoteID: UUID, originalState: AnalysisState) { + guard let voiceNote = fetch(voiceNoteID) else { return } + switch originalState { + case .pending: + startTranscription(for: voiceNote, previousState: .pending) + case .transcribed: + startGrammarCheckAndSummarization(for: voiceNote, previousState: .transcribed) + default: + break + } + } + + private func startRegenerationPipeline(for voiceNoteID: UUID, previousState: AnalysisState) { + guard let voiceNote = fetch(voiceNoteID) else { return } + startSummarization( + for: voiceNote, + previousState: previousState, + transientState: .regenerating + ) + } + private func makeUpdated( from voiceNote: VoiceNote, transcript: Transcript? = nil, diff --git a/Presentation/Sources/Component/VoiceNote/VoiceNoteCardView.swift b/Presentation/Sources/Component/VoiceNote/VoiceNoteCardView.swift index f036a7cf..651b97a9 100644 --- a/Presentation/Sources/Component/VoiceNote/VoiceNoteCardView.swift +++ b/Presentation/Sources/Component/VoiceNote/VoiceNoteCardView.swift @@ -85,6 +85,8 @@ extension VoiceNoteCardView { func analysisText(binding: AnalysisState.BindingKey) -> some View { var currentText: String { switch binding { + case .waiting: + "대기 중" case .progress: "요약 중" case .success: @@ -95,7 +97,7 @@ extension VoiceNoteCardView { } switch binding { - case .progress, .failed: + case .progress, .failed, .waiting: Text(currentText) .typography(.label) .padding(.vertical, 4) diff --git a/Presentation/Sources/View/VoiceNote/VoiceNoteScriptViewController.swift b/Presentation/Sources/View/VoiceNote/VoiceNoteScriptViewController.swift index cb60a5bc..d1266ea4 100644 --- a/Presentation/Sources/View/VoiceNote/VoiceNoteScriptViewController.swift +++ b/Presentation/Sources/View/VoiceNote/VoiceNoteScriptViewController.swift @@ -84,7 +84,7 @@ private extension VoiceNoteScriptViewController { private extension VoiceNoteScriptViewController { var isShowingSkeleton: Bool { switch viewModel.voiceNote.analysisState { - case .pending, .transcribing, .grammarChecked: + case .pending, .transcribing, .grammarChecked, .waiting: return true case .transcribed, .summarizing, .regenerating, .completed, .transcriptionFailed, .summarizationFailed, .grammarChecking, .grammarCheckFailed: diff --git a/Presentation/Sources/View/VoiceNote/VoiceNoteSummaryViewController.swift b/Presentation/Sources/View/VoiceNote/VoiceNoteSummaryViewController.swift index 934dae84..9a9f7a6d 100644 --- a/Presentation/Sources/View/VoiceNote/VoiceNoteSummaryViewController.swift +++ b/Presentation/Sources/View/VoiceNote/VoiceNoteSummaryViewController.swift @@ -243,7 +243,7 @@ private extension VoiceNoteSummaryViewController { guard !viewModel.isTrashMode else { return nil } switch viewModel.voiceNote.analysisState { // 첫 분석 중에는 요약 섹션이 비어 있어 칩을 숨긴다. - case .pending, .summarizing, .transcribed, .transcribing, .transcriptionFailed, .grammarChecked, .grammarChecking, .grammarCheckFailed: return nil + case .pending, .waiting, .summarizing, .transcribed, .transcribing, .transcriptionFailed, .grammarChecked, .grammarChecking, .grammarCheckFailed: return nil case .regenerating: return .loading case .completed: return viewModel.isSummaryOutdated ? .outdated : .idle case .summarizationFailed: return .idle @@ -252,7 +252,7 @@ private extension VoiceNoteSummaryViewController { var isShowingSkeleton: Bool { switch viewModel.voiceNote.analysisState { - case .pending, .regenerating, .summarizing, .transcribed, .transcribing, .grammarChecking, .grammarChecked: + case .pending, .waiting, .regenerating, .summarizing, .transcribed, .transcribing, .grammarChecking, .grammarChecked: return true case .completed, .summarizationFailed, .transcriptionFailed, .grammarCheckFailed: return false From 344efe29f442a19b7a07f48c309f03da3a714dfe Mon Sep 17 00:00:00 2001 From: Kim yonghae Date: Sat, 27 Jun 2026 15:48:50 +0900 Subject: [PATCH 10/21] =?UTF-8?q?refactor:=20swiftformat=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- App/Sources/Debug/DebugSeeder.swift | 7 ++++--- .../OnDevice/MLXSupport/MLXModelProvider.swift | 2 +- .../VoiceNotes/DefaultMLXSummaryRepository.swift | 9 +++++++-- .../VoiceNotes/VoiceNoteAnalysisService.swift | 14 ++++++-------- .../VoiceNote/VoiceNoteScriptViewController.swift | 2 +- .../VoiceNote/VoiceNoteSummaryViewController.swift | 6 ++++-- .../ViewModel/VoiceNote/VoiceNoteViewModel.swift | 2 +- 7 files changed, 24 insertions(+), 18 deletions(-) diff --git a/App/Sources/Debug/DebugSeeder.swift b/App/Sources/Debug/DebugSeeder.swift index 1aec5f29..0250d3aa 100644 --- a/App/Sources/Debug/DebugSeeder.swift +++ b/App/Sources/Debug/DebugSeeder.swift @@ -18,7 +18,7 @@ AppLogger.debug("기본 폴더 미존재. 온보딩 이후 다시 시도합니다.") return } - + // 디버그 빌드 시 매번 실행하여 최신 시드 데이터를 갱신합니다. // 중복 및 이전 시드를 방지하기 위해 기존 시드 폴더를 먼저 삭제합니다 (Core Data cascade 삭제됨). let seedFolderNames = ["업무", "개인", "학습", "회의록"] @@ -27,7 +27,7 @@ try folderRepository.delete(id: folder.id) } } - + try performSeed() AppLogger.info("시드 데이터 초기화 및 재설정 완료") } catch { @@ -302,7 +302,8 @@ switch state { case .pending, .transcribing, .transcriptionFailed, .waiting: return false - case .transcribed, .summarizing, .regenerating, .completed, .summarizationFailed, .grammarCheckFailed, .grammarChecked, .grammarChecking: + case .transcribed, .summarizing, .regenerating, .completed, .summarizationFailed, .grammarCheckFailed, + .grammarChecked, .grammarChecking: return true } } diff --git a/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXModelProvider.swift b/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXModelProvider.swift index de27f25b..0370b750 100644 --- a/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXModelProvider.swift +++ b/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXModelProvider.swift @@ -67,7 +67,7 @@ public actor MLXModelProvider: MLXModelDataSource { container = nil AppLogger.info("MLX model cleared fMemory.clearCache") } - + /// 메모리의 캐시(KVCache 등)만 해제하고 모델 컨테이너는 유지합니다. public func clearCache() { AppLogger.info("MLXModelProvider clearCache 호출됨 - 전체 메모리 해제 시작") diff --git a/Data/Sources/Repositories/VoiceNotes/DefaultMLXSummaryRepository.swift b/Data/Sources/Repositories/VoiceNotes/DefaultMLXSummaryRepository.swift index da2955be..6191a8c5 100644 --- a/Data/Sources/Repositories/VoiceNotes/DefaultMLXSummaryRepository.swift +++ b/Data/Sources/Repositories/VoiceNotes/DefaultMLXSummaryRepository.swift @@ -55,8 +55,13 @@ public struct DefaultMLXSummaryRepository: SummaryRepository { // LLM이 반환한 JSON 데이터에서 불필요한 Trailing Comma(배열/객체의 마지막 쉼표)를 정규식으로 제거합니다. if let regex = try? NSRegularExpression(pattern: ",\\s*(?=[\\}\\]])", options: []) { - let range = NSRange(summaryResponse.startIndex.. Date: Sat, 27 Jun 2026 16:03:16 +0900 Subject: [PATCH 11/21] =?UTF-8?q?refactor(presentation):=20Preview?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=EB=90=9C=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Presentation/Tests/VoiceNote/VoiceNoteViewModelSearchTest.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Presentation/Tests/VoiceNote/VoiceNoteViewModelSearchTest.swift b/Presentation/Tests/VoiceNote/VoiceNoteViewModelSearchTest.swift index 04166ac3..9dbe7ec3 100644 --- a/Presentation/Tests/VoiceNote/VoiceNoteViewModelSearchTest.swift +++ b/Presentation/Tests/VoiceNote/VoiceNoteViewModelSearchTest.swift @@ -356,6 +356,8 @@ private struct FakeVoiceNoteUseCase: VoiceNoteUseCase { func regenerateSummary(id _: UUID) {} + func enqueue(id _: UUID) {} + func moveToTrash(noteID _: UUID) throws(VoiceNoteUseCaseError) {} func restore(noteID _: UUID) throws(VoiceNoteUseCaseError) {} func delete(noteID _: UUID) throws(VoiceNoteUseCaseError) {} From e7f93577cd5d3d827a5420ae0a8960eaf4528810 Mon Sep 17 00:00:00 2001 From: Kim yonghae Date: Sat, 27 Jun 2026 16:22:46 +0900 Subject: [PATCH 12/21] =?UTF-8?q?feat(widget):=20LiveActivity=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=EC=84=A4=EC=A0=95=20-=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20app=20-=20info=20=ED=99=9C?= =?UTF-8?q?=EC=84=B1=ED=99=94=20-=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=83=80=EA=B2=9F=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/RecordingActivityWidget.swift | 86 +++++++++++++++++++ .../Sources/WidgetBundle.swift | 9 ++ .../RecordingActivityAttributes.swift | 22 +++++ Widget/Project.swift | 71 +++++++++++++++ Widget/Sources/RecordingActivityWidget.swift | 86 +++++++++++++++++++ Widget/Sources/WidgetBundle.swift | 9 ++ .../Tests/RecordingActivityWidgetTests.swift | 8 ++ Workspace.swift | 3 +- 8 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 App/WidgetExtension/Sources/RecordingActivityWidget.swift create mode 100644 App/WidgetExtension/Sources/WidgetBundle.swift create mode 100644 Domain/Sources/Entities/RecordingActivityAttributes.swift create mode 100644 Widget/Project.swift create mode 100644 Widget/Sources/RecordingActivityWidget.swift create mode 100644 Widget/Sources/WidgetBundle.swift create mode 100644 Widget/Tests/RecordingActivityWidgetTests.swift diff --git a/App/WidgetExtension/Sources/RecordingActivityWidget.swift b/App/WidgetExtension/Sources/RecordingActivityWidget.swift new file mode 100644 index 00000000..f638823d --- /dev/null +++ b/App/WidgetExtension/Sources/RecordingActivityWidget.swift @@ -0,0 +1,86 @@ +import ActivityKit +import Domain +import SwiftUI +import WidgetKit + +struct RecordingActivityWidget: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: RecordingActivityAttributes.self) { context in + LockScreenView(context: context) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + HStack(spacing: 8) { + Image(systemName: "mic.fill") + .foregroundColor(.red) + Text("녹음 중") + .font(.headline) + .foregroundColor(.white) + } + } + DynamicIslandExpandedRegion(.trailing) { + Text(context.state.displayDuration) + .font(.headline) + .monospacedDigit() + .foregroundColor(.white) + } + DynamicIslandExpandedRegion(.bottom) { + HStack { + Text(context.attributes.title) + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + } + } + } compactLeading: { + Image(systemName: "mic.fill") + .foregroundColor(.red) + } compactTrailing: { + Text(context.state.displayDuration) + .monospacedDigit() + .foregroundColor(.red) + } minimal: { + Image(systemName: "mic.fill") + .foregroundColor(.red) + } + } + } +} + +struct LockScreenView: View { + let context: ActivityViewContext + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(context.attributes.title) + .font(.headline) + .foregroundColor(.primary) + Text(context.state.isPaused ? "일시정지됨" : "녹음 중") + .font(.subheadline) + .foregroundColor(.secondary) + } + Spacer() + Text(context.state.displayDuration) + .font(.title) + .monospacedDigit() + .foregroundColor(.primary) + } + .padding() + .activityBackgroundTint(Color.black.opacity(0.6)) + } +} + +private extension RecordingActivityAttributes.ContentState { + var displayDuration: String { + let duration = Int(self.duration) + let hours = duration / 3600 + let minutes = (duration % 3600) / 60 + let seconds = duration % 60 + if hours > 0 { + return String(format: "%02d:%02d:%02d", hours, minutes, seconds) + } else { + return String(format: "%02d:%02d", minutes, seconds) + } + } +} diff --git a/App/WidgetExtension/Sources/WidgetBundle.swift b/App/WidgetExtension/Sources/WidgetBundle.swift new file mode 100644 index 00000000..53bc4643 --- /dev/null +++ b/App/WidgetExtension/Sources/WidgetBundle.swift @@ -0,0 +1,9 @@ +import SwiftUI +import WidgetKit + +@main +struct ChaGokWidgetBundle: WidgetBundle { + var body: some Widget { + RecordingActivityWidget() + } +} diff --git a/Domain/Sources/Entities/RecordingActivityAttributes.swift b/Domain/Sources/Entities/RecordingActivityAttributes.swift new file mode 100644 index 00000000..6d0b33de --- /dev/null +++ b/Domain/Sources/Entities/RecordingActivityAttributes.swift @@ -0,0 +1,22 @@ +import ActivityKit +import Foundation + +public struct RecordingActivityAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + public var duration: TimeInterval + public var isPaused: Bool + + public init(duration: TimeInterval, isPaused: Bool) { + self.duration = duration + self.isPaused = isPaused + } + } + + public var title: String + public var startDate: Date + + public init(title: String, startDate: Date) { + self.title = title + self.startDate = startDate + } +} diff --git a/Widget/Project.swift b/Widget/Project.swift new file mode 100644 index 00000000..c6c4a873 --- /dev/null +++ b/Widget/Project.swift @@ -0,0 +1,71 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +private let widgetScheme = Scheme.scheme( + name: "ChaGokWidget", + shared: true, + buildAction: .buildAction( + targets: [.target("ChaGokWidget")], + findImplicitDependencies: true + ) +) + +private let widgetTestsScheme = Scheme.scheme( + name: "ChaGokWidgetTests", + shared: true, + buildAction: .buildAction( + targets: [.target("ChaGokWidgetTests")], + findImplicitDependencies: true + ), + testAction: .targets([ + .testableTarget(target: .target("ChaGokWidgetTests"), parallelization: .disabled) + ]) +) + +private let widgetTarget = Target.target( + name: "ChaGokWidget", + destinations: [.iPhone], + product: .appExtension, + bundleId: "\(bundleId).widget", + deploymentTargets: deploymentTargets, + infoPlist: .extendingDefault( + with: [ + "CFBundleDisplayName": "ChaGokWidget", + "NSExtension": [ + "NSExtensionPointIdentifier": "com.apple.widgetkit-extension" + ] + ] + ), + sources: ["Sources/**/*.swift"], + dependencies: [ + .project(target: "Domain", path: "../Domain"), + .project(target: "Presentation", path: "../Presentation") + ] +) + +private let widgetTestsTarget = Target.target( + name: "ChaGokWidgetTests", + destinations: [.iPhone], + product: .unitTests, + bundleId: "\(bundleId).widgetTests", + deploymentTargets: deploymentTargets, + infoPlist: .default, + sources: ["Tests/**/*.swift", "Sources/RecordingActivityWidget.swift"], // @main이 선언된 WidgetBundle.swift는 컴파일에서 제외 + dependencies: [ + .project(target: "Domain", path: "../Domain"), + .project(target: "Presentation", path: "../Presentation") + ] +) + +let project = Project( + name: "Widget", + settings: settings, + targets: [ + widgetTarget, + widgetTestsTarget + ], + schemes: [ + widgetScheme, + widgetTestsScheme + ] +) diff --git a/Widget/Sources/RecordingActivityWidget.swift b/Widget/Sources/RecordingActivityWidget.swift new file mode 100644 index 00000000..f638823d --- /dev/null +++ b/Widget/Sources/RecordingActivityWidget.swift @@ -0,0 +1,86 @@ +import ActivityKit +import Domain +import SwiftUI +import WidgetKit + +struct RecordingActivityWidget: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: RecordingActivityAttributes.self) { context in + LockScreenView(context: context) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + HStack(spacing: 8) { + Image(systemName: "mic.fill") + .foregroundColor(.red) + Text("녹음 중") + .font(.headline) + .foregroundColor(.white) + } + } + DynamicIslandExpandedRegion(.trailing) { + Text(context.state.displayDuration) + .font(.headline) + .monospacedDigit() + .foregroundColor(.white) + } + DynamicIslandExpandedRegion(.bottom) { + HStack { + Text(context.attributes.title) + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + } + } + } compactLeading: { + Image(systemName: "mic.fill") + .foregroundColor(.red) + } compactTrailing: { + Text(context.state.displayDuration) + .monospacedDigit() + .foregroundColor(.red) + } minimal: { + Image(systemName: "mic.fill") + .foregroundColor(.red) + } + } + } +} + +struct LockScreenView: View { + let context: ActivityViewContext + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(context.attributes.title) + .font(.headline) + .foregroundColor(.primary) + Text(context.state.isPaused ? "일시정지됨" : "녹음 중") + .font(.subheadline) + .foregroundColor(.secondary) + } + Spacer() + Text(context.state.displayDuration) + .font(.title) + .monospacedDigit() + .foregroundColor(.primary) + } + .padding() + .activityBackgroundTint(Color.black.opacity(0.6)) + } +} + +private extension RecordingActivityAttributes.ContentState { + var displayDuration: String { + let duration = Int(self.duration) + let hours = duration / 3600 + let minutes = (duration % 3600) / 60 + let seconds = duration % 60 + if hours > 0 { + return String(format: "%02d:%02d:%02d", hours, minutes, seconds) + } else { + return String(format: "%02d:%02d", minutes, seconds) + } + } +} diff --git a/Widget/Sources/WidgetBundle.swift b/Widget/Sources/WidgetBundle.swift new file mode 100644 index 00000000..53bc4643 --- /dev/null +++ b/Widget/Sources/WidgetBundle.swift @@ -0,0 +1,9 @@ +import SwiftUI +import WidgetKit + +@main +struct ChaGokWidgetBundle: WidgetBundle { + var body: some Widget { + RecordingActivityWidget() + } +} diff --git a/Widget/Tests/RecordingActivityWidgetTests.swift b/Widget/Tests/RecordingActivityWidgetTests.swift new file mode 100644 index 00000000..0bf19e21 --- /dev/null +++ b/Widget/Tests/RecordingActivityWidgetTests.swift @@ -0,0 +1,8 @@ +import Domain +import XCTest + +final class RecordingActivityWidgetTests: XCTestCase { + func test_example() { + XCTAssertTrue(true) + } +} diff --git a/Workspace.swift b/Workspace.swift index c7fef1ae..63a4be8a 100644 --- a/Workspace.swift +++ b/Workspace.swift @@ -8,6 +8,7 @@ let workspace = Workspace( "Core", "Domain", "Data", - "Presentation" + "Presentation", + "Widget" ] ) From 4c2cd412d3128e3cd03018dd17c0d4209ffaf106 Mon Sep 17 00:00:00 2001 From: Kim yonghae Date: Sun, 28 Jun 2026 14:43:08 +0900 Subject: [PATCH 13/21] =?UTF-8?q?refactor(domain):=20=EB=9E=A8=20=EC=82=AC?= =?UTF-8?q?=EC=96=91=20=ED=99=95=EC=9D=B8=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=205.8=EC=9D=98=20=EA=B2=BD=EC=9A=B0=20Int=20?= =?UTF-8?q?=ED=98=95=EB=B3=80=ED=99=98=EC=97=90=EC=84=9C=205=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=EB=90=98=EB=8A=94=EA=B2=83=EC=9D=84=20?= =?UTF-8?q?=EB=B0=9C=EA=B2=AC..=20->=20rounded=EB=A1=9C=20=EB=B0=98?= =?UTF-8?q?=EC=98=AC=EB=A6=BC=20=ED=95=98=EC=97=AC=206GB=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=ED=95=A9=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Domain/Sources/Entities/ChaGokModelSupport.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Domain/Sources/Entities/ChaGokModelSupport.swift b/Domain/Sources/Entities/ChaGokModelSupport.swift index c79797b8..39eb3351 100644 --- a/Domain/Sources/Entities/ChaGokModelSupport.swift +++ b/Domain/Sources/Entities/ChaGokModelSupport.swift @@ -21,7 +21,9 @@ public struct ChaGokModelSupport: Hashable, Sendable { /// 현재 기기 정보를 바로 가져오는 속성 (에러 수정됨) public static var current: ChaGokModelSupport { - let ram = Int(ProcessInfo.processInfo.physicalMemory / (1024 * 1024 * 1024)) + let ramBytes = ProcessInfo.processInfo.physicalMemory + let ramGB = Double(ramBytes) / (1024.0 * 1024.0 * 1024.0) + let ram = Int(ramGB.rounded()) return ChaGokModelSupport(ramSizeGB: ram) } } From 4c415602fd94d790a6ecf642f6f54de9ed31dc04 Mon Sep 17 00:00:00 2001 From: Kim yonghae Date: Sun, 28 Jun 2026 14:44:41 +0900 Subject: [PATCH 14/21] =?UTF-8?q?feat(core):=20Process=EA=B0=84=20Notifica?= =?UTF-8?q?tion=20=ED=97=AC=ED=8D=BC=20=EC=9E=91=EC=84=B1=20-=20Widget=20P?= =?UTF-8?q?rocess=EC=99=80=20App=20Process=EB=8A=94=20=EB=B3=84=EA=B0=9C?= =?UTF-8?q?=EB=A1=9C=20notificationCenter=EB=A5=BC=20=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EB=AA=BB=ED=95=98=EB=AF=80=EB=A1=9C=20Dar?= =?UTF-8?q?winNotificationCenter=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=A9?= =?UTF-8?q?=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Extensions/DarwinNotificationCenter.swift | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 Core/Sources/Extensions/DarwinNotificationCenter.swift diff --git a/Core/Sources/Extensions/DarwinNotificationCenter.swift b/Core/Sources/Extensions/DarwinNotificationCenter.swift new file mode 100644 index 00000000..d91489fd --- /dev/null +++ b/Core/Sources/Extensions/DarwinNotificationCenter.swift @@ -0,0 +1,83 @@ +import Foundation + +/// 프로세스 간(IPC) 통신을 위한 Darwin Notification 헬퍼입니다. +/// Widget Extension ↔ 메인 앱 간 통신에 사용합니다. +/// NotificationCenter.default는 동일 프로세스 내에서만 작동하므로, +/// 별도 프로세스인 Widget Extension과는 Darwin Notification을 사용해야 합니다. +public enum DarwinNotificationCenter { + + /// Darwin Notification 이름 정의 + public enum Name: String { + case pauseRecording = "com.chagok.recording.pause" + case resumeRecording = "com.chagok.recording.resume" + + var cfName: CFNotificationName { + CFNotificationName(rawValue as CFString) + } + } + + /// Darwin Notification을 전송합니다. + public static func post(_ name: Name) { + CFNotificationCenterGetDarwinNotifyCenter() + .post(name: name) + } + + /// Darwin Notification을 구독합니다. + /// 반환된 `DarwinNotificationObservation`을 유지해야 구독이 유지됩니다. + /// 해제 시 자동으로 구독이 제거됩니다. + public static func observe(_ name: Name, handler: @escaping @Sendable () -> Void) -> DarwinNotificationObservation { + DarwinNotificationObservation(name: name, handler: handler) + } +} + +/// Darwin Notification 구독 관리 객체입니다. +/// deinit 시 자동으로 구독이 해제됩니다. +public final class DarwinNotificationObservation: @unchecked Sendable { + private let name: DarwinNotificationCenter.Name + private let handler: @Sendable () -> Void + + init(name: DarwinNotificationCenter.Name, handler: @escaping @Sendable () -> Void) { + self.name = name + self.handler = handler + + let center = CFNotificationCenterGetDarwinNotifyCenter() + let observer = Unmanaged.passUnretained(self).toOpaque() + + CFNotificationCenterAddObserver( + center, + observer, + { _, observer, _, _, _ in + guard let observer else { return } + let observation = Unmanaged.fromOpaque(observer).takeUnretainedValue() + observation.handler() + }, + name.cfName.rawValue, + nil, + .deliverImmediately + ) + } + + public func cancel() { + let center = CFNotificationCenterGetDarwinNotifyCenter() + let observer = Unmanaged.passUnretained(self).toOpaque() + CFNotificationCenterRemoveObserver(center, observer, name.cfName, nil) + } + + deinit { + cancel() + } +} + +// MARK: - CFNotificationCenter Extension + +private extension CFNotificationCenter { + func post(name: DarwinNotificationCenter.Name) { + CFNotificationCenterPostNotification( + self, + name.cfName, + nil, + nil, + true + ) + } +} From edec5cc354db35532dbb2be42e5827a46d8fd379 Mon Sep 17 00:00:00 2001 From: Kim yonghae Date: Sun, 28 Jun 2026 14:47:21 +0900 Subject: [PATCH 15/21] =?UTF-8?q?fix(widget):=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=20=EC=95=A1=ED=8B=B0=EB=B9=84=ED=8B=B0=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83=20=ED=9D=94=EB=93=A4=EB=A6=BC=20?= =?UTF-8?q?=EB=B0=8F=20=EB=8B=A4=EC=9D=B4=EB=82=B4=EB=AF=B9=20=EC=95=84?= =?UTF-8?q?=EC=9D=BC=EB=9E=9C=EB=93=9C=20=EB=8A=98=EC=96=B4=EB=82=A8=20?= =?UTF-8?q?=ED=98=84=EC=83=81=EC=88=98=EC=A0=95=20-=20=EC=9E=A0=EA=B8=88?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20TimerView=EB=A5=BC=20HStack=EA=B3=BC=20Spa?= =?UTF-8?q?cer=EB=A1=9C=20=EA=B0=90=EC=8B=B8=20=EC=9E=AC=EC=83=9D=20?= =?UTF-8?q?=EC=A4=91=20=EC=99=BC=EC=AA=BD=EC=9C=BC=EB=A1=9C=20=EB=B6=99?= =?UTF-8?q?=EB=8A=94=20=EB=B2=84=EA=B7=B8=20=EB=B0=8F=20=EB=A7=A4=EC=B4=88?= =?UTF-8?q?=20=EC=9C=84=EC=B9=98=EA=B0=80=20=ED=9D=94=EB=93=A4=EB=A6=AC?= =?UTF-8?q?=EB=8A=94=20=ED=98=84=EC=83=81(Jittering)=20=ED=95=B4=EA=B2=B0?= =?UTF-8?q?=20-=20LockScreenView=20=EC=9A=B0=EC=B8=A1=20VStack=EC=9D=98=20?= =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20`.frame(width:=20.infini?= =?UTF-8?q?ty)`=20=EC=A0=9C=EA=B1=B0=20-=20compactTrailing=20=EC=98=81?= =?UTF-8?q?=EC=97=AD=EC=9D=98=20TimerView=EB=A5=BC=20=EA=B3=A0=EC=A0=95=20?= =?UTF-8?q?=ED=81=AC=EA=B8=B0(38x24)=EC=9D=98=20ZStack=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EA=B0=90=EC=8B=B8=EA=B3=A0=20`.clipped()`=EB=A5=BC=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=ED=95=98=EC=97=AC=20=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EB=82=B4=EB=AF=B9=20=EC=95=84=EC=9D=BC=EB=9E=9C=EB=93=9C?= =?UTF-8?q?=EA=B0=80=20=EA=B0=80=EB=A1=9C=EB=A1=9C=20=EB=8A=98=EC=96=B4?= =?UTF-8?q?=EB=82=98=EB=8A=94=20=ED=98=84=EC=83=81=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- App/Sources/AppIntentsRegistry.swift | 13 ++ .../RecordingActivityAttributes.swift | 12 +- .../Recording/PresentationAppIntents.swift | 9 + .../Recording/ToggleRecordingIntent.swift | 30 +++ .../Recording/RecordingViewModel.swift | 132 +++++++++++++ Widget/Project.swift | 2 + Widget/Sources/ChaGokWidgetAppIntents.swift | 13 ++ Widget/Sources/Recording/AudioMeterView.swift | 174 +++++++++++++++++ .../Recording/RecordingActivityWidget.swift | 183 ++++++++++++++++++ Widget/Sources/RecordingActivityWidget.swift | 86 -------- .../Tests/RecordingActivityWidgetTests.swift | 18 +- 11 files changed, 579 insertions(+), 93 deletions(-) create mode 100644 App/Sources/AppIntentsRegistry.swift create mode 100644 Presentation/Sources/Recording/PresentationAppIntents.swift create mode 100644 Presentation/Sources/Recording/ToggleRecordingIntent.swift create mode 100644 Widget/Sources/ChaGokWidgetAppIntents.swift create mode 100644 Widget/Sources/Recording/AudioMeterView.swift create mode 100644 Widget/Sources/Recording/RecordingActivityWidget.swift delete mode 100644 Widget/Sources/RecordingActivityWidget.swift diff --git a/App/Sources/AppIntentsRegistry.swift b/App/Sources/AppIntentsRegistry.swift new file mode 100644 index 00000000..b5cf8dab --- /dev/null +++ b/App/Sources/AppIntentsRegistry.swift @@ -0,0 +1,13 @@ +import AppIntents +import Presentation + +/// 메인 앱에서 Presentation 프레임워크의 AppIntent(LiveActivityIntent)를 +/// 시스템이 인식할 수 있도록 등록합니다. +/// +/// LiveActivityIntent는 메인 앱 프로세스에서 실행되므로, +/// 앱 타겟에서도 프레임워크의 Intent 메타데이터를 참조해야 합니다. +struct AppIntentsRegistry: AppIntentsPackage { + static var includedPackages: [any AppIntentsPackage.Type] { + [PresentationAppIntents.self] + } +} diff --git a/Domain/Sources/Entities/RecordingActivityAttributes.swift b/Domain/Sources/Entities/RecordingActivityAttributes.swift index 6d0b33de..480d451b 100644 --- a/Domain/Sources/Entities/RecordingActivityAttributes.swift +++ b/Domain/Sources/Entities/RecordingActivityAttributes.swift @@ -1,21 +1,23 @@ import ActivityKit import Foundation -public struct RecordingActivityAttributes: ActivityAttributes { - public struct ContentState: Codable, Hashable { +public struct RecordingActivityAttributes: ActivityAttributes, Sendable { + public struct ContentState: Codable, Hashable, Sendable { public var duration: TimeInterval public var isPaused: Bool + public var amplitude: Float - public init(duration: TimeInterval, isPaused: Bool) { + public init(duration: TimeInterval, isPaused: Bool, amplitude: Float) { self.duration = duration self.isPaused = isPaused + self.amplitude = amplitude } } + public var startDate: String public var title: String - public var startDate: Date - public init(title: String, startDate: Date) { + public init(title: String, startDate: String) { self.title = title self.startDate = startDate } diff --git a/Presentation/Sources/Recording/PresentationAppIntents.swift b/Presentation/Sources/Recording/PresentationAppIntents.swift new file mode 100644 index 00000000..ee2a3776 --- /dev/null +++ b/Presentation/Sources/Recording/PresentationAppIntents.swift @@ -0,0 +1,9 @@ +import AppIntents + +/// Presentation 프레임워크에 정의된 AppIntent(LiveActivityIntent 포함)를 +/// 시스템이 자동으로 검색할 수 있도록 등록합니다. +/// +/// AppIntent가 별도 프레임워크 모듈에 정의된 경우, +/// 시스템의 메타데이터 추출(metadata extraction) 단계에서 해당 Intent를 발견하지 못합니다. +/// `AppIntentsPackage`를 구현하면 빌드 시스템이 이 모듈의 Intent를 인식하고 등록합니다. +public struct PresentationAppIntents: AppIntentsPackage {} diff --git a/Presentation/Sources/Recording/ToggleRecordingIntent.swift b/Presentation/Sources/Recording/ToggleRecordingIntent.swift new file mode 100644 index 00000000..a60a1910 --- /dev/null +++ b/Presentation/Sources/Recording/ToggleRecordingIntent.swift @@ -0,0 +1,30 @@ +import ActivityKit +import AppIntents +import Core +import Foundation +import Domain + +public struct ToggleRecordingIntent: LiveActivityIntent { + public static let title: LocalizedStringResource = "녹음 재생/일시정지 토글" + + public init() {} + + public func perform() async throws -> some IntentResult { + // 현재 활성화된 Live Activity를 가져와 일시정지 상태에 맞는 Darwin Notification을 전송합니다. + // NotificationCenter.default는 프로세스 내(in-process) 통신만 가능하므로, + // Widget Extension → 메인 앱 간 통신에는 Darwin Notification을 사용해야 합니다. + guard let activity = Activity.activities.first else { + return .result() + } + + let isPaused = activity.content.state.isPaused + AppLogger.info("isPaused : \(isPaused)") + if isPaused { + DarwinNotificationCenter.post(.resumeRecording) + } else { + DarwinNotificationCenter.post(.pauseRecording) + } + + return .result() + } +} diff --git a/Presentation/Sources/ViewModel/Recording/RecordingViewModel.swift b/Presentation/Sources/ViewModel/Recording/RecordingViewModel.swift index bf541d7f..21278fb7 100644 --- a/Presentation/Sources/ViewModel/Recording/RecordingViewModel.swift +++ b/Presentation/Sources/ViewModel/Recording/RecordingViewModel.swift @@ -1,15 +1,20 @@ import Domain +import ActivityKit import Foundation +import Core @MainActor public protocol RecordingCoordinating: AnyObject { func cancelRecording() func finishRecording(voiceNote: VoiceNote) } +extension Activity: @unchecked @retroactive Sendable {} @MainActor @Observable public final class RecordingViewModel { + private var activeActivity: Activity? + struct State: Equatable { enum RecordingState { case idle @@ -66,6 +71,8 @@ public final class RecordingViewModel { private var waveformTask: Task? private var timerTask: Task? private var actionTask: Task? + private var amplitudeUpdateTask: Task? + private var darwinObservations: [DarwinNotificationObservation] = [] public init( repository: any VoiceRecordRepository, @@ -73,6 +80,7 @@ public final class RecordingViewModel { ) { self.repository = repository self.voiceNoteUseCase = voiceNoteUseCase + subscribeToWidgetNotifications() } public func send(_ action: Action) { @@ -99,8 +107,10 @@ public final class RecordingViewModel { showCompleteAlert?() case .cancelButtonTapped: stopTimer() + stopAmplitudeUpdates() waveformTask?.cancel() waveformTask = nil + endLiveActivity() actionTask?.cancel() actionTask = Task { try? await repository.cancelRecording() @@ -111,8 +121,10 @@ public final class RecordingViewModel { actionTask = Task { do { stopTimer() + stopAmplitudeUpdates() waveformTask?.cancel() waveformTask = nil + endLiveActivity() let voiceRecord = try await repository.finishRecording() let voiceNote = try voiceNoteUseCase.create(voiceRecord) coordinator?.finishRecording(voiceNote: voiceNote) @@ -132,6 +144,8 @@ public final class RecordingViewModel { state.recordingStartDate = .now state.recordingState = .recording startTimer() + startLiveActivity() + startAmplitudeUpdates() waveformTask?.cancel() waveformTask = Task { [weak self] in @@ -152,7 +166,9 @@ public final class RecordingViewModel { do { try await repository.pauseRecording() stopTimer() + stopAmplitudeUpdates() state.recordingState = .paused + updateLiveActivity(isPaused: true) } catch { send(.errorOccurred(error)) } @@ -164,7 +180,9 @@ public final class RecordingViewModel { do { try await repository.resumeRecording() startTimer() + startAmplitudeUpdates() state.recordingState = .recording + updateLiveActivity(isPaused: false) } catch { send(.errorOccurred(error)) } @@ -186,4 +204,118 @@ public final class RecordingViewModel { timerTask?.cancel() timerTask = nil } + + // MARK: - Live Activity Amplitude Update + + /// Live Activity에 amplitude를 주기적으로 반영합니다. + /// ActivityKit는 업데이트 빈도에 제한이 있으므로 1초 간격으로 업데이트합니다. + private func startAmplitudeUpdates() { + amplitudeUpdateTask?.cancel() + amplitudeUpdateTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled, let self else { return } + updateLiveActivity(isPaused: false) + } + } + } + + private func stopAmplitudeUpdates() { + amplitudeUpdateTask?.cancel() + amplitudeUpdateTask = nil + } + + // MARK: - Live Activity + + private func startLiveActivity() { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + + let attributes = RecordingActivityAttributes( + title: state.title, + startDate: state.displayStartDate + ) + + let initialContentState = RecordingActivityAttributes.ContentState( + duration: state.recordingDuration, + isPaused: false, + amplitude: state.amplitude + ) + + let content = ActivityContent(state: initialContentState, staleDate: nil) + + do { + activeActivity = try Activity.request( + attributes: attributes, + content: content, + pushType: nil + ) + } catch { + AppLogger.error("Failed to start Live Activity: \(error.localizedDescription)") + } + } + + private func updateLiveActivity(isPaused: Bool) { + guard let activeActivity else { return } + + let updatedContentState = RecordingActivityAttributes.ContentState( + duration: state.recordingDuration, + isPaused: isPaused, + amplitude: state.amplitude + ) + + let content = ActivityContent(state: updatedContentState, staleDate: nil) + + Task { @MainActor in + await activeActivity.update(content) + } + } + + private func endLiveActivity() { + guard let activeActivity else { return } + + let finalContentState = RecordingActivityAttributes.ContentState( + duration: state.recordingDuration, + isPaused: state.recordingState == .paused, + amplitude: state.amplitude + ) + + let content = ActivityContent(state: finalContentState, staleDate: nil) + + Task { @MainActor in + await activeActivity.end(content, dismissalPolicy: .immediate) + self.activeActivity = nil + } + } + + // MARK: - Widget IPC (Darwin Notification) + + /// Widget Extension에서 보내는 Darwin Notification을 구독합니다. + /// NotificationCenter.default는 동일 프로세스 내에서만 작동하므로, + /// 별도 프로세스인 Widget Extension과는 Darwin Notification을 사용합니다. + private func subscribeToWidgetNotifications() { + let pauseObservation = DarwinNotificationCenter.observe(.pauseRecording) { [weak self] in + Task { @MainActor [weak self] in + self?.handlePauseFromWidget() + } + } + let resumeObservation = DarwinNotificationCenter.observe(.resumeRecording) { [weak self] in + Task { @MainActor [weak self] in + self?.handleResumeFromWidget() + } + } + darwinObservations = [pauseObservation, resumeObservation] + } + + @MainActor + private func handlePauseFromWidget() { + guard state.recordingState == .recording else { return } + pauseRecording() + } + + @MainActor + private func handleResumeFromWidget() { + guard state.recordingState == .paused else { return } + resumeRecording() + } } + diff --git a/Widget/Project.swift b/Widget/Project.swift index c6c4a873..28f1074d 100644 --- a/Widget/Project.swift +++ b/Widget/Project.swift @@ -31,6 +31,7 @@ private let widgetTarget = Target.target( infoPlist: .extendingDefault( with: [ "CFBundleDisplayName": "ChaGokWidget", + "NSSupportsLiveActivities": true, "NSExtension": [ "NSExtensionPointIdentifier": "com.apple.widgetkit-extension" ] @@ -38,6 +39,7 @@ private let widgetTarget = Target.target( ), sources: ["Sources/**/*.swift"], dependencies: [ + .project(target: "Core", path: "../Core"), .project(target: "Domain", path: "../Domain"), .project(target: "Presentation", path: "../Presentation") ] diff --git a/Widget/Sources/ChaGokWidgetAppIntents.swift b/Widget/Sources/ChaGokWidgetAppIntents.swift new file mode 100644 index 00000000..201403a0 --- /dev/null +++ b/Widget/Sources/ChaGokWidgetAppIntents.swift @@ -0,0 +1,13 @@ +import AppIntents +import Presentation + +/// Widget Extension에서 Presentation 프레임워크의 AppIntent(LiveActivityIntent)를 +/// 시스템이 인식할 수 있도록 등록합니다. +/// +/// `includedPackages`를 통해 Presentation 프레임워크의 `PresentationAppIntents`를 참조하여, +/// 빌드 시스템이 프레임워크 내 ToggleRecordingIntent의 메타데이터를 이 Extension에도 연결합니다. +struct ChaGokWidgetAppIntents: AppIntentsPackage { + static var includedPackages: [any AppIntentsPackage.Type] { + [PresentationAppIntents.self] + } +} diff --git a/Widget/Sources/Recording/AudioMeterView.swift b/Widget/Sources/Recording/AudioMeterView.swift new file mode 100644 index 00000000..d9039e57 --- /dev/null +++ b/Widget/Sources/Recording/AudioMeterView.swift @@ -0,0 +1,174 @@ +import Foundation +import SwiftUI + +/// Live Activity / Dynamic Island에서 현재 녹음이 진행 중임을 시각적으로 보여주는 오디오 미터 뷰입니다. +/// amplitude(0.0~1.0) 값을 기반으로 각 바의 높이를 결정합니다. +/// +/// 디자인 특징: +/// - 중앙 바가 가장 높고, 양 끝은 dot(원) 형태로 짧습니다. +/// - 바마다 고유한 변동을 적용하여 유기적인 웨이브폼을 표현합니다. +/// - RoundedRectangle로 끝이 둥근 바를 표현합니다. +public struct AudioMeterView: View { + let barCount: Int + let amplitude: Double + let isPaused: Bool + let activeColor: Color + + public init( + barCount: Int = 27, + amplitude: Double, + isPaused: Bool, + activeColor: Color = Color(red: 111 / 255, green: 83 / 255, blue: 253 / 255) + ) { + self.barCount = barCount + self.amplitude = amplitude + self.isPaused = isPaused + self.activeColor = activeColor + } + + public var body: some View { + HStack(alignment: .center, spacing: 2) { + ForEach(0 ..< barCount, id: \.self) { index in + RoundedRectangle(cornerRadius: barWidth / 2) + .fill(isPaused ? Color.white.opacity(0.3) : activeColor) + .frame(width: barWidth, height: barHeight(for: index)) + } + } + } + + private var barWidth: CGFloat { 3.0 } + + private func barHeight(for index: Int) -> CGFloat { + let dotHeight: CGFloat = barWidth // 양 끝의 dot은 정사각형(원) + let maxHeight: CGFloat = 22.0 + + guard !isPaused else { + return dotHeight + } + + let clampedAmplitude = min(max(amplitude, 0.0), 1.0) + + // ────────────────────────────────────────────── + // 시각적 amplitude 보정 + // AVAudioRecorder의 dB→선형 변환 결과는 일반 음성에서 0.01~0.1 수준으로 매우 작습니다. + // pow(x, 0.3) 곡선을 적용하여 작은 값을 시각적으로 증폭합니다. + // 예) 0.01 → 0.25, 0.05 → 0.42, 0.1 → 0.50, 0.3 → 0.67, 1.0 → 1.0 + // ────────────────────────────────────────────── + let boostedAmplitude = pow(clampedAmplitude, 0.3) + + // 중앙에서의 거리를 0.0(중앙) ~ 1.0(양 끝)으로 정규화 + let midIndex = Double(barCount - 1) / 2.0 + let distanceFromCenter = abs(Double(index) - midIndex) + let normalizedDistance = midIndex > 0 ? distanceFromCenter / midIndex : 0.0 + + // 중앙 가중치: 중앙(1.0) → 양 끝(0.0) + // pow를 사용하여 양 끝에서 급격히 낮아지는 곡선 적용 + let centerWeight = pow(1.0 - normalizedDistance, 1.5) + + // 바마다 고유한 변동 패턴 (index 기반 의사 랜덤) + let seed = Double(index * 13 + 5) + let variation = sin(seed) * 0.5 + 0.5 // 0.0 ~ 1.0 + let jitter = 0.6 + variation * 0.8 // 0.6 ~ 1.4 + + // 최종 높이: boostedAmplitude × centerWeight × jitter로 유기적 변화 + let targetHeight = dotHeight + (maxHeight - dotHeight) * boostedAmplitude * centerWeight * jitter + + return max(dotHeight, min(maxHeight, targetHeight)) + } +} + +/// Live Activity / recording UI에 적합한 재사용 가능한 오디오 미터 컴포넌트입니다. +/// level 값에 따라 각 바의 높이가 랜덤하게 변하며, 자연스럽게 애니메이션됩니다. +public struct LiveAudioMeter: View { + private let level: CGFloat + private let barCount: Int + private let maxHeight: CGFloat + private let minHeight: CGFloat + + @State private var heights: [CGFloat] = [] + + public init( + level: CGFloat, + barCount: Int = 7, + maxHeight: CGFloat = 24, + minHeight: CGFloat = 4 + ) { + self.level = level + self.barCount = max(1, barCount) + self.maxHeight = max(minHeight, maxHeight) + self.minHeight = max(1, minHeight) + } + + public var body: some View { + HStack(alignment: .center, spacing: 2) { + ForEach(0 ..< barCount, id: \.self) { index in + RoundedRectangle(cornerRadius: 1.5) + .fill(Color.white.opacity(0.9)) + .frame(width: 3, height: barHeight(for: index)) + } + } + .onAppear { + refreshHeights(animated: false) + } + .onChange(of: level) { _, _ in + refreshHeights(animated: true) + } + .onChange(of: barCount) { _, _ in + refreshHeights(animated: false) + } + .onReceive(Timer.publish(every: 0.16, tolerance: 0.05, on: .main, in: .common).autoconnect()) { _ in + refreshHeights(animated: true) + } + } + + private func barHeight(for index: Int) -> CGFloat { + guard heights.indices.contains(index) else { + return Self.makeHeight( + for: index, + barCount: barCount, + level: level, + maxHeight: maxHeight, + minHeight: minHeight + ) + } + + return heights[index] + } + + private func refreshHeights(animated: Bool) { + let nextHeights = (0 ..< barCount).map { index in + Self.makeHeight( + for: index, + barCount: barCount, + level: level, + maxHeight: maxHeight, + minHeight: minHeight + ) + } + + if animated { + withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) { + heights = nextHeights + } + } else { + heights = nextHeights + } + } + + static func makeHeight( + for index: Int, + barCount: Int, + level: CGFloat, + maxHeight: CGFloat, + minHeight: CGFloat + ) -> CGFloat { + let normalizedLevel = min(max(level, 0.0), 1.0) + let amplitudeLimit = minHeight + (maxHeight - minHeight) * normalizedLevel + let availableRange = max(0.0, amplitudeLimit - minHeight) + let variation = CGFloat(index % 5 + 1) / 5.0 + let randomFactor = CGFloat.random(in: 0.3 ... 1.0) * (0.65 + variation * 0.35) + let rawHeight = minHeight + availableRange * randomFactor + + return min(maxHeight, max(minHeight, rawHeight)) + } +} diff --git a/Widget/Sources/Recording/RecordingActivityWidget.swift b/Widget/Sources/Recording/RecordingActivityWidget.swift new file mode 100644 index 00000000..0952a883 --- /dev/null +++ b/Widget/Sources/Recording/RecordingActivityWidget.swift @@ -0,0 +1,183 @@ +import ActivityKit +import AppIntents +import Domain +import Core +import Presentation +import SwiftUI +import WidgetKit + +struct RecordingActivityWidget: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: RecordingActivityAttributes.self) { context in + LockScreenView(context: context) + } dynamicIsland: { context in + DynamicIsland { + // 다이내믹 아일랜드 확장형 (왼쪽) + DynamicIslandExpandedRegion(.leading) { + HStack(spacing: 8) { + Button(intent: ToggleRecordingIntent()) { + Circle() + .fill(LinearGradient( + colors: [ + Color(red: 111/255, green: 83/255, blue: 253/255), + Color(red: 94/255, green: 92/255, blue: 230/255) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + )) + .frame(width: 40, height: 40) + .overlay( + Image(systemName: context.state.isPaused ? "play.fill" : "pause.fill") + .foregroundColor(.white) + .font(.headline) + ) + } + .buttonStyle(.plain) + + VStack(alignment: .leading, spacing: 4) { + Text(context.attributes.title) + .font(.subheadline) + .foregroundColor(.white) + + TimerView( + duration: context.state.duration, + isPaused: context.state.isPaused, + color: Color.white.opacity(0.7) + ) + .font(.body) + .multilineTextAlignment(.leading) + } + } + } + + // 다이내믹 아일랜드 확장형 (오른쪽) + DynamicIslandExpandedRegion(.trailing) { + AudioMeterView(barCount: 17, amplitude: Double(context.state.amplitude), isPaused: context.state.isPaused) + .padding(.trailing, 8) + .frame(maxHeight: .infinity) + } + } compactLeading: { + // 다이내믹 아일랜드 최소형 (왼쪽 버튼) + Image(systemName: "waveform") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 24, height: 24) + .foregroundStyle(LinearGradient( + colors: [ + Color(red: 111/255, green: 83/255, blue: 253/255), + Color(red: 94/255, green: 92/255, blue: 230/255) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + )) + } compactTrailing: { + // 다이내믹 아일랜드 최소형 (오른쪽 타이머) + ZStack(alignment: .trailing) { + TimerView( + duration: context.state.duration, + isPaused: context.state.isPaused, + color: .white + ) + .font(.caption.bold()) + } + .frame(width: 38, height: 24) + .clipped() + } minimal: { + // 다이내믹 아일랜드 독립형 최소 형태 + Button(intent: ToggleRecordingIntent()) { + Circle() + .fill(LinearGradient( + colors: [ + Color(red: 111/255, green: 83/255, blue: 253/255), + Color(red: 94/255, green: 92/255, blue: 230/255) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + )) + .frame(width: 24, height: 24) + .overlay( + Image(systemName: context.state.isPaused ? "play.fill" : "pause.fill") + .foregroundColor(.white) + .font(.caption2.bold()) + ) + } + .buttonStyle(.plain) + } + } + } +} + +struct LockScreenView: View { + let context: ActivityViewContext + + var body: some View { + HStack(spacing: 16) { + Button(intent: ToggleRecordingIntent()) { + Circle() + .fill(LinearGradient( + colors: [ + Color(red: 111/255, green: 83/255, blue: 253/255), + Color(red: 94/255, green: 92/255, blue: 230/255) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + )) + .frame(width: 48, height: 48) + .overlay( + Image(systemName: context.state.isPaused ? "play.fill" : "pause.fill") + .foregroundColor(.white) + .font(.title2.bold()) + ) + } + .buttonStyle(.plain) + + // 가운데: 녹음 타이틀 및 시작 날짜/시간 + VStack(alignment: .leading, spacing: 4) { + Text(context.attributes.title) + .font(.headline) + .foregroundColor(.white) + Text(context.attributes.startDate) + .font(.caption) + .foregroundColor(Color.white.opacity(0.6)) + } + + Spacer() + + // 오른쪽: 실시간 타이머 및 오디오 미터 + VStack(alignment: .trailing) { + TimerView( + duration: context.state.duration, + isPaused: context.state.isPaused, + color: .white + ) + .multilineTextAlignment(.trailing) + .font(.title.bold()) + Spacer() + AudioMeterView(barCount: 15, amplitude: Double(context.state.amplitude), isPaused: context.state.isPaused) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + .activityBackgroundTint(Color(red: 22/255, green: 21/255, blue: 27/255).opacity(0.95)) + } +} + +struct TimerView: View { + let duration: TimeInterval + let isPaused: Bool + let color: Color + + var body: some View { + if isPaused { + Text(duration.durationString) + .monospacedDigit() + .foregroundColor(color) + .multilineTextAlignment(.trailing) + } else { + // 시스템 타이머 연동 (과거 시작 시점으로부터 카운트업 진행) + Text(Date(timeIntervalSinceNow: -duration), style: .timer) + .monospacedDigit() + .foregroundColor(color) + } + } +} diff --git a/Widget/Sources/RecordingActivityWidget.swift b/Widget/Sources/RecordingActivityWidget.swift deleted file mode 100644 index f638823d..00000000 --- a/Widget/Sources/RecordingActivityWidget.swift +++ /dev/null @@ -1,86 +0,0 @@ -import ActivityKit -import Domain -import SwiftUI -import WidgetKit - -struct RecordingActivityWidget: Widget { - var body: some WidgetConfiguration { - ActivityConfiguration(for: RecordingActivityAttributes.self) { context in - LockScreenView(context: context) - } dynamicIsland: { context in - DynamicIsland { - DynamicIslandExpandedRegion(.leading) { - HStack(spacing: 8) { - Image(systemName: "mic.fill") - .foregroundColor(.red) - Text("녹음 중") - .font(.headline) - .foregroundColor(.white) - } - } - DynamicIslandExpandedRegion(.trailing) { - Text(context.state.displayDuration) - .font(.headline) - .monospacedDigit() - .foregroundColor(.white) - } - DynamicIslandExpandedRegion(.bottom) { - HStack { - Text(context.attributes.title) - .font(.subheadline) - .foregroundColor(.secondary) - Spacer() - } - } - } compactLeading: { - Image(systemName: "mic.fill") - .foregroundColor(.red) - } compactTrailing: { - Text(context.state.displayDuration) - .monospacedDigit() - .foregroundColor(.red) - } minimal: { - Image(systemName: "mic.fill") - .foregroundColor(.red) - } - } - } -} - -struct LockScreenView: View { - let context: ActivityViewContext - - var body: some View { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text(context.attributes.title) - .font(.headline) - .foregroundColor(.primary) - Text(context.state.isPaused ? "일시정지됨" : "녹음 중") - .font(.subheadline) - .foregroundColor(.secondary) - } - Spacer() - Text(context.state.displayDuration) - .font(.title) - .monospacedDigit() - .foregroundColor(.primary) - } - .padding() - .activityBackgroundTint(Color.black.opacity(0.6)) - } -} - -private extension RecordingActivityAttributes.ContentState { - var displayDuration: String { - let duration = Int(self.duration) - let hours = duration / 3600 - let minutes = (duration % 3600) / 60 - let seconds = duration % 60 - if hours > 0 { - return String(format: "%02d:%02d:%02d", hours, minutes, seconds) - } else { - return String(format: "%02d:%02d", minutes, seconds) - } - } -} diff --git a/Widget/Tests/RecordingActivityWidgetTests.swift b/Widget/Tests/RecordingActivityWidgetTests.swift index 0bf19e21..a7ea1f81 100644 --- a/Widget/Tests/RecordingActivityWidgetTests.swift +++ b/Widget/Tests/RecordingActivityWidgetTests.swift @@ -1,8 +1,22 @@ import Domain import XCTest +@testable import ChaGokWidget + final class RecordingActivityWidgetTests: XCTestCase { - func test_example() { - XCTAssertTrue(true) + func test_라이브오디오미터는_설정된_높이_범위_내에서_높이를_반환한다() { + let heights = (0..<7).map { + LiveAudioMeter.makeHeight( + for: $0, + barCount: 7, + level: 0.8, + maxHeight: 24, + minHeight: 4 + ) + } + + XCTAssertEqual(heights.count, 7) + XCTAssertTrue(heights.allSatisfy { $0 >= 4 && $0 <= 24 }) + XCTAssertTrue(heights.contains { $0 > 4 }) } } From eb3f672ab13898930fe2f4fe2031e4dc586138f6 Mon Sep 17 00:00:00 2001 From: Kim yonghae Date: Tue, 30 Jun 2026 13:54:57 +0900 Subject: [PATCH 16/21] =?UTF-8?q?refactor(presentation):=20foreground?= =?UTF-8?q?=EC=A0=84=ED=99=98=20UIView.anitme=EC=82=B4=EB=A6=AC=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ProgressView/ImmutableProgressView.swift | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Presentation/Sources/Component/Common/ProgressView/ImmutableProgressView.swift b/Presentation/Sources/Component/Common/ProgressView/ImmutableProgressView.swift index 0ca9a6aa..685eb372 100644 --- a/Presentation/Sources/Component/Common/ProgressView/ImmutableProgressView.swift +++ b/Presentation/Sources/Component/Common/ProgressView/ImmutableProgressView.swift @@ -31,6 +31,7 @@ public final class ImmutableProgressView: UIView { setup() setupHierarchy() setupLayout() + registerNotifications() } required init?(coder: NSCoder) { @@ -38,6 +39,11 @@ public final class ImmutableProgressView: UIView { setup() setupHierarchy() setupLayout() + registerNotifications() + } + + deinit { + NotificationCenter.default.removeObserver(self) } override public func layoutSubviews() { @@ -63,6 +69,20 @@ extension ImmutableProgressView { translatesAutoresizingMaskIntoConstraints = false } + private func registerNotifications() { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + } + + @objc private func handleWillEnterForeground() { + stopAnimation() + startAnimation() + } + private func setupHierarchy() { addSubview(trackView) trackView.addSubview(indicatorView) From a340306f7ec6ab09250b95814ef14617102f76b4 Mon Sep 17 00:00:00 2001 From: Kim yonghae Date: Tue, 30 Jun 2026 13:58:03 +0900 Subject: [PATCH 17/21] =?UTF-8?q?refactor(data):=20=EB=AC=B8=EB=B2=95=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=20=EC=88=9C=EC=B0=A8=20=EA=B2=80=EC=82=AC=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94=20-=20=EB=B3=91=EB=A0=AC=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=EB=8A=94=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20OOM=EC=9A=B0?= =?UTF-8?q?=EB=A0=A4=EB=A1=9C=20=EC=A0=9C=EC=99=B8=20-=205=EA=B0=9C?= =?UTF-8?q?=EC=94=A9=20=EB=AC=B6=EC=96=B4=EC=84=9C=20=EB=AC=B8=EB=B2=95?= =?UTF-8?q?=EA=B2=80=EC=82=AC=EB=A5=BC=20=EC=B2=98=EB=A6=AC=20-=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=EB=A5=BC=20=EB=AC=B6=EC=96=B4=EC=84=9C=20?= =?UTF-8?q?=ED=95=9C=EB=B2=88=20=EA=B2=80=EC=82=AC=ED=95=98=EB=8A=94?= =?UTF-8?q?=EA=B2=8C=20=EA=B0=80=EC=9E=A5=20=EB=B9=A0=EB=A5=B4=EA=B2=A0?= =?UTF-8?q?=EC=A7=80=EB=A7=8C=20=EC=9D=B4=EA=B2=83=EB=8F=84=20KVCache=20?= =?UTF-8?q?=ED=8F=AD=EC=A3=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- App/Sources/Debug/DebugSeeder.swift | 9 +- App/Sources/Debug/SeedContent.swift | 141 ++++++++++++++++-- .../DefaultMLXGrammarRepository.swift | 100 ++++++++++++- Domain/Sources/Policy.swift | 30 +++- .../VoiceNote/VoiceNoteViewModel.swift | 62 +++++++- 5 files changed, 318 insertions(+), 24 deletions(-) diff --git a/App/Sources/Debug/DebugSeeder.swift b/App/Sources/Debug/DebugSeeder.swift index 0250d3aa..f47df3f7 100644 --- a/App/Sources/Debug/DebugSeeder.swift +++ b/App/Sources/Debug/DebugSeeder.swift @@ -257,7 +257,9 @@ } private func createSeededNote(spec: Spec) throws { - let sections = SeedContent.buildSections(texts: spec.texts) + let sections = spec.title.contains("문법") + ? SeedContent.buildLongSections(texts: spec.texts, totalDuration: 3600) + : SeedContent.buildSections(texts: spec.texts) let duration = (sections.last?.timestamp ?? 0) + 3.0 let audioPath = try makeSilentAudioFile(duration: max(duration, 2.5)) let record = VoiceRecord( @@ -309,6 +311,9 @@ } private func makeSilentAudioFile(duration: Double) throws -> String { + // 디버그용 무음 파일이므로 실제 파일 길이는 최대 5초로 제한하여 + // 메인 스레드 병목 및 과도한 메모리/디스크 사용을 방지합니다. + let physicalDuration = min(duration, 5.0) let directory = "VoiceRecords" let fileName = "seed-\(UUID().uuidString).m4a" let docURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] @@ -331,7 +336,7 @@ commonFormat: .pcmFormatFloat32, interleaved: false ) - let frameCount = AVAudioFrameCount(file.processingFormat.sampleRate * duration) + let frameCount = AVAudioFrameCount(file.processingFormat.sampleRate * physicalDuration) guard let buffer = AVAudioPCMBuffer( pcmFormat: file.processingFormat, frameCapacity: frameCount diff --git a/App/Sources/Debug/SeedContent.swift b/App/Sources/Debug/SeedContent.swift index 01287002..848a1840 100644 --- a/App/Sources/Debug/SeedContent.swift +++ b/App/Sources/Debug/SeedContent.swift @@ -13,6 +13,18 @@ return result } + static func buildLongSections(texts: [String], totalDuration: TimeInterval) -> [TranscriptSection] { + var result: [TranscriptSection] = [] + guard !texts.isEmpty else { return result } + let interval = totalDuration / Double(texts.count) + var timestamp: TimeInterval = 0 + for text in texts { + result.append(TranscriptSection(timestamp: timestamp, text: text)) + timestamp += interval + } + return result + } + // MARK: - 업무 static let weeklyMeeting: [String] = [ @@ -505,15 +517,126 @@ ] static let grammarCheckTest: [String] = [ - "아진짜오늘그그미팅할때내가말했던거있자나요그거사실그케파가안맞는건데", - "근데또팀장님이그그냥하라고하니까어쩔수업이알겟다고는했는데말이안댐", - "솔직히 일정이넘후달려가꾸 이거 다끝낼수있으련지모르겟고 디자이너분들도지금바뿐거같은대", - "일단은 대충해보고 마일스토마일스톤을좀미루던지해야될듯여 그치안나여", - "그런데 만약에 저도 한단 1단은 저도 최우선은 그 개발을 먼저 끝내고 나서 디자인을 입혀야 돼는건대", - "지금 막 순서가 디죽박죽 엉켜버려가꼬 아 진자 일할맛 안나내요 어쨋든둥 일정 조정을 해달라고 말을 꺼내긴 해야될듯", - "근데또버그가계속나오구있어가지고아 진짜 답이안나옴 이거언제다 고치고있냐진짜 ㅠㅠ", - "막 서버도 자꾸 터지구 막 디비연결두 끈기고 하는대 왜이러는지 1도 모르겟음 근대 아무도 신경안씀 ㅠㅠ", - "담주 월욜날 싱크회의때 이거 팩트체크 해가지구 다 까발려야겟음 아진자 일하기싫다 퇴사마렵네여" + "자 어... 오늘 셰션은요 ios앱 성능 최적화랑 메무리 관리에대해성 본격적으로 얘기해볼려고 함니다.", + "최그네 울이 프로젝트가 기능이 막 만하 지면서 기기에서 메무리 경고도 자주 뜨구, 가끔 특정 화면에서 버벅인다는 피드백이 들어왔잔아요.", + "구래서 오늘 한시간 동안 메무리누수랑 메인스래드 병목을 어떡게 잡을지 코도를 보면서 심도있개 다뤄보갯슴니다.", + "우선 메무리 관리의 가장 기본이 돼는 ARC, 그러니까 아웃토메틱 레퍼런스 카운팅부터 가볍개 복습하구 가죠.", + "다들 아시다시피 Swift는 가비지컬랙터가 업구 ARC를 통해성 컴파일타임에 참조카운트를 계산해성 메무리를 해재함니다.", + "근대 이게 되개 편리하긴 한대, 개발자가 실수로 순환참조우를 만들어버리묜 메무리가 해재되지 안고 계속 남아잇는 누수가 발생해여.", + "가장 흔하게 실수하는 부분이 바로 클로저 내부에서 self를 캡쳐할때 weak이나 unowned를 빼먹는 경우임니다.", + "특히 그... escaping 클로저나 뷰모델의 비동기 작업 내에서 self를 참조우할때 순환참조우가 아주 쉽게 발생하죠.", + "예룰 들어서 네트워크 요청을 보내고 응답을 처리하는 클로저에서 self를 강하게 참조우하면, 요청이 끝날때까지 뷰컨트롤러가 메무리에서 안 내려감니다.", + "마냑 네트워크 요청이 실패하거나 대기 시간이 엄청 길어지면 그동안 사용자는 화면을 나갓는데도 메무리엔 계속 남아잇는 거죠.", + "이런 현상을 방지할려면 클로저 캡쳐 리스트에다가 괄호 열고 weak self 괄호 닫고를 꼭 명시해 줘 야 함니다.", + "근대 무조건 weak self만 쓰는 게 답은 아니여. 상황에 따라서는 unowned self가 더 적합할때도 잇슴니다.", + "unowned는 참조우하는 객체가 절대 nil이 되지 안는다고 보장할때 쓰는 건데, 마냑 객체가 이미 해재된 상태에서 접근하면 앱이 바로 크래시 남니다.", + "소위 말하는 댕글링 포인터 에러가 발생하는 건대, 그래서 실무에서는 안전성을 위해성 웬만하면 weak self를 쓰는 걸 권장해여.", + "자, 그럼 실제로 울이 앱에 메무리 누수가 잇는지 어떡게 찾아낼 수 잇을까요?", + "가장 좋은 도구는 역시 Xcode에 내장되어 잇는 Instruments의 Leaks와 Allocations 템플릿임니다.", + "Leaks 도구를 실행하구 앱을 조작하다 보면, 메무리 누수가 발생하는 시점에 빨간색 아이콘으로 경고가 표시됨니다.", + "그때 콜 스택을 추적해 보면 어느 클래스의 어느 코도에서 누수가 시작되엇는지 아주 정확하게 짚어줌니다.", + "그리구 Allocations 도구를 쓰면 전체 메무리 사용량의 흐름을 그래프로 볼 수 잇어서, 메무리가 지속적으로 우상향하는지 모니터링할 수 잇어요.", + "마냑 화면을 열었다가 닫았는데도 메무리 사용량이 원래대로 돌아오지 안고 계단식으로 계속 증가한다면, 100퍼센트 누수가 잇는 겁니다.", + "또 다른 꿀팁은 Xcode의 디버그 메무리 그래프 기능을 활용하는 거에여. 디버그 중에 버튼 하나만 누르면 현재 메무리 관계도가 시각적으로 그려짐니다.", + "여기서 두 객체가 서로를 가리키며 순환참조우를 이루고 잇는 모습을 한눈에 볼 수 잇어서 아주 편리해여.", + "이제 메무리 누수를 잡았으면, 다음으로 중요한 건 메인스래드 병목을 해결하는 검니다.", + "iOS에서 모든 UI 업데이트는 반드시 메인스래드에서만 수행되어야 한다는 대원칙이 잇습니다.", + "마냑 무거운 데이터 연산이나 이미지 디코딩 같은 작업을 메인스래드에서 해버리면 화면이 버벅이는 프레임 드랍이 발생해여.", + "이걸 방지하기 위해성 Swift 6의 새로운 동시성 모델인 async-await와 Task를 적극적으로 도입해야 함니다.", + "무거운 작업은 글로벌 액터나 백그라운드 스래드로 보내고, UI를 그릴 때만 @MainActor 격리를 통해성 메인스래드로 돌아오게 설계해야 하죠.", + "특히 메인 액터 격리를 과도하게 클래스 전체에 지정하면 오히려 모든 작업이 메인스래드로 몰리는 역효과가 날 수 있으니 주의해야 해여.", + "자, 이번에는 SwiftUI의 렌더링 최적화에 대해성도 짚고 넘어가 보갯습니다.", + "SwiftUI는 선언형 뷰라서 상태가 바뀔 때마다 body 프로퍼티가 통째로 다시 호출되는 구조임니다.", + "이게 편리하긴 한대, 의도치 안게 너무 만은 뷰가 불필요하게 리렌더링되면 심각한 성능 저하로 이어져여.", + "예를 들어 상위 뷰의 @State 변수가 바뀔 때 하위 뷰가 굳이 그 값을 쓰지 안는데도 같이 그려지는 현상이 발생함니다.", + "이걸 막을려면 뷰를 최대한 잘게 쪼개고, 상태의 전파 범위를 최소화해야 함니다.", + "그리구 Observable 프로토콜을 사용하면 상태 변화에 따른 재랜더링 대상을 Swift가 컴파일 타임에 정확하게 추적해 줘서 성능에 매우 유리함니다.", + "또한 List나 LazyVStack 같은 컴포넌트를 쓸 때도 주의할 점이 만아요.", + "LazyVStack은 화면에 보이는 뷰만 렌더링하지만, 스크롤을 빠르게 할 때 셀이 급격히 생성되면서 순간적인 버벅임이 생길 수 잇습니다.", + "이럴 때는 각 셀의 레이아웃을 단순화하고, 셀 내부에서 무거운 계산이나 이미지 처리가 일어나지 안도록 사전에 캐싱 처리를 해두어야 함니다.", + "이미지 캐싱 얘기가 나와성 말인데, 모바일 앱에서 메무리를 가장 마니 잡아먹는 주범이 바로 이미지임니다.", + "만은 초보 개발자분들이 실수하는 게, 화면에 50x50 크기로 보여줄 이미지를 서버에서 3000x3000 크기 그대로 받아서 메무리에 올리는 경우임니다.", + "이미지의 크기는 파일 용량이 아니라 픽셀 해상도에 비례해서 메무리를 차지함니다. 3000x3000 이미지는 무려 36메가바이트의 메무리를 먹게 되죠.", + "그리구 서버에서 썸네일 이미지를 따로 제공받거나, 클라이언트 앱 내에서 뷰 크기에 맞게 다운샘플링하는 프로세스가 꼭 들어가야 함니다.", + "그다음으로 코어 데이터 최적화도 빼놓을 수 없는 핵심 영역임니다.", + "코어 데이터에서 대량의 레코드를 삽입하거나 업데이트할때, 한 건씩 컨텍스트를 저장(save)하면 디스크 I/O 병목이 발생함니다.", + "이럴 때는 NSBatchInsertRequest나 NSBatchUpdateRequest를 사용해서 영속성 스토어에 직접 쿼리를 날려야 훨씬 빠름니다.", + "그리구 데이터를 조회할때도 fetch limit과 fetch offset을 적절히 설정해서 페이징 처리를 구현해야 하고요.", + "불필요한 관계(Relationship) 데이터까지 한 번에 로드하지 안도록 relationshipKeyPathsForPrefetching을 적절히 활용하는 것도 좋슴니다.", + "네트워크 영역에서의 최적화도 간단히 언급하고 갈개요.", + "모든 네트워크 요청은 데이터 통신 요금과 배터리를 소모하므로, 캐싱 정책을 적극적으로 활용해야 함니다.", + "URLSession을 쓸때 URLCache를 설정해서 로컬에 응답을 저장해두면, 동일한 요청에 대해성 네트워크를 타지 안고 즉시 반환할 수 있죠.", + "특히 정적인 이미지나 설정 데이터 파일은 캐시 만료 시간을 길게 잡아서 네트워크 요청 자체를 최소화하는 게 상책임니다.", + "이제 앱의 기동 시간, 즉 앱 스타트업 타임 최적화에 대해성 이야기해 봅시다.", + "사용자가 앱 아이콘을 누르고 첫 화면이 뜰 때까지 2초 이상 걸리면 사용자 경험이 극도로 나빠짐니다.", + "스타트업 타임은 크게 dyld 단계(메인 함수 실행 전)와 main 단계(앱 델리게이트 완료 전)로 나뉨니다.", + "dyld 단계를 줄일려면 앱이 로드하는 외부 동적 라이브러리(Dynamic Library)의 개수를 최소화하고, 가급적 정적 라이브러리를 써야 함니다.", + "그리구 main 단계를 줄일려면 AppDelegate의 didFinishLaunchingWithOptions 메서드 내에서 하는 초기화 작업을 최소화해야 해여.", + "서드파티 SDK 초기화나 로컬 데이터베이스 웜업 작업은 메인스래드에서 동기적으로 실행하지 말고, 비동기로 처리하거나 백그라운드 Task로 빼야 함니다.", + "자, 지금까지 다룬 내용들을 실제로 어떻게 지속적으로 관리할 수 잇을까요?", + "가장 추천하는 방법은 CI/CD 파이프라인에 성능 측정 자동화 단계를 추가하는 것임니다.", + "Xcode 16부터는 XCTest를 통해성 CPU 사용량, 메무리 할당량, 앱 기동 시간 등을 자동 테스트하고 임계치를 넘으면 빌드를 실패하게 만들 수 잇습니다.", + "이렇게 자동화된 시스템을 갖춰두면 개발자가 코딩하다가 실수로 메무리 누수를 내더라도 커밋 단계에서 바로 걸러낼 수 있죠.", + "자, 오늘 준비한 이론 내용은 여기까지고요, 이제 남은 시간 동안 여러분의 개별 프로젝트 코드베이스를 보면서 같이 리팩토링을 해보겠습니다.", + "먼저 민수 님이 작업 중이신 동기화 모듈 코도부터 같이 화면에 띄워놓고 메무리 릭이 발생하는 부분을 분석해 보죠.", + "여기를 보시면 델리게이트 패턴을 구현하면서 프로토콜 타입의 변수를 weak으로 선언하지 안으셨네요. 이 부분이 릭의 원인임니다.", + "프로토콜을 weak으로 선언할려면 해당 프로토콜이 AnyObject를 상속받도록 정의해야 함니다. 클래스 전용 프로토콜로 만드는 거죠.", + "아, 그렇게 하니까 컴파일 에러도 사라지고 메무리 그래프에서도 순환참조우 고리가 끊어진 걸 볼 수 있네요. 아주 좋슴니다.", + "그다음으로 지은 님이 공유해 주신 이미지 로딩 뷰 컴포넌트를 보겠슴니다.", + "스크롤을 빠르게 할때 화면이 순간적으로 뚝뚝 끊기는 현상이 있는데, 이미지 디코딩 작업을 메인스래드에서 처리하고 있기 때문임니다.", + "SwiftUI의 AsyncImage는 내부적으로 이 디코딩 캐싱 처리가 미흡해서, 대량의 고해상도 이미지를 보여줄 때는 커스텀 이미지 로더를 쓰는 게 안전해여.", + "우리가 직접 캐싱 메커니즘이 들어간 이미지 뷰를 만들어서 적용해 보면 훨씬 부드러워질 검니다.", + "실제로 다운샘플링 옵션을 켜고 캐시를 적용하니까 스크롤 프레임이 60프레임으로 아주 안정적으로 유지되는 걸 볼 수 잇습니다.", + "이처럼 아주 작은 최적화 기법 몇 가지만 제대로 알고 적용해도 앱의 사용성이 180도 달라짐니다.", + "특히 배터리 소모량이나 기기 발열 문제도 결국 CPU와 메무리 최적화와 직결되어 있기 때문에 아주 중요한 작업이죠.", + "오늘 다룬 내용 중 질문 있으신 분 편하게 말씀해 주세요.", + "네, 영희 님 질문 주셨는데, weak self 대신 unowned self를 쓰면 성능상 이점이 체감될 정도로 큰가요라는 질문이네요.", + "이론적으로는 weak은 ARC 테이블을 통해성 옵셔널 관리를 하므로 약간의 오버헤드가 있지만, 실제 성능 체감은 불가능한 수준임니다.", + "따라서 성능 이점 때문에 unowned를 쓰는 것은 위험성 대비 실익이 전혀 없으므로 무조건 weak self를 쓰는 게 맞습니다.", + "철수 님은 썸네일 다운샘플링 코도를 프론트엔드에서 구현하는 게 맞는지, 아니면 이미지 서버(CDN)를 도입하는 게 나은지 질문해 주셨네요.", + "가장 이상적인 솔루션은 이미지 CDN 서버를 두고 쿼리 스트링으로 원하는 해상도를 요청하는 방식임니다.", + "클라이언트 연산도 아끼고 네트워크 대역폭도 대폭 절약할 수 있어서 엔터프라이즈 환경에서는 필수적인 구성임니다.", + "하지만 그게 어렵다면 임시방편으로 클라이언트에서라도 다운샘플링을 적용해서 메무리 폭발을 막아야 함니다.", + "자, 추가 질문이 없으시면 오늘 1시간의 성능 최적화 워크숍은 이것으로 마치겠습니다.", + "긴 시간 동안 집중해서 들어주셔서 감사하고, 오늘 수정한 코도는 각자 브랜치에 커밋해서 코도 리뷰 올려주세요. 수고하셨습니다.", + "그 외에도 몇 가지 팁을 드리자면, 컬렉션 타입의 성능 특성을 잘 알고 사용하는 것도 중요함니다.", + "Array는 순차 접근에 빠르지만, 특정 원소를 검색하거나 중간에 삽입하는 연산은 O(N)의 시간이 걸림니다.", + "만약 중복 없이 데이터의 존재 여부만 빠르게 확인해야 한다면, O(1) 성능을 내는 Set을 쓰는 것이 훨씬 유리하죠.", + "그리구 키-값 매핑이 필요할 때는 Dictionary를 쓰는데, 이 역시 해시 충돌이 발생하면 성능이 떨어질 수 있으니 키 타입의 Hashable 구현을 신경 써야 함니다.", + "또한 큰 용량의 파일 데이터를 읽고 쓸 때는 Data(contentsOf:)를 메인스래드에서 호출하는 실수를 피해야 함니다.", + "동기적으로 디스크에서 수십 메가바이트의 파일을 읽는 순간 화면이 완전히 멈춰버리는 현상이 발생하니까요.", + "이런 파일 입출력 작업은 반드시 DispatchGroup이나 Swift Concurrency의 Task를 통해성 백그라운드에서 실행해야 함니다.", + "그리구 화면 렌더링 측면에서 오프스크린 렌더링(Offscreen Rendering)을 유발하는 코도를 줄여야 함니다.", + "오프스크린 렌더링은 GPU가 프레임 버퍼 외에 별도의 메무리 영역에 그림을 그린 뒤 합성하는 작업이라 리소스를 많이 먹슴니다.", + "주로 코너 라운딩(cornerRadius)과 클리핑(clipsToBounds), 그리구 그림자(shadow) 효과를 무분별하게 중첩해서 적용할때 발생하죠.", + "그림자 효과를 줄 때는 shadowPath를 명시적으로 지정해서 GPU가 경로를 미리 계산할 수 있게 도와주면 성능이 크게 개선됨니다.", + "또한 뷰의 계층 구조가 너무 깊어지지 않도록 플랫한 레이아웃을 지향해야 함니다.", + "오토레이아웃 제약 조건이 복잡해질수록 뷰의 크기를 계산하는 레이아웃 엔진의 연산량이 기하급수적으로 증가하거든요.", + "특히 뷰가 스크롤되거나 애니메이션될때 이 레이아웃 계산이 매 프레임마다 일어나므로 병목의 원인이 되기 쉽습니다.", + "배터리 효율성에 대해성도 한 말씀 드리자면, 백그라운드에서의 불필요한 연산을 엄격하게 차단해야 함니다.", + "앱이 백그라운드로 전환되면 위치 추적이나 오디오 재생 같은 특별한 허가된 작업 외에는 모든 스레드를 정지시켜야 해요.", + "그렇지 않으면 백그라운드에서 타이머나 네트워크 루프가 계속 돌면서 사용자의 배터리를 무서운 속도로 소모하게 되죠.", + "기기 발열의 원인이 되는 CPU 과열 현상도 백그라운드 무한 루프나 비효율적인 동기화 코도에서 자주 비롯됨니다.", + "우리는 에너지를 절약하기 위해성 비효율적인 폴링(Polling) 방식 대신 푸시 알림(Push Notification)이나 웹소켓을 써야 함니다.", + "그리구 다크 모드를 지원하는 것도 물리적으로 디스플레이 패널의 전력 소비를 줄이는 데 긍정적인 영향을 미침니다.", + "특히 OLED 패널을 탑재한 최신 아이폰에서는 검은색 픽셀이 아예 꺼지기 때문에 배터리 절약 효과가 아주 뚜렷하죠.", + "이제 디버깅 도구 중에서 메무리 누수와 좀비 객체를 잡는 방법에 대해성 좀 더 깊이 들어가 볼게요.", + "좀비 객체(Zombie Object)란 이미 메무리에서 해제되었는데 어떤 포인터가 여전히 그 주소를 가리키고 있어서 발생하는 버그임니다.", + "이 상태에서 해제된 객체의 메서드를 호출하려고 하면 EXC_BAD_ACCESS 에러가 나면서 앱이 즉시 크래시되죠.", + "Xcode 스킴 설정에서 Enable Zombie Objects 옵션을 켜두면, 해제된 객체를 진짜 메무리에서 지우지 안고 좀비 상태로 유지함니다.", + "이를 통해성 해제된 객체에 잘못 접근하는 시점에 경고를 띄워주고 정확히 어떤 객체였는지 디버그 콘솔에 찍어줘서 쉽게 고칠 수 있어요.", + "다만 이 옵션을 켜두면 메무리가 해제되지 안고 계속 쌓이므로, 디버깅이 끝난 뒤에는 반드시 꺼주어야 함니다.", + "프로덕션 빌드에 이 옵션이 켜진 채로 나가면 앱의 메무리가 순식간에 고갈되어 강제 종료될 테니까요.", + "이어서 멀티스레딩 환경에서 데이터의 안정성을 지키는 스레드 세이프티(Thread Safety)에 대해성도 살펴봅시다.", + "여러 스레드가 동시에 하나의 변수나 객체에 접근해서 값을 쓰려고 하면 데이터 정합성이 깨지는 레이스 컨디션이 발생함니다.", + "이걸 방지하기 위해성 흔히 NSLock이나 Semaphore, 혹은 DispatchQueue의 barrier를 사용해서 동기화 처리를 해왔습니다.", + "하지만 동기화 잠금(Lock)을 잘못 쓰면 두 스레드가 서로의 작업이 끝나기만을 무한히 기다리는 데드락(Deadlock)에 빠질 수 있죠.", + "그래서 Swift 6에서는 언어 차원에서 데이터 레이스를 원천 차단하는 액터(Actor)와 Sendable 규칙을 도입한 것임니다.", + "액터는 자체적으로 직렬화 큐를 내장하고 있어서 외부에서 액터 내부의 상태를 변경할려면 반드시 await 키워드를 써야 함니다.", + "이를 통해성 컴파일 타임에 스레드 안전성을 완벽하게 보장받을 수 있고 개발자의 실수 가능성을 획기적으로 줄여줍니다.", + "물론 액터를 도입하면서 생기는 호출 오버헤드나 구조적 변경은 설계 단계에서 충분히 고려되어야 할 부분임니다.", + "오늘 나눈 성능 최적화 노하우들을 여러분의 개발 프로세스에 잘 녹여내시길 바람니다.", + "결국 완성도 높은 명품 앱은 화려한 기능보다 이런 보이지 않는 세부적인 최적화 작업들이 모여서 만들어지는 법이니까요." ] } #endif diff --git a/Data/Sources/Repositories/VoiceNotes/DefaultMLXGrammarRepository.swift b/Data/Sources/Repositories/VoiceNotes/DefaultMLXGrammarRepository.swift index 0c3bdf5d..8182972f 100644 --- a/Data/Sources/Repositories/VoiceNotes/DefaultMLXGrammarRepository.swift +++ b/Data/Sources/Repositories/VoiceNotes/DefaultMLXGrammarRepository.swift @@ -20,15 +20,48 @@ public struct DefaultMLXGrammarRepository: GrammarRepository { // MLX 모델 로드 let context: ModelContext = try await provider.loadModel() let container: ModelContainer = ModelContainer(context: context) - let session = ChatSession(container, instructions: Policy.sttCorrectionPrompt) + + // 배칭을 위한 System Prompt 적용 + let session = ChatSession(container, instructions: Policy.sttBatchCorrectionPrompt) + let sections = transcript.sections + let totalSections = sections.count + let batchSize = 5 var correctedSections: [TranscriptSection] = [] - for section in transcript.sections { + + var i = 0 + while i < totalSections { if Task.isCancelled { break } - let response = try await session.respond(to: Policy.correctionPrompt(text: section.text)) - let trimmed = response.trimmingCharacters(in: .whitespacesAndNewlines) - correctedSections.append(TranscriptSection(timestamp: section.timestamp, text: trimmed)) + + // 이번 배치에 포함할 섹션들 분할 (최대 5개씩) + let end = min(i + batchSize, totalSections) + let batchSections = Array(sections[i.. [String] { + var results = Array(repeating: "", count: batchSize) + let lines = response.components(separatedBy: .newlines) + + // 정규식 패턴: [1] 이나 1. 또는 1: 로 시작하는 패턴 매칭 + let pattern = #"^(?:\[?(\d+)\]?[\.:\s\-]*)\s*(.*)$"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + return originalTexts + } + + var currentIdx: Int? = nil + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { continue } + + let nsString = trimmed as NSString + let matches = regex.matches(in: trimmed, options: [], range: NSRange(location: 0, length: nsString.length)) + + if let match = matches.first, match.numberOfRanges >= 3 { + let indexStr = nsString.substring(with: match.range(at: 1)) + let textStr = nsString.substring(with: match.range(at: 2)) + + if let parsedIndex = Int(indexStr) { + let idx = parsedIndex - 1 + if idx >= 0 && idx < batchSize { + results[idx] = textStr.trimmingCharacters(in: .whitespacesAndNewlines) + currentIdx = idx + } + } + } else if let lastIdx = currentIdx { + // 이전 문장의 줄바꿈 연장선인 경우 + if !results[lastIdx].isEmpty { + results[lastIdx] += " " + trimmed + } else { + results[lastIdx] = trimmed + } + } + } + + // 만약 파싱에 실패하여 빈 값이 있는 경우 원본 텍스트로 채워줍니다. + for i in 0.. String { """ - Correct the grammar of the following text and polish it to sound natural. - Do not include any explanations, introduction, or additional text. Return ONLY the corrected text. + Correct the grammar of the following text and polish it to sound natural. + Do not include any explanations, introduction, or additional text. Return ONLY the corrected text. - Text: - \(text) - """ + Text: + \(text) + """ + } + + /// STT를 통해 전사된 여러 문장을 한 번에 교정하는 배치 프롬프트입니다. + static let sttBatchCorrectionPrompt: String = """ + You are a grammar correction assistant. + Your task is to correct the grammar, spelling, and punctuation of the provided numbered list of sentences. + Preserve the meaning and tone of each sentence. + Keep the original language of the input text. + Return the corrected sentences in the exact same numbered format (e.g., [1] Corrected sentence). + Do not include any explanations, introduction, or additional text. Return ONLY the numbered list of corrected sentences. + """ + + /// 교정할 문장 목록을 주입하는 사용자 배치 프롬프트 텍스트입니다. + static func batchCorrectionPrompt(texts: [String]) -> String { + var prompt = "Correct the grammar of the following sentences and polish them to sound natural.\n" + prompt += "You must return them in the exact same format: [number] Corrected sentence.\n\n" + for (index, text) in texts.enumerated() { + prompt += "[\(index + 1)] \(text)\n" + } + return prompt } } diff --git a/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel.swift b/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel.swift index 21e56a80..4f2cf1c4 100644 --- a/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel.swift +++ b/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel.swift @@ -30,8 +30,14 @@ public final class VoiceNoteViewModel { @ObservationIgnored private var voiceNoteObservationTask: Task? @ObservationIgnored + private var grammarProgressTask: Task? + @ObservationIgnored private var wasPlayingBeforeSeek = false public weak var coordinator: VoiceNoteCoordinatorDelegate? + + // MARK: - Grammar Progress + public private(set) var grammarProgress: (current: Int, total: Int)? + private var realTimeCorrectedSections: [TranscriptSection]? // MARK: - UseCases @@ -66,8 +72,8 @@ public final class VoiceNoteViewModel { observeVoiceNote() checkMLXSupport() - // 분석이 미완료 상태(.pending, .transcribed)인 경우 상세 화면 진입 시 자동으로 분석(STT/문법교정/요약)을 재개하도록 큐잉합니다. - if voiceNote.analysisState == .pending || voiceNote.analysisState == .transcribed { + // 분석이 미완료 상태인 경우 상세 화면 진입 시 자동으로 분석(STT/문법교정/요약)을 재개하도록 큐잉합니다. + if voiceNote.analysisState.isUnfinished { voiceNoteUseCase.enqueue(id: voiceNote.id) } } @@ -84,6 +90,8 @@ public final class VoiceNoteViewModel { playbackObservationTask = nil voiceNoteObservationTask?.cancel() voiceNoteObservationTask = nil + grammarProgressTask?.cancel() + grammarProgressTask = nil stop() } @@ -301,14 +309,59 @@ public final class VoiceNoteViewModel { let stream = try voiceNoteUseCase.observe(id: voiceNote.id) for await note in stream { let folderChanged = voiceNote.folderID != note.folderID - voiceNote = note + let stateChanged = voiceNote.analysisState != note.analysisState + + self.voiceNote = note if folderChanged { fetchFolderName() } + + if stateChanged { + if note.analysisState == .grammarChecking { + self.grammarProgress = nil + self.realTimeCorrectedSections = nil + } else if note.analysisState != .grammarChecking { + self.grammarProgress = nil + self.realTimeCorrectedSections = nil + } + } } } catch { errorMessage = error.localizedDescription } } } + + private func setupGrammarProgressObservation() { + grammarProgressTask?.cancel() + grammarProgressTask = Task { + let sequence = NotificationCenter.default.notifications(named: NSNotification.Name("GrammarCorrectionProgress")) + for await notification in sequence { + guard let userInfo = notification.userInfo, + let transcriptID = userInfo["transcriptID"] as? UUID, + let originalTranscriptID = voiceNote.transcript?.id, + transcriptID == originalTranscriptID else { continue } + + let current = userInfo["current"] as? Int ?? 0 + let total = userInfo["total"] as? Int ?? 0 + let sectionIndex = userInfo["sectionIndex"] as? Int ?? 0 + let correctedText = userInfo["correctedText"] as? String ?? "" + + await MainActor.run { + self.grammarProgress = (current, total) + + if self.realTimeCorrectedSections == nil { + self.realTimeCorrectedSections = self.voiceNote.transcript?.sections + } + if var sections = self.realTimeCorrectedSections, sectionIndex < sections.count { + sections[sectionIndex] = TranscriptSection( + timestamp: sections[sectionIndex].timestamp, + text: correctedText + ) + self.realTimeCorrectedSections = sections + } + } + } + } + } private func play() { do { @@ -405,6 +458,9 @@ public extension VoiceNoteViewModel { var scriptSections: [TranscriptSection] { if editingMode == .script { return editableScriptSections } + if voiceNote.analysisState == .grammarChecking, let realTime = realTimeCorrectedSections { + return realTime + } return voiceNote.transcript?.sections ?? [] } From fc7d94c418fee4a9b57dfe290c726fe631f89d91 Mon Sep 17 00:00:00 2001 From: Kim yonghae Date: Tue, 30 Jun 2026 14:01:43 +0900 Subject: [PATCH 18/21] =?UTF-8?q?refactor:=20=EB=AC=B8=EB=B2=95=20?= =?UTF-8?q?=EA=B5=90=EC=A0=95=20=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=AD=20=EC=B5=9C=EC=A0=81=ED=99=94=20=EB=B0=8F=20?= =?UTF-8?q?=EB=94=94=EB=B2=84=EA=B7=B8=20=EC=8B=9C=EB=93=9C=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=88=98=EC=A0=95=20-=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=AD=20=EC=9D=91=EB=8B=B5=20=ED=8C=8C=EC=8B=B1=20=EB=B0=8F?= =?UTF-8?q?=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EC=9B=90=EB=B3=B8=20?= =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=9C=A0=EC=A7=80?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20=EB=94=94=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=EC=8B=9C=20=EB=A9=94=EC=9D=B8=20=EC=8A=A4?= =?UTF-8?q?=EB=A0=88=EB=93=9C=20=EB=B3=91=EB=AA=A9=20=ED=95=B4=EA=B2=B0?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=B4=20=EC=9E=84=EC=8B=9C=20=EC=98=A4?= =?UTF-8?q?=EB=94=94=EC=98=A4=20=ED=8C=8C=EC=9D=BC=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EA=B8=B8=EC=9D=B4=20=EC=A0=9C=ED=95=9C=20(=EC=B5=9C=EB=8C=80?= =?UTF-8?q?=205=EC=B4=88)=20-'SeedContent'=EC=97=90=20'buildLongSections'?= =?UTF-8?q?=20=EB=B3=B5=EA=B5=AC=20=EB=B0=8F=20'grammarCheckTest'=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4=EB=B2=95=20=EC=98=A4=EB=A5=98=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=A0=81=EC=9A=A9=20-=20=EB=AC=B8?= =?UTF-8?q?=EB=B2=95=20=EA=B5=90=EC=A0=95=20=EC=A7=84=ED=96=89=EB=A5=A0?= =?UTF-8?q?=EC=9D=84=20AppLogger.info=EB=A1=9C=20=EC=8B=A4=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EB=A1=9C=EA=B9=85=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20-AppLogger=20=EB=A0=88=EB=B2=A8=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD:=20info=20->=20debug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Core/Sources/Logger/AppLogger.swift | 2 +- Domain/Sources/Entities/VoiceNote.swift | 9 ++++++++ .../VoiceNotes/VoiceNoteAnalysisService.swift | 12 ++++++++-- .../Recording/ToggleRecordingIntent.swift | 23 +++++++++++++++---- .../Recording/RecordingViewModel.swift | 2 ++ 5 files changed, 40 insertions(+), 8 deletions(-) diff --git a/Core/Sources/Logger/AppLogger.swift b/Core/Sources/Logger/AppLogger.swift index ffa8038e..8ccb2415 100644 --- a/Core/Sources/Logger/AppLogger.swift +++ b/Core/Sources/Logger/AppLogger.swift @@ -9,7 +9,7 @@ public enum AppLogger: AppLoggerProtocol, Sendable { #if DEBUG return .debug #else - return .info + return .warning #endif }() diff --git a/Domain/Sources/Entities/VoiceNote.swift b/Domain/Sources/Entities/VoiceNote.swift index 47f2e477..cf49f010 100644 --- a/Domain/Sources/Entities/VoiceNote.swift +++ b/Domain/Sources/Entities/VoiceNote.swift @@ -33,6 +33,15 @@ public enum AnalysisState: String, Sendable, Hashable { .failed } } + + public var isUnfinished: Bool { + switch self { + case .pending, .waiting, .transcribing, .transcribed, .summarizing, .regenerating, .grammarChecking, .grammarChecked: + return true + case .completed, .transcriptionFailed, .summarizationFailed, .grammarCheckFailed: + return false + } + } } public struct VoiceNote: Sendable, Identifiable, Hashable { diff --git a/Domain/Sources/Services/VoiceNotes/VoiceNoteAnalysisService.swift b/Domain/Sources/Services/VoiceNotes/VoiceNoteAnalysisService.swift index d6192aae..cf81012d 100644 --- a/Domain/Sources/Services/VoiceNotes/VoiceNoteAnalysisService.swift +++ b/Domain/Sources/Services/VoiceNotes/VoiceNoteAnalysisService.swift @@ -305,10 +305,18 @@ public final class DefaultVoiceNoteAnalysisService: VoiceNoteAnalysisService { private func startPipeline(for voiceNoteID: UUID, originalState: AnalysisState) { guard let voiceNote = fetch(voiceNoteID) else { return } switch originalState { - case .pending: + case .pending, .transcribing: startTranscription(for: voiceNote, previousState: .pending) - case .transcribed: + case .transcribed, .grammarChecking: startGrammarCheckAndSummarization(for: voiceNote, previousState: .transcribed) + case .grammarChecked, .summarizing: + startSummarization(for: voiceNote, previousState: .grammarChecked) + case .waiting: + if voiceNote.transcript == nil { + startTranscription(for: voiceNote, previousState: .pending) + } else { + startGrammarCheckAndSummarization(for: voiceNote, previousState: .transcribed) + } default: break } diff --git a/Presentation/Sources/Recording/ToggleRecordingIntent.swift b/Presentation/Sources/Recording/ToggleRecordingIntent.swift index a60a1910..a325f028 100644 --- a/Presentation/Sources/Recording/ToggleRecordingIntent.swift +++ b/Presentation/Sources/Recording/ToggleRecordingIntent.swift @@ -17,12 +17,25 @@ public struct ToggleRecordingIntent: LiveActivityIntent { return .result() } - let isPaused = activity.content.state.isPaused - AppLogger.info("isPaused : \(isPaused)") - if isPaused { - DarwinNotificationCenter.post(.resumeRecording) - } else { + let currentIsPaused = activity.content.state.isPaused + let newIsPaused = !currentIsPaused + + AppLogger.info("Widget Toggle - currentIsPaused: \(currentIsPaused) -> newIsPaused: \(newIsPaused)") + + // 1. Widget Extension에서 직접 Live Activity 상태를 먼저 업데이트하여 즉각적인 UI 반응을 확보합니다. + let updatedState = RecordingActivityAttributes.ContentState( + duration: activity.content.state.duration, + isPaused: newIsPaused, + amplitude: activity.content.state.amplitude + ) + let content = ActivityContent(state: updatedState, staleDate: nil) + await activity.update(content) + + // 2. 메인 앱에 상태 변경을 알려 실제 녹음 동작을 동기화합니다. + if newIsPaused { DarwinNotificationCenter.post(.pauseRecording) + } else { + DarwinNotificationCenter.post(.resumeRecording) } return .result() diff --git a/Presentation/Sources/ViewModel/Recording/RecordingViewModel.swift b/Presentation/Sources/ViewModel/Recording/RecordingViewModel.swift index 21278fb7..2eeb1e2c 100644 --- a/Presentation/Sources/ViewModel/Recording/RecordingViewModel.swift +++ b/Presentation/Sources/ViewModel/Recording/RecordingViewModel.swift @@ -80,6 +80,8 @@ public final class RecordingViewModel { ) { self.repository = repository self.voiceNoteUseCase = voiceNoteUseCase + // 앱이 백그라운드에서 재시작되거나 뷰모델이 다시 생성되었을 때 기존 활성화된 Live Activity 인스턴스 참조를 복원합니다. + self.activeActivity = Activity.activities.first subscribeToWidgetNotifications() } From fc10ba85d104e57a546f680d7604b9a17d107613 Mon Sep 17 00:00:00 2001 From: Kim yonghae Date: Tue, 30 Jun 2026 14:12:20 +0900 Subject: [PATCH 19/21] =?UTF-8?q?refactor:=20swiftforamt=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/RecordingActivityWidget.swift | 2 +- .../Extensions/DarwinNotificationCenter.swift | 1 - .../DefaultMLXGrammarRepository.swift | 47 ++++++++++--------- Domain/Sources/Entities/VoiceNote.swift | 3 +- Domain/Sources/Policy.swift | 10 ++-- .../ProgressView/ImmutableProgressView.swift | 3 +- .../Recording/ToggleRecordingIntent.swift | 16 +++---- .../Recording/RecordingViewModel.swift | 8 ++-- .../VoiceNote/VoiceNoteViewModel.swift | 18 +++---- Widget/Sources/Recording/AudioMeterView.swift | 9 ++-- .../Recording/RecordingActivityWidget.swift | 42 ++++++++++------- .../Tests/RecordingActivityWidgetTests.swift | 5 +- 12 files changed, 90 insertions(+), 74 deletions(-) diff --git a/App/WidgetExtension/Sources/RecordingActivityWidget.swift b/App/WidgetExtension/Sources/RecordingActivityWidget.swift index f638823d..23bb4c67 100644 --- a/App/WidgetExtension/Sources/RecordingActivityWidget.swift +++ b/App/WidgetExtension/Sources/RecordingActivityWidget.swift @@ -73,7 +73,7 @@ struct LockScreenView: View { private extension RecordingActivityAttributes.ContentState { var displayDuration: String { - let duration = Int(self.duration) + let duration = Int(duration) let hours = duration / 3600 let minutes = (duration % 3600) / 60 let seconds = duration % 60 diff --git a/Core/Sources/Extensions/DarwinNotificationCenter.swift b/Core/Sources/Extensions/DarwinNotificationCenter.swift index d91489fd..fd029c9b 100644 --- a/Core/Sources/Extensions/DarwinNotificationCenter.swift +++ b/Core/Sources/Extensions/DarwinNotificationCenter.swift @@ -5,7 +5,6 @@ import Foundation /// NotificationCenter.default는 동일 프로세스 내에서만 작동하므로, /// 별도 프로세스인 Widget Extension과는 Darwin Notification을 사용해야 합니다. public enum DarwinNotificationCenter { - /// Darwin Notification 이름 정의 public enum Name: String { case pauseRecording = "com.chagok.recording.pause" diff --git a/Data/Sources/Repositories/VoiceNotes/DefaultMLXGrammarRepository.swift b/Data/Sources/Repositories/VoiceNotes/DefaultMLXGrammarRepository.swift index 8182972f..6422c1c4 100644 --- a/Data/Sources/Repositories/VoiceNotes/DefaultMLXGrammarRepository.swift +++ b/Data/Sources/Repositories/VoiceNotes/DefaultMLXGrammarRepository.swift @@ -20,7 +20,7 @@ public struct DefaultMLXGrammarRepository: GrammarRepository { // MLX 모델 로드 let context: ModelContext = try await provider.loadModel() let container: ModelContainer = ModelContainer(context: context) - + // 배칭을 위한 System Prompt 적용 let session = ChatSession(container, instructions: Policy.sttBatchCorrectionPrompt) @@ -28,38 +28,41 @@ public struct DefaultMLXGrammarRepository: GrammarRepository { let totalSections = sections.count let batchSize = 5 var correctedSections: [TranscriptSection] = [] - + var i = 0 while i < totalSections { if Task.isCancelled { break } - + // 이번 배치에 포함할 섹션들 분할 (최대 5개씩) let end = min(i + batchSize, totalSections) - let batchSections = Array(sections[i.. [String] { var results = Array(repeating: "", count: batchSize) let lines = response.components(separatedBy: .newlines) - + // 정규식 패턴: [1] 이나 1. 또는 1: 로 시작하는 패턴 매칭 let pattern = #"^(?:\[?(\d+)\]?[\.:\s\-]*)\s*(.*)$"# guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return originalTexts } - + var currentIdx: Int? = nil - + for line in lines { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { continue } - + let nsString = trimmed as NSString let matches = regex.matches(in: trimmed, options: [], range: NSRange(location: 0, length: nsString.length)) - + if let match = matches.first, match.numberOfRanges >= 3 { let indexStr = nsString.substring(with: match.range(at: 1)) let textStr = nsString.substring(with: match.range(at: 2)) - + if let parsedIndex = Int(indexStr) { let idx = parsedIndex - 1 - if idx >= 0 && idx < batchSize { + if idx >= 0, idx < batchSize { results[idx] = textStr.trimmingCharacters(in: .whitespacesAndNewlines) currentIdx = idx } @@ -134,14 +137,14 @@ public struct DefaultMLXGrammarRepository: GrammarRepository { } } } - + // 만약 파싱에 실패하여 빈 값이 있는 경우 원본 텍스트로 채워줍니다. - for i in 0.. String { """ - Correct the grammar of the following text and polish it to sound natural. - Do not include any explanations, introduction, or additional text. Return ONLY the corrected text. + Correct the grammar of the following text and polish it to sound natural. + Do not include any explanations, introduction, or additional text. Return ONLY the corrected text. - Text: - \(text) - """ + Text: + \(text) + """ } /// STT를 통해 전사된 여러 문장을 한 번에 교정하는 배치 프롬프트입니다. diff --git a/Presentation/Sources/Component/Common/ProgressView/ImmutableProgressView.swift b/Presentation/Sources/Component/Common/ProgressView/ImmutableProgressView.swift index 685eb372..7c189c39 100644 --- a/Presentation/Sources/Component/Common/ProgressView/ImmutableProgressView.swift +++ b/Presentation/Sources/Component/Common/ProgressView/ImmutableProgressView.swift @@ -78,7 +78,8 @@ extension ImmutableProgressView { ) } - @objc private func handleWillEnterForeground() { + @objc + private func handleWillEnterForeground() { stopAnimation() startAnimation() } diff --git a/Presentation/Sources/Recording/ToggleRecordingIntent.swift b/Presentation/Sources/Recording/ToggleRecordingIntent.swift index a325f028..1ad38e55 100644 --- a/Presentation/Sources/Recording/ToggleRecordingIntent.swift +++ b/Presentation/Sources/Recording/ToggleRecordingIntent.swift @@ -1,14 +1,14 @@ import ActivityKit import AppIntents import Core -import Foundation import Domain +import Foundation public struct ToggleRecordingIntent: LiveActivityIntent { public static let title: LocalizedStringResource = "녹음 재생/일시정지 토글" - + public init() {} - + public func perform() async throws -> some IntentResult { // 현재 활성화된 Live Activity를 가져와 일시정지 상태에 맞는 Darwin Notification을 전송합니다. // NotificationCenter.default는 프로세스 내(in-process) 통신만 가능하므로, @@ -16,12 +16,12 @@ public struct ToggleRecordingIntent: LiveActivityIntent { guard let activity = Activity.activities.first else { return .result() } - + let currentIsPaused = activity.content.state.isPaused let newIsPaused = !currentIsPaused - + AppLogger.info("Widget Toggle - currentIsPaused: \(currentIsPaused) -> newIsPaused: \(newIsPaused)") - + // 1. Widget Extension에서 직접 Live Activity 상태를 먼저 업데이트하여 즉각적인 UI 반응을 확보합니다. let updatedState = RecordingActivityAttributes.ContentState( duration: activity.content.state.duration, @@ -30,14 +30,14 @@ public struct ToggleRecordingIntent: LiveActivityIntent { ) let content = ActivityContent(state: updatedState, staleDate: nil) await activity.update(content) - + // 2. 메인 앱에 상태 변경을 알려 실제 녹음 동작을 동기화합니다. if newIsPaused { DarwinNotificationCenter.post(.pauseRecording) } else { DarwinNotificationCenter.post(.resumeRecording) } - + return .result() } } diff --git a/Presentation/Sources/ViewModel/Recording/RecordingViewModel.swift b/Presentation/Sources/ViewModel/Recording/RecordingViewModel.swift index 2eeb1e2c..2a9e6d15 100644 --- a/Presentation/Sources/ViewModel/Recording/RecordingViewModel.swift +++ b/Presentation/Sources/ViewModel/Recording/RecordingViewModel.swift @@ -1,13 +1,14 @@ -import Domain import ActivityKit -import Foundation import Core +import Domain +import Foundation @MainActor public protocol RecordingCoordinating: AnyObject { func cancelRecording() func finishRecording(voiceNote: VoiceNote) } + extension Activity: @unchecked @retroactive Sendable {} @MainActor @@ -81,7 +82,7 @@ public final class RecordingViewModel { self.repository = repository self.voiceNoteUseCase = voiceNoteUseCase // 앱이 백그라운드에서 재시작되거나 뷰모델이 다시 생성되었을 때 기존 활성화된 Live Activity 인스턴스 참조를 복원합니다. - self.activeActivity = Activity.activities.first + activeActivity = Activity.activities.first subscribeToWidgetNotifications() } @@ -320,4 +321,3 @@ public final class RecordingViewModel { resumeRecording() } } - diff --git a/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel.swift b/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel.swift index 4f2cf1c4..c8c4a7b4 100644 --- a/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel.swift +++ b/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel.swift @@ -34,8 +34,9 @@ public final class VoiceNoteViewModel { @ObservationIgnored private var wasPlayingBeforeSeek = false public weak var coordinator: VoiceNoteCoordinatorDelegate? - + // MARK: - Grammar Progress + public private(set) var grammarProgress: (current: Int, total: Int)? private var realTimeCorrectedSections: [TranscriptSection]? @@ -310,10 +311,10 @@ public final class VoiceNoteViewModel { for await note in stream { let folderChanged = voiceNote.folderID != note.folderID let stateChanged = voiceNote.analysisState != note.analysisState - + self.voiceNote = note if folderChanged { fetchFolderName() } - + if stateChanged { if note.analysisState == .grammarChecking { self.grammarProgress = nil @@ -329,25 +330,26 @@ public final class VoiceNoteViewModel { } } } - + private func setupGrammarProgressObservation() { grammarProgressTask?.cancel() grammarProgressTask = Task { - let sequence = NotificationCenter.default.notifications(named: NSNotification.Name("GrammarCorrectionProgress")) + let sequence = NotificationCenter.default + .notifications(named: NSNotification.Name("GrammarCorrectionProgress")) for await notification in sequence { guard let userInfo = notification.userInfo, let transcriptID = userInfo["transcriptID"] as? UUID, let originalTranscriptID = voiceNote.transcript?.id, transcriptID == originalTranscriptID else { continue } - + let current = userInfo["current"] as? Int ?? 0 let total = userInfo["total"] as? Int ?? 0 let sectionIndex = userInfo["sectionIndex"] as? Int ?? 0 let correctedText = userInfo["correctedText"] as? String ?? "" - + await MainActor.run { self.grammarProgress = (current, total) - + if self.realTimeCorrectedSections == nil { self.realTimeCorrectedSections = self.voiceNote.transcript?.sections } diff --git a/Widget/Sources/Recording/AudioMeterView.swift b/Widget/Sources/Recording/AudioMeterView.swift index d9039e57..984854f9 100644 --- a/Widget/Sources/Recording/AudioMeterView.swift +++ b/Widget/Sources/Recording/AudioMeterView.swift @@ -36,7 +36,9 @@ public struct AudioMeterView: View { } } - private var barWidth: CGFloat { 3.0 } + private var barWidth: CGFloat { + 3.0 + } private func barHeight(for index: Int) -> CGFloat { let dotHeight: CGFloat = barWidth // 양 끝의 dot은 정사각형(원) @@ -68,7 +70,7 @@ public struct AudioMeterView: View { // 바마다 고유한 변동 패턴 (index 기반 의사 랜덤) let seed = Double(index * 13 + 5) let variation = sin(seed) * 0.5 + 0.5 // 0.0 ~ 1.0 - let jitter = 0.6 + variation * 0.8 // 0.6 ~ 1.4 + let jitter = 0.6 + variation * 0.8 // 0.6 ~ 1.4 // 최종 높이: boostedAmplitude × centerWeight × jitter로 유기적 변화 let targetHeight = dotHeight + (maxHeight - dotHeight) * boostedAmplitude * centerWeight * jitter @@ -85,7 +87,8 @@ public struct LiveAudioMeter: View { private let maxHeight: CGFloat private let minHeight: CGFloat - @State private var heights: [CGFloat] = [] + @State + private var heights: [CGFloat] = [] public init( level: CGFloat, diff --git a/Widget/Sources/Recording/RecordingActivityWidget.swift b/Widget/Sources/Recording/RecordingActivityWidget.swift index 0952a883..fe07c80b 100644 --- a/Widget/Sources/Recording/RecordingActivityWidget.swift +++ b/Widget/Sources/Recording/RecordingActivityWidget.swift @@ -1,7 +1,7 @@ import ActivityKit import AppIntents -import Domain import Core +import Domain import Presentation import SwiftUI import WidgetKit @@ -19,8 +19,8 @@ struct RecordingActivityWidget: Widget { Circle() .fill(LinearGradient( colors: [ - Color(red: 111/255, green: 83/255, blue: 253/255), - Color(red: 94/255, green: 92/255, blue: 230/255) + Color(red: 111 / 255, green: 83 / 255, blue: 253 / 255), + Color(red: 94 / 255, green: 92 / 255, blue: 230 / 255) ], startPoint: .topLeading, endPoint: .bottomTrailing @@ -33,12 +33,12 @@ struct RecordingActivityWidget: Widget { ) } .buttonStyle(.plain) - + VStack(alignment: .leading, spacing: 4) { Text(context.attributes.title) .font(.subheadline) .foregroundColor(.white) - + TimerView( duration: context.state.duration, isPaused: context.state.isPaused, @@ -49,12 +49,16 @@ struct RecordingActivityWidget: Widget { } } } - + // 다이내믹 아일랜드 확장형 (오른쪽) DynamicIslandExpandedRegion(.trailing) { - AudioMeterView(barCount: 17, amplitude: Double(context.state.amplitude), isPaused: context.state.isPaused) - .padding(.trailing, 8) - .frame(maxHeight: .infinity) + AudioMeterView( + barCount: 17, + amplitude: Double(context.state.amplitude), + isPaused: context.state.isPaused + ) + .padding(.trailing, 8) + .frame(maxHeight: .infinity) } } compactLeading: { // 다이내믹 아일랜드 최소형 (왼쪽 버튼) @@ -64,8 +68,8 @@ struct RecordingActivityWidget: Widget { .frame(width: 24, height: 24) .foregroundStyle(LinearGradient( colors: [ - Color(red: 111/255, green: 83/255, blue: 253/255), - Color(red: 94/255, green: 92/255, blue: 230/255) + Color(red: 111 / 255, green: 83 / 255, blue: 253 / 255), + Color(red: 94 / 255, green: 92 / 255, blue: 230 / 255) ], startPoint: .topLeading, endPoint: .bottomTrailing @@ -88,8 +92,8 @@ struct RecordingActivityWidget: Widget { Circle() .fill(LinearGradient( colors: [ - Color(red: 111/255, green: 83/255, blue: 253/255), - Color(red: 94/255, green: 92/255, blue: 230/255) + Color(red: 111 / 255, green: 83 / 255, blue: 253 / 255), + Color(red: 94 / 255, green: 92 / 255, blue: 230 / 255) ], startPoint: .topLeading, endPoint: .bottomTrailing @@ -116,8 +120,8 @@ struct LockScreenView: View { Circle() .fill(LinearGradient( colors: [ - Color(red: 111/255, green: 83/255, blue: 253/255), - Color(red: 94/255, green: 92/255, blue: 230/255) + Color(red: 111 / 255, green: 83 / 255, blue: 253 / 255), + Color(red: 94 / 255, green: 92 / 255, blue: 230 / 255) ], startPoint: .topLeading, endPoint: .bottomTrailing @@ -153,12 +157,16 @@ struct LockScreenView: View { .multilineTextAlignment(.trailing) .font(.title.bold()) Spacer() - AudioMeterView(barCount: 15, amplitude: Double(context.state.amplitude), isPaused: context.state.isPaused) + AudioMeterView( + barCount: 15, + amplitude: Double(context.state.amplitude), + isPaused: context.state.isPaused + ) } } .padding(.horizontal, 20) .padding(.vertical, 16) - .activityBackgroundTint(Color(red: 22/255, green: 21/255, blue: 27/255).opacity(0.95)) + .activityBackgroundTint(Color(red: 22 / 255, green: 21 / 255, blue: 27 / 255).opacity(0.95)) } } diff --git a/Widget/Tests/RecordingActivityWidgetTests.swift b/Widget/Tests/RecordingActivityWidgetTests.swift index a7ea1f81..541f7c24 100644 --- a/Widget/Tests/RecordingActivityWidgetTests.swift +++ b/Widget/Tests/RecordingActivityWidgetTests.swift @@ -1,11 +1,10 @@ +@testable import ChaGokWidget import Domain import XCTest -@testable import ChaGokWidget - final class RecordingActivityWidgetTests: XCTestCase { func test_라이브오디오미터는_설정된_높이_범위_내에서_높이를_반환한다() { - let heights = (0..<7).map { + let heights = (0 ..< 7).map { LiveAudioMeter.makeHeight( for: $0, barCount: 7, From 951d16e694430d65248248a33531b129b0061c4a Mon Sep 17 00:00:00 2001 From: Kim yonghae Date: Tue, 30 Jun 2026 14:59:31 +0900 Subject: [PATCH 20/21] =?UTF-8?q?ci:=20=EC=9C=84=EC=A0=AF=20=ED=83=80?= =?UTF-8?q?=EA=B2=9F=20=EC=BD=94=EB=93=9C=EC=82=AC=EC=9D=B8=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B9=8C?= =?UTF-8?q?=EB=93=9C=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95=20-=20Fastf?= =?UTF-8?q?ile=EC=97=90=20=EC=9C=84=EC=A0=AF=20Bundle=20ID=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EB=B0=B0=ED=8F=AC=20=EB=B9=8C=EB=93=9C?= =?UTF-8?q?=20=EC=8B=9C=20=ED=94=84=EB=A1=9C=EB=B9=84=EC=A0=80=EB=8B=9D=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=8C=8C=EC=9D=BC=20=EB=A7=A4=ED=95=91=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20-=20Widget/Project.swift=EC=97=90=20?= =?UTF-8?q?=EC=9C=84=EC=A0=AF=20=ED=83=80=EA=B2=9F=20=EC=88=98=EB=8F=99=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EC=82=AC=EC=9D=B8=20=EB=B0=8F=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=8C=8C=EC=9D=BC(match)=20=EC=84=B8=ED=8C=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20ChaGokWidgetTests=20=EC=BB=B4=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EB=8C=80=EC=83=81=EC=97=90=20=EB=88=84=EB=9D=BD?= =?UTF-8?q?=EB=90=9C=20=EC=9C=84=EC=A0=AF=20=EC=86=8C=EC=8A=A4=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EB=93=A4=EC=9D=84=20=ED=8F=AC=ED=95=A8=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EB=A7=81=EC=BB=A4=20=EC=97=90=EB=9F=AC=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Widget/Project.swift | 25 +++++++++++++++++++++++-- fastlane/Fastfile | 8 ++++++-- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/Widget/Project.swift b/Widget/Project.swift index 28f1074d..a6a25ef0 100644 --- a/Widget/Project.swift +++ b/Widget/Project.swift @@ -42,7 +42,24 @@ private let widgetTarget = Target.target( .project(target: "Core", path: "../Core"), .project(target: "Domain", path: "../Domain"), .project(target: "Presentation", path: "../Presentation") - ] + ], + settings: .settings( + base: [ + "CODE_SIGN_IDENTITY": "Apple Development", + "PROVISIONING_PROFILE_SPECIFIER": "match Development com.yongms.ChaGokChaGok.widget" + ], + configurations: [ + .debug(name: "Debug", settings: [ + "CODE_SIGN_IDENTITY": "Apple Development", + "PROVISIONING_PROFILE_SPECIFIER": "match Development com.yongms.ChaGokChaGok.widget" + ]), + .release(name: "Release", settings: [ + "CODE_SIGN_IDENTITY": "Apple Distribution", + "PROVISIONING_PROFILE_SPECIFIER": "match AppStore com.yongms.ChaGokChaGok.widget" + ]) + ], + defaultSettings: .recommended + ) ) private let widgetTestsTarget = Target.target( @@ -52,7 +69,11 @@ private let widgetTestsTarget = Target.target( bundleId: "\(bundleId).widgetTests", deploymentTargets: deploymentTargets, infoPlist: .default, - sources: ["Tests/**/*.swift", "Sources/RecordingActivityWidget.swift"], // @main이 선언된 WidgetBundle.swift는 컴파일에서 제외 + sources: [ + "Tests/**/*.swift", + "Sources/**/*.swift", + "!Sources/WidgetBundle.swift" + ], dependencies: [ .project(target: "Domain", path: "../Domain"), .project(target: "Presentation", path: "../Presentation") diff --git a/fastlane/Fastfile b/fastlane/Fastfile index dc87cde0..3d7281e8 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -18,7 +18,10 @@ default_platform(:ios) platform :ios do desc "코드 사이닝 인증서 및 프로비저닝 프로파일 동기화" lane :sync_certificates do |options| - match(type: options[:type] || "appstore") + match( + type: options[:type] || "appstore", + app_identifier: ["com.yongms.ChaGokChaGok", "com.yongms.ChaGokChaGok.widget"] + ) end def marketing_version @@ -70,7 +73,8 @@ platform :ios do teamID: "78QTJM9AD7", signingStyle: "manual", provisioningProfiles: { - "com.yongms.ChaGokChaGok" => "match AppStore com.yongms.ChaGokChaGok" + "com.yongms.ChaGokChaGok" => "match AppStore com.yongms.ChaGokChaGok", + "com.yongms.ChaGokChaGok.widget" => "match AppStore com.yongms.ChaGokChaGok.widget" } } ) From 3f7873c3ecd6f10fbb988ef5db5f30005455c966 Mon Sep 17 00:00:00 2001 From: Kim yonghae Date: Tue, 30 Jun 2026 15:27:51 +0900 Subject: [PATCH 21/21] =?UTF-8?q?refactor(widgetTest):=20testable=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Widget/Tests/RecordingActivityWidgetTests.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Widget/Tests/RecordingActivityWidgetTests.swift b/Widget/Tests/RecordingActivityWidgetTests.swift index 541f7c24..762241c2 100644 --- a/Widget/Tests/RecordingActivityWidgetTests.swift +++ b/Widget/Tests/RecordingActivityWidgetTests.swift @@ -1,4 +1,3 @@ -@testable import ChaGokWidget import Domain import XCTest