EcritureWav.swift 100 lignes · 3711 octets
import Foundation

/// Écrit un fichier WAV PCM (16 kHz mono 16 bits par défaut).
///
/// Séparée d'AudioNoteRecorder pour être testable sans dépendance AVFoundation.
/// L'en-tête RIFF/fmt/data est réécrit à la clôture avec les tailles exactes.
///
/// Port direct de `EcritureWav.kt` (Android).
final class EcritureWav {
    private let handle: FileHandle
    private let frequence: Int
    private var octetsData: Int64 = 0

    init(url: URL, frequence: Int = 16_000) throws {
        self.frequence = frequence
        // Crée le fichier s'il n'existe pas encore
        if !FileManager.default.fileExists(atPath: url.path) {
            FileManager.default.createFile(atPath: url.path, contents: nil)
        }
        handle = try FileHandle(forWritingTo: url)
        // Réserve 44 octets pour l'en-tête ; sera réécrit lors de fermer()
        handle.write(Data(count: 44))
    }

    /// Écrit des échantillons PCM 16 bits signés (little-endian).
    func ecrireEchantillons(_ data: [Int16]) {
        guard !data.isEmpty else { return }
        var bytes = Data(capacity: data.count * 2)
        data.forEach { sample in
            var le = sample.littleEndian
            Swift.withUnsafeBytes(of: &le) { bytes.append(contentsOf: $0) }
        }
        handle.write(bytes)
        octetsData += Int64(data.count * 2)
    }

    /// Finalise l'en-tête WAV et ferme le fichier.
    /// - Returns: Nombre d'octets de données PCM écrits.
    /// - Throws: `EcritureWavErreur.tailleTropGrande` si les données dépassent Int32.max.
    @discardableResult
    func fermer() throws -> Int64 {
        // 10 min à 16 kHz 16 bits mono ≈ 18,3 Mo — largement sous Int32.max.
        // La garde protège contre un enregistrement pathologiquement long ou un bug de comptage.
        guard octetsData <= Int64(Int32.max) else {
            handle.closeFile()
            throw EcritureWavErreur.tailleTropGrande(octetsData)
        }
        let tailleData = Int32(octetsData)
        // tailleFichier = octets après "RIFF"+int32 = 4(WAVE)+8(fmt tag+size)+16(fmt)+8(data tag+size)+data
        let tailleFichier = tailleData + 36
        let canaux: Int16 = 1
        let bitsParEchantillon: Int16 = 16
        let byteRate = Int32(frequence) * Int32(canaux) * Int32(bitsParEchantillon) / 8
        let blockAlign = canaux * (bitsParEchantillon / 8)

        var entete = Data(capacity: 44)
        entete.appendAscii("RIFF")
        entete.appendInt32LE(tailleFichier)
        entete.appendAscii("WAVE")
        entete.appendAscii("fmt ")
        entete.appendInt32LE(16)                // taille du chunk fmt
        entete.appendInt16LE(1)                 // format PCM
        entete.appendInt16LE(canaux)
        entete.appendInt32LE(Int32(frequence))
        entete.appendInt32LE(byteRate)
        entete.appendInt16LE(blockAlign)
        entete.appendInt16LE(bitsParEchantillon)
        entete.appendAscii("data")
        entete.appendInt32LE(tailleData)

        handle.seek(toFileOffset: 0)
        handle.write(entete)
        handle.closeFile()
        return Int64(tailleData)
    }
}

// MARK: - Erreurs

enum EcritureWavErreur: Error, Equatable {
    case tailleTropGrande(Int64)
}

// MARK: - Helpers d'encodage little-endian

private extension Data {
    mutating func appendAscii(_ string: String) {
        append(contentsOf: string.utf8)
    }

    mutating func appendInt16LE(_ value: Int16) {
        var le = value.littleEndian
        Swift.withUnsafeBytes(of: &le) { append(contentsOf: $0) }
    }

    mutating func appendInt32LE(_ value: Int32) {
        var le = value.littleEndian
        Swift.withUnsafeBytes(of: &le) { append(contentsOf: $0) }
    }
}