blip
blip

swiftui sound effects,
without a bundled file.

AVAudioEngine plays audio from an in-memory PCM buffer just as easily as from a bundled file — which means a UI sound generated in code needs no asset in your app bundle at all, and works the same from any SwiftUI view.

  • 0 files in the bundle
  • 1 engine, reused
  • ios + macos
Pro mode: the same oscillator, envelope, and filter values the Swift export writes out as constants.

why AVAudioEngine instead of AVAudioPlayer?

AVAudioPlayer is built to play a file from disk or memory in one shot — fine for a static asset, but it means shipping an audio file for every cue. AVAudioEngine with an AVAudioPlayerNode plays an AVAudioPCMBuffer you fill yourself, so a sound generated as raw samples plays exactly like a bundled one, with no file in your app bundle.

AVAudioPlayer

plays a file

  • Needs a real asset — one per cue, in the app bundle
  • Built around decoding, not around generated samples
  • Retuning a cue means going back to an audio editor
  • Bundle size grows with every sound you add
AVAudioEngine

plays a buffer

  • Takes an AVAudioPCMBuffer you fill yourself
  • Nothing to decode, so nothing to wait for on the first tap
  • Parameters are constants in Swift — change and rebuild
  • Same API on iOS and macOS
the playback pattern

set it up once, then
just feed it buffers.

A single engine and player node, started once, then handed a fresh buffer on every tap. Four objects, and only one of them changes per sound.
  1. 01

    AVAudioEngine

    The graph and its clock. Created once, started once, and kept alive for the lifetime of the app.

    let engine = AVAudioEngine()
  2. 02

    AVAudioPlayerNode

    The source. Attached to the engine and connected to the mixer, then handed buffers whenever a sound should play.

    engine.attach(player)
  3. 03

    AVAudioPCMBuffer

    The sound itself, as raw float samples you fill in code. This is the piece that replaces a file in your app bundle.

    player.scheduleBuffer(buffer)
  4. 04

    mainMixerNode

    The output. Connecting the player here is the entire routing step — no session juggling for a short UI cue.

    engine.connect(player, to: engine.mainMixerNode)
ClickSound.swiftavfoundation
import AVFoundation

enum ClickSound {
  static let engine = AVAudioEngine()
  static let player = AVAudioPlayerNode()

  static func start() {
    engine.attach(player)
    engine.connect(player, to: engine.mainMixerNode, format: nil)
    try? engine.start()
  }

  static func play(buffer: AVAudioPCMBuffer) {
    player.scheduleBuffer(buffer, at: nil, options: .interrupts)
    if !player.isPlaying { player.play() }
  }
}
SaveButton.swiftswiftui
struct SaveButton: View {
  var body: some View {
    Button("Save") {
      ClickSound.play(buffer: ClickSound.click)
    }
    .onAppear { ClickSound.start() }
  }
}
synthesise()floatChannelData[0]AVAudioPCMBuffer0.000.92-0.740.61-0.440.38-0.260.22-0.150.11play()no file involved
The generator writes float samples into the buffer, and AVAudioPlayerNode schedules that buffer. Nothing in this path touches the file system.

Treat this as an illustrative sketch, not a literal copy of what Blip Blip's export button produces — the real export is longer, parameter-driven, and bakes per-play randomized variation, harmonics, and any filter, drive, delay, or reverb stage in as literal constants. Shape a sound in the studio and the export writes that fuller version for you automatically. Building the same kind of tap sound for Android instead? The Kotlin and AudioTrack pattern follows the same idea.

is the engine set up once or per sound?

Once. The pattern above keeps a single AVAudioEngine and AVAudioPlayerNode alive for the app's lifetime, attached and connected on first use, then reused for every play call — starting and tearing down the engine per tap adds latency you do not want on a button press.

does this work from a SwiftUI view?

Yes — AVAudioEngine is pure AVFoundation with no UIKit dependency, so calling the play function from a SwiftUI Button's action closure works exactly as it would from UIKit. Haptic feedback, such as SwiftUI's sensoryFeedback modifier or UIImpactFeedbackGenerator, is a separate, complementary API — it drives the Taptic Engine, not audio playback, so pair the two rather than expecting one to cover both.

where does the PCM buffer come from?

Shape a sound in Blip Blip's studio — oscillator, filter, envelope, and any effects — then export it as Swift. The export writes the full AVAudioEngine setup above plus a function that fills an AVAudioPCMBuffer with the exact samples for that sound, including per-play pitch and timing variation, ready to paste into an Xcode project.

export
// blip blip — click
// swift · avaudioengine

import AVFoundation

private let sampleRate: Double = 44_100
private let duration: Double = 0.11

func makeClickBuffer() -> AVAudioPCMBuffer {
  let format = AVAudioFormat(standardFormatWithSampleRate: sampleRate,
                             channels: 1)!
  let frames = AVAudioFrameCount(sampleRate * duration)
  let buffer = AVAudioPCMBuffer(pcmFormat: format,
                                frameCapacity: frames)!
  buffer.frameLength = frames
  …
one export unlocks web, swift, and kotlin
faq

the useful details.

Do I need to add an audio file to my Xcode project?

No. The exported Swift code fills an AVAudioPCMBuffer from generated sample data at runtime — there is nothing to add to your asset catalog or app bundle.

Does this work on both iOS and macOS?

AVAudioEngine and AVAudioPlayerNode are shared AVFoundation APIs available on both platforms, so the same pattern applies to a macOS SwiftUI app without changes.

Can I try this without writing any Swift myself?

Yes — design the sound in the studio and use the Swift export to get a complete, ready-to-paste implementation, including the parts this page only sketches.

ready to make
some noise?

Open Blip Blip and give it a voice.

open the studio