Half time
Template by nthState
Preview
The app rendered this preview offline from the template’s own graph.
A .phonique document is a folder, so it comes as a ZIP archive, 0.4 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
5 nodes and 4 cables, read from the template’s own document.
Groove
File Player · Sources
Varispeed
Varispeed · Effects
Half time
Trigger · Control
Room
Reverb · Effects
Speakers
Speakers · Outputs
The Swift
What Phonique exports from this template: a Swift package, HalfTime, that needs nothing beyond Apple’s frameworks. Its README’s “Use it”:
import HalfTime
let graph = try HalfTimeGraph()
try graph.start()
// …
graph.stop()The whole of HalfTimeGraph.swift, 323 lines
//
// HalfTimeGraph.swift
// Generated by Phonique from “Half time”.
//
// 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
/// “Half time”, as Phonique plays it: its nodes on an `AVAudioEngine`, connected as
/// the graph's cables connect them.
///
/// Make one, `start()` it to play through the device's output, and `stop()` it; it plays
/// 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 HalfTimeGraph {
/// 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 graph's Triggers: `press(_:)` holds one's button down, and `release(_:)` lets it go.
public nonisolated enum Trigger: Hashable, Sendable {
/// Trigger “Half time”.
case halfTime
}
/// The engine the graph plays on.
public let engine: AVAudioEngine
/// The render time of the control nodes' last step, in frames at the output's sample rate;
/// nil before their first.
private var lastStepTime: AVAudioFramePosition?
/// The render time the first step after a start saw, which the seconds count from: the
/// output's sample time need not start at zero.
private var stepOrigin: AVAudioFramePosition?
/// The render time of the control nodes' last step, in seconds since the graph started,
/// which a Timed Trigger works its presses out from; zero at the first.
private var renderTime = 0.0
/// Steps the control nodes while the engine runs.
private var stepper: Task<Void, Never>?
/// File Player “Groove”.
private let groove: AVAudioPlayerNode
/// Varispeed.
private let varispeed: AVAudioUnitVarispeed
/// Reverb “Room”.
private let room: AVAudioUnitReverb
/// 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 output's sample rate.
let output = engine.outputNode.outputFormat(forBus: 0)
guard output.sampleRate > 0, output.channelCount > 0,
let format = AVAudioFormat(standardFormatWithSampleRate: output.sampleRate, channels: 2)
else {
throw Failure.outputUnavailable
}
// Make each node.
let groove = AVAudioPlayerNode()
groove.sourceMode = .pointSource
groove.renderingAlgorithm = .HRTFHQ
let varispeed = AVAudioUnitVarispeed()
let room = AVAudioUnitReverb()
// Attach each to the engine.
engine.attach(groove)
engine.attach(varispeed)
engine.attach(room)
// Set each node's values.
groove.position = AVAudio3DPoint(x: 0.0, y: 0.0, z: 0.0)
varispeed.bypass = false
varispeed.rate = 1.0
room.bypass = false
room.loadFactoryPreset(.mediumRoom)
room.wetDryMix = 25.0
// Connect the cables, in the order they were made.
try engine.connectNode(groove, to: varispeed, fromBus: 0, toBus: 0, format: format)
try engine.connectNode(varispeed, to: room, fromBus: 0, toBus: 0, format: format)
try engine.connectNode(room, to: engine.outputNode, fromBus: 0, toBus: 0, format: format)
// What each node does once it is connected.
try groove.scheduleBuffer(
Self.decodedAudio(at: Self.audioFile("groove.wav"), into: groove.outputFormat(forBus: 0)),
at: nil,
options: .loops,
completionHandler: nil
)
self.engine = engine
self.groove = groove
self.varispeed = varispeed
self.room = room
}
/// 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 {
// The control nodes' first step is at render time zero, before the engine starts, so what
// they set is there from the first frame.
lastStepTime = nil
stepOrigin = nil
renderTime = 0
stepControls(elapsed: 0)
try engine.start()
do {
try startNodes()
} catch {
stop()
throw error
}
startStepping()
}
/// Stops the graph: each node that has something to do first, then the engine. `start()`
/// plays it again.
public func stop() {
stepper?.cancel()
stepper = nil
lastStepTime = nil
stepOrigin = nil
renderTime = 0
groove.pause()
halfTimeIsOn = false
varispeed.rate = .init(1.0)
engine.stop()
}
/// What each node does once the engine has started.
private func startNodes() throws {
try groove.playAudio()
// A reverb preset loaded before the engine starts is not heard on macOS 27.0, though the
// audio unit still names it as its current preset: it is loaded again once the engine has
// started, which is heard, and the mix set above stays.
room.loadFactoryPreset(.mediumRoom)
}
/// The package's audio file `name`, from the package's resources: “Always use
/// `Bundle.module` when you access resources” (Bundling resources with a Swift package).
private static func audioFile(_ name: String) throws -> URL {
guard let url = Bundle.module.url(forResource: name, withExtension: nil) else {
throw Failure.audioFileUnreadable(name)
}
return url
}
/// Steps the graph's control nodes — its LFOs, Triggers and Listeners — to render time `time`,
/// in frames at the output's sample rate: each moves on by the render time since the last
/// step, and sets the value it moves. While the graph plays, `start()` does this every 5 ms
/// from the output node's `lastRenderTime`; an app that renders the graph offline, in manual
/// rendering mode, calls it instead at each render time it reaches, every 256 frames, say.
public func step(to time: AVAudioFramePosition) {
let sampleRate = engine.outputNode.outputFormat(forBus: 0).sampleRate
var elapsed = 0.0
if let lastStepTime, sampleRate > 0 {
elapsed = Double(time - lastStepTime) / sampleRate
}
lastStepTime = time
let origin = stepOrigin ?? time
stepOrigin = origin
if sampleRate > 0 {
renderTime = max(0, Double(time - origin) / sampleRate)
}
stepControls(elapsed: max(0, elapsed))
}
/// Looks for a new render time every 5 ms, on the main actor, and steps the control nodes to
/// it: the look is only how often; the render time says how far they move.
private func startStepping() {
stepper = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(5))
guard let self, !Task.isCancelled else { return }
if let time = engine.outputNode.lastRenderTime, time.isSampleTimeValid {
step(to: time.sampleTime)
}
}
}
}
/// One step of every control node, `elapsed` seconds of render time after the last, at
/// `renderTime` seconds since the graph started.
private func stepControls(elapsed: Double) {
// Trigger “Half time” sets Varispeed's rate to 0.5 one press sets it and the next lets go; let go, the value is the document's.
if halfTimeIsOn {
varispeed.rate = .init(0.5)
} else {
varispeed.rate = .init(1.0)
}
}
/// Presses `trigger`'s button, as holding it down in Phonique does, and steps the control nodes
/// at once, so what it sets lands before the next render cycle.
public func press(_ trigger: Trigger) {
switch trigger {
case .halfTime:
halfTimeIsOn.toggle()
}
stepControls(elapsed: 0)
}
/// Releases `trigger`'s button, and steps the control nodes at once.
public func release(_ trigger: Trigger) {
switch trigger {
case .halfTime:
break
}
stepControls(elapsed: 0)
}
/// The whole of the audio file at `url`, decoded to loop in `format`: read until the file
/// ends, then converted to its sample rate and its channels — a mono file into every
/// channel, a file with more channels mixed down — as Phonique's File Player reads its file.
/// A file that would decode to more than 512 MiB is refused before a byte is decoded,
/// and one that ends before its length is unreadable, never a loop missing its end.
private static func decodedAudio(at url: URL, into format: AVAudioFormat) throws -> AVAudioPCMBuffer {
let name = url.lastPathComponent
let file = try AVAudioFile(forReading: url)
let source = file.processingFormat
guard file.length > 0, source.sampleRate > 0, source.channelCount > 0, format.sampleRate > 0 else {
throw Failure.audioFileUnreadable(name)
}
let largest = Double(512 * 1024 * 1024)
let frames = (Double(file.length) * format.sampleRate / source.sampleRate).rounded(.up)
let bytesPerFrame = Double(max(source.channelCount, format.channelCount)) * Double(MemoryLayout<Float>.size)
guard frames * bytesPerFrame <= largest,
Double(file.length) * Double(source.channelCount) * Double(MemoryLayout<Float>.size) <= largest,
let whole = AVAudioPCMBuffer(pcmFormat: source, frameCapacity: AVAudioFrameCount(file.length))
else {
throw Failure.audioFileUnreadable(name)
}
// One read "attempts to fill the buffer to its capacity" and can come back short with no
// error, so what is left is read into a buffer of its own and copied on, until the file's
// position reaches its length. A file that ends before its length is an error, never a
// buffer missing its end: AVFAudio's error for a read past the end, or this one for a
// read that gives no frames.
try file.read(into: whole)
while file.framePosition < file.length {
let left = AVAudioFrameCount(file.length - file.framePosition)
guard let rest = AVAudioPCMBuffer(pcmFormat: source, frameCapacity: left) else {
throw Failure.audioFileUnreadable(name)
}
try file.read(into: rest)
guard rest.frameLength > 0 else {
throw Failure.audioFileUnreadable(name)
}
// Onto the end of the whole, in the one format both are in: each of its buffers — one
// a channel deinterleaved, one for all of them interleaved — at the format's bytes a
// frame.
let bytesPerFrame = Int(source.streamDescription.pointee.mBytesPerFrame)
let read = UnsafeMutableAudioBufferListPointer(UnsafeMutablePointer(mutating: rest.audioBufferList))
let kept = UnsafeMutableAudioBufferListPointer(whole.mutableAudioBufferList)
for (from, to) in zip(read, kept) {
guard let from = from.mData, let to = to.mData else { continue }
(to + Int(whole.frameLength) * bytesPerFrame).copyMemory(from: from, byteCount: Int(rest.frameLength) * bytesPerFrame)
}
whole.frameLength += rest.frameLength
}
if source == format {
return whole
}
guard let converter = AVAudioConverter(from: source, to: format) else {
throw Failure.audioFileUnreadable(name)
}
converter.downmix = true
converter.sampleRateConverterQuality = AVAudioQuality.max.rawValue
let capacity = AVAudioFrameCount((Double(whole.frameLength) * format.sampleRate / source.sampleRate).rounded(.up)) + 1
guard let converted = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: capacity) else {
throw Failure.audioFileUnreadable(name)
}
// The whole file, once, then the end of the stream: the converter calls this on the
// calling thread, inside `convert(to:error:withInputFrom:)`.
var given = false
var error: NSError?
let status = converter.convert(to: converted, error: &error) { _, inputStatus in
if given {
inputStatus.pointee = .endOfStream
return nil
}
given = true
inputStatus.pointee = .haveData
return whole
}
if let error {
throw error
}
guard status == .haveData || status == .endOfStream, converted.frameLength > 0 else {
throw Failure.audioFileUnreadable(name)
}
return converted
}
/// Trigger “Half time”: whether it holds its value.
private var halfTimeIsOn = false
}
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.