Skip to content

AVAudioRecorderベースの長時間録音対応オーディオレコーダー実装 #87

@entaku0818

Description

@entaku0818

概要

現在のAVAudioEngineベースの実装から、長時間録音に最適化されたAVAudioRecorderベースの新しいオーディオレコーダーを実装する。

現在の実装の問題点

AVAudioEngineベースの課題

  1. メモリ使用量: 長時間録音でメモリが蓄積される
  2. CPU負荷: リアルタイム処理による継続的なCPU使用
  3. バッテリー消費: 音声認識処理による電力消費
  4. 安定性: 長時間録音での予期しない停止
  5. 録音時間初期化: 前回の録音時間が残る問題

AVAudioRecorderベースの利点

長時間録音への最適化

  • 低メモリ使用量: ファイルに直接書き込み、メモリ蓄積なし
  • 低CPU負荷: シンプルな録音処理のみ
  • 省電力: 不要な音声認識処理を排除
  • 高い安定性: Appleの最適化されたレコーダー実装
  • 自動フォーマット変換: 指定フォーマットへの自動変換

新しい実装設計

ファイル構成

VoiLog/Recording/
├── LongRecordingAudioClient.swift     # 新しいクライアントインターフェース
├── LongRecordingAudioRecorder.swift   # AVAudioRecorderベースの実装
├── RecordingConfiguration.swift       # 録音設定管理
└── RecordingState.swift              # 状態管理

インターフェース設計

struct LongRecordingAudioClient {
    var currentTime: @Sendable () async -> TimeInterval
    var requestRecordPermission: @Sendable () async -> Bool
    var startRecording: @Sendable (URL, RecordingConfiguration) async throws -> Bool
    var stopRecording: @Sendable () async -> Void
    var pauseRecording: @Sendable () async -> Void
    var resumeRecording: @Sendable () async -> Void
    var audioLevel: @Sendable () async -> Float
    var recordingState: @Sendable () async -> RecordingState
}

状態管理

enum RecordingState: Equatable {
    case idle
    case preparing
    case recording(startTime: Date)
    case paused(startTime: Date, pausedTime: Date, duration: TimeInterval)
    case completed(duration: TimeInterval)
    case error(RecordingError)
}

enum RecordingError: Error, Equatable {
    case permissionDenied
    case fileCreationFailed
    case audioSessionFailed
    case recordingFailed(String)
    case diskSpaceInsufficient
}

録音設定

struct RecordingConfiguration: Equatable {
    let fileFormat: AudioFileFormat
    let quality: AudioQuality
    let sampleRate: Double
    let numberOfChannels: Int
    
    static let `default` = RecordingConfiguration(
        fileFormat: .m4a,
        quality: .high,
        sampleRate: 44100,
        numberOfChannels: 1
    )
    
    enum AudioFileFormat: String, CaseIterable {
        case m4a = "m4a"
        case wav = "wav"
        case aiff = "aiff"
        case caf = "caf"
        
        var settings: [String: Any] {
            switch self {
            case .m4a:
                return [
                    AVFormatIDKey: kAudioFormatMPEG4AAC,
                    AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
                ]
            case .wav:
                return [
                    AVFormatIDKey: kAudioFormatLinearPCM,
                    AVLinearPCMBitDepthKey: 16,
                    AVLinearPCMIsFloatKey: false
                ]
            case .aiff:
                return [
                    AVFormatIDKey: kAudioFormatLinearPCM,
                    AVLinearPCMBitDepthKey: 16,
                    AVLinearPCMIsFloatKey: false
                ]
            case .caf:
                return [
                    AVFormatIDKey: kAudioFormatAppleLossless
                ]
            }
        }
    }
    
    enum AudioQuality: String, CaseIterable {
        case low, medium, high, max
        
        var avQuality: AVAudioQuality {
            switch self {
            case .low: return .low
            case .medium: return .medium
            case .high: return .high
            case .max: return .max
            }
        }
    }
}

実装詳細

AVAudioRecorderベースの実装

private actor LongRecordingAudioRecorder {
    private var audioRecorder: AVAudioRecorder?
    private var recordingTimer: Timer?
    private var currentTime: TimeInterval = 0
    private var startTime: Date?
    private var pausedDuration: TimeInterval = 0
    private var state: RecordingState = .idle
    
    // 長時間録音最適化設定
    func setupAudioSession() async throws {
        let session = AVAudioSession.sharedInstance()
        
        try session.setCategory(
            .playAndRecord,
            mode: .default,
            options: [
                .defaultToSpeaker,
                .allowBluetooth,
                .allowBluetoothA2DP,
                .mixWithOthers  // 他のアプリとの共存
            ]
        )
        
        // 長時間録音に最適化された設定
        try session.setPreferredIOBufferDuration(0.005) // 5ms バッファ
        try session.setActive(true)
    }
    
    func startRecording(url: URL, configuration: RecordingConfiguration) async throws -> Bool {
        // 前回の状態をリセット
        currentTime = 0
        pausedDuration = 0
        startTime = Date()
        state = .preparing
        
        try await setupAudioSession()
        
        // AVAudioRecorderの設定
        var settings = configuration.fileFormat.settings
        settings[AVSampleRateKey] = configuration.sampleRate
        settings[AVNumberOfChannelsKey] = configuration.numberOfChannels
        settings[AVEncoderAudioQualityKey] = configuration.quality.avQuality.rawValue
        
        audioRecorder = try AVAudioRecorder(url: url, settings: settings)
        audioRecorder?.delegate = self
        audioRecorder?.isMeteringEnabled = true
        
        // 録音開始
        guard audioRecorder?.record() == true else {
            state = .error(.recordingFailed("Failed to start recording"))
            return false
        }
        
        state = .recording(startTime: Date())
        startTimer()
        return true
    }
    
    func stopRecording() async {
        audioRecorder?.stop()
        stopTimer()
        
        let finalDuration = currentTime
        state = .completed(duration: finalDuration)
        
        // リソースクリーンアップ
        audioRecorder = nil
        try? AVAudioSession.sharedInstance().setActive(false)
    }
    
    func pauseRecording() async {
        guard case .recording(let startTime) = state else { return }
        
        audioRecorder?.pause()
        stopTimer()
        
        let pauseTime = Date()
        state = .paused(startTime: startTime, pausedTime: pauseTime, duration: currentTime)
    }
    
    func resumeRecording() async {
        guard case .paused(let startTime, _, let duration) = state else { return }
        
        pausedDuration += duration
        audioRecorder?.record()
        startTimer()
        
        state = .recording(startTime: startTime)
    }
    
    private func startTimer() {
        recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
            Task { await self?.updateCurrentTime() }
        }
    }
    
    private func stopTimer() {
        recordingTimer?.invalidate()
        recordingTimer = nil
    }
    
    private func updateCurrentTime() {
        guard let recorder = audioRecorder, recorder.isRecording else { return }
        currentTime = recorder.currentTime + pausedDuration
    }
    
    func getCurrentTime() -> TimeInterval {
        return currentTime
    }
    
    func getAudioLevel() -> Float {
        guard let recorder = audioRecorder, recorder.isRecording else { return 0.0 }
        recorder.updateMeters()
        return recorder.averagePower(forChannel: 0)
    }
    
    func getCurrentState() -> RecordingState {
        return state
    }
}

// AVAudioRecorderDelegate実装
extension LongRecordingAudioRecorder: AVAudioRecorderDelegate {
    func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) {
        if flag {
            state = .completed(duration: currentTime)
        } else {
            state = .error(.recordingFailed("Recording failed unexpectedly"))
        }
    }
    
    func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) {
        let errorMessage = error?.localizedDescription ?? "Unknown encoding error"
        state = .error(.recordingFailed(errorMessage))
    }
}

長時間録音最適化

メモリ管理

  • 直接ファイル書き込み: メモリ蓄積を防止
  • 適切なバッファサイズ: 5msの小さなバッファで安定性確保
  • リソースクリーンアップ: 録音終了時の確実なリソース解放

電力効率

  • 音声認識処理の除去: 不要な処理を排除
  • タイマー最適化: 0.1秒間隔での効率的な時間更新
  • セッション管理: 適切なAudioSession設定

安定性向上

  • デリゲートベースのエラーハンドリング
  • 状態管理の明確化
  • 中断・復帰処理の改善

タスク

Phase 1: 基本実装 (Week 1)

  • LongRecordingAudioClient構造体の作成
  • 基本的な録音/停止機能の実装
  • RecordingConfiguration設計
  • RecordingState列挙型の実装

Phase 2: 高度な機能 (Week 2)

  • 一時停止/再開機能の実装
  • リアルタイム音量測定
  • エラーハンドリングの充実
  • バックグラウンド録音対応

Phase 3: 最適化 (Week 3)

  • 長時間録音のメモリ最適化
  • バッテリー使用量の最適化
  • 中断処理の改善
  • パフォーマンステスト

Phase 4: 統合 (Week 4)

  • RecordingFeatureとの統合
  • 既存実装からの移行
  • テスト作成
  • ドキュメント作成

受け入れ基準

機能要件

  • 1時間以上の連続録音が可能
  • 録音時間が正しく初期化される(0から開始)
  • メモリ使用量が一定範囲内に収まる
  • 音声品質が劣化しない
  • 一時停止/再開が正確に動作する

非機能要件

  • CPU使用率が現在実装の50%以下
  • メモリ使用量が現在実装の30%以下
  • バッテリー消費が現在実装の40%以下
  • 連続3時間録音でクラッシュしない

パフォーマンステスト

  • 30分連続録音テスト
  • 1時間連続録音テスト
  • 3時間連続録音テスト
  • バックグラウンド録音テスト
  • 通話割り込みテスト

実装上の注意点

  1. 状態初期化: 録音開始時に前回の時間を確実にリセット
  2. メモリ管理: AVAudioRecorderインスタンスの適切な管理
  3. エラー処理: デリゲートメソッドでの包括的エラーハンドリング
  4. TCA統合: 既存のRecordingFeatureとの互換性維持

マイルストーン

Milestone 1: 基本録音機能 (2週間)

基本的な録音/停止/一時停止機能の完成

Milestone 2: 長時間録音対応 (1週間)

1時間以上の安定した録音機能の実現

Milestone 3: 統合完了 (1週間)

既存システムとの統合とテスト完了

関連Issue

Definition of Done

  • 3時間の連続録音が安定して動作する
  • メモリリークが発生しない
  • 録音時間が正しく初期化される
  • 既存機能との互換性が保たれている
  • 包括的なテストが完了している

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions