AudioNoteRecorder.swift 100 lignes · 3619 octets
import Foundation

/// Résultat d'un enregistrement terminé.
struct ResultatEnregistrement {
    let chemin: String
    let dureeMsec: Int64
}

/// Erreurs de AudioNoteRecorder.
enum AudioNoteRecorderErreur: Error, Equatable {
    case enregistrementDejaEnCours
}

/// Enregistre un fichier WAV (PCM 16 kHz mono 16 bits) via un MoteurAudioCapture.
///
/// Le moteur audio est injecté par le constructeur (MoteurAudioEngine par défaut),
/// ce qui permet d'utiliser un faux en test sans accès au matériel audio.
///
/// Durée maximale : 10 minutes avec arrêt automatique.
///
/// API :
/// - `demarrer(fichierCible:transcripteur:)` — lance l'enregistrement.
/// - `arreter()` — stoppe et retourne le résultat.
final class AudioNoteRecorder {
    private let moteur: MoteurAudioCapture
    private let dureeMaxSecondes: Double

    private var enregistrementEnCours = false
    private var ecriture: EcritureWav?
    private var fichierCourant: URL?
    private var transcripteurCourant: (any TranscripteurLocal)?
    private var debutEnregistrement: Date?
    private var timerArret: DispatchWorkItem?

    init(moteur: MoteurAudioCapture = MoteurAudioEngine(),
         dureeMaxSecondes: Double = 10 * 60) {
        self.moteur = moteur
        self.dureeMaxSecondes = dureeMaxSecondes
    }

    /// `true` si un enregistrement est en cours.
    var estEnCours: Bool { enregistrementEnCours }

    // MARK: - API publique

    /// Démarre l'enregistrement vers `fichierCible`.
    /// `transcripteur` est alimenté en streaming avec chaque buffer capturé.
    /// - Throws: `AudioNoteRecorderErreur.enregistrementDejaEnCours` si déjà actif.
    func demarrer(fichierCible: URL, transcripteur: (any TranscripteurLocal)? = nil) throws {
        guard !enregistrementEnCours else { throw AudioNoteRecorderErreur.enregistrementDejaEnCours }
        enregistrementEnCours = true
        debutEnregistrement = Date()
        fichierCourant = fichierCible
        transcripteurCourant = transcripteur
        ecriture = try EcritureWav(url: fichierCible)

        // Arrêt automatique après dureeMaxSecondes
        let arret = DispatchWorkItem { [weak self] in
            self?.arreter()
        }
        timerArret = arret
        DispatchQueue.main.asyncAfter(deadline: .now() + dureeMaxSecondes, execute: arret)

        do {
            try moteur.demarrer { [weak self] echantillons in
                self?.traiterEchantillons(echantillons)
            }
        } catch {
            // Rollback : le moteur n'a pas pu démarrer
            enregistrementEnCours = false
            timerArret?.cancel()
            timerArret = nil
            ecriture = nil
            throw error
        }
    }

    /// Stoppe l'enregistrement.
    /// - Returns: Résultat avec chemin et durée, `nil` si pas d'enregistrement actif.
    @discardableResult
    func arreter() -> ResultatEnregistrement? {
        guard enregistrementEnCours else { return nil }
        enregistrementEnCours = false
        timerArret?.cancel()
        timerArret = nil
        moteur.arreter()
        let dureeMsec = Int64((Date().timeIntervalSince(debutEnregistrement ?? Date())) * 1000)
        try? ecriture?.fermer()
        ecriture = nil
        guard let chemin = fichierCourant?.path else { return nil }
        return ResultatEnregistrement(chemin: chemin, dureeMsec: dureeMsec)
    }

    // MARK: - Traitement interne

    private func traiterEchantillons(_ echantillons: [Int16]) {
        ecriture?.ecrireEchantillons(echantillons)
        _ = transcripteurCourant?.accepterEchantillons(echantillons)
    }
}