├── .gitignore ├── LICENSE.txt ├── README.md ├── RainGenerator.xcodeproj ├── project.pbxproj └── project.xcworkspace │ └── contents.xcworkspacedata └── RainGenerator ├── Assets.xcassets ├── AccentColor.colorset │ └── Contents.json ├── AppIcon.appiconset │ └── Contents.json └── Contents.json ├── AudioEngine.swift ├── ContentView.swift ├── Preview Content └── Preview Assets.xcassets │ └── Contents.json ├── RainGenerator.entitlements └── RainGeneratorApp.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | project.xcworkspace/ 3 | *.xcscmblueprint 4 | xcuserdata/ 5 | .build 6 | Package.pins 7 | Package.resolved 8 | Packages/ 9 | .swiftpm 10 | buildServer.json -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright © 2024 Matt Gallagher ( https://cocoawithlove.com ). All rights reserved. 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 12 | SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR 15 | IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RainGenerator 2 | 3 | A basic raindrop and noise synthesizer, written in Swift using AVAudioGenerator and SwiftUI. 4 | 5 | Read the associated article on Cocoa with Love: [Using Copilot to write a raindrop audio synthesizer using AVAudioEngine](https://www.cocoawithlove.com/blog/copilot-raindrop-generator.html) 6 | 7 | This project was inspired by https://github.com/747745124/Raindrop-Generator 8 | -------------------------------------------------------------------------------- /RainGenerator.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXFileReference section */ 10 | C90227232CFC3E2400E5BD04 /* RainGenerator.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RainGenerator.app; sourceTree = BUILT_PRODUCTS_DIR; }; 11 | /* End PBXFileReference section */ 12 | 13 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 14 | C90227252CFC3E2400E5BD04 /* RainGenerator */ = { 15 | isa = PBXFileSystemSynchronizedRootGroup; 16 | path = RainGenerator; 17 | sourceTree = ""; 18 | }; 19 | /* End PBXFileSystemSynchronizedRootGroup section */ 20 | 21 | /* Begin PBXFrameworksBuildPhase section */ 22 | C90227202CFC3E2400E5BD04 /* Frameworks */ = { 23 | isa = PBXFrameworksBuildPhase; 24 | buildActionMask = 2147483647; 25 | files = ( 26 | ); 27 | runOnlyForDeploymentPostprocessing = 0; 28 | }; 29 | /* End PBXFrameworksBuildPhase section */ 30 | 31 | /* Begin PBXGroup section */ 32 | C902271A2CFC3E2300E5BD04 = { 33 | isa = PBXGroup; 34 | children = ( 35 | C90227252CFC3E2400E5BD04 /* RainGenerator */, 36 | C90227242CFC3E2400E5BD04 /* Products */, 37 | ); 38 | sourceTree = ""; 39 | }; 40 | C90227242CFC3E2400E5BD04 /* Products */ = { 41 | isa = PBXGroup; 42 | children = ( 43 | C90227232CFC3E2400E5BD04 /* RainGenerator.app */, 44 | ); 45 | name = Products; 46 | sourceTree = ""; 47 | }; 48 | /* End PBXGroup section */ 49 | 50 | /* Begin PBXNativeTarget section */ 51 | C90227222CFC3E2400E5BD04 /* RainGenerator */ = { 52 | isa = PBXNativeTarget; 53 | buildConfigurationList = C90227322CFC3E2500E5BD04 /* Build configuration list for PBXNativeTarget "RainGenerator" */; 54 | buildPhases = ( 55 | C902271F2CFC3E2400E5BD04 /* Sources */, 56 | C90227202CFC3E2400E5BD04 /* Frameworks */, 57 | C90227212CFC3E2400E5BD04 /* Resources */, 58 | ); 59 | buildRules = ( 60 | ); 61 | dependencies = ( 62 | ); 63 | fileSystemSynchronizedGroups = ( 64 | C90227252CFC3E2400E5BD04 /* RainGenerator */, 65 | ); 66 | name = RainGenerator; 67 | packageProductDependencies = ( 68 | ); 69 | productName = RainGenerator; 70 | productReference = C90227232CFC3E2400E5BD04 /* RainGenerator.app */; 71 | productType = "com.apple.product-type.application"; 72 | }; 73 | /* End PBXNativeTarget section */ 74 | 75 | /* Begin PBXProject section */ 76 | C902271B2CFC3E2300E5BD04 /* Project object */ = { 77 | isa = PBXProject; 78 | attributes = { 79 | BuildIndependentTargetsInParallel = 1; 80 | LastSwiftUpdateCheck = 1610; 81 | LastUpgradeCheck = 1610; 82 | TargetAttributes = { 83 | C90227222CFC3E2400E5BD04 = { 84 | CreatedOnToolsVersion = 16.1; 85 | }; 86 | }; 87 | }; 88 | buildConfigurationList = C902271E2CFC3E2300E5BD04 /* Build configuration list for PBXProject "RainGenerator" */; 89 | developmentRegion = en; 90 | hasScannedForEncodings = 0; 91 | knownRegions = ( 92 | en, 93 | Base, 94 | ); 95 | mainGroup = C902271A2CFC3E2300E5BD04; 96 | minimizedProjectReferenceProxies = 1; 97 | preferredProjectObjectVersion = 77; 98 | productRefGroup = C90227242CFC3E2400E5BD04 /* Products */; 99 | projectDirPath = ""; 100 | projectRoot = ""; 101 | targets = ( 102 | C90227222CFC3E2400E5BD04 /* RainGenerator */, 103 | ); 104 | }; 105 | /* End PBXProject section */ 106 | 107 | /* Begin PBXResourcesBuildPhase section */ 108 | C90227212CFC3E2400E5BD04 /* Resources */ = { 109 | isa = PBXResourcesBuildPhase; 110 | buildActionMask = 2147483647; 111 | files = ( 112 | ); 113 | runOnlyForDeploymentPostprocessing = 0; 114 | }; 115 | /* End PBXResourcesBuildPhase section */ 116 | 117 | /* Begin PBXSourcesBuildPhase section */ 118 | C902271F2CFC3E2400E5BD04 /* Sources */ = { 119 | isa = PBXSourcesBuildPhase; 120 | buildActionMask = 2147483647; 121 | files = ( 122 | ); 123 | runOnlyForDeploymentPostprocessing = 0; 124 | }; 125 | /* End PBXSourcesBuildPhase section */ 126 | 127 | /* Begin XCBuildConfiguration section */ 128 | C90227302CFC3E2500E5BD04 /* Debug */ = { 129 | isa = XCBuildConfiguration; 130 | buildSettings = { 131 | ALWAYS_SEARCH_USER_PATHS = NO; 132 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 133 | CLANG_ANALYZER_NONNULL = YES; 134 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 135 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 136 | CLANG_ENABLE_MODULES = YES; 137 | CLANG_ENABLE_OBJC_ARC = YES; 138 | CLANG_ENABLE_OBJC_WEAK = YES; 139 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 140 | CLANG_WARN_BOOL_CONVERSION = YES; 141 | CLANG_WARN_COMMA = YES; 142 | CLANG_WARN_CONSTANT_CONVERSION = YES; 143 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 144 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 145 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 146 | CLANG_WARN_EMPTY_BODY = YES; 147 | CLANG_WARN_ENUM_CONVERSION = YES; 148 | CLANG_WARN_INFINITE_RECURSION = YES; 149 | CLANG_WARN_INT_CONVERSION = YES; 150 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 151 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 152 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 153 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 154 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 155 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 156 | CLANG_WARN_STRICT_PROTOTYPES = YES; 157 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 158 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 159 | CLANG_WARN_UNREACHABLE_CODE = YES; 160 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 161 | COPY_PHASE_STRIP = NO; 162 | DEBUG_INFORMATION_FORMAT = dwarf; 163 | ENABLE_STRICT_OBJC_MSGSEND = YES; 164 | ENABLE_TESTABILITY = YES; 165 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 166 | GCC_C_LANGUAGE_STANDARD = gnu17; 167 | GCC_DYNAMIC_NO_PIC = NO; 168 | GCC_NO_COMMON_BLOCKS = YES; 169 | GCC_OPTIMIZATION_LEVEL = 0; 170 | GCC_PREPROCESSOR_DEFINITIONS = ( 171 | "DEBUG=1", 172 | "$(inherited)", 173 | ); 174 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 175 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 176 | GCC_WARN_UNDECLARED_SELECTOR = YES; 177 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 178 | GCC_WARN_UNUSED_FUNCTION = YES; 179 | GCC_WARN_UNUSED_VARIABLE = YES; 180 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 181 | MACOSX_DEPLOYMENT_TARGET = 15.1; 182 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 183 | MTL_FAST_MATH = YES; 184 | ONLY_ACTIVE_ARCH = YES; 185 | SDKROOT = macosx; 186 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 187 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 188 | }; 189 | name = Debug; 190 | }; 191 | C90227312CFC3E2500E5BD04 /* Release */ = { 192 | isa = XCBuildConfiguration; 193 | buildSettings = { 194 | ALWAYS_SEARCH_USER_PATHS = NO; 195 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 196 | CLANG_ANALYZER_NONNULL = YES; 197 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 198 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 199 | CLANG_ENABLE_MODULES = YES; 200 | CLANG_ENABLE_OBJC_ARC = YES; 201 | CLANG_ENABLE_OBJC_WEAK = YES; 202 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 203 | CLANG_WARN_BOOL_CONVERSION = YES; 204 | CLANG_WARN_COMMA = YES; 205 | CLANG_WARN_CONSTANT_CONVERSION = YES; 206 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 207 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 208 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 209 | CLANG_WARN_EMPTY_BODY = YES; 210 | CLANG_WARN_ENUM_CONVERSION = YES; 211 | CLANG_WARN_INFINITE_RECURSION = YES; 212 | CLANG_WARN_INT_CONVERSION = YES; 213 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 214 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 215 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 216 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 217 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 218 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 219 | CLANG_WARN_STRICT_PROTOTYPES = YES; 220 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 221 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 222 | CLANG_WARN_UNREACHABLE_CODE = YES; 223 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 224 | COPY_PHASE_STRIP = NO; 225 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 226 | ENABLE_NS_ASSERTIONS = NO; 227 | ENABLE_STRICT_OBJC_MSGSEND = YES; 228 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 229 | GCC_C_LANGUAGE_STANDARD = gnu17; 230 | GCC_NO_COMMON_BLOCKS = YES; 231 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 232 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 233 | GCC_WARN_UNDECLARED_SELECTOR = YES; 234 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 235 | GCC_WARN_UNUSED_FUNCTION = YES; 236 | GCC_WARN_UNUSED_VARIABLE = YES; 237 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 238 | MACOSX_DEPLOYMENT_TARGET = 15.1; 239 | MTL_ENABLE_DEBUG_INFO = NO; 240 | MTL_FAST_MATH = YES; 241 | SDKROOT = macosx; 242 | SWIFT_COMPILATION_MODE = wholemodule; 243 | }; 244 | name = Release; 245 | }; 246 | C90227332CFC3E2500E5BD04 /* Debug */ = { 247 | isa = XCBuildConfiguration; 248 | buildSettings = { 249 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 250 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 251 | CODE_SIGN_ENTITLEMENTS = RainGenerator/RainGenerator.entitlements; 252 | CODE_SIGN_STYLE = Automatic; 253 | COMBINE_HIDPI_IMAGES = YES; 254 | CURRENT_PROJECT_VERSION = 1; 255 | DEVELOPMENT_ASSET_PATHS = "\"RainGenerator/Preview Content\""; 256 | DEVELOPMENT_TEAM = S7YQ892G6G; 257 | ENABLE_HARDENED_RUNTIME = YES; 258 | ENABLE_PREVIEWS = YES; 259 | GENERATE_INFOPLIST_FILE = YES; 260 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 261 | LD_RUNPATH_SEARCH_PATHS = ( 262 | "$(inherited)", 263 | "@executable_path/../Frameworks", 264 | ); 265 | MARKETING_VERSION = 1.0; 266 | PRODUCT_BUNDLE_IDENTIFIER = com.mattgallagher.RainGenerator; 267 | PRODUCT_NAME = "$(TARGET_NAME)"; 268 | SWIFT_EMIT_LOC_STRINGS = YES; 269 | SWIFT_VERSION = 6.0; 270 | }; 271 | name = Debug; 272 | }; 273 | C90227342CFC3E2500E5BD04 /* Release */ = { 274 | isa = XCBuildConfiguration; 275 | buildSettings = { 276 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 277 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 278 | CODE_SIGN_ENTITLEMENTS = RainGenerator/RainGenerator.entitlements; 279 | CODE_SIGN_STYLE = Automatic; 280 | COMBINE_HIDPI_IMAGES = YES; 281 | CURRENT_PROJECT_VERSION = 1; 282 | DEVELOPMENT_ASSET_PATHS = "\"RainGenerator/Preview Content\""; 283 | DEVELOPMENT_TEAM = S7YQ892G6G; 284 | ENABLE_HARDENED_RUNTIME = YES; 285 | ENABLE_PREVIEWS = YES; 286 | GENERATE_INFOPLIST_FILE = YES; 287 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 288 | LD_RUNPATH_SEARCH_PATHS = ( 289 | "$(inherited)", 290 | "@executable_path/../Frameworks", 291 | ); 292 | MARKETING_VERSION = 1.0; 293 | PRODUCT_BUNDLE_IDENTIFIER = com.mattgallagher.RainGenerator; 294 | PRODUCT_NAME = "$(TARGET_NAME)"; 295 | SWIFT_EMIT_LOC_STRINGS = YES; 296 | SWIFT_VERSION = 6.0; 297 | }; 298 | name = Release; 299 | }; 300 | /* End XCBuildConfiguration section */ 301 | 302 | /* Begin XCConfigurationList section */ 303 | C902271E2CFC3E2300E5BD04 /* Build configuration list for PBXProject "RainGenerator" */ = { 304 | isa = XCConfigurationList; 305 | buildConfigurations = ( 306 | C90227302CFC3E2500E5BD04 /* Debug */, 307 | C90227312CFC3E2500E5BD04 /* Release */, 308 | ); 309 | defaultConfigurationIsVisible = 0; 310 | defaultConfigurationName = Release; 311 | }; 312 | C90227322CFC3E2500E5BD04 /* Build configuration list for PBXNativeTarget "RainGenerator" */ = { 313 | isa = XCConfigurationList; 314 | buildConfigurations = ( 315 | C90227332CFC3E2500E5BD04 /* Debug */, 316 | C90227342CFC3E2500E5BD04 /* Release */, 317 | ); 318 | defaultConfigurationIsVisible = 0; 319 | defaultConfigurationName = Release; 320 | }; 321 | /* End XCConfigurationList section */ 322 | }; 323 | rootObject = C902271B2CFC3E2300E5BD04 /* Project object */; 324 | } 325 | -------------------------------------------------------------------------------- /RainGenerator.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /RainGenerator/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /RainGenerator/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "256x256" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "1x", 46 | "size" : "512x512" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "2x", 51 | "size" : "512x512" 52 | } 53 | ], 54 | "info" : { 55 | "author" : "xcode", 56 | "version" : 1 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /RainGenerator/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /RainGenerator/AudioEngine.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import Foundation 3 | import Synchronization 4 | 5 | @MainActor 6 | @Observable 7 | class AudioEngine { 8 | struct Raindrop: Equatable { 9 | fileprivate var currentSample: Int = 0 10 | var sampleRate: Float = 44100 11 | var tInit: Float = 0.001 12 | var deltaT1: Float = 0.0075 13 | var deltaT2: Float = 0.015 14 | var deltaT3: Float = 0.030 15 | var a1: Float = 0.4 16 | var a2: Float = 0.7 17 | var frequency: Float = 400.0 18 | 19 | var time: Float { 20 | Float(currentSample) / sampleRate 21 | } 22 | 23 | mutating func generateSample() -> Float { 24 | let time = self.time 25 | currentSample += 1 26 | if time < tInit { 27 | return 0.0 28 | } else if time < deltaT1 { 29 | let t = .pi * (time - tInit) / (deltaT1 - tInit) - 0.5 * .pi 30 | return a1 * cos(t) 31 | } else if time < deltaT2 { 32 | return 0.0 33 | } else if time < deltaT3 { 34 | let bubbleTime = time - deltaT2 35 | let decay = exp(-6 * bubbleTime / (deltaT3 - deltaT2)) 36 | let expand = exp(0.5 * bubbleTime / (deltaT3 - deltaT2)) 37 | return decay * a2 * sin(2.0 * .pi * frequency * bubbleTime * expand) 38 | } else if time < deltaT3 + tInit { 39 | return 0.0 40 | } 41 | return 0.0 42 | } 43 | } 44 | 45 | struct Parameters: Equatable { 46 | var raindrop = Raindrop() 47 | 48 | var volume: Float = 0.5 49 | var dropsPerMinute: Float = 300 50 | var dropRandomness: Float = 1.0 51 | var frequencyRandomness: Float = 0.75 52 | var pinkNoise: Float = 0.025 53 | var brownNoise: Float = 0.05 54 | var whiteNoise: Float = 0.0 55 | 56 | func createNewRaindrop(fixedValue: Bool) -> Raindrop { 57 | var newDrop = raindrop 58 | 59 | // Amplitude parameters 60 | newDrop.a1 = Float.randomnessAroundMidpoint(randomness: fixedValue ? 0 : dropRandomness, midpoint: newDrop.a1) 61 | newDrop.a2 = Float.randomnessAroundMidpoint(randomness: fixedValue ? 0 : dropRandomness, midpoint: newDrop.a2) 62 | 63 | // Timing parameters 64 | newDrop.deltaT1 = newDrop.tInit + Float.randomnessAroundMidpoint(randomness: fixedValue ? 0 : dropRandomness, midpoint: newDrop.deltaT1) 65 | newDrop.deltaT2 = newDrop.deltaT1 + Float.randomnessAroundMidpoint(randomness: fixedValue ? 0 : dropRandomness, midpoint: newDrop.deltaT2) 66 | newDrop.deltaT3 = newDrop.deltaT2 + Float.randomnessAroundMidpoint(randomness: fixedValue ? 0 : dropRandomness, midpoint: newDrop.deltaT3) 67 | 68 | // Sound parameters 69 | newDrop.frequency = Float.randomnessAroundMidpoint(randomness: fixedValue ? 0 : dropRandomness, midpoint: newDrop.frequency) 70 | 71 | return newDrop 72 | } 73 | } 74 | 75 | struct GeneratorState { 76 | var currentTime: Float = 0.0 77 | var raindrops: [Raindrop] = [] 78 | var nextDropTime: Float = 0 79 | var pinkNoiseState: [Float] = Array(repeating: 0.0, count: 7) 80 | var brownNoiseState: Float = 0.0 81 | var parameters = Parameters() 82 | 83 | mutating func generatePinkNoise() -> Float { 84 | let white = Float.random(in: -1.0...1.0) 85 | pinkNoiseState[0] = 0.99886 * pinkNoiseState[0] + white * 0.0555179 86 | pinkNoiseState[1] = 0.99332 * pinkNoiseState[1] + white * 0.0750759 87 | pinkNoiseState[2] = 0.96900 * pinkNoiseState[2] + white * 0.1538520 88 | pinkNoiseState[3] = 0.86650 * pinkNoiseState[3] + white * 0.3104856 89 | pinkNoiseState[4] = 0.55000 * pinkNoiseState[4] + white * 0.5329522 90 | pinkNoiseState[5] = -0.7616 * pinkNoiseState[5] - white * 0.0168980 91 | let pink = pinkNoiseState[0] + pinkNoiseState[1] + pinkNoiseState[2] + pinkNoiseState[3] + pinkNoiseState[4] + pinkNoiseState[5] + pinkNoiseState[6] + white * 0.5362 92 | pinkNoiseState[6] = white * 0.115926 93 | return pink 94 | } 95 | 96 | func generateWhiteNoise() -> Float { 97 | return Float.random(in: -1.0...1.0) 98 | } 99 | 100 | mutating func generateBrownNoise() -> Float { 101 | let white = Float.random(in: -1.0...1.0) 102 | brownNoiseState = (brownNoiseState + white).clamped(to: -1.0...1.0) 103 | return brownNoiseState 104 | } 105 | } 106 | 107 | private var engine: AVAudioEngine 108 | private var mainMixer: AVAudioMixerNode 109 | private var player: AVAudioSourceNode? 110 | 111 | var parameters = Parameters() { 112 | didSet { 113 | generatorState.withLock { state in 114 | state.parameters = parameters 115 | } 116 | } 117 | } 118 | 119 | private let generatorState = Mutex(GeneratorState()) 120 | 121 | private(set) var isRunning: Bool = false 122 | 123 | init() { 124 | let engine = AVAudioEngine() 125 | self.engine = engine 126 | mainMixer = engine.mainMixerNode 127 | let outputFormat = engine.outputNode.outputFormat(forBus: 0) 128 | let playerFormat = AVAudioFormat(standardFormatWithSampleRate: outputFormat.sampleRate, channels: 1)! 129 | parameters.raindrop.sampleRate = Float(playerFormat.sampleRate) 130 | 131 | let player = AVAudioSourceNode { @Sendable [weak self] _, _, frameCount, audioBufferList in 132 | guard let self = self else { return noErr } 133 | return self.generateAudio(audioBufferList: audioBufferList, frameCount: frameCount) 134 | } 135 | self.player = player 136 | 137 | engine.attach(player) 138 | engine.connect(player, to: mainMixer, format: playerFormat) 139 | engine.connect(mainMixer, to: engine.outputNode, format: playerFormat) 140 | mainMixer.outputVolume = 1.0 141 | } 142 | 143 | func raindropSampleCount() -> Int { 144 | let newDrop = parameters.createNewRaindrop(fixedValue: true) 145 | return Int((newDrop.deltaT3 + newDrop.tInit) * newDrop.sampleRate) 146 | } 147 | 148 | func generateWaveform(samples: Int) -> [Float] { 149 | var waveform: [Float] = [] 150 | var raindrop = parameters.createNewRaindrop(fixedValue: true) 151 | 152 | for _ in 0.., 161 | frameCount: AVAudioFrameCount 162 | ) -> OSStatus { 163 | let ablPointer = UnsafeMutableAudioBufferListPointer(audioBufferList) 164 | let frameLength = Int(frameCount) 165 | 166 | var generatorState = self.generatorState.withLock { state in 167 | state 168 | } 169 | let parameters = generatorState.parameters 170 | 171 | let dropInterval = 60.0 / parameters.dropsPerMinute 172 | for frame in 0..= generatorState.nextDropTime { 175 | generatorState.raindrops.append(parameters.createNewRaindrop(fixedValue: false)) 176 | generatorState.nextDropTime = generatorState.currentTime + Float.randomnessAroundMidpoint(randomness: 0.95 * parameters.frequencyRandomness, midpoint: dropInterval) 177 | } 178 | 179 | // Sum the samples from all active raindrops 180 | var sample: Float = 0.0 181 | generatorState.raindrops = generatorState.raindrops.filter { drop in 182 | return drop.time < (drop.deltaT3 + drop.tInit) // Keep drop if it hasn't finished playing 183 | } 184 | 185 | for i in 0.. = UnsafeMutableBufferPointer(buffer) 196 | buf[frame] = sample * parameters.volume 197 | } 198 | 199 | generatorState.currentTime += 1.0 / parameters.raindrop.sampleRate 200 | } 201 | 202 | self.generatorState.withLock { state in 203 | state = generatorState 204 | } 205 | 206 | return noErr 207 | } 208 | 209 | func start() throws { 210 | try engine.start() 211 | isRunning = true 212 | } 213 | 214 | func stop() { 215 | engine.pause() 216 | isRunning = false 217 | } 218 | } 219 | 220 | extension Float { 221 | func clamped(to: ClosedRange) -> Float { 222 | if self < to.lowerBound { 223 | return to.lowerBound 224 | } else if self > to.upperBound { 225 | return to.upperBound 226 | } else { 227 | return self 228 | } 229 | } 230 | 231 | static func randomnessAroundMidpoint(randomness: Float, midpoint: Float) -> Float { 232 | if randomness != 0 { 233 | let scale = randomness > 1 ? randomness : 1 234 | let scaledRandomness = randomness > 1 ? 1 : randomness 235 | return Float.random(in: (scale * midpoint * (1 - scaledRandomness))...(scale * midpoint * (1 + scaledRandomness))) 236 | } else { 237 | return midpoint 238 | } 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /RainGenerator/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // RainGenerator 4 | // 5 | // Created by Matthew Gallagher on 1/12/2024. 6 | // 7 | 8 | import SwiftUI 9 | import Charts 10 | 11 | struct ContentView: View { 12 | @State private var audioEngine = AudioEngine() 13 | @State private var waveform: [Float] = [] 14 | 15 | var body: some View { 16 | VStack(spacing: 20) { 17 | HStack(alignment: .top, spacing: 20) { 18 | VStack(alignment: .leading) { 19 | Text("Impact duration: \(audioEngine.parameters.raindrop.deltaT1 * 1000) milliseconds") 20 | Slider(value: $audioEngine.parameters.raindrop.deltaT1, in: 0.0002...0.1) 21 | 22 | Text("Impact amplitude: \(audioEngine.parameters.raindrop.a1) amplitude") 23 | Slider(value: $audioEngine.parameters.raindrop.a1, in: 0...1.5) 24 | 25 | Text("Pause duration: \(audioEngine.parameters.raindrop.deltaT2 * 1000) milliseconds") 26 | Slider(value: $audioEngine.parameters.raindrop.deltaT2, in: 0.0002...0.08) 27 | 28 | Text("Bubble duration: \(audioEngine.parameters.raindrop.deltaT3 * 1000) milliseconds") 29 | Slider(value: $audioEngine.parameters.raindrop.deltaT3, in: 0.001...0.4) 30 | 31 | Text("Bubble amplitude: \(audioEngine.parameters.raindrop.a2) amplitude") 32 | Slider(value: $audioEngine.parameters.raindrop.a2, in: 0...1.5) 33 | 34 | Text("Bubble frequency: \(audioEngine.parameters.raindrop.frequency) Hz") 35 | Slider(value: $audioEngine.parameters.raindrop.frequency, in: 2...2000) 36 | } 37 | VStack(alignment: .leading) { 38 | Text("Drop randomness: \(Int(audioEngine.parameters.dropRandomness * 100))%") 39 | Slider(value: $audioEngine.parameters.dropRandomness, in: 0...2) 40 | 41 | Text("Drop rate: \(Int(audioEngine.parameters.dropsPerMinute)) per minute") 42 | Slider(value: $audioEngine.parameters.dropsPerMinute, in: 20...4000) 43 | 44 | Text("Rate randomness: \(Int(audioEngine.parameters.frequencyRandomness * 100))%") 45 | Slider(value: $audioEngine.parameters.frequencyRandomness, in: 0...1) 46 | 47 | Text("White noise: \(audioEngine.parameters.whiteNoise * 200)%") 48 | Slider(value: $audioEngine.parameters.whiteNoise, in: 0...0.5) 49 | 50 | Text("Brown noise: \(audioEngine.parameters.brownNoise * 200)%") 51 | Slider(value: $audioEngine.parameters.brownNoise, in: 0...0.5) 52 | 53 | Text("Pink noise: \(audioEngine.parameters.pinkNoise * 200)%") 54 | Slider(value: $audioEngine.parameters.pinkNoise, in: 0...0.5) 55 | } 56 | } 57 | .padding(.horizontal) 58 | 59 | Chart { 60 | ForEach(Array(waveform.enumerated()), id: \.offset) { index, value in 61 | LineMark( 62 | x: .value("Time", Float(index) / audioEngine.parameters.raindrop.sampleRate), 63 | y: .value("Amplitude", value) 64 | ) 65 | .foregroundStyle(by: .value("Segment", color(index: index))) 66 | } 67 | } 68 | .chartXAxisLabel("Seconds", position: .bottomLeading) 69 | .chartYScale(domain: -1.0...1.0) 70 | .chartLegend(position: .overlay) 71 | 72 | Text("Volume: \(Int(audioEngine.parameters.volume * 100))%") 73 | Slider(value: $audioEngine.parameters.volume, in: 0...1) 74 | 75 | Button { 76 | if audioEngine.isRunning { 77 | audioEngine.stop() 78 | } else { 79 | try? audioEngine.start() 80 | } 81 | } label: { 82 | Text(audioEngine.isRunning ? "Stop" : "Start").frame(maxWidth: .infinity) 83 | } 84 | } 85 | .padding() 86 | .onChange(of: audioEngine.parameters, initial: true) { _, _ in 87 | updateWaveform() 88 | } 89 | } 90 | 91 | private func color(index: Int) -> String { 92 | audioEngine.parameters.raindrop.color(index: index) 93 | } 94 | 95 | private func updateWaveform() { 96 | waveform = audioEngine.generateWaveform(samples: audioEngine.raindropSampleCount()) 97 | } 98 | } 99 | 100 | private extension AudioEngine.Raindrop { 101 | func color(index: Int) -> String { 102 | switch Float(index) / sampleRate { 103 | case 0.. 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /RainGenerator/RainGeneratorApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RainGeneratorApp.swift 3 | // RainGenerator 4 | // 5 | // Created by Matthew Gallagher on 1/12/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct RainGeneratorApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | ContentView() 15 | } 16 | .defaultSize(width: 800, height: 800) 17 | } 18 | } 19 | --------------------------------------------------------------------------------