MoteurAudioCapture.swift 94 lignes · 3504 octets
import AVFoundation

/// Abstraction du moteur d'enregistrement audio.
///
/// Isole AVAudioEngine pour rendre AudioNoteRecorder testable sans simulateur audio.
/// L'implémentation réelle est MoteurAudioEngine ; un faux est fourni dans les tests.
protocol MoteurAudioCapture: AnyObject {
    /// Démarre la capture.
    /// Le callback reçoit les échantillons PCM Int16 à 16 kHz mono déjà convertis.
    /// - Throws: si le moteur ne peut pas démarrer (permission refusée, matériel absent).
    func demarrer(onEchantillons: @escaping ([Int16]) -> Void) throws

    /// Arrête la capture et libère les ressources.
    func arreter()
}

// MARK: - Implémentation réelle

/// Implémentation de MoteurAudioCapture basée sur AVAudioEngine.
///
/// Convertit automatiquement le format natif du matériel en PCM 16 kHz mono Int16
/// via AVAudioConverter si nécessaire.
final class MoteurAudioEngine: MoteurAudioCapture {
    private let engine = AVAudioEngine()
    private let formatCible: AVAudioFormat

    init() {
        formatCible = AVAudioFormat(
            commonFormat: .pcmFormatInt16,
            sampleRate: 16_000,
            channels: 1,
            interleaved: true
        )!
    }

    func demarrer(onEchantillons: @escaping ([Int16]) -> Void) throws {
        let inputNode = engine.inputNode
        let formatEntree = inputNode.outputFormat(forBus: 0)

        guard let convertisseur = AVAudioConverter(from: formatEntree, to: formatCible) else {
            throw MoteurAudioErreur.conversionImpossible
        }

        let tailleBuffer: AVAudioFrameCount = 1_024
        inputNode.installTap(onBus: 0, bufferSize: tailleBuffer, format: formatEntree) { [weak self] buffer, _ in
            guard let self else { return }
            self.convertirEtEnvoyer(buffer: buffer,
                                    formatEntree: formatEntree,
                                    convertisseur: convertisseur,
                                    onEchantillons: onEchantillons)
        }
        try engine.start()
    }

    func arreter() {
        engine.inputNode.removeTap(onBus: 0)
        engine.stop()
    }

    // MARK: - Conversion PCM

    private func convertirEtEnvoyer(buffer: AVAudioPCMBuffer,
                                    formatEntree: AVAudioFormat,
                                    convertisseur: AVAudioConverter,
                                    onEchantillons: @escaping ([Int16]) -> Void) {
        let frameCount = AVAudioFrameCount(
            ceil(Double(buffer.frameLength) * formatCible.sampleRate / formatEntree.sampleRate)
        )
        guard let sortie = AVAudioPCMBuffer(pcmFormat: formatCible, frameCapacity: frameCount) else { return }

        var erreurConversion: NSError?
        var bufferFourni = false
        let status = convertisseur.convert(to: sortie, error: &erreurConversion) { _, outStatus in
            if bufferFourni {
                outStatus.pointee = .noDataNow
                return nil
            }
            bufferFourni = true
            outStatus.pointee = .haveData
            return buffer
        }
        guard status != .error, let ptr = sortie.int16ChannelData else { return }
        let count = Int(sortie.frameLength)
        guard count > 0 else { return }
        let echantillons = Array(UnsafeBufferPointer(start: ptr[0], count: count))
        onEchantillons(echantillons)
    }
}

// MARK: - Erreurs

enum MoteurAudioErreur: Error {
    case conversionImpossible
}