Four corners (Space)
Template by nthState
Preview
Binaural preview · wear headphones. The app rendered it offline through AVAudioEnvironmentNode: it is a preview, not PHASE’s own output.
Download Four corners (Space).phonique
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
1 source, 0 occluders and a room, read from the template’s own document.
Sources
- Woodblock1 file · Playback:
recur
The room
Reverb: none
The Swift
What Phonique exports from this template: a Swift package, FourCornersSpace, that needs nothing beyond Apple’s frameworks. Its README’s “Use it”:
import FourCornersSpace
let space = try FourCornersSpaceSpace()
try space.start()
space.pause()
try space.start() // resumes
space.stop()The whole of FourCornersSpaceSpace.swift, 224 lines
//
// FourCornersSpaceSpace.swift
// Generated by Phonique from “Four corners (Space)”.
//
// 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 ModelIO
import PHASE
import simd
/// The document's Space. Make a fresh instance after stop(); pause/start resumes it.
/// PHASE renders in real time. The caller owns its audio session and calls stop() when done.
@MainActor
public final class FourCornersSpaceSpace {
public enum Failure: Error { case missingAsset(String), invalidLayout, soundFailed(Int) }
public enum State { case built, playing, paused, stopped }
public let engine: PHASEEngine
public let listener: PHASEListener
public private(set) var state: State = .built
public private(set) var sources: [String: PHASESource] = [:]
public private(set) var occluders: [String: PHASEOccluder] = [:]
/// Names for comparing the built scene through PHASE's public API.
public private(set) var namedObjects: [(object: AnyObject, name: String)] = []
public private(set) var registeredAssets: [String] = []
public private(set) var failure: (any Error)?
private var sounds: [Sound] = []
private var events: [UUID: PHASESoundEvent] = [:]
private var motion: [String: (Double) -> simd_float4x4] = [:]
private var mover: Task<Void, Never>?
private var origin: Int64?
private var heldSeconds = 0.0
/// Builds without starting. listenerGain: 0 permits silent structural tests.
public init(listenerGain: Double = 1) throws {
engine = PHASEEngine(updateMode: .automatic)
engine.outputSpatializationMode = .automatic
engine.defaultReverbPreset = .none
engine.defaultMedium = PHASEMedium(engine: engine, preset: .air)
listener = PHASEListener(engine: engine)
listener.transform = simd_float4x4(columns: (SIMD4<Float>(1.0, 0.0, 0.0, 0.0), SIMD4<Float>(0.0, 1.0, 0.0, 0.0), SIMD4<Float>(0.0, 0.0, 1.0, 0.0), SIMD4<Float>(0.0, 0.0, 0.0, 1.0)))
listener.gain = listenerGain
do {
try engine.rootObject.addChild(listener)
namedObjects.append((listener, "listener"))
let assets = try Self.audioFiles()
try register("audio/woodblock.wav.mono", files: assets)
do {
let object = PHASESource(engine: engine)
object.transform = simd_float4x4(columns: (SIMD4<Float>(1.0, 0.0, 0.0, 0.0), SIMD4<Float>(0.0, 1.0, 0.0, 0.0), SIMD4<Float>(0.0, 0.0, 1.0, 0.0), SIMD4<Float>(-2.0, 0.0, 2.0, 1.0)))
object.gain = 1.0
try engine.rootObject.addChild(object)
sources["3550C04E-73A1-45D9-8BE0-361827AA9001"] = object
namedObjects.append((object, "3550C04E-73A1-45D9-8BE0-361827AA9001"))
let parameters = PHASEMixerParameters()
parameters.addSpatialMixerParameters(identifier: "3550C04E-73A1-45D9-8BE0-361827AA9001.mixer", source: object, listener: listener)
do {
guard let pipeline = PHASESpatialPipeline(flags: [.directPathTransmission, .earlyReflections, .lateReverb]) else { throw Failure.invalidLayout }
pipeline.entries[.directPathTransmission]?.sendLevel = 1.0
pipeline.entries[.directPathTransmission]?.sendLevelMetaParameterDefinition = PHASENumberMetaParameterDefinition(value: 1.0, minimum: 0.0, maximum: 1.0, identifier: "3550C04E-73A1-45D9-8BE0-361827AA9001.send.directPath")
pipeline.entries[.earlyReflections]?.sendLevel = 1.0
pipeline.entries[.earlyReflections]?.sendLevelMetaParameterDefinition = PHASENumberMetaParameterDefinition(value: 1.0, minimum: 0.0, maximum: 1.0, identifier: "3550C04E-73A1-45D9-8BE0-361827AA9001.send.earlyReflections")
pipeline.entries[.lateReverb]?.sendLevel = 1.0
pipeline.entries[.lateReverb]?.sendLevelMetaParameterDefinition = PHASENumberMetaParameterDefinition(value: 1.0, minimum: 0.0, maximum: 1.0, identifier: "3550C04E-73A1-45D9-8BE0-361827AA9001.send.lateReverb")
let mixer = PHASESpatialMixerDefinition(spatialPipeline: pipeline, identifier: "3550C04E-73A1-45D9-8BE0-361827AA9001.mixer")
let distance = PHASEGeometricSpreadingDistanceModelParameters()
distance.rolloffFactor = 1.0
distance.fadeOutParameters = PHASEDistanceModelFadeOutParameters(cullDistance: 12.0)
mixer.distanceModelParameters = distance
let sampler = PHASESamplerNodeDefinition(soundAssetIdentifier: "audio/woodblock.wav.mono", mixerDefinition: mixer, identifier: "3550C04E-73A1-45D9-8BE0-361827AA9001.sampler.0")
sampler.playbackMode = .oneShot
_ = try engine.assetRegistry.registerSoundEventAsset(rootNode: sampler, identifier: "3550C04E-73A1-45D9-8BE0-361827AA9001.event.0")
registeredAssets.append("3550C04E-73A1-45D9-8BE0-361827AA9001.event.0")
}
sounds.append(Sound(owner: "3550C04E-73A1-45D9-8BE0-361827AA9001", assets: ["3550C04E-73A1-45D9-8BE0-361827AA9001.event.0"], parameters: parameters, isOn: true, interval: 1.0...1.0))
motion["3550C04E-73A1-45D9-8BE0-361827AA9001"] = { seconds in Self.transform(base: simd_float4x4(columns: (SIMD4<Float>(1.0, 0.0, 0.0, 0.0), SIMD4<Float>(0.0, 1.0, 0.0, 0.0), SIMD4<Float>(0.0, 0.0, 1.0, 0.0), SIMD4<Float>(-2.0, 0.0, 2.0, 1.0))), route: [SIMD3<Double>(-2.0, 0.0, 2.0), SIMD3<Double>(-2.0, 0.0, -2.0), SIMD3<Double>(2.0, 0.0, -2.0), SIMD3<Double>(2.0, 0.0, 2.0), SIMD3<Double>(-2.0, 0.0, 2.0)], duration: 4.0, mode: "loop", seconds: seconds) }
}
for sound in sounds where sound.isOn && sound.interval == nil { try play(sound, starting: false) }
} catch {
stop()
throw error
}
}
private final class Sound {
let owner: String
let assets: [String]
let parameters: PHASEMixerParameters
let isOn: Bool
let interval: ClosedRange<Double>?
var schedule: Task<Void, Never>?
init(owner: String, assets: [String], parameters: PHASEMixerParameters, isOn: Bool, interval: ClosedRange<Double>?) {
self.owner = owner; self.assets = assets; self.parameters = parameters; self.isOn = isOn; self.interval = interval
}
}
public var renderSeconds: Double {
guard state == .playing, let time = engine.lastRenderTime, time.isSampleTimeValid, time.sampleRate > 0 else { return heldSeconds }
guard let origin else { self.origin = time.sampleTime; return heldSeconds }
// PHASE can reset its sample clock after a pause. Establish the new origin
// rather than moving a path backwards or waiting for the old clock to catch up.
if time.sampleTime < origin { self.origin = time.sampleTime; return heldSeconds }
return heldSeconds + Double(time.sampleTime - origin) / time.sampleRate
}
public func start() throws {
guard state == .built || state == .paused else { return }
let first = state == .built
try engine.start()
state = .playing
moveSources(at: renderSeconds)
if first { for (token, event) in events { event.start(completion: Self.ended(self, token: token)) } }
for sound in sounds where sound.isOn {
guard let interval = sound.interval else { continue }
sound.schedule = Task { @MainActor [weak self, weak sound] in
let clock = ContinuousClock()
var due = clock.now
while !Task.isCancelled {
due = due.advanced(by: .seconds(Double.random(in: interval)))
do { try await Task.sleep(until: due, clock: clock) } catch { return }
guard !Task.isCancelled else { return }
do {
guard let self, let sound, self.state == .playing else { return }
self.moveSources(at: self.renderSeconds)
try self.play(sound, starting: true)
} catch { self?.failure = error; return }
}
}
}
if !motion.isEmpty {
mover = Task { @MainActor [weak self] in
while !Task.isCancelled {
guard self?.state == .playing else { return }
if let seconds = self?.renderSeconds { self?.moveSources(at: seconds) }
do { try await Task.sleep(for: .seconds(0.01)) } catch { return }
}
}
}
}
public func pause() {
guard state == .playing else { return }
heldSeconds = renderSeconds; origin = nil
engine.pause(); state = .paused
mover?.cancel(); mover = nil
for sound in sounds { sound.schedule?.cancel(); sound.schedule = nil }
}
public func stop() {
guard state != .stopped else { return }
heldSeconds = renderSeconds; origin = nil
mover?.cancel(); mover = nil
for sound in sounds { sound.schedule?.cancel(); sound.schedule = nil }
for event in events.values { event.stopAndInvalidate() }
events = [:]
engine.stop()
for asset in registeredAssets.reversed() { engine.assetRegistry.unregisterAsset(identifier: asset, completion: Self.unregistered()) }
registeredAssets = []
for source in sources.values { engine.rootObject.removeChild(source) }
for occluder in occluders.values { engine.rootObject.removeChild(occluder) }
engine.rootObject.removeChild(listener)
sounds = []; sources = [:]; occluders = [:]; motion = [:]; namedObjects = []
state = .stopped
}
public func moveSources(at seconds: Double) {
for (id, transform) in motion { sources[id]?.transform = transform(seconds) }
}
private func play(_ sound: Sound, starting: Bool) throws {
guard let identifier = sound.assets.randomElement() else { return }
let event = try PHASESoundEvent(engine: engine, assetIdentifier: identifier, mixerParameters: sound.parameters)
let token = UUID()
events[token] = event
namedObjects.append((event, sound.owner + ".event"))
if starting { event.start(completion: Self.ended(self, token: token)) }
}
private nonisolated static func ended(_ space: FourCornersSpaceSpace, token: UUID) -> @Sendable (PHASESoundEvent.StartHandlerReason) -> Void {
{ [weak space] reason in
let raw = reason.rawValue
Task { @MainActor in
guard let space, let event = space.events.removeValue(forKey: token) else { return }
space.namedObjects.removeAll { $0.object === event }
if raw == PHASESoundEvent.StartHandlerReason.failure.rawValue { space.failure = Failure.soundFailed(raw) }
}
}
}
private nonisolated static func unregistered() -> @Sendable (Bool) -> Void { { _ in } }
private nonisolated static func transform(base: simd_float4x4, route: [SIMD3<Double>], duration: Double, mode: String, seconds: Double) -> simd_float4x4 {
guard let start = route.first, seconds.isFinite, seconds > 0 else { return base }
let legs = zip(route, route.dropFirst()).map { simd_distance($0, $1) }
let total = legs.reduce(0, +)
guard total > 0 else { return base }
let laps = seconds / duration
let fraction = mode == "loop" ? laps - laps.rounded(.down) : mode == "thereAndBack" ? 1 - abs(laps.truncatingRemainder(dividingBy: 2) - 1) : min(laps, 1)
var left = fraction * total
var position = start
for (index, leg) in legs.enumerated() {
if left <= leg || index == legs.count - 1 {
let share = leg > 0 ? min(1, max(0, left / leg)) : 0
position = route[index] + (route[index + 1] - route[index]) * share
break
}
left -= leg
}
var result = base
result.columns.3 = SIMD4<Float>(Float(position.x), Float(position.y), Float(position.z), 1)
return result
}
private var layouts: [String: AVAudioChannelLayout] = [:]
private static func audioFiles() throws -> [String: String] {
guard let url = Bundle.module.url(forResource: "FourCornersSpaceSpace.assets", withExtension: "json") else { throw Failure.missingAsset("FourCornersSpaceSpace.assets.json") }
return try JSONDecoder().decode([String: String].self, from: Data(contentsOf: url))
}
private func register(_ identifier: String, files: [String: String]) throws {
guard let file = files[identifier], let url = Bundle.module.url(forResource: file, withExtension: nil) else { throw Failure.missingAsset(identifier) }
let audio = try AVAudioFile(forReading: url)
let count = audio.processingFormat.channelCount
let tag = count == 1 ? kAudioChannelLayoutTag_Mono : count == 2 ? kAudioChannelLayoutTag_Stereo : kAudioChannelLayoutTag_DiscreteInOrder | count
guard let layout = AVAudioChannelLayout(layoutTag: tag) else { throw Failure.invalidLayout }
_ = try engine.assetRegistry.registerSoundAsset(url: url, identifier: identifier, assetType: .resident, channelLayout: layout, normalizationMode: .none)
layouts[identifier] = layout
registeredAssets.append(identifier)
}
}
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.