Rain on a tin roof
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 Rain on a tin roof.phonique
A .phonique document is a folder, so it comes as a ZIP archive, 1.7 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
3 sources, 1 occluder and a room, read from the template’s own document.
Sources
- Rain loop1 file · Playback:
loop - Gutter drips6 files · Playback:
recur - Thunder1 file · Playback:
recur
Occluders
- WindowMaterial:
glass
The room
Reverb: mediumRoom
The Swift
What Phonique exports from this template: a Swift package, RainOnATinRoof, that needs nothing beyond Apple’s frameworks. Its README’s “Use it”:
import RainOnATinRoof
let space = try RainOnATinRoofSpace()
try space.start()
space.pause()
try space.start() // resumes
space.stop()The whole of RainOnATinRoofSpace.swift, 388 lines
//
// RainOnATinRoofSpace.swift
// Generated by Phonique from “Rain on a tin roof”.
//
// 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 RainOnATinRoofSpace {
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 = .mediumRoom
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/rain-loop.wav.mono", files: assets)
try register("audio/drip-1.wav.mono", files: assets)
try register("audio/drip-2.wav.mono", files: assets)
try register("audio/drip-3.wav.mono", files: assets)
try register("audio/drip-4.wav.mono", files: assets)
try register("audio/drip-5.wav.mono", files: assets)
try register("audio/drip-6.wav.mono", files: assets)
try register("audio/thunder.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>(0.0, 2.5, 0.0, 1.0)))
object.gain = 1.0
try engine.rootObject.addChild(object)
sources["58918859-BA15-41D2-8089-E2E07E28E101"] = object
namedObjects.append((object, "58918859-BA15-41D2-8089-E2E07E28E101"))
let parameters = PHASEMixerParameters()
parameters.addSpatialMixerParameters(identifier: "58918859-BA15-41D2-8089-E2E07E28E101.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: "58918859-BA15-41D2-8089-E2E07E28E101.send.directPath")
pipeline.entries[.earlyReflections]?.sendLevel = 1.0
pipeline.entries[.earlyReflections]?.sendLevelMetaParameterDefinition = PHASENumberMetaParameterDefinition(value: 1.0, minimum: 0.0, maximum: 1.0, identifier: "58918859-BA15-41D2-8089-E2E07E28E101.send.earlyReflections")
pipeline.entries[.lateReverb]?.sendLevel = 1.0
pipeline.entries[.lateReverb]?.sendLevelMetaParameterDefinition = PHASENumberMetaParameterDefinition(value: 1.0, minimum: 0.0, maximum: 1.0, identifier: "58918859-BA15-41D2-8089-E2E07E28E101.send.lateReverb")
let mixer = PHASESpatialMixerDefinition(spatialPipeline: pipeline, identifier: "58918859-BA15-41D2-8089-E2E07E28E101.mixer")
let distance = PHASEGeometricSpreadingDistanceModelParameters()
distance.rolloffFactor = 1.0
distance.fadeOutParameters = PHASEDistanceModelFadeOutParameters(cullDistance: 12.0)
mixer.distanceModelParameters = distance
let sampler = PHASESamplerNodeDefinition(soundAssetIdentifier: "audio/rain-loop.wav.mono", mixerDefinition: mixer, identifier: "58918859-BA15-41D2-8089-E2E07E28E101.sampler.0")
sampler.playbackMode = .looping
_ = try engine.assetRegistry.registerSoundEventAsset(rootNode: sampler, identifier: "58918859-BA15-41D2-8089-E2E07E28E101.event.0")
registeredAssets.append("58918859-BA15-41D2-8089-E2E07E28E101.event.0")
}
sounds.append(Sound(owner: "58918859-BA15-41D2-8089-E2E07E28E101", assets: ["58918859-BA15-41D2-8089-E2E07E28E101.event.0"], parameters: parameters, isOn: true, interval: nil))
}
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>(1.5, 0.0, 0.10000000149011612, 1.0)))
object.gain = 1.0
try engine.rootObject.addChild(object)
sources["58918859-BA15-41D2-8089-E2E07E28E102"] = object
namedObjects.append((object, "58918859-BA15-41D2-8089-E2E07E28E102"))
let parameters = PHASEMixerParameters()
parameters.addSpatialMixerParameters(identifier: "58918859-BA15-41D2-8089-E2E07E28E102.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: "58918859-BA15-41D2-8089-E2E07E28E102.send.directPath")
pipeline.entries[.earlyReflections]?.sendLevel = 1.0
pipeline.entries[.earlyReflections]?.sendLevelMetaParameterDefinition = PHASENumberMetaParameterDefinition(value: 1.0, minimum: 0.0, maximum: 1.0, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.send.earlyReflections")
pipeline.entries[.lateReverb]?.sendLevel = 1.0
pipeline.entries[.lateReverb]?.sendLevelMetaParameterDefinition = PHASENumberMetaParameterDefinition(value: 1.0, minimum: 0.0, maximum: 1.0, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.send.lateReverb")
let mixer = PHASESpatialMixerDefinition(spatialPipeline: pipeline, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.mixer")
let distance = PHASEGeometricSpreadingDistanceModelParameters()
distance.rolloffFactor = 1.0
distance.fadeOutParameters = PHASEDistanceModelFadeOutParameters(cullDistance: 12.0)
mixer.distanceModelParameters = distance
let sampler = PHASESamplerNodeDefinition(soundAssetIdentifier: "audio/drip-1.wav.mono", mixerDefinition: mixer, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.sampler.0")
sampler.playbackMode = .oneShot
_ = try engine.assetRegistry.registerSoundEventAsset(rootNode: sampler, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.event.0")
registeredAssets.append("58918859-BA15-41D2-8089-E2E07E28E102.event.0")
}
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: "58918859-BA15-41D2-8089-E2E07E28E102.send.directPath")
pipeline.entries[.earlyReflections]?.sendLevel = 1.0
pipeline.entries[.earlyReflections]?.sendLevelMetaParameterDefinition = PHASENumberMetaParameterDefinition(value: 1.0, minimum: 0.0, maximum: 1.0, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.send.earlyReflections")
pipeline.entries[.lateReverb]?.sendLevel = 1.0
pipeline.entries[.lateReverb]?.sendLevelMetaParameterDefinition = PHASENumberMetaParameterDefinition(value: 1.0, minimum: 0.0, maximum: 1.0, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.send.lateReverb")
let mixer = PHASESpatialMixerDefinition(spatialPipeline: pipeline, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.mixer")
let distance = PHASEGeometricSpreadingDistanceModelParameters()
distance.rolloffFactor = 1.0
distance.fadeOutParameters = PHASEDistanceModelFadeOutParameters(cullDistance: 12.0)
mixer.distanceModelParameters = distance
let sampler = PHASESamplerNodeDefinition(soundAssetIdentifier: "audio/drip-2.wav.mono", mixerDefinition: mixer, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.sampler.1")
sampler.playbackMode = .oneShot
_ = try engine.assetRegistry.registerSoundEventAsset(rootNode: sampler, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.event.1")
registeredAssets.append("58918859-BA15-41D2-8089-E2E07E28E102.event.1")
}
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: "58918859-BA15-41D2-8089-E2E07E28E102.send.directPath")
pipeline.entries[.earlyReflections]?.sendLevel = 1.0
pipeline.entries[.earlyReflections]?.sendLevelMetaParameterDefinition = PHASENumberMetaParameterDefinition(value: 1.0, minimum: 0.0, maximum: 1.0, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.send.earlyReflections")
pipeline.entries[.lateReverb]?.sendLevel = 1.0
pipeline.entries[.lateReverb]?.sendLevelMetaParameterDefinition = PHASENumberMetaParameterDefinition(value: 1.0, minimum: 0.0, maximum: 1.0, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.send.lateReverb")
let mixer = PHASESpatialMixerDefinition(spatialPipeline: pipeline, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.mixer")
let distance = PHASEGeometricSpreadingDistanceModelParameters()
distance.rolloffFactor = 1.0
distance.fadeOutParameters = PHASEDistanceModelFadeOutParameters(cullDistance: 12.0)
mixer.distanceModelParameters = distance
let sampler = PHASESamplerNodeDefinition(soundAssetIdentifier: "audio/drip-3.wav.mono", mixerDefinition: mixer, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.sampler.2")
sampler.playbackMode = .oneShot
_ = try engine.assetRegistry.registerSoundEventAsset(rootNode: sampler, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.event.2")
registeredAssets.append("58918859-BA15-41D2-8089-E2E07E28E102.event.2")
}
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: "58918859-BA15-41D2-8089-E2E07E28E102.send.directPath")
pipeline.entries[.earlyReflections]?.sendLevel = 1.0
pipeline.entries[.earlyReflections]?.sendLevelMetaParameterDefinition = PHASENumberMetaParameterDefinition(value: 1.0, minimum: 0.0, maximum: 1.0, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.send.earlyReflections")
pipeline.entries[.lateReverb]?.sendLevel = 1.0
pipeline.entries[.lateReverb]?.sendLevelMetaParameterDefinition = PHASENumberMetaParameterDefinition(value: 1.0, minimum: 0.0, maximum: 1.0, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.send.lateReverb")
let mixer = PHASESpatialMixerDefinition(spatialPipeline: pipeline, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.mixer")
let distance = PHASEGeometricSpreadingDistanceModelParameters()
distance.rolloffFactor = 1.0
distance.fadeOutParameters = PHASEDistanceModelFadeOutParameters(cullDistance: 12.0)
mixer.distanceModelParameters = distance
let sampler = PHASESamplerNodeDefinition(soundAssetIdentifier: "audio/drip-4.wav.mono", mixerDefinition: mixer, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.sampler.3")
sampler.playbackMode = .oneShot
_ = try engine.assetRegistry.registerSoundEventAsset(rootNode: sampler, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.event.3")
registeredAssets.append("58918859-BA15-41D2-8089-E2E07E28E102.event.3")
}
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: "58918859-BA15-41D2-8089-E2E07E28E102.send.directPath")
pipeline.entries[.earlyReflections]?.sendLevel = 1.0
pipeline.entries[.earlyReflections]?.sendLevelMetaParameterDefinition = PHASENumberMetaParameterDefinition(value: 1.0, minimum: 0.0, maximum: 1.0, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.send.earlyReflections")
pipeline.entries[.lateReverb]?.sendLevel = 1.0
pipeline.entries[.lateReverb]?.sendLevelMetaParameterDefinition = PHASENumberMetaParameterDefinition(value: 1.0, minimum: 0.0, maximum: 1.0, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.send.lateReverb")
let mixer = PHASESpatialMixerDefinition(spatialPipeline: pipeline, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.mixer")
let distance = PHASEGeometricSpreadingDistanceModelParameters()
distance.rolloffFactor = 1.0
distance.fadeOutParameters = PHASEDistanceModelFadeOutParameters(cullDistance: 12.0)
mixer.distanceModelParameters = distance
let sampler = PHASESamplerNodeDefinition(soundAssetIdentifier: "audio/drip-5.wav.mono", mixerDefinition: mixer, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.sampler.4")
sampler.playbackMode = .oneShot
_ = try engine.assetRegistry.registerSoundEventAsset(rootNode: sampler, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.event.4")
registeredAssets.append("58918859-BA15-41D2-8089-E2E07E28E102.event.4")
}
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: "58918859-BA15-41D2-8089-E2E07E28E102.send.directPath")
pipeline.entries[.earlyReflections]?.sendLevel = 1.0
pipeline.entries[.earlyReflections]?.sendLevelMetaParameterDefinition = PHASENumberMetaParameterDefinition(value: 1.0, minimum: 0.0, maximum: 1.0, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.send.earlyReflections")
pipeline.entries[.lateReverb]?.sendLevel = 1.0
pipeline.entries[.lateReverb]?.sendLevelMetaParameterDefinition = PHASENumberMetaParameterDefinition(value: 1.0, minimum: 0.0, maximum: 1.0, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.send.lateReverb")
let mixer = PHASESpatialMixerDefinition(spatialPipeline: pipeline, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.mixer")
let distance = PHASEGeometricSpreadingDistanceModelParameters()
distance.rolloffFactor = 1.0
distance.fadeOutParameters = PHASEDistanceModelFadeOutParameters(cullDistance: 12.0)
mixer.distanceModelParameters = distance
let sampler = PHASESamplerNodeDefinition(soundAssetIdentifier: "audio/drip-6.wav.mono", mixerDefinition: mixer, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.sampler.5")
sampler.playbackMode = .oneShot
_ = try engine.assetRegistry.registerSoundEventAsset(rootNode: sampler, identifier: "58918859-BA15-41D2-8089-E2E07E28E102.event.5")
registeredAssets.append("58918859-BA15-41D2-8089-E2E07E28E102.event.5")
}
sounds.append(Sound(owner: "58918859-BA15-41D2-8089-E2E07E28E102", assets: ["58918859-BA15-41D2-8089-E2E07E28E102.event.0", "58918859-BA15-41D2-8089-E2E07E28E102.event.1", "58918859-BA15-41D2-8089-E2E07E28E102.event.2", "58918859-BA15-41D2-8089-E2E07E28E102.event.3", "58918859-BA15-41D2-8089-E2E07E28E102.event.4", "58918859-BA15-41D2-8089-E2E07E28E102.event.5"], parameters: parameters, isOn: true, interval: 0.5...1.5))
}
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>(-3.130000114440918, 0.0, -0.949999988079071, 1.0)))
object.gain = 1.0
try engine.rootObject.addChild(object)
sources["58918859-BA15-41D2-8089-E2E07E28E103"] = object
namedObjects.append((object, "58918859-BA15-41D2-8089-E2E07E28E103"))
let parameters = PHASEMixerParameters()
parameters.addSpatialMixerParameters(identifier: "58918859-BA15-41D2-8089-E2E07E28E103.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: "58918859-BA15-41D2-8089-E2E07E28E103.send.directPath")
pipeline.entries[.earlyReflections]?.sendLevel = 1.0
pipeline.entries[.earlyReflections]?.sendLevelMetaParameterDefinition = PHASENumberMetaParameterDefinition(value: 1.0, minimum: 0.0, maximum: 1.0, identifier: "58918859-BA15-41D2-8089-E2E07E28E103.send.earlyReflections")
pipeline.entries[.lateReverb]?.sendLevel = 1.0
pipeline.entries[.lateReverb]?.sendLevelMetaParameterDefinition = PHASENumberMetaParameterDefinition(value: 1.0, minimum: 0.0, maximum: 1.0, identifier: "58918859-BA15-41D2-8089-E2E07E28E103.send.lateReverb")
let mixer = PHASESpatialMixerDefinition(spatialPipeline: pipeline, identifier: "58918859-BA15-41D2-8089-E2E07E28E103.mixer")
let distance = PHASEGeometricSpreadingDistanceModelParameters()
distance.rolloffFactor = 1.0
distance.fadeOutParameters = PHASEDistanceModelFadeOutParameters(cullDistance: 12.0)
mixer.distanceModelParameters = distance
let sampler = PHASESamplerNodeDefinition(soundAssetIdentifier: "audio/thunder.wav.mono", mixerDefinition: mixer, identifier: "58918859-BA15-41D2-8089-E2E07E28E103.sampler.0")
sampler.playbackMode = .oneShot
_ = try engine.assetRegistry.registerSoundEventAsset(rootNode: sampler, identifier: "58918859-BA15-41D2-8089-E2E07E28E103.event.0")
registeredAssets.append("58918859-BA15-41D2-8089-E2E07E28E103.event.0")
}
sounds.append(Sound(owner: "58918859-BA15-41D2-8089-E2E07E28E103", assets: ["58918859-BA15-41D2-8089-E2E07E28E103.event.0"], parameters: parameters, isOn: true, interval: 12.0...24.0))
}
do {
let mesh = MDLMesh(boxWithExtent: SIMD3<Float>(1.75, 2.5, 0.10000000149011612), segments: SIMD3(1, 1, 1), inwardNormals: false, geometryType: .triangles, allocator: nil)
let material = PHASEMaterial(engine: engine, preset: .glass)
let object = PHASEOccluder(engine: engine, shapes: [PHASEShape(engine: engine, mesh: mesh, materials: [material])])
object.transform = simd_float4x4(columns: (SIMD4<Float>(1.7938038916988965e-16, 0.0, 1.0, 0.0), SIMD4<Float>(-0.0, 1.0, 0.0, 0.0), SIMD4<Float>(-1.0, -0.0, 1.9967345893642037e-16, 0.0), SIMD4<Float>(-2.700000047683716, 0.0, -0.699999988079071, 1.0)))
try engine.rootObject.addChild(object)
occluders["58918859-BA15-41D2-8089-E2E07E28E111"] = object
namedObjects.append((object, "58918859-BA15-41D2-8089-E2E07E28E111"))
namedObjects.append((material, "58918859-BA15-41D2-8089-E2E07E28E111.material"))
}
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: RainOnATinRoofSpace, 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: "RainOnATinRoofSpace.assets", withExtension: "json") else { throw Failure.missingAsset("RainOnATinRoofSpace.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.