A pure-Swift PaulStretch engine for turning short sounds into long ambient drones — extreme time stretching, tape-style slow-down and spectral freeze, built on Accelerate/vDSP with no third-party dependencies. Ported faithfully from a battle-tested in-house engine (bit-for-bit verified) and designed for reuse across macOS and iOS apps: every render can stream in bounded memory, so an hour-long export never has to exist in RAM.
- 🌀 Classic PaulStretch — windowed STFT with per-window phase randomisation and 4× Hann overlap-add; stretch ratios into the thousands with layering (1/3/5 passes), FFT-domain pitch shift and onset preservation.
- ✨ Shimmer layers — layer presets with per-layer pitch offsets (octave-up / sub-octave voices) for Eno-style shimmer drones.
- 🐢 Tape slow-down — varispeed "slowed + reverb" treatment, tiled with equal-power crossfades to any target length. No FFT, nearly free.
- ❄️ Spectral freeze — capture one instant's spectrum and sustain it forever with shimmering random phase; a smear control morphs tonal → washy, and a scan control lets the frozen spectrum drift through the source — frozen but alive.
- 🎼 Phase-vocoder stretch — coherent, propagated phases for clean 2–8× slow-downs that keep the source's structure and pitch: no wash, no tape pitch-drop, and a fraction of PaulStretch's amplitude flutter.
- 🌫 Granular cloud — dense Hann grains with position jitter, pitch spread and stereo scatter; the grainy, shimmering sibling of the PaulStretch wash.
- 📱 iOS-safe chunked rendering — stream any render as ordered chunks or straight to disk with a peak footprint of a few megabytes, bit-for-bit identical to the in-memory render.
- 💾 Every Apple-encodable format — WAV, AIFF, CAF, AAC (CBR/VBR/HE), Apple Lossless, FLAC and Opus, with bit-depth/bit-rate/quality controls; compressed formats encode on the fly during chunked export (a 60-minute render: ~950 MB WAV vs ~115 MB AAC).
- 🔁 Seamless loops — equal-power loop crossfade baked into the render, so a 45-second file repeats invisibly (the memory-free way to play "endless" ambience on iOS).
- 🎧 Realtime playback node —
StretchSourceNodesynthesises the timeline just-in-time on a background thread and plays it through anAVAudioSourceNode: endless looped ambience with zero pre-render and a few megabytes of lookahead, for all three engines. ▶️ Drop-in buffer player —StretchPlayer(PaulStretchEffects) plays renderedStereoBuffers with seek, pause, region-looping, the live stock effect chain and a built-in spectrum tap — observable from SwiftUI, with theAVAudioPlayerNodecompletion-handler races already handled.- 📊 Display helpers —
SpectrumAnalyzerfolds engine-tap buffers into smoothed log-band meter values;StereoBuffer.peaks(columns:)produces waveform-view peak columns. - ⏳ async/await throughout —
try awaitrenders that honourTaskcancellation, plus a pull-basedAsyncSequenceof chunks with natural backpressure. - 🎲 Deterministic seeding — same source + parameters + seed always reproduces identical output; variation seeds give batch-friendly alternates of the same settings.
- ⚡ Multicore — the output timeline is partitioned across cores with lock-free, seed-stable workers (~2000× realtime for a single-layer stretch on an M-series Mac).
- 🎛 Optional stock effects — a second product (
PaulStretchEffects) wraps Apple's reverb/EQ/filter/delay as a live chain plus offline and streaming bakes, so what you monitor is what you export. - 🌠 Shimmer reverb — the library's own DSP (Freeverb tank + pitch-shifted
feedback), because
AVAudioEnginegraphs can't hold the feedback cycle shimmer needs. The wash climbs in octaves; bakes offline and streams. - 🏛 Convolution reverb — algorithmically-generated impulse responses (plate / hall / cathedral / exponential wash) with a real decay-time control up to 30 s, convolved in streaming FFT partitions. Seeded and deterministic.
- 🌊 Ambient motion suite — a breathing sweep filter (TPT state-variable core, LFO in octaves, bass-cut companion), tape wow & flutter (cubic-interpolated, with seeded drift), a tidal breathing pump, and a slow auto-pan — movement, colour and space for drones.
- 📈 Automation lanes — Catmull-Rom spline curves
(
AutomationLane) drive effect parameters across the whole render: a filter opening over twenty minutes is composition, not just processing. - 📦 Codable parameters — persist presets as JSON straight from
StretchParameters/EffectsParameters.
- iOS 13.0+ / macOS 10.15+ / tvOS 13.0+ / watchOS 6.0+ / visionOS 1.0+
- Swift 5.9+
PaulStretchEffectsis unavailable on watchOS (theAVAudioUniteffect classes don't exist there); the corePaulStretchproduct works everywhere.
dependencies: [
.package(url: "https://github.com/arraypress/swift-paul-stretch.git", from: "2.0.0")
]Add PaulStretch to your target; add PaulStretchEffects for the effect
chains and channel strips, and PaulStretchSession for the multitrack
arrangement model.
import PaulStretch
let source = try AudioFileIO.readStereo(url: fileURL) // any format AVFoundation reads
.trimmed(fromSeconds: 2.0, toSeconds: 6.5) // optional region
.peakNormalized()
var params = StretchParameters() // layered-drone defaults
params.targetSeconds = 300
params.windowSeconds = 0.30
let drone = StretchRenderer.render(source, parameters: params)
// drone is a StereoBuffer — play it, export it, inspect itRendering a short loop and repeating it forever costs a few megabytes and a fraction of a second, and the PaulStretch wash makes the repeat all but imperceptible:
var params = StretchParameters()
params.targetSeconds = 45
params.seamlessLoop = true // renders 6 s extra, crossfades tail → head
let loop = StretchRenderer.render(source, parameters: params)
// schedule `loop` end-to-start with no gap; the seam is inaudibleA 60-minute stereo render is ~1.3 GB as a single buffer — enough for iOS to jetsam-kill the app. The chunked renderer streams the identical audio with a peak footprint of a few chunks:
var params = StretchParameters()
params.targetSeconds = 3600
try StretchRenderer.renderToFile(source, parameters: params,
url: exportURL, // use .m4a for the AAC/ALAC formats
format: .aac256, // ~115 MB/hour instead of ~950 MB WAV
progress: { print("\\($0 * 100)%") })AudioFileFormat covers everything Apple platforms can encode — .wav /
.aiff / .caf PCM (16/24-bit int, 32-bit float), .m4aAAC(bitRate:quality:),
.m4aAACVBR(quality:), .m4aHEAAC(bitRate:) (tiny background-ambience
files), .m4aALAC(bitDepth:), .flac(bitDepth:) and .opusCAF(bitRate:)
(48 kHz streams only). One caveat: lossy .m4a files carry encoder
priming, so a file meant to loop directly in a streaming player should
be PCM or lossless — decoding an .m4a back to memory first loops
seamlessly (AVFoundation trims the priming on read).
Or drive the chunks yourself (feed a player, a network stream, …):
StretchRenderer.renderChunks(source, parameters: params) { chunk in
try myWriter.append(l: chunk.l, r: chunk.r) // arrives in timeline order
}Chunked output is bit-for-bit identical to
StretchRenderer.render(...) — the test suite asserts exact equality for
every mode.
For playback (rather than export) you can skip rendering entirely:
StretchSourceNode synthesises the timeline just-in-time while it plays,
holding only a few seconds of lookahead. With seamlessLoop it wraps
forever — endless ambience from a one-second source with ~2 MB of memory:
var params = StretchParameters()
params.targetSeconds = 120
params.seamlessLoop = true
let node = try await StretchSourceNode.prepare(source: source, parameters: params)
engine.attach(node.avAudioNode)
engine.connect(node.avAudioNode, to: engine.mainMixerNode, format: node.format)
try engine.start() // plays endlessly, synthesised liveWorks for all three modes (tape-slow even prepares instantly — it has no peak passes). The node plays the same deterministic timeline the offline renderers produce, so what you preview live is exactly what an export writes.
Every render has an async form that runs off the cooperative pool and
honours Task cancellation (cancelling throws CancellationError and, for
file renders, deletes the partial file):
let drone = try await StretchRenderer.render(source, parameters: params)
try await StretchRenderer.renderToFile(source, parameters: params,
url: exportURL, format: .aac256)
for try await chunk in StretchRenderer.renderChunkSequence(source, parameters: params) {
feed(chunk) // pull-based: chunks are computed as you consume them
}params.mode = .paulStretch // the classic wash (layering, pitch, onsets)
params.mode = .tapeSlow // varispeed slow-down, tiled to length
params.mode = .spectralFreeze // one frozen instant, sustained forever
params.mode = .phaseVocoder // clean structural stretch, pitch preserved
params.mode = .granularCloud // grain cloud: jitter, pitch spread, pan scatter// Shimmer drones: layering presets with pitched voices.
params.layering = .shimmer // adds an octave-up stretch layer
params.layering = .shimmerDeep // octave-up + sub-octave
// A freeze that slowly morphs through the source:
params.mode = .spectralFreeze
params.freezeScan = 0.6 // capture point drifts 60% of the way
// Granular clouds:
params.mode = .granularCloud
params.grainSeconds = 0.12
params.grainPitchSpread = 7 // grains scattered across ±7 semitones
params.grainPanSpread = 1.0 // full stereo fieldNotes: the phase vocoder wants short windows (windowSeconds ≈ 0.05–0.1)
and ignores layering/phase-randomness; its chunked path renders through
sequential streams (single-core) — still far faster than realtime.
for i in 0..<10 {
let seed = StretchRenderer.variationSeed(i)
let take = StretchRenderer.render(source, parameters: params, seed: seed)
// ten audibly different washes from the same settings, reproducibly
}The pipeline pieces are exposed directly when you don't want the full render:
let washed = PaulStretcher.stretch(source, ratio: 8) // raw stretch
let frozen = SpectralFreezer.render(source, position: 0.5, smear: 0.3,
targetSeconds: 120) // raw freeze
let slow = source.applyingTapeSpeed(0.5) // varispeed
let wide = drone.applyingStereoWidth(1.4) // mid/side width
let ready = drone.seamlesslyLooped() // loop crossfadeimport PaulStretchEffects
var fx = EffectsParameters()
fx.reverbEnabled = true
fx.reverbPreset = .cathedral // the classic PaulStretch pairing
fx.reverbMix = 42
// Live, on a playback graph:
let chain = EffectChain()
chain.install(in: engine, from: playerNode, to: engine.mainMixerNode, format: format)
chain.apply(fx) // update any time, even while playing
// Baked into an export (same parameters → what you hear is what you export):
let wet = EffectsBaker.bake(drone, effects: fx)
// Shimmer reverb (baked path only — feedback cycles can't live on an
// AVAudioEngine graph; hosts re-bake to audition):
fx.shimmerEnabled = true
fx.shimmerPitch = 12 // octave-up bloom (+7 for fifths)
fx.shimmerFeedback = 55
let halo = EffectsBaker.bake(drone, effects: fx)
// Or streamed, for long effected exports in bounded memory:
try StretchRenderer.renderToWAVFile(source, parameters: params, effects: fx, url: exportURL)EffectStack is the Ableton-style device model: an ordered, reorderable
chain of EffectDevices — each one effect with its own settings and bypass,
duplicates welcome. The library's pure-DSP devices and the whole Apple
palette share one stack, and EffectStackBaker honours the order exactly
(consecutive Apple devices group into a single offline pass).
var strip = EffectStack([
EffectDevice(.sweepFilter(filter)), // darken the send…
EffectDevice(.shimmer(shimmer)), // …bloom it…
EffectDevice(.convolutionReverb(space)), // …then place it in a room
EffectDevice(.apple(.peakLimiter(.init()))),
])
strip.move(fromIndex: 0, toIndex: 2) // filter the tail instead
let wet = EffectStackBaker.bake(dry, stack: strip)
// strip.signature — cache key: re-bake playback when (and only when) it changesA time-based multitrack arrangement model for generative ambient — the
ruler is seconds, not bars. Tracks hold Clips: blocks with a timeline
position, placed length, left-trim, gain, edge fades, and loop-fill (the
clip's voice tiles to fill the block; blocks with different voice lengths
phase against each other endlessly — the tape-loop model behind Music for
Airports). A clip's voice renders deterministically and caches:
dragging, trimming and fading never re-render — only source or strip
changes do. The same session always bounces the identical file.
import PaulStretchSession
var session = Session()
var track = Track(name: "piano drone")
track.stack = EffectStack([EffectDevice(.shimmer(shimmer))]) // baked per voice
track.gainLane = AutomationLane(points: [AutomationPoint(t: 0, v: 0.2),
AutomationPoint(t: 1, v: 0.9)])
var clip = Clip(name: "drone",
source: .generative(GenerativeSource(audio: .init(path: noteURL.path),
parameters: droneParams, seed: 3)),
startSeconds: 0, durationSeconds: 1800) // 61 s voice tiles 30 min
clip.fadeInSeconds = 20
track.clips = [clip]
session.tracks = [track]
session.durationSeconds = 1800
session.master = EffectStack([EffectDevice(.convolutionReverb(hall))])
let mix = try SessionRenderer.render(session) // deterministic bounce
try AudioFileIO.write(mix, to: outURL, format: .aac256)
// Live: render voices once (cache by SessionRenderer.voiceCacheKey), then
let mixer = SessionMixer() // player-per-clip, one shared sample-locked anchor
mixer.prepare(session: session, voices: voicesByClipID)
mixer.play(from: 0) // live gain/pan/mute/solo + `levels` metersStretchPlayer is the playback half of a host app: load a render, scrub it,
loop a region, and tweak the stock chain live. Its published isPlaying /
currentTime / duration / spectrum drive SwiftUI directly.
@StateObject var player = StretchPlayer()
player.load(render, loop: true) // or region: 2.0...6.5
player.setEffects(fx) // live stock chain on top
player.play()
player.seek(to: 30)The pure-DSP stages (shimmer, convolution reverb, sweep filter, wow, pump,
auto-pan) can't run on a live graph — bake them with
EffectsBaker.bakePureStages(_:effects:) and reload the player when
fx.pureDSPSignature changes:
if fx.pureDSPSignature != lastBakedSignature {
lastBakedSignature = fx.pureDSPSignature
player.load(EffectsBaker.bakePureStages(dry, effects: fx), loop: true)
}Every render takes an isCancelled closure (poll-based, thread-safe with the
provided CancelToken) and a progress closure:
let token = CancelToken()
Task.detached {
let out = StretchRenderer.render(source, parameters: params,
isCancelled: { token.isCancelled },
progress: { fraction in /* update UI */ })
}
// from the UI:
token.cancel()source ──► reverse? ──► tape speed? ──► ┌──────────────────────────────┐
│ paulStretch: STFT → random │
│ phases → IFFT → overlap-add│
│ (× layers, × tiles) │
│ tapeSlow: tile the │
│ varispeed source │
│ spectralFreeze: resynthesise │
│ one captured spectrum │
└──────────────┬───────────────┘
▼
stereo width ──► seamless loop OR fade in/out
The property that makes the library special: every STFT window is seeded independently (splitmix64-mixed per-window seeds), so any output range can be rendered in isolation with identical results. That one invariant powers both the lock-free multicore renderer (cores own disjoint output segments) and the chunked renderer (time owns disjoint output segments). Where the in-memory path normalises buffers in place, the chunked path first sweeps the timeline to measure the same peaks, then streams the final pass — the arithmetic is identical down to the operation order, which is why the outputs match bit for bit.
Measured on an M-series Mac (14 cores), 3 s source → 10-minute render:
| Render | Speed | Peak memory |
|---|---|---|
| In-memory, single layer | ~2100× realtime | whole output (~100 MB/10 min) |
| In-memory, 3 layers | ~600× realtime | whole output + intermediates |
| Chunked, single layer | ~590× realtime | a few chunks (~10 MB) |
| Chunked, 3 layers | ~120× realtime | a few chunks (~10 MB) |
Chunked rendering trades compute (the peak-measuring passes re-render) for bounded memory. Rules of thumb:
- Mac, or renders under ~10 minutes →
render(...), simplest and fastest. - iOS playback →
seamlessLoop+ a short target; loop the file forever. - iOS export of a genuine long render →
renderToWAVFile(...); a 60-minute file streams to disk in well under a minute of CPU on modern phones instead of holding ~1.3 GB (+ intermediates) in RAM.
A bare stretch carries ~30 % amplitude flutter — that is inherent to
PaulStretch's phase randomisation (reference implementations measure the
same), not a defect. The traditional masker is reverb; ReverbPreset.cathedral
exists for exactly this.
swift test -c release147 tests cover FFT correctness against a scalar oracle, determinism, every
StereoBuffer transform, all pipeline modes, file I/O round trips and the
effects bakers. The load-bearing suite asserts that chunked output is
bit-identical to in-memory output across the full mode matrix and
arbitrary chunk sizes. (Use -c release — unoptimised FFT loops are
20–50× slower.)
MIT License — see LICENSE file for details.
Created by David Sherlock (ArrayPress) in 2026.