iOS/App

Scrumdinger 개발 10 : Recording audio

태애니 2025. 4. 10. 18:55
728x90

 

SwiftUI의 상태 관리(state management) 
생명 주기(life cycle) 기능을 활용해 데이터 흐름(data flow) 을 구현해본다.

 

 

ScrumTimer 가 변경됨에 따라 MeetingView 에서 각 View 들에게 어떻게 처리하는지를 알아본다.

 

 

보면 ScrumTimer @Observable의 변동에 대한 알림은 MeetingView 만 받는다.

MeetingView 에서 @State 가 변경 됨에 따라 각 뷰들을 자동으로 변화되기 때문이다.

 

 

 

그리고 SpeechRecognizer 는 마이크에 접근해 오디오를 캡처한 후, 들리는 음성을 텍스트로 작성한다.

이 또한 @Observable 매크로를 적용한다.

@State를 사용하면 회의 뷰 안에서 음성 인식기의 단일 인스턴스를 생성할 수 있고, 앱의 수명 동안 해당 인스턴스를 계속해서 공유할 수 있다.

 

 

 

 

 

ScrumTimer 모델과

SpeechRecognizer 는 모두 observable 하지만 

차이가 있다.

 

ScrumTimer 는 자주 업데이트 된다. 하지만 이 데이터들은 MeetingView 자체에 의존되어있지 않는다.

 

SpeechRecognizer MeetingView 에서 객체를 생성하긴 하지만 변하는 모든 정보를 업데이트 하는 아니라, “최종 넘겨주면 된다.

 

 

 

그럼 MeetingView 나 Appear 될 때  실행해야하는 건 뭘까?

  1. 만약 음성인식 중이였다면 이를 초기화한다.
  2. 그리고 다시 STT 텍스트 변환을 시작한다.

 

Disappear 될 때는?

  1. stopTranscribing() 으로 텍스트 변환을 멈추고, 기록 작성을 중단한다.
  2. 해당 기록들을 받아서 최종적으로 회의 기록을 생성한다.

 

 

Transcribing speech to text 

 

 

 

 

 

제공해주는 TranscriptionKit 도 import 시켜준다

 

 

 

SpeechRecognizer 에서 권한 요청 전 처리

 

 

권한이 허용되지 않았을 경우 throw 를 던진다.

 

 

 

Integrate speech recognition

MeetingRoom 에 recording -> transcription 기능을 추가한다.

 

앱의 life cycle 안에서 기능이 구현되도록 해야한다.

 

 

MeetingRoom 에서는 시작 / 종료여부를 체크하고 @State 인 isRecording 의 값을 변경시켜 전파시킨다.

 

import TranscriptionKit


// 중략

@State var speechRecognizer = SpeechRecognizer()
@State private var isRecording = false //녹음여부



// 


private func startScrum() {
        // scrumTimer 를 reset 시킨 뒤, 시작한다
        // attendees 들의 name 을 맵핑하여 리스트로 넘긴다.
        scrumTimer.reset(lengthInMinutes: scrum.lengthInMinutes, attendeeNames: scrum.attendees.map { $0.name })
        scrumTimer.speakerChangedAction = {
            player.seek(to: .zero)
            player.play()
        }
        
        // speechRecognizer 를 리셋하고, 시작한다.
        speechRecognizer.resetTranscript()
        speechRecognizer.startTranscribing()
        
        isRecording = true
        
        // scrumTimer 공부 좀 더 해보자
        scrumTimer.startScrum()
    }
    
    private func endScrum() throws {
        scrumTimer.stopScrum()
        // view 가 사라지면 멈춘다.
        
        // speechRecognizer 를 멈춘다
        speechRecognizer.stopTranscribing()
        isRecording = false
        
        // history 를 작성하여 저장한다.
        let newHistory = History(attendees: scrum.attendees)
        scrum.history.insert(newHistory, at: 0)
        
        try? context.save() // swiftData
    }

 

 

struct MeetingTimerView: View {
    // TimerView 를 그리기 위해서는 Speaker 배열 정보만 MeetingView 에서 넘겨주면 된다.
    let speakers: [ScrumTimer.Speaker]
    let theme: Theme
    
    let isRecording: Bool // MeetingView 에서 넘기는 값이다
    
    
    // 중략
    }

 

isRecording 을 받을 수 있도록 값을 변경해주고, Preview 에서도 값을 던진다.

 

 

 

MeetingRoom 에서 MeetingTimerView 를 부를 때도 isRecording 값을 추가한다.

MeetingTimerView(speakers: scrumTimer.speakers, theme: scrum.theme,  isRecording: isRecording)

 

그리고, isRecording 상태에 따라 아이콘을 다르게 보이게 한다.

 Image(systemName: isRecording ? "mic" : "mic.slash")
                        .font(.title)
                        .padding(.top)
                        .accessibilityLabel(isRecording ? "with transcription" : "without transcription")

 

 

 

 

Create a history view 

import Foundation
import SwiftData

@Model
class History: Identifiable {
    var id: UUID
    var date: Date
    var attendees: [Attendee]
    
    var dailyScrum: DailyScrum? // 1:N 관계 작업
    
    var transcript: String?
    
    
    init(id: UUID = UUID(), date: Date = Date(), attendees: [Attendee],  transcript: String? = nil ){
        self.id = id
        self.date = date
        self.attendees = attendees
        self.transcript = transcript
    }
}

transcript 를 추가한다.

 

 

그리고 transcript 를 볼 수 있는 화면인 HistoryView 를 구현한다.

 

 

 

ListFormatter.localizedString  배열에 있는 문자열들을 자연스럽고 읽기 좋은 방식으로 연결해준다.

커스터마이징을 딱히 할 수 없기 때문에 이런식으로 쓰면 비슷하게 코드를 구현할 수 있다.

 

func joinWithCustomConjunction(_ items: [String], conjunction: String) -> String {
    switch items.count {
    case 0:
        return ""
    case 1:
        return items[0]
    case 2:
        return "\(items[0]) \(conjunction) \(items[1])"
    default:
        let allExceptLast = items.dropLast().joined(separator: ", ")
        return "\(allExceptLast), \(conjunction) \(items.last!)"
    }
}

let names = ["태니", "탱구", "태애니"]
let result = joinWithCustomConjunction(names, conjunction: "또는")
// "태니, 탱구, 또는 태애니"

 

 

 

//
//  HistoryView.swift
//  Scrumdinger
//
//  Created by taeni on 4/8/25.
//
import SwiftUI

struct HistoryView: View {
    let history: History
    
    var body: some View {
        ScrollView {
            VStack (alignment: .leading) {
                Divider()
                    .padding(.bottom)
                Text("참석자")
                    .font(.headline)
                Text(history.attendeeString)
                
                if let transcript = history.transcript {
                    Text("Transcript")
                        .font(.headline)
                        .padding(.top)
                    Text(transcript)
                }
            }
        }
        .navigationTitle(Text(history.date, style: .date))
        .padding()
    }
}

extension History {
    var attendeeString: String {
        ListFormatter.localizedString(byJoining: attendees.map { $0.name })
    }
}

#Preview {
    let history = History(attendees: [
        Attendee(name: "Taeni"),
        Attendee(name: "MarTaeni"),
        Attendee(name: "GarTaeni")
    ],transcript: "아.. 오늘 회의 몇시까지해요?")
    
    HistoryView(history: history)
}

 ["A", "B", "C"] 같은 문자열 배열을 넣어주면 "A, B, and C"처럼 연결된 문자열로 반환해준다.


한국어로 설정되어 있으면 "A, B, 그리고 C"

로 나온다.

 

 

/*
 See LICENSE folder for this sample’s licensing information.
 */

import SwiftUI

struct HistoryView: View {
    let history: History

    var body: some View {
        ScrollView {
            VStack(alignment: .leading) {
                Divider()
                    .padding(.bottom)
                Text("Attendees")
                    .font(.headline)
                Text(history.attendeeString)
                if let transcript = history.transcript {
                    Text("Transcript")
                        .font(.headline)
                        .padding(.top)
                    Text(transcript)
                }
            }
        }
        .navigationTitle(Text(history.date, style: .date))
        .padding()
    }
}

extension History {
    var attendeeString: String {
        ListFormatter.localizedString(byJoining: attendees.map { $0.name })
    }
}

#Preview {
    let history = History(attendees: [
        Attendee(name: "Jon"),
        Attendee(name: "Darla"),
        Attendee(name: "Luis")
    ],
                          transcript: "Darla, would you like to start today? Sure, yesterday I reviewed Luis' PR and met with the design team to finalize the UI...")

    HistoryView(history: history)
}

 

 

HistoryView 까지 작업 완

 

Scrumdinger 앱이 끝났다. (?) 뭐지 벌써?

더 길줄알았는데 뒤에는 다른 개발관련 문서들이 있었다.

 

1/3 인 줄 알았는데... 아니였넴..

 

 

아무튼 하나의 앱 서비스 구현이 완료됐다.

전체적인 코드 개발 방식을 확인해보고 느낀거는

리팩토링 할때 진짜 정신이 없다는 것... 역시 초반에 잘 잡아놔야함


챌린지2가 이제 시작되었다. 열심히 해봐야지. 할 수 있따.

 

728x90