Microphone recorder
nthState によるテンプレート
プレビュー
このテンプレートは、マイクのライブ入力を使います。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
}
}
}
}
タグ
ライセンス
商用かどうかにかかわらず、自分のアプリで自由に使えます。クレジット表記は不要です。ライセンスの全文は利用規約にあります。