ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 구조 개선 2 - 비동기스트림
    JDTuner/개발기록 2026. 3. 10. 20:55

    현재 구조 및 문제점

     

    현재 구조는 튜너쪽에서 계속 오디오 입력을 받아 결과를 계산하고

    뷰쪽에서 Timer를 통해 0.1초마다 Wrapper를 통해 튜너의 결과를 사용하는 방식이다.

    private let timer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
    
    // ...
    
    .onReceive(timer) { _ in
      guard let wrapperResult = wrapper.getTunerResult() else { return }
      result = wrapperResult
    }

     

    튜너의 기능을 본격적으로 사용하고 있든 말든

    뷰쪽에서는 Timer로 계속 호출해 낭비가 생긴다고 판단해

    튜너에서 값이 실제로 업데이트 될때마다 뷰를 업데이트 하는 방식으로 구조를 변경하려고 한다.

     

    구조 후보

    1. 델리게이트

    iOS 앱에서 만들때 객체간 데이터 교환시 자주 사용되는 패턴이다.

    하지만 뷰 하나밖에 없는 프로젝트에 비해 프로토콜 및 인터페이스 선언 등 불필요하게 볼륨이 커질거 같아 패스

    2. 콜백

    델리게이트를 사용하지 않는다면 클로저를 자주 사용하니 떠올릴 수 있는 방식이지만

    콜백 지옥에서 탈출하고 싶고 약한 참조 등 메모리 관리가 까다로울거 같아서 패스

    3. 비동기 스트림

    SwiftUI도 사용하고 있고 modern concurrency를 사용하는게 성능상으로도 좋고

    메모리도 안정적일거 같아서 채택

     

    작업 과정

    1. 튜너쪽 c++ 콜백 생성

    std::function<void(TunerResult)> onResultReady;

     

    헤더쪽에 Wrapper로 데이터를 넘긴 콜백 함수를 만든다.

    형태는 std::function<반환타입(파라미터타입)> 변수명 이다.

     

        this->result = result; 
        collector.clear(); 
         
        // 데이터 처리가 완료되면 등록된 콜백 실행 
        if (onResultReady) { 
          onResultReady(result); 
        }

     

    튜너의 구현부쪽에는 맨 마지막에 해당 콜백 함수를 실행시키는 코드를 추가한다.

     

    2. Wrapper쪽 콜백 생성

    @property (nonatomic, copy) void (^onResultUpdate)(WrapperResult *result);

     

    헤더쪽에 Swift쪽으로 값을 넘겨줄 콜백을 만든다.

    void (^이름)(매개변수) 형태는 Objective-C의 고유 문법이며, Swift에서는 클로저로 인식한다.

        
        // init....
        __weak typeof(self) weakSelf = self;
        engine->onResultReady = [weakSelf](TunerResult tunerResult) {
          [weakSelf getTunerResult:tunerResult];
        };
      }
      return self;
    }
    
    - (void)getTunerResult: (TunerResult) tunerResult {
      WrapperResult *result = [WrapperResult new];
      result.frequency = tunerResult.frequency;
      
      float clampedCents = fmaxf(-50.0f, fminf(50.0f, tunerResult.cents));
      result.cents = clampedCents;
      result.noteName = [NSString stringWithUTF8String:tunerResult.noteName.c_str()];
      result.isMatched = std::abs(clampedCents) <= _centsLimit;
      
      // 튜너로 값을 받아 뷰로 넘긴다
      if (_onResultUpdate) {
        dispatch_async(dispatch_get_main_queue(), ^{
          self->_onResultUpdate(result);
        });
      }

     

    구현부쪽에서는 튜너 결과값을 정리해 콜백을 실행시키는 코드를 추가한다.

     

    3. Wrapper의 AsyncStream화

    extension JDTunerWrapper {
      var resultsStream: AsyncStream<WrapperResult> {
        AsyncStream { continuation in
          self.onResultUpdate = { result in
            guard let result else { return }
            continuation.yield(result)
          }
          continuation.onTermination = { @Sendable _ in
            self.onResultUpdate = nil
          }
        }
      }
    }

     

    Swift 파일을 만들고 Wrapper를 extension해 AsyncStream을 만든다.

    단순히 WrapperResult값을 받아서 continuation으로 yield하는 코드다.

    뷰쪽에서 이걸 받아서 상태값을 업데이트 해주려고 한다.

     

    4.  뷰에서 task 사용

        .task {
          // wrapper의 asyncstream 사용
          for await newResult in wrapper.resultsStream {
            self.result = newResult
          }

     

    onReceive 코드를 삭제하고 task 코드로 수정해 비동기 스트림을 사용한다.

     

    결과 및 추가 개선 방향

    튜너의 주파수 및 노트 검출 성능에는 영향이 없지만

    프로젝트의 구조를 개선해 Timer의 부정확한 타이밍 또는 불필요한 호출을 제거하고

    진정한 결과값의 변화를 사용하고 표시하게 됐다.

     

    더 확실한 타이밍에 뷰를 업데이트 하기 위해서

     

    1. 일정 입력 볼륨값일때만 튜너 작동하기

    2. 기존 결과값과 다른 경우에만 표시하게 하기

     

    와 같은 작업도 필요해보인다. 이건 다음 포스트에...

     

    'JDTuner > 개발기록' 카테고리의 다른 글

    정확도 개선 2 - 1차 IIR 필터  (0) 2026.03.11
    정확도 개선 1 - Parabolic Interpolation  (0) 2026.03.11
    구조 개선 1 - 데이터 관리 + 테스트 영상  (0) 2026.03.09
    튜너 UI 구현  (0) 2026.03.08
    Cents 계산  (0) 2026.03.04
Designed by Tistory.