Microphone recorder
Template by nthState
Preview
This template uses live microphone input. Open it in Phonique and press Play to record, then Pause to keep the take. It opens stopped and has no Speakers connection.
A .phonique document is a folder, so it comes as a ZIP archive, 0.0 MB. Unzip it and open the folder in Phonique.
Opening a template straight from this page waits until the app can open one; until then the download is the way in.
What’s inside
2 nodes and 1 cable, read from the template’s own document.
Microphone
Microphone · Sources
Recorder
Recorder · Outputs
The Swift
What Phonique exports from this template: a Swift package, MicrophoneRecorder, that needs nothing beyond Apple’s frameworks. Its README’s “Use it”:
import MicrophoneRecorder
let graph = try MicrophoneRecorderGraph()
try graph.start()
// …
graph.stop()The whole of MicrophoneRecorderGraph.swift, 239 lines
//
// 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
}
}
}
}
Tags
Licence
Free to use in your own apps, commercial or not, with no credit needed. The terms of use carry the licence in full.