メインコンテンツへスキップ

Microphone recorder

nthState によるテンプレート

グラフ · AVAudioEngine · ノード 2 個

プレビュー

このテンプレートは、マイクのライブ入力を使います。Phonique で開いて「再生」を押すと録音が始まり、「一時停止」を押すとテイクが保存されます。停止した状態で開き、スピーカーへの接続はありません。

Microphone recorder.phonique をダウンロード

.phonique 書類はフォルダなので、ZIP アーカイブ(0.0 MB)として届きます。解凍して、そのフォルダを Phonique で開いてください。

このページから直接 Phonique で開く機能は、アプリがテンプレートを開けるようになってから追加します。それまでは、ダウンロードが入口です。

中身

ノード 2 個とケーブル 1 本。テンプレート自身の書類から読み取っています。

書き出される Swift

Phonique がこのテンプレートから書き出すもの:Apple のフレームワーク以外に何も必要としない Swift パッケージ MicrophoneRecorder です。README の「Use it」:

import MicrophoneRecorder

let graph = try MicrophoneRecorderGraph()
try graph.start()
// …
graph.stop()
MicrophoneRecorderGraph.swift の全文(239 行)
//
//  MicrophoneRecorderGraph.swift
//  Generated by Phonique from “Microphone recorder”.
//
//  Phonique writes this file afresh each time it exports the graph, so a
//  change made here is gone after the next export.
//

import AVFAudio
import Foundation
import Synchronization

/// “Microphone recorder”, as Phonique plays it: its nodes on an `AVAudioEngine`, connected as
/// the graph's cables connect them.
///
/// Make one, `start()` it to record without live monitoring, and `stop()` it; it records
/// again when it is started again. It is isolated to the main actor, where its engine is
/// driven: make it, start it and stop it there. Everything that can fail throws —
/// `Failure` for what this code checks itself, and AVFAudio's own errors — and nothing here
/// stops the app.
@MainActor
public final class MicrophoneRecorderGraph {
    /// Why the graph could not be built or started, besides the errors AVFAudio throws.
    public nonisolated enum Failure: Error, Hashable, Sendable {
        /// The device has no audio output to play through: the engine's output node has no
        /// format.
        case outputUnavailable
        /// The device has no audio input to listen to: the graph uses the microphone, and the
        /// engine's input node has no format.
        case inputUnavailable
        /// A node could not be made: its name in the graph.
        case nodeNotMade(String)
        /// One of the package's audio files could not be found or read: its name.
        case audioFileUnreadable(String)
    }

    /// The engine the graph plays on.
    public let engine: AVAudioEngine

    /// Recorder.
    private let recorder: RecordingTap

    /// Builds the graph on `engine`, ready to `start()`: makes each node, attaches it to the
    /// engine, sets its values and connects the cables, each on the bus Phonique connects it on.
    ///
    /// To render the graph offline, give it an engine already in manual rendering mode
    /// (`enableManualRenderingMode(_:format:maximumFrameCount:)`), which has to come before
    /// anything asks for the engine's output node.
    ///
    /// - Parameter engine: The engine to build on: a new one, unless you give your own.
    /// - Throws: `Failure`, or the error AVFAudio throws when it refuses a cable or a file.
    public init(engine: AVAudioEngine = AVAudioEngine()) throws {
        // Every cable carries one format: two channels of 32-bit float at the input's sample rate,
        // so nothing converts it on the way to the output; the input's own format comes out of the
        // input, and out of every effect it feeds.
        let output = engine.outputNode.outputFormat(forBus: 0)
        guard output.sampleRate > 0, output.channelCount > 0 else {
            throw Failure.outputUnavailable
        }
        let hardwareInput = engine.inputNode.inputFormat(forBus: 0)
        guard hardwareInput.sampleRate > 0, hardwareInput.channelCount > 0 else {
            throw Failure.inputUnavailable
        }
        let inputFormat = hardwareInput
        // The input's sample rate, so nothing between the input and the output converts it.
        guard let format = AVAudioFormat(standardFormatWithSampleRate: inputFormat.sampleRate, channels: 2) else {
            throw Failure.outputUnavailable
        }

        // Make each node.
        let recorder = RecordingTap(name: "Recorder")

        // Render recording-only branches without monitoring their audio.
        let _recordingMixer = AVAudioMixerNode()
        engine.attach(_recordingMixer)

        // Connect the cables, in the order they were made.
        try engine.connectNode(engine.inputNode, to: _recordingMixer, fromBus: 0, toBus: _recordingMixer.nextAvailableInputBus, format: inputFormat)
        try engine.connectNode(_recordingMixer, to: engine.outputNode, fromBus: 0, toBus: 0, format: format)
        // Apply the mute after the mixer is attached and connected.
        _recordingMixer.outputVolume = 0

        // What each node does once it is connected.
        // Recorder listens to engine.inputNode's output through a tap, which copies each buffer into
        // a slot made here and returns; a task writes the slots to the file.
        try recorder.prepare(format: engine.inputNode.outputFormat(forBus: 0))
        try engine.inputNode.installAudioTap(onBus: 0, bufferSize: recorder.tapBufferSize, format: nil, tapProvider: recorder.tapProvider())

        self.engine = engine
        self.recorder = recorder
    }

    /// Starts the engine, then each node that plays once the engine has started. If a node
    /// does not start, the graph stops again and the error is thrown.
    public func start() throws {
        try engine.start()
        do {
            try startNodes()
        } catch {
            stop()
            throw error
        }
    }

    /// Stops the graph: each node that has something to do first, then the engine. `start()`
    /// plays it again.
    public func stop() {
        recorder.stopWriting()
        engine.stop()
    }

    /// What each node does once the engine has started.
    private func startNodes() throws {
        try recorder.startWriting(named: "Recording", in: Self.recordingsFolder)
    }

    /// The file Recorder wrote the last time the graph stopped — `Recording.caf`, linear PCM at the
    /// graph's sample rate — or nil before it has. It is in the app's temporary directory: move or copy
    /// it where your app keeps it.
    public var recorderRecording: URL? {
        recorder.recording
    }

    /// Where the Recorders' files go: `MicrophoneRecorder Recordings` in the app's temporary
    /// directory, a new folder for each take.
    private static var recordingsFolder: URL {
        FileManager.default.temporaryDirectory.appending(path: "MicrophoneRecorder Recordings", directoryHint: .isDirectory)
    }

    /// Records what a tap hears into a Core Audio Format file, off the tap's thread. The tap copies
    /// each buffer into one of the slots made before the engine started and returns: nothing on
    /// the tap's thread allocates, takes a lock or waits. A task writes the slots to the file every
    /// 20 ms, and `stopWriting()` writes what is left and closes it. The slots hold two seconds; a
    /// buffer that finds no room is dropped.
    private nonisolated final class RecordingTap: @unchecked Sendable {
        /// The Recorder's name in the graph, for an error.
        private let name: String
        /// The bus's format, once the graph is connected.
        private var format: AVAudioFormat?
        /// The slots the tap copies into, 100 ms each.
        private var slots: [AVAudioPCMBuffer] = []
        /// How many buffers the tap has copied, and how many the writer has written.
        private let copied = Atomic<Int>(0)
        private let drained = Atomic<Int>(0)
        /// The file being written, under the writer's lock, which the tap never takes.
        private let lock = NSLock()
        private var file: AVAudioFile?
        private var writer: Task<Void, Never>?
        /// The last file written, once the graph has stopped.
        private(set) var recording: URL?

        init(name: String) {
            self.name = name
        }

        /// 100 ms a buffer: the shortest a tap delivers, so no slot is ever too small.
        var tapBufferSize: AVAudioFrameCount {
            AVAudioFrameCount((format?.sampleRate ?? 0) / 10)
        }

        /// Makes the slots for `format`, the format of the bus the tap is on.
        func prepare(format: AVAudioFormat) throws {
            self.format = format
            let frames = AVAudioFrameCount(format.sampleRate / 10)
            guard frames > 0 else {
                throw Failure.nodeNotMade(name)
            }
            slots = try (0 ..< 20).map { _ in
                guard let slot = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frames) else {
                    throw Failure.nodeNotMade(name)
                }
                return slot
            }
        }

        /// The tap: copies the buffer into the next slot, if one is free, and returns.
        func tapProvider() -> @Sendable (AVReadOnlyAudioPCMBuffer, AVAudioTime) -> Void {
            { [self] buffer, _ in
                let count = copied.load(ordering: .acquiring)
                guard count - drained.load(ordering: .acquiring) < slots.count else { return }
                let slot = slots[count % slots.count]
                slot.frameLength = AVAudioFrameCount(min(buffer.frameLength, Int(slot.frameCapacity)))
                buffer.withUnsafeAudioBufferList { source in
                    let from = UnsafeMutableAudioBufferListPointer(UnsafeMutablePointer(mutating: source))
                    let into = UnsafeMutableAudioBufferListPointer(slot.mutableAudioBufferList)
                    for channel in 0 ..< min(from.count, into.count) {
                        guard let data = from[channel].mData, let room = into[channel].mData else { continue }
                        memcpy(room, data, Int(min(from[channel].mDataByteSize, into[channel].mDataByteSize)))
                    }
                }
                copied.store(count + 1, ordering: .releasing)
            }
        }

        /// Opens `<name>.caf` in a new folder under `folder` and starts writing to it.
        func startWriting(named name: String, in folder: URL) throws {
            guard let format else { return }
            let url = folder.appending(path: UUID().uuidString, directoryHint: .isDirectory).appending(path: name + ".caf")
            try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
            let opened = try AVAudioFile(forWriting: url, settings: format.settings, commonFormat: .pcmFormatFloat32, interleaved: false)
            lock.withLock {
                file = opened
                recording = nil
            }
            writer = Task.detached(priority: .userInitiated) { [self] in
                while !Task.isCancelled {
                    try? await Task.sleep(for: .milliseconds(20))
                    drain()
                }
            }
        }

        /// Writes every slot the tap has filled since the last time, under the writer's lock.
        private func drain() {
            lock.withLock {
                guard let file else { return }
                var count = drained.load(ordering: .acquiring)
                while count < copied.load(ordering: .acquiring) {
                    try? file.write(from: slots[count % slots.count])
                    count += 1
                    drained.store(count, ordering: .releasing)
                }
            }
        }

        /// Writes what is left, closes the file and keeps its URL as `recording`.
        func stopWriting() {
            writer?.cancel()
            writer = nil
            drain()
            lock.withLock {
                file?.close()
                recording = file?.url
                file = nil
            }
        }
    }
}

タグ

  • グラフ · AVAudioEngine
  • ソース
  • 出力

ライセンス

商用かどうかにかかわらず、自分のアプリで自由に使えます。クレジット表記は不要です。ライセンスの全文は利用規約にあります。