Swift 6.4 migration and exhancements (#1)

This PR contains the work done to update the library and the attached example project to use the Swift 6.4 computer as a minimum supported version and also, to use the latest features introduced in it.

Reviewed-on: #1
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
This commit was merged in pull request #1.
This commit is contained in:
2026-09-18 13:15:08 +00:00
committed by javier
parent d0a561b91f
commit c5037dd716
105 changed files with 1390 additions and 1397 deletions
@@ -1,5 +1,4 @@
extension Sound {
/// A note as a MIDI note number, where 60 is middle C. Fractional values
/// are valid.
/// A MIDI note number (60 is middle C); fractional values are valid.
public typealias MIDINote = Float
}
@@ -1,8 +1,8 @@
extension Sound.Effect {
/// Processes up to `AUDIO_FRAMES_PER_CYCLE` sample frames in signed
/// Q8.24 format. `bufferActive` is `false` when the input buffer is
/// silent. Returns `true` if the effect produced output.
public typealias Processor = (_ left: UnsafeMutableBufferPointer<Int32>,
_ right: UnsafeMutableBufferPointer<Int32>?,
/// Processes up to 512 (`AUDIO_FRAMES_PER_CYCLE`) signed Q8.24 frames in place.
/// `right` is empty on mono channels; `bufferActive` is `false` if nothing was
/// written. Returns `true` if it changed the samples.
public typealias Processor = (_ left: inout MutableSpan<Int32>,
_ right: inout MutableSpan<Int32>,
_ bufferActive: Bool) -> Bool
}
@@ -18,17 +18,17 @@ extension Sound {
}
}
/// When `true`, `setDepth` values map exponentially to bit depth.
/// If `true`, quantizing scales with amplitude so quiet sounds survive; if `false`,
/// it clears a fixed number of low-order bits.
public func setExponential(_ flag: Bool) {
BitCrusher.api.pointee.setExponential.unsafelyUnwrapped(pointer, flag)
}
/// The amount of crushing, 0 (none) to 1 (quantized to 1 bit).
/// Quantizing, 0 (none) to 1 (1-bit output).
public func setDepth(_ depth: Float) {
BitCrusher.api.pointee.setDepth.unsafelyUnwrapped(pointer, depth)
}
/// Modulates the crush depth.
public var depthModulator: SignalValue? {
get { SignalValue.wrap(BitCrusher.api.pointee.getDepthModulator.unsafelyUnwrapped(pointer)) }
set {
@@ -37,12 +37,11 @@ extension Sound {
}
}
/// The amount of downsampling, 0 (none) to 1 (every sample repeated).
/// Sample-rate reduction, 0 (none) to 1 (so much that audio stops).
public func setDownsampling(_ downsampling: Float) {
BitCrusher.api.pointee.setDownsampling.unsafelyUnwrapped(pointer, downsampling)
}
/// Modulates the downsampling amount.
public var downsamplingModulator: SignalValue? {
get { SignalValue.wrap(BitCrusher.api.pointee.getDownsamplingModulator.unsafelyUnwrapped(pointer)) }
set {
@@ -5,7 +5,7 @@ extension Sound {
public final class DelayLine: Effect {
private static var api: UnsafePointer<playdate_sound_effect_delayline> { Playdate.delayLineAPI.unsafelyUnwrapped }
/// Creates a delay line holding `length` frames.
/// `length` is in frames.
public init(length: Int, stereo: Bool = false) {
super.init(pointer: DelayLine.api.pointee.newDelayLine.unsafelyUnwrapped(
Int32(length), stereo ? 1 : 0).unsafelyUnwrapped, isOwned: true)
@@ -17,19 +17,18 @@ extension Sound {
}
}
/// Changes the delay length. Cannot be larger than the line's
/// original length.
/// Clears the buffer and reallocates, so not safe while the line is in use.
public func setLength(frames: Int) {
DelayLine.api.pointee.setLength.unsafelyUnwrapped(pointer, Int32(frames))
}
/// The feedback level, 0...1.
/// 0...1.
public func setFeedback(_ feedback: Float) {
DelayLine.api.pointee.setFeedback.unsafelyUnwrapped(pointer, feedback)
}
/// Adds a tap `delay` frames behind the write head. The tap can be
/// added to a channel as a sound source.
/// `delay` is in frames behind the write head, at most the line's length.
/// The tap keeps the line alive.
public func addTap(delay: Int) -> DelayLineTap? {
guard let tap = DelayLine.api.pointee.addTap.unsafelyUnwrapped(pointer, Int32(delay)) else {
return nil
@@ -1,12 +1,11 @@
internal import CPlaydate
extension Sound {
/// A tap into a delay line; produces audio and can be added to a channel
/// as a source. Wraps `DelayLineTap`.
/// A read point on a delay line, playable as a channel source. Wraps `DelayLineTap`.
public final class DelayLineTap: Source {
private static var api: UnsafePointer<playdate_sound_effect_delayline> { Playdate.delayLineAPI.unsafelyUnwrapped }
/// The delay line is retained so the tap stays valid.
/// Kept alive: the tap reads from its buffer.
private let delayLine: DelayLine
private var retainedDelayModulator: SignalValue?
@@ -19,12 +18,12 @@ extension Sound {
DelayLineTap.api.pointee.freeTap.unsafelyUnwrapped(pointer)
}
/// The tap's position in the delay line, in frames.
/// In frames, up to the delay line's length.
public func setDelay(frames: Int) {
DelayLineTap.api.pointee.setTapDelay.unsafelyUnwrapped(pointer, Int32(frames))
}
/// Modulates the tap's delay.
/// A continuous signal speeds up or slows down playback.
public var delayModulator: SignalValue? {
get { SignalValue.wrap(DelayLineTap.api.pointee.getTapDelayModulator.unsafelyUnwrapped(pointer)) }
set {
@@ -33,7 +32,7 @@ extension Sound {
}
}
/// For stereo delay lines: swaps the left and right channels.
/// Stereo delay lines only.
public func setChannelsFlipped(_ flipped: Bool) {
DelayLineTap.api.pointee.setTapChannelsFlipped.unsafelyUnwrapped(pointer, flipped ? 1 : 0)
}
@@ -1,11 +1,9 @@
internal import CPlaydate
/// The cached `playdate->sound->effect` C API table.
private var effectAPI: UnsafePointer<playdate_sound_effect> { Playdate.effectAPI.unsafelyUnwrapped }
extension Sound {
/// An effect that processes a channel's audio: the base class of the
/// built-in effects. Wraps `SoundEffect`.
/// Processes a channel's audio; base of the built-in effects. Wraps `SoundEffect`.
public class Effect {
let pointer: OpaquePointer
let isOwned: Bool
@@ -22,7 +20,7 @@ extension Sound {
self.isOwned = isOwned
}
/// Creates an effect that processes audio with a Swift callback.
/// Runs `processor` each audio cycle; keeps it alive until deinit.
public init(processor: @escaping Processor) {
let box = Unmanaged.passRetained(ProcessorBox(processor))
processorBox = box
@@ -30,18 +28,15 @@ extension Sound {
guard let effect, let left,
let userdata = effectAPI.pointee.getUserdata.unsafelyUnwrapped(effect) else { return 0 }
let box = Unmanaged<ProcessorBox>.fromOpaque(userdata).takeUnretainedValue()
let leftBuffer = UnsafeMutableBufferPointer(start: left, count: Int(nsamples))
let rightBuffer = right.map { UnsafeMutableBufferPointer(start: $0, count: Int(nsamples)) }
return box.processor(leftBuffer, rightBuffer, bufactive != 0) ? 1 : 0
var leftSpan = UnsafeMutableBufferPointer(start: left, count: Int(nsamples)).mutableSpan
var rightSpan = UnsafeMutableBufferPointer(start: right, count: right == nil ? 0 : Int(nsamples)).mutableSpan
return box.processor(&leftSpan, &rightSpan, bufactive != 0) ? 1 : 0
}, box.toOpaque()).unsafelyUnwrapped
isOwned = true
}
deinit {
// Subclasses free the C object in their own deinit with the
// subsystem's type-specific free (freeDelayLine, freeOverdrive,
// ...); freeing here as well would double-free. The base class
// owns only the custom-processor effects it creates itself.
// Subclasses free their C object themselves; freeing here would double-free.
if let processorBox {
if isOwned {
effectAPI.pointee.freeEffect.unsafelyUnwrapped(pointer)
@@ -50,12 +45,11 @@ extension Sound {
}
}
/// The wet/dry mix: 1 is fully processed, 0 fully dry.
/// Wet/dry mix: 0 leaves the effect out, 1 replaces the input with its output.
public func setMix(_ level: Float) {
effectAPI.pointee.setMix.unsafelyUnwrapped(pointer, level)
}
/// Modulates the wet/dry mix.
public var mixModulator: SignalValue? {
get { SignalValue.wrap(effectAPI.pointee.getMixModulator.unsafelyUnwrapped(pointer)) }
set {
@@ -18,13 +18,11 @@ extension Sound {
}
}
/// The filter's cutoff: -1 to 1, where values above 0 are low-pass
/// and values below 0 high-pass.
/// The cutoff, -1 to 1: above 0 is high-pass, below 0 low-pass.
public func setParameter(_ parameter: Float) {
OnePoleFilter.api.pointee.setParameter.unsafelyUnwrapped(pointer, parameter)
}
/// Modulates the filter's cutoff parameter.
public var parameterModulator: SignalValue? {
get { SignalValue.wrap(OnePoleFilter.api.pointee.getParameterModulator.unsafelyUnwrapped(pointer)) }
set {
@@ -18,7 +18,7 @@ extension Sound {
}
}
/// The input gain applied before clipping.
/// Input gain, applied before clipping.
public func setGain(_ gain: Float) {
Overdrive.api.pointee.setGain.unsafelyUnwrapped(pointer, gain)
}
@@ -28,7 +28,6 @@ extension Sound {
Overdrive.api.pointee.setLimit.unsafelyUnwrapped(pointer, limit)
}
/// Modulates the clipping limit.
public var limitModulator: SignalValue? {
get { SignalValue.wrap(Overdrive.api.pointee.getLimitModulator.unsafelyUnwrapped(pointer)) }
set {
@@ -37,12 +36,11 @@ extension Sound {
}
}
/// A DC offset applied to the input, making the clipping asymmetric.
/// Added to the upper and lower limits, making clipping asymmetric.
public func setOffset(_ offset: Float) {
Overdrive.api.pointee.setOffset.unsafelyUnwrapped(pointer, offset)
}
/// Modulates the DC offset.
public var offsetModulator: SignalValue? {
get { SignalValue.wrap(Overdrive.api.pointee.getOffsetModulator.unsafelyUnwrapped(pointer)) }
set {
@@ -18,12 +18,11 @@ extension Sound {
}
}
/// The modulation frequency, in Hz.
/// In Hz.
public func setFrequency(_ frequency: Float) {
RingModulator.api.pointee.setFrequency.unsafelyUnwrapped(pointer, frequency)
}
/// Modulates the modulation frequency.
public var frequencyModulator: SignalValue? {
get { SignalValue.wrap(RingModulator.api.pointee.getFrequencyModulator.unsafelyUnwrapped(pointer)) }
set {
@@ -24,12 +24,12 @@ extension Sound {
TwoPoleFilter.api.pointee.setType.unsafelyUnwrapped(pointer, kind.cValue)
}
/// The center/corner frequency, in Hz.
/// Center or corner frequency, in Hz.
public func setFrequency(_ frequency: Float) {
TwoPoleFilter.api.pointee.setFrequency.unsafelyUnwrapped(pointer, frequency)
}
/// Modulates the filter's frequency.
/// 1 is half the sample rate.
public var frequencyModulator: SignalValue? {
get { SignalValue.wrap(TwoPoleFilter.api.pointee.getFrequencyModulator.unsafelyUnwrapped(pointer)) }
set {
@@ -38,7 +38,7 @@ extension Sound {
}
}
/// The gain, used by PEQ and shelf filters.
/// Used by `.peq` and shelf filters.
public func setGain(_ gain: Float) {
TwoPoleFilter.api.pointee.setGain.unsafelyUnwrapped(pointer, gain)
}
@@ -47,7 +47,6 @@ extension Sound {
TwoPoleFilter.api.pointee.setResonance.unsafelyUnwrapped(pointer, resonance)
}
/// Modulates the filter's resonance.
public var resonanceModulator: SignalValue? {
get { SignalValue.wrap(TwoPoleFilter.api.pointee.getResonanceModulator.unsafelyUnwrapped(pointer)) }
set {
@@ -1,13 +1,13 @@
internal import CPlaydate
extension Sound.TwoPoleFilter {
/// The filter's response type.
public enum Kind: UInt32, Sendable {
case lowPass = 0
case highPass = 1
case bandPass = 2
/// Band-reject.
case notch = 3
/// A parametric EQ filter.
/// Parametric EQ.
case peq = 4
case lowShelf = 5
case highShelf = 6
@@ -1,8 +1,7 @@
internal import CPlaydate
extension Sound {
/// A signal whose values are set on a sequence timeline. Wraps
/// `ControlSignal`.
/// Values set at sequence steps, for automating parameters. Wraps `ControlSignal`.
public final class ControlSignal: SignalValue {
private static var api: UnsafePointer<playdate_control_signal> { Playdate.controlSignalAPI.unsafelyUnwrapped }
@@ -21,24 +20,21 @@ extension Sound {
}
}
/// Removes all events from the signal's timeline.
public func clearEvents() {
ControlSignal.api.pointee.clearEvents.unsafelyUnwrapped(pointer)
}
/// Adds a value at `step` in the signal's timeline. If `interpolate`
/// is `true`, the value ramps from the previous event.
/// If `interpolate`, ramps to `value` from the previous event.
public func addEvent(step: Int, value: Float, interpolate: Bool = false) {
ControlSignal.api.pointee.addEvent.unsafelyUnwrapped(pointer, Int32(step), value,
interpolate ? 1 : 0)
}
/// Removes the event at `step`, if any.
public func removeEvent(step: Int) {
ControlSignal.api.pointee.removeEvent.unsafelyUnwrapped(pointer, Int32(step))
}
/// The MIDI controller number for signals loaded from a MIDI file.
/// For signals created by `Sequence.loadMIDIFile(path:)`.
public var midiControllerNumber: Int {
Int(ControlSignal.api.pointee.getMIDIControllerNumber.unsafelyUnwrapped(pointer))
}
@@ -5,8 +5,7 @@ extension Sound {
public final class Envelope: SignalValue {
private static var api: UnsafePointer<playdate_sound_envelope> { Playdate.envelopeAPI.unsafelyUnwrapped }
/// Creates an envelope with the given attack and decay times
/// (seconds), sustain level (0...1), and release time (seconds).
/// `attack`, `decay`, and `release` are in seconds; `sustain` is 0...1.
public init(attack: Float = 0, decay: Float = 0, sustain: Float = 1, release: Float = 0) {
let pointer = Envelope.api.pointee.newEnvelope.unsafelyUnwrapped(attack, decay, sustain, release)
super.init(pointer: pointer.unsafelyUnwrapped, isOwned: true)
@@ -22,55 +21,51 @@ extension Sound {
}
}
/// The attack time, in seconds.
/// In seconds.
public func setAttack(_ attack: Float) {
Envelope.api.pointee.setAttack.unsafelyUnwrapped(pointer, attack)
}
/// The decay time, in seconds.
/// In seconds.
public func setDecay(_ decay: Float) {
Envelope.api.pointee.setDecay.unsafelyUnwrapped(pointer, decay)
}
/// The sustain level, 0...1.
/// 0...1.
public func setSustain(_ sustain: Float) {
Envelope.api.pointee.setSustain.unsafelyUnwrapped(pointer, sustain)
}
/// The release time, in seconds.
/// In seconds.
public func setRelease(_ release: Float) {
Envelope.api.pointee.setRelease.unsafelyUnwrapped(pointer, release)
}
/// When `true`, a new note while a note is playing does not restart
/// the envelope.
/// If `true`, retriggering before release stays in sustain instead of re-attacking.
public func setLegato(_ flag: Bool) {
Envelope.api.pointee.setLegato.unsafelyUnwrapped(pointer, flag ? 1 : 0)
}
/// When `true`, a new note restarts the envelope from zero instead of
/// its current value.
/// If `true`, each note starts from 0 instead of the current value.
public func setRetrigger(_ flag: Bool) {
Envelope.api.pointee.setRetrigger.unsafelyUnwrapped(pointer, flag ? 1 : 0)
}
/// Bends the envelope's segments: 0 is linear, 1 is maximum curvature.
/// Segment shape, 0 (linear) to 1 (exponential).
public func setCurvature(_ amount: Float) {
Envelope.api.pointee.setCurvature.unsafelyUnwrapped(pointer, amount)
}
/// How much note velocity scales the envelope's output.
/// 1 (default) scales output by velocity; 0 ignores it.
public func setVelocitySensitivity(_ sensitivity: Float) {
Envelope.api.pointee.setVelocitySensitivity.unsafelyUnwrapped(pointer, sensitivity)
}
/// Scales the envelope's rate by note: notes above `start` play the
/// envelope faster (up to `scaling` at `end` and beyond).
/// Rate scale by note: 1 below `start`, `scaling` above `end`, interpolated between.
public func setRateScaling(_ scaling: Float, start: MIDINote, end: MIDINote) {
Envelope.api.pointee.setRateScaling.unsafelyUnwrapped(pointer, scaling, start, end)
}
/// The envelope's current value.
public var value: Float {
Envelope.api.pointee.getValue.unsafelyUnwrapped(pointer)
}
@@ -22,33 +22,32 @@ extension Sound {
LFO.api.pointee.setType.unsafelyUnwrapped(pointer, shape.cValue)
}
/// The LFO rate, in cycles per second.
/// In cycles per second.
public func setRate(_ rate: Float) {
LFO.api.pointee.setRate.unsafelyUnwrapped(pointer, rate)
}
/// The current phase, 0...1.
/// 0...1.
public func setPhase(_ phase: Float) {
LFO.api.pointee.setPhase.unsafelyUnwrapped(pointer, phase)
}
/// The phase the LFO starts at when a note starts, 0...1.
/// 0...1; used when the LFO is retriggered.
public func setStartPhase(_ phase: Float) {
LFO.api.pointee.setStartPhase.unsafelyUnwrapped(pointer, phase)
}
/// The center value of the LFO output.
public func setCenter(_ center: Float) {
LFO.api.pointee.setCenter.unsafelyUnwrapped(pointer, center)
}
/// The amplitude of the LFO around its center.
/// The output's amplitude around its center.
public func setDepth(_ depth: Float) {
LFO.api.pointee.setDepth.unsafelyUnwrapped(pointer, depth)
}
/// For `.arpeggiator` LFOs: the sequence of values (in half-steps)
/// to step through.
/// Switches to `.arpeggiator` over `steps`, in half-steps from the center note
/// (e.g. `[0, 4, 7, 12]` for a major chord).
public func setArpeggiation(_ steps: [Float]) {
var steps = steps
steps.withUnsafeMutableBufferPointer { buffer in
@@ -57,8 +56,7 @@ extension Sound {
}
}
/// For `.function` LFOs: the Swift function providing the value. If
/// `interpolate` is `true`, values are interpolated between calls.
/// For `.function` LFOs; `interpolate` smooths between calls. Keeps `function` alive.
public func setFunction(interpolate: Bool = false, _ function: @escaping (LFO) -> Float) {
self.function = function
LFO.api.pointee.setFunction.unsafelyUnwrapped(pointer, { _, userdata in
@@ -68,28 +66,27 @@ extension Sound {
}, Unmanaged.passUnretained(self).toOpaque(), interpolate ? 1 : 0)
}
/// Waits `holdoff` seconds after a note starts, then ramps the LFO
/// depth up over `rampTime` seconds.
/// Holds at center `holdoff` seconds after a note starts, then ramps linearly to
/// full depth over `rampTime` seconds.
public func setDelay(holdoff: Float, rampTime: Float) {
LFO.api.pointee.setDelay.unsafelyUnwrapped(pointer, holdoff, rampTime)
}
/// Whether the LFO phase restarts on every new note.
/// If `true`, notes on a synth using the LFO reset its phase to the start phase.
public func setRetrigger(_ flag: Bool) {
LFO.api.pointee.setRetrigger.unsafelyUnwrapped(pointer, flag ? 1 : 0)
}
/// When `true`, the LFO runs globally instead of per-note.
/// If `true`, updates continuously, even when not in use.
public func setGlobal(_ global: Bool) {
LFO.api.pointee.setGlobal.unsafelyUnwrapped(pointer, global ? 1 : 0)
}
/// Seeds the random number generator used by `.sampleAndHold` LFOs.
/// Seeds the random generator, for reproducible `.sampleAndHold` output.
public func setRandomSeed(_ seed: UInt16) {
LFO.api.pointee.setRandomSeed.unsafelyUnwrapped(pointer, seed)
}
/// The LFO's current value.
public var value: Float {
LFO.api.pointee.getValue.unsafelyUnwrapped(pointer)
}
@@ -1,8 +1,8 @@
internal import CPlaydate
extension Sound {
/// A signal object; also provides custom signals driven by Swift
/// callbacks. Wraps `PDSynthSignal`.
/// A scaled, offset signal: custom (Swift callbacks) or tracking another value.
/// Wraps `PDSynthSignal`.
public final class Signal: SignalValue {
private static var api: UnsafePointer<playdate_sound_signal> { Playdate.signalAPI.unsafelyUnwrapped }
@@ -11,7 +11,7 @@ extension Sound {
init(_ callbacks: Callbacks) { self.callbacks = callbacks }
}
/// Creates a signal driven by the given callbacks.
/// `callbacks` stay alive until the C signal is freed.
public init(callbacks: Callbacks) {
let box = Unmanaged.passRetained(Box(callbacks))
let pointer = Signal.api.pointee.newSignal.unsafelyUnwrapped(
@@ -38,8 +38,7 @@ extension Sound {
super.init(pointer: pointer.unsafelyUnwrapped, isOwned: true)
}
/// Creates a plain signal object wrapping an existing signal value,
/// so it can be scaled and offset.
/// Tracks `value` so it can be scaled and offset; does not keep `value` alive.
public init(value: SignalValue) {
let pointer = Signal.api.pointee.newSignalForValue.unsafelyUnwrapped(value.pointer)
super.init(pointer: pointer.unsafelyUnwrapped, isOwned: true)
@@ -55,17 +54,15 @@ extension Sound {
}
}
/// The signal's current value.
public var value: Float {
Signal.api.pointee.getValue.unsafelyUnwrapped(pointer)
}
/// Scales the signal's output.
/// Applied before the offset.
public func setValueScale(_ scale: Float) {
Signal.api.pointee.setValueScale.unsafelyUnwrapped(pointer, scale)
}
/// Offsets the signal's output.
public func setValueOffset(_ offset: Float) {
Signal.api.pointee.setValueOffset.unsafelyUnwrapped(pointer, offset)
}
@@ -1,6 +1,7 @@
extension Sound {
/// A value that can modulate a parameter. The base class of `Signal`,
/// `LFO`, `Envelope`, and `ControlSignal`. Wraps `PDSynthSignalValue`.
/// A value that can modulate a parameter. Wraps `PDSynthSignalValue`; base of
/// `Signal`, `LFO`, `Envelope`, and `ControlSignal`. What it modulates keeps it
/// alive; assigning `nil` to a modulator property clears it.
public class SignalValue {
let pointer: OpaquePointer
let isOwned: Bool
@@ -10,7 +11,7 @@ extension Sound {
self.isOwned = isOwned
}
/// Wraps a signal value pointer returned by the OS (not owned).
/// Wraps a C API pointer without taking ownership.
static func wrap(_ pointer: OpaquePointer?) -> SignalValue? {
guard let pointer else { return nil }
return SignalValue(pointer: pointer, isOwned: false)
@@ -1,15 +1,17 @@
internal import CPlaydate
extension Sound.LFO {
/// The oscillator's waveform.
public enum Shape: UInt32, Sendable {
case square = 0
case triangle = 1
case sine = 2
/// Random values, held for each cycle.
case sampleAndHold = 3
case sawtoothUp = 4
case sawtoothDown = 5
/// Steps through the values set by `setArpeggiation(_:)`.
case arpeggiator = 6
/// Values come from the function set by `setFunction(interpolate:_:)`.
case function = 7
var cValue: LFOType { LFOType(LFOType.RawValue(rawValue)) }
@@ -1,16 +1,14 @@
extension Sound.Signal {
/// Custom signal callbacks.
/// Custom signal callbacks, run on the audio render thread; return quickly.
public struct Callbacks {
/// Returns the signal's value at the end of the current cycle.
/// `ioFrames` is the number of frames until the cycle ends and
/// may be lowered to interpolate toward `interpolationValue`.
/// Returns the value at the end of the cycle; `ioFrames` holds its frames left. For
/// a mid-cycle value, write it to `interpolationValue`, set `ioFrames` to its offset.
public var step: (_ ioFrames: UnsafeMutablePointer<Int32>?,
_ interpolationValue: UnsafeMutablePointer<Float>?) -> Float
/// Called on note-on events. `length` is -1 for indefinite notes.
/// `length` is in seconds, or -1 if indefinite.
public var noteOn: ((_ note: Sound.MIDINote, _ velocity: Float, _ length: Float) -> Void)?
/// Called on note-off events. `stopped` is `false` when the note
/// is released and `true` when it actually stops playing;
/// `offset` is the frame offset within the current cycle.
/// `stopped` is `false` on release, `true` on stop; `offset` is the frame offset
/// into the cycle.
public var noteOff: ((_ stopped: Bool, _ offset: Int) -> Void)?
public init(step: @escaping (_ ioFrames: UnsafeMutablePointer<Int32>?,
+16 -23
View File
@@ -10,8 +10,7 @@ extension Sound {
/// Middle C (`NOTE_C4`).
public static let noteC4: MIDINote = 60
/// The number of audio frames rendered per system audio cycle
/// (`AUDIO_FRAMES_PER_CYCLE`).
/// Audio frames rendered per audio cycle (`AUDIO_FRAMES_PER_CYCLE`).
public static let audioFramesPerCycle = 512
/// Converts a MIDI note to a frequency in Hz.
@@ -24,7 +23,7 @@ extension Sound {
pd_frequencyToNote(frequency)
}
/// The most recent sound error as a thrown error.
/// The last sound error, as a `PlaydateError`.
static func lastError() -> PlaydateError {
PlaydateError(cString: snd.pointee.getError.unsafelyUnwrapped())
}
@@ -41,7 +40,8 @@ extension Sound {
String(playdateCString: snd.pointee.getError.unsafelyUnwrapped())
}
/// Removes a source from its channel.
/// Removes `source` from its channel; `false` if it wasn't in one. Also releases a
/// `CallbackSource`'s callback.
@discardableResult
public static func removeSource(_ source: Source) -> Bool {
let removed = snd.pointee.removeSource.unsafelyUnwrapped(source.pointer) != 0
@@ -49,16 +49,15 @@ extension Sound {
return removed
}
/// Sets a callback that records microphone input. Return `false` from the
/// callback to stop recording. Pass `nil` to stop recording immediately.
/// The buffer contains mono 16-bit samples.
/// `callback` gets mono 16-bit mic samples each audio cycle and returns `false` to stop;
/// `nil` stops now. Returns `false` on error, e.g. access denied (`requestMicAccess`).
@discardableResult
public static func setMicCallback(source: MicSource = .autodetect,
_ callback: ((UnsafeMutableBufferPointer<Int16>) -> Bool)?) -> Bool {
_ callback: ((Span<Int16>) -> Bool)?) -> Bool {
micCallback = callback
if callback != nil {
return snd.pointee.setMicCallback.unsafelyUnwrapped({ _, buffer, length in
let samples = UnsafeMutableBufferPointer(start: buffer, count: Int(length))
let samples = UnsafeBufferPointer(start: buffer, count: Int(length)).span
return Sound.micCallback?(samples) == true ? 1 : 0
}, nil, CPlaydate.MicSource(CPlaydate.MicSource.RawValue(source.rawValue))) != 0
} else {
@@ -66,12 +65,10 @@ extension Sound {
}
}
nonisolated(unsafe) private static var micCallback: ((UnsafeMutableBufferPointer<Int16>) -> Bool)?
nonisolated(unsafe) private static var micCallback: ((Span<Int16>) -> Bool)?
/// Asks the user for permission to record from the microphone. `purpose`
/// is shown in the permission prompt. The completion receives whether
/// access was granted; it is not called if the reply was already
/// determined (the returned value is `.deny` or `.allow`).
/// Asks for mic permission before `setMicCallback`; `purpose` is shown in the prompt.
/// `completion` gets the answer only when this returns `.ask` (else already known).
@discardableResult
public static func requestMicAccess(purpose: String? = nil,
_ completion: @escaping (Bool) -> Void) -> AccessReply {
@@ -84,7 +81,7 @@ extension Sound {
}
let reply: accessReply
if let purpose {
reply = purpose.withPlaydateCString {
reply = purpose.withCString {
snd.pointee.requestMicAccess.unsafelyUnwrapped($0, trampoline, box.toOpaque())
}
} else {
@@ -104,8 +101,8 @@ extension Sound {
return (headphone != 0, headsetMic != 0)
}
/// Installs a callback invoked when the headphone or headset-mic state
/// changes.
/// Called when headphone or headset-mic state changes; `nil` removes it. While set,
/// output doesn't auto-switch speaker/headphones; call `setOutputsActive` from it.
public static func setHeadphoneChangeCallback(_ callback: ((_ headphone: Bool, _ headsetMic: Bool) -> Void)?) {
headphoneChangeCallback = callback
if callback != nil {
@@ -119,16 +116,12 @@ extension Sound {
nonisolated(unsafe) private static var headphoneChangeCallback: ((Bool, Bool) -> Void)?
/// Forces audio output to the headphone and/or speaker. When the
/// headphone jack drives output and `speaker` is also set, the speaker
/// plays too.
/// Forces audio output to the given outputs, regardless of headphone state.
public static func setOutputsActive(headphone: Bool, speaker: Bool) {
snd.pointee.setOutputsActive.unsafelyUnwrapped(headphone ? 1 : 0, speaker ? 1 : 0)
}
/// Adds a callback-based source to the default channel. The callback
/// fills the sample buffers and returns `true` if it produced output.
/// Buffers hold 16-bit samples; `right` is non-nil only when `stereo`.
/// Adds a `CallbackSource` to the default channel.
public static func addSource(stereo: Bool,
_ callback: @escaping CallbackSource.Callback) -> CallbackSource {
let source = CallbackSource(callback: callback)
@@ -1,6 +1,6 @@
extension Sound.CallbackSource {
/// Fills the sample buffers and returns `true` if output was
/// produced. `right` is non-nil only for stereo sources.
public typealias Callback = (_ left: UnsafeMutableBufferPointer<Int16>,
_ right: UnsafeMutableBufferPointer<Int16>?) -> Bool
/// Fills `left` and, if stereo, `right` (else empty) with 16-bit samples.
/// Returns `false` if the source was silent this cycle.
public typealias Callback = (_ left: inout MutableSpan<Int16>,
_ right: inout MutableSpan<Int16>) -> Bool
}
@@ -13,7 +13,7 @@ extension Sound {
self.isOwned = isOwned
}
/// Allocates a sample buffer with room for `byteCount` bytes.
/// An empty buffer sized for a `byteCount`-byte file; fill it with `load(path:)`.
public convenience init(byteCount: Int) {
self.init(pointer: AudioSample.api.pointee.newSampleBuffer.unsafelyUnwrapped(
Int32(byteCount)).unsafelyUnwrapped, isOwned: true)
@@ -21,17 +21,15 @@ extension Sound {
/// Loads the wav or aiff file at `path`.
public convenience init(path: String) throws(PlaydateError) {
let pointer = path.withPlaydateCString { AudioSample.api.pointee.load.unsafelyUnwrapped($0) }
let pointer = path.withCString { AudioSample.api.pointee.load.unsafelyUnwrapped($0) }
guard let pointer else {
throw PlaydateError(message: "unable to load sample: \(path)")
}
self.init(pointer: pointer, isOwned: true)
}
/// Creates a sample referencing existing sample data. If
/// `freeWhenDone` is `true`, the OS frees `data` when the sample is
/// freed; otherwise the caller must keep `data` valid for the
/// sample's lifetime.
/// References `data` without copying; it must outlive the sample, which frees it
/// if `freeWhenDone`. Returns `nil` on failure.
public convenience init?(data: UnsafeMutablePointer<UInt8>, format: Format,
sampleRate: UInt32, byteCount: Int, freeWhenDone: Bool) {
guard let pointer = AudioSample.api.pointee.newSampleFromData.unsafelyUnwrapped(
@@ -47,9 +45,8 @@ extension Sound {
}
}
/// Loads the file at `path` into this sample's buffer.
public func load(path: String) throws(PlaydateError) {
let loaded = path.withPlaydateCString {
let loaded = path.withCString {
AudioSample.api.pointee.loadIntoSample.unsafelyUnwrapped(pointer, $0) != 0
}
if !loaded {
@@ -57,7 +54,7 @@ extension Sound {
}
}
/// The sample's raw data, format, and rate.
/// Data pointer (owned by the sample), format, rate in Hz, and length in bytes.
public var data: (data: UnsafeMutablePointer<UInt8>?, format: Format,
sampleRate: UInt32, byteLength: UInt32) {
var data: UnsafeMutablePointer<UInt8>?
@@ -67,13 +64,13 @@ extension Sound {
return (data, Format(format), sampleRate, byteLength)
}
/// The sample's length in seconds.
/// Length in seconds.
public var length: Float {
AudioSample.api.pointee.getLength.unsafelyUnwrapped(pointer)
}
/// Decompresses an ADPCM sample to 16-bit PCM so it can be used in a
/// synth. Returns `false` if there is not enough memory.
/// Decompresses ADPCM to 16-bit PCM (4x memory), needed for synths and reverse
/// play. Returns `false` if out of memory.
@discardableResult
public func decompress() -> Bool {
AudioSample.api.pointee.decompress.unsafelyUnwrapped(pointer) != 0
@@ -1,15 +1,14 @@
extension Sound {
/// A source that produces audio by calling back into Swift.
/// A source rendered by a Swift callback every audio cycle. Create with
/// `Sound.addSource(stereo:_:)`; it stays alive until removed with `removeSource`.
public final class CallbackSource: Source {
let callback: Callback
/// Every callback source is kept alive here while the C side may
/// still invoke its trampoline: from creation until it is removed
/// with `Sound.removeSource`/`Channel.removeSource`, or until its
/// owning channel is freed.
/// Keeps sources alive for the C trampoline until removed (`Sound`/`Channel`
/// `.removeSource`) or their channel is freed.
nonisolated(unsafe) static var live: [CallbackSource] = []
/// Releases the registration added by `adopt(pointer:)`.
/// Drops the reference added by `adopt(pointer:)`.
static func release(_ source: Source) {
live.removeAll { $0 === source }
}
@@ -27,9 +26,9 @@ extension Sound {
UnsafeMutablePointer<Int16>?, Int32) -> Int32 = { context, left, right, length in
guard let context, let left else { return 0 }
let source = Unmanaged<CallbackSource>.fromOpaque(context).takeUnretainedValue()
let leftBuffer = UnsafeMutableBufferPointer(start: left, count: Int(length))
let rightBuffer = right.map { UnsafeMutableBufferPointer(start: $0, count: Int(length)) }
return source.callback(leftBuffer, rightBuffer) ? 1 : 0
var leftSpan = UnsafeMutableBufferPointer(start: left, count: Int(length)).mutableSpan
var rightSpan = UnsafeMutableBufferPointer(start: right, count: right == nil ? 0 : Int(length)).mutableSpan
return source.callback(&leftSpan, &rightSpan) ? 1 : 0
}
/// Attaches the C object created for this source.
@@ -7,7 +7,7 @@ extension Sound {
var loopCallback: ((FilePlayer) -> Void)?
var fadeCallback: ((FilePlayer) -> Void)?
var mp3DataSource: ((UnsafeMutableBufferPointer<UInt8>) -> Int)?
var mp3DataSource: ((inout MutableSpan<UInt8>) -> Int)?
private var retainedRateModulator: SignalValue?
override init(pointer: OpaquePointer?, isOwned: Bool) {
@@ -19,7 +19,6 @@ extension Sound {
isOwned: true)
}
/// Creates a player and loads the audio file at `path`.
public convenience init(path: String) throws(PlaydateError) {
self.init()
try load(path: path)
@@ -31,9 +30,8 @@ extension Sound {
}
}
/// Prepares the player to stream the file at `path`.
public func load(path: String) throws(PlaydateError) {
let loaded = path.withPlaydateCString {
let loaded = path.withCString {
FilePlayer.api.pointee.loadIntoPlayer.unsafelyUnwrapped(pointer, $0) != 0
}
if !loaded {
@@ -41,63 +39,60 @@ extension Sound {
}
}
/// Sets the length of the stream buffer, in seconds. Default 0.25.
/// Stream buffer length, in seconds; default 0.25.
public func setBufferLength(_ seconds: Float) {
FilePlayer.api.pointee.setBufferLength.unsafelyUnwrapped(pointer, seconds)
}
/// Starts playback, looping `repeat` times; 0 loops endlessly.
/// Plays `repeat` times (0 loops forever); `false` if buffer allocation failed.
@discardableResult
public func play(repeat repeatCount: Int = 1) -> Bool {
FilePlayer.api.pointee.play.unsafelyUnwrapped(pointer, Int32(repeatCount)) != 0
}
/// Pauses playback.
public func pause() {
FilePlayer.api.pointee.pause.unsafelyUnwrapped(pointer)
}
/// Stops playback.
public func stop() {
FilePlayer.api.pointee.stop.unsafelyUnwrapped(pointer)
}
/// The file's length in seconds.
/// Length in seconds.
public var length: Float {
FilePlayer.api.pointee.getLength.unsafelyUnwrapped(pointer)
}
/// The playback position in seconds.
/// Playback position, in seconds.
public var offset: Float {
get { FilePlayer.api.pointee.getOffset.unsafelyUnwrapped(pointer) }
set { FilePlayer.api.pointee.setOffset.unsafelyUnwrapped(pointer, newValue) }
}
/// The playback rate; 1 is normal speed, negative values are not
/// supported.
/// Playback rate; 1 is normal. Negative (reverse) is unsupported.
public var rate: Float {
get { FilePlayer.api.pointee.getRate.unsafelyUnwrapped(pointer) }
set { FilePlayer.api.pointee.setRate.unsafelyUnwrapped(pointer, newValue) }
}
/// Loops playback between `start` and `end` (seconds) while playing
/// with `repeat` 0. An `end` of 0 means the end of the file.
/// Loop region, in seconds; `end` 0 means end of file. Loops only if played
/// with `repeat` 0 or 2.
public func setLoopRange(start: Float, end: Float) {
FilePlayer.api.pointee.setLoopRange.unsafelyUnwrapped(pointer, start, end)
}
/// Whether playback underran because the file could not be read fast
/// enough.
/// Whether playback underran because the file couldn't be read fast enough.
public var didUnderrun: Bool {
FilePlayer.api.pointee.didUnderrun.unsafelyUnwrapped(pointer) != 0
}
/// Stops playback (instead of looping the buffer) on underrun.
/// If `true`, an underrun stops playback and calls the finish callback; by
/// default playback resumes after a stutter once data arrives.
public func setStopOnUnderrun(_ flag: Bool) {
FilePlayer.api.pointee.setStopOnUnderrun.unsafelyUnwrapped(pointer, flag ? 1 : 0)
}
/// Sets a function called every time playback loops.
/// Called each time playback loops; `nil` removes it.
public func setLoopCallback(_ callback: ((FilePlayer) -> Void)?) {
loopCallback = callback
if callback != nil {
@@ -111,8 +106,8 @@ extension Sound {
}
}
/// Fades the volume to the given levels over `length` sample frames,
/// then calls `completion`.
/// Fades to `left`/`right` (01) over `length` sample frames, then calls
/// `completion`.
public func fadeVolume(left: Float, right: Float, length: Int32,
completion: ((FilePlayer) -> Void)? = nil) {
fadeCallback = completion
@@ -127,21 +122,20 @@ extension Sound {
}
}
/// Streams MP3 data from a callback instead of a file. The callback
/// fills the buffer and returns the number of bytes written; return 0
/// to signal the end of the stream.
/// Streams MP3 from `dataSource`, buffering `bufferLength` seconds. `dataSource`
/// fills the span and returns bytes written; 0 ends the stream.
public func setMP3StreamSource(bufferLength: Float,
_ dataSource: @escaping (UnsafeMutableBufferPointer<UInt8>) -> Int) {
_ dataSource: @escaping (inout MutableSpan<UInt8>) -> Int) {
mp3DataSource = dataSource
FilePlayer.api.pointee.setMP3StreamSource.unsafelyUnwrapped(pointer, { data, bytes, userdata in
guard let userdata, let data else { return 0 }
let player = Unmanaged<FilePlayer>.fromOpaque(userdata).takeUnretainedValue()
let buffer = UnsafeMutableBufferPointer(start: data, count: Int(bytes))
return Int32(player.mp3DataSource?(buffer) ?? 0)
var buffer = UnsafeMutableBufferPointer(start: data, count: Int(bytes)).mutableSpan
return Int32(player.mp3DataSource?(&buffer) ?? 0)
}, Unmanaged.passUnretained(self).toOpaque(), bufferLength)
}
/// Modulates the playback rate.
/// A signal added to `rate`; `nil` clears it. The player retains it.
public var rateModulator: SignalValue? {
get { SignalValue.wrap(FilePlayer.api.pointee.getRateModulator.unsafelyUnwrapped(pointer)) }
set {
@@ -18,7 +18,6 @@ extension Sound {
isOwned: true)
}
/// Creates a player for the sample at `path`.
public convenience init(path: String) throws(PlaydateError) {
self.init()
sample = try AudioSample(path: path)
@@ -30,7 +29,7 @@ extension Sound {
}
}
/// The sample to play.
/// Retained by the player.
public var sample: AudioSample? {
get { retainedSample }
set {
@@ -39,46 +38,43 @@ extension Sound {
}
}
/// Starts playback at `rate`, looping `repeat` times; 0 loops
/// endlessly, -1 loops ping-pong.
/// Plays `repeat` times at `rate` (1 is normal); 0 loops forever, -1 ping-pongs.
@discardableResult
public func play(repeat repeatCount: Int = 1, rate: Float = 1) -> Bool {
SamplePlayer.api.pointee.play.unsafelyUnwrapped(pointer, Int32(repeatCount), rate) != 0
}
/// Stops playback.
public func stop() {
SamplePlayer.api.pointee.stop.unsafelyUnwrapped(pointer)
}
/// Pauses or resumes playback.
public func setPaused(_ paused: Bool) {
SamplePlayer.api.pointee.setPaused.unsafelyUnwrapped(pointer, paused ? 1 : 0)
}
/// The sample's length in seconds.
/// Length in seconds.
public var length: Float {
SamplePlayer.api.pointee.getLength.unsafelyUnwrapped(pointer)
}
/// The playback position in seconds.
/// Playback position, in seconds.
public var offset: Float {
get { SamplePlayer.api.pointee.getOffset.unsafelyUnwrapped(pointer) }
set { SamplePlayer.api.pointee.setOffset.unsafelyUnwrapped(pointer, newValue) }
}
/// The playback rate; 1 is normal speed, negative plays backward.
/// Playback rate; 1 is normal. Negative plays backward (PCM only, not ADPCM).
public var rate: Float {
get { SamplePlayer.api.pointee.getRate.unsafelyUnwrapped(pointer) }
set { SamplePlayer.api.pointee.setRate.unsafelyUnwrapped(pointer, newValue) }
}
/// Restricts playback to the given range of sample frames.
/// Restricts playback to `start``end`, in sample frames.
public func setPlayRange(start: Int, end: Int) {
SamplePlayer.api.pointee.setPlayRange.unsafelyUnwrapped(pointer, Int32(start), Int32(end))
}
/// Sets a function called every time playback loops.
/// Called each time playback loops; `nil` removes it.
public func setLoopCallback(_ callback: ((SamplePlayer) -> Void)?) {
loopCallback = callback
if callback != nil {
@@ -92,7 +88,7 @@ extension Sound {
}
}
/// Modulates the playback rate.
/// A signal added to `rate`; `nil` clears it. The player retains it.
public var rateModulator: SignalValue? {
get { SignalValue.wrap(SamplePlayer.api.pointee.getRateModulator.unsafelyUnwrapped(pointer)) }
set {
@@ -1,12 +1,12 @@
internal import CPlaydate
extension Sound {
/// A source of audio: the base class of `FilePlayer`, `SamplePlayer`,
/// `Synth`, `DelayLineTap`, and `CallbackSource`. Wraps `SoundSource`.
/// Base class of `FilePlayer`, `SamplePlayer`, `Synth`, `DelayLineTap`, and
/// `CallbackSource`. Wraps `SoundSource`.
public class Source {
private static var api: UnsafePointer<playdate_sound_source> { Playdate.sourceAPI.unsafelyUnwrapped }
/// The underlying C object. Set once, immediately after creation.
/// Set once, right after creation.
var pointer: OpaquePointer!
let isOwned: Bool
var finishCallback: ((Source) -> Void)?
@@ -16,7 +16,7 @@ extension Sound {
self.isOwned = isOwned
}
/// The playback volume of the left and right channels, 0...1.
/// Per-channel volume, 01.
public var volume: (left: Float, right: Float) {
get {
var left: Float = 0, right: Float = 0
@@ -26,7 +26,6 @@ extension Sound {
set { Source.api.pointee.setVolume.unsafelyUnwrapped(pointer, newValue.left, newValue.right) }
}
/// Sets the playback volume of both channels.
public func setVolume(_ volume: Float) {
self.volume = (volume, volume)
}
@@ -35,7 +34,7 @@ extension Sound {
Source.api.pointee.isPlaying.unsafelyUnwrapped(pointer) != 0
}
/// Sets a function called when the source finishes playing.
/// Called when the source finishes playing; `nil` removes it.
public func setFinishCallback(_ callback: ((Source) -> Void)?) {
finishCallback = callback
if callback != nil {
@@ -1,8 +1,8 @@
internal import CPlaydate
extension Sound {
/// A bank of synth voices for playing a sequence track. Wraps
/// `PDSynthInstrument`.
/// A pool of synth voices for polyphonic playback. Wraps `PDSynthInstrument`.
/// Keeps added voices alive.
public final class Instrument {
private static var api: UnsafePointer<playdate_sound_instrument> { Playdate.instrumentAPI.unsafelyUnwrapped }
@@ -26,9 +26,8 @@ extension Sound {
}
}
/// Adds a voice to the instrument, handling notes in
/// `rangeStart...rangeEnd` (0...127 handles all notes), transposed by
/// `transpose` half-steps.
/// Voices notes `rangeStart...rangeEnd`, transposed `transpose` half-steps on top of
/// the instrument. Returns `false` if `synth` has another instrument or channel.
@discardableResult
public func addVoice(_ synth: Synth, rangeStart: MIDINote = 0, rangeEnd: MIDINote = 127,
transpose: Float = 0) -> Bool {
@@ -40,8 +39,8 @@ extension Sound {
return added
}
/// Plays a note at `frequency` Hz on an available voice. Returns the
/// synth used, if any.
/// Uses the next free voice, else the one released or playing longest. Arguments
/// as in `Synth.playNote`. Returns the voice used, if any.
@discardableResult
public func playNote(frequency: Float, velocity: Float = 1,
length: Float? = nil, when: UInt32 = 0) -> Synth? {
@@ -50,7 +49,7 @@ extension Sound {
return voice(for: synth)
}
/// Plays a MIDI note on an available voice. Returns the synth used.
/// Like `playNote(frequency:velocity:length:when:)`; returns the voice used, if any.
@discardableResult
public func playMIDINote(_ note: MIDINote, velocity: Float = 1,
length: Float? = nil, when: UInt32 = 0) -> Synth? {
@@ -67,33 +66,32 @@ extension Sound {
return Synth(pointer: pointer, isOwned: false)
}
/// Bends played notes by `bend` × the pitch bend range.
/// A fraction of the pitch bend range.
public func setPitchBend(_ bend: Float) {
Instrument.api.pointee.setPitchBend.unsafelyUnwrapped(pointer, bend)
}
/// The range of `setPitchBend(_:)`, in half-steps.
/// The range of `setPitchBend(_:)`; default 12.
public func setPitchBendRange(halfSteps: Float) {
Instrument.api.pointee.setPitchBendRange.unsafelyUnwrapped(pointer, halfSteps)
}
/// Transposes played notes by `halfSteps` (fractional values
/// allowed).
/// Transposes all voices; fractional values allowed.
public func setTranspose(halfSteps: Float) {
Instrument.api.pointee.setTranspose.unsafelyUnwrapped(pointer, halfSteps)
}
/// Releases the voice playing `note` at time `when` (0 = now).
/// Releases the voice playing `note` at audio-clock time `when`, or now if 0.
public func noteOff(_ note: MIDINote, when: UInt32 = 0) {
Instrument.api.pointee.noteOff.unsafelyUnwrapped(pointer, note, when)
}
/// Releases every playing voice at time `when` (0 = now).
/// Releases every voice at audio-clock time `when`, or now if 0.
public func allNotesOff(when: UInt32 = 0) {
Instrument.api.pointee.allNotesOff.unsafelyUnwrapped(pointer, when)
}
/// The volume of the left and right channels, 0...1.
/// Left and right volume, 0...1.
public var volume: (left: Float, right: Float) {
get {
var left: Float = 0, right: Float = 0
@@ -103,7 +101,6 @@ extension Sound {
set { Instrument.api.pointee.setVolume.unsafelyUnwrapped(pointer, newValue.left, newValue.right) }
}
/// The number of voices currently playing.
public var activeVoiceCount: Int {
Int(Instrument.api.pointee.activeVoiceCount.unsafelyUnwrapped(pointer))
}
@@ -1,8 +1,8 @@
internal import CPlaydate
extension Sound {
/// A collection of tracks with tempo and loop control, playable from a
/// MIDI file. Wraps `SoundSequence`.
/// Tracks played at a shared tempo. Wraps `SoundSequence`.
/// Owns, or keeps alive, every track it returns or is given.
public final class Sequence {
private static var api: UnsafePointer<playdate_sound_sequence> { Playdate.sequenceAPI.unsafelyUnwrapped }
@@ -14,7 +14,6 @@ extension Sound {
pointer = Sequence.api.pointee.newSequence.unsafelyUnwrapped().unsafelyUnwrapped
}
/// Creates a sequence and loads the MIDI file at `path`.
public convenience init(path: String) throws(PlaydateError) {
self.init()
try loadMIDIFile(path: path)
@@ -25,7 +24,7 @@ extension Sound {
}
public func loadMIDIFile(path: String) throws(PlaydateError) {
let loaded = path.withPlaydateCString {
let loaded = path.withCString {
Sequence.api.pointee.loadMIDIFile.unsafelyUnwrapped(pointer, $0) != 0
}
if !loaded {
@@ -33,7 +32,7 @@ extension Sound {
}
}
/// Starts playback. `completion` is called when the sequence finishes.
/// `completion` is called when the sequence finishes.
public func play(completion: ((Sequence) -> Void)? = nil) {
finishCallback = completion
if completion != nil {
@@ -47,48 +46,45 @@ extension Sound {
}
}
/// Stops playback.
public func stop() {
Sequence.api.pointee.stop.unsafelyUnwrapped(pointer)
}
/// Whether the sequence is playing.
public var isPlaying: Bool {
Sequence.api.pointee.isPlaying.unsafelyUnwrapped(pointer) != 0
}
/// The playback position, in samples.
/// The playback position, in samples (not steps).
public var time: UInt32 {
get { Sequence.api.pointee.getTime.unsafelyUnwrapped(pointer) }
set { Sequence.api.pointee.setTime.unsafelyUnwrapped(pointer, newValue) }
}
/// The tempo, in steps per second.
/// In steps per second.
public var tempo: Float {
get { Sequence.api.pointee.getTempo.unsafelyUnwrapped(pointer) }
set { Sequence.api.pointee.setTempo.unsafelyUnwrapped(pointer, newValue) }
}
/// The sequence's length in steps, including the tail of the last note.
/// The length of the longest track, in steps.
public var length: UInt32 {
Sequence.api.pointee.getLength.unsafelyUnwrapped(pointer)
}
/// Loops the range `loopStart..<loopEnd` (steps) `loops` times while
/// playing; 0 loops endlessly.
/// Loops steps `start` to `end` `count` times; 0 loops forever.
public func setLoops(start: Int, end: Int, count: Int = 0) {
Sequence.api.pointee.setLoops.unsafelyUnwrapped(pointer, Int32(start), Int32(end), Int32(count))
}
/// The current step, and the time offset (in samples) into that step.
/// `timeOffset` is in samples.
public var currentStep: (step: Int, timeOffset: Int) {
var timeOffset: Int32 = 0
let step = Sequence.api.pointee.getCurrentStep.unsafelyUnwrapped(pointer, &timeOffset)
return (Int(step), Int(timeOffset))
}
/// Moves playback to the given step. If `playNotes` is `true`, notes
/// at the position (that started before it) are played.
/// `timeOffset` is in samples. If `playNotes`, plays the notes at `step`
/// (ignoring `timeOffset`).
public func setCurrentStep(_ step: Int, timeOffset: Int = 0, playNotes: Bool = false) {
Sequence.api.pointee.setCurrentStep.unsafelyUnwrapped(pointer, Int32(step),
Int32(timeOffset), playNotes ? 1 : 0)
@@ -100,8 +96,6 @@ extension Sound {
Int(Sequence.api.pointee.getTrackCount.unsafelyUnwrapped(pointer))
}
/// Adds a new track to the sequence. The track is owned by the
/// sequence.
@discardableResult
public func addTrack() -> SequenceTrack {
let track = SequenceTrack(
@@ -111,14 +105,12 @@ extension Sound {
return track
}
/// The track at `index`. Owned by the sequence.
public func track(at index: Int) -> SequenceTrack? {
guard let track = Sequence.api.pointee.getTrackAtIndex.unsafelyUnwrapped(
pointer, UInt32(index)) else { return nil }
return SequenceTrack(pointer: track, isOwned: false)
}
/// Installs `track` at `index`.
public func setTrack(_ track: SequenceTrack, at index: Int) {
if !retainedTracks.contains(where: { $0 === track }) {
retainedTracks.append(track)
@@ -126,7 +118,6 @@ extension Sound {
Sequence.api.pointee.setTrackAtIndex.unsafelyUnwrapped(pointer, track.pointer, UInt32(index))
}
/// Releases every playing note in the sequence.
public func allNotesOff() {
Sequence.api.pointee.allNotesOff.unsafelyUnwrapped(pointer)
}
@@ -1,7 +1,8 @@
internal import CPlaydate
extension Sound {
/// A track of notes played by an instrument. Wraps `SequenceTrack`.
/// Notes and control signals played on one instrument. Wraps `SequenceTrack`.
/// Owns the control signals it returns; keeps an instrument set on it alive.
public final class SequenceTrack {
private static var api: UnsafePointer<playdate_sound_track> { Playdate.trackAPI.unsafelyUnwrapped }
@@ -25,7 +26,6 @@ extension Sound {
}
}
/// The instrument that plays this track's notes.
public var instrument: Instrument? {
get {
if let retainedInstrument { return retainedInstrument }
@@ -40,32 +40,29 @@ extension Sound {
}
}
/// Adds a note starting at `step`, lasting `length` steps.
/// `length` is in steps.
public func addNote(step: UInt32, length: UInt32, note: MIDINote, velocity: Float = 1) {
SequenceTrack.api.pointee.addNoteEvent.unsafelyUnwrapped(pointer, step, length, note, velocity)
}
/// Removes the note at `step`, if any.
public func removeNote(step: UInt32, note: MIDINote) {
SequenceTrack.api.pointee.removeNoteEvent.unsafelyUnwrapped(pointer, step, note)
}
/// Removes all notes from the track.
public func clearNotes() {
SequenceTrack.api.pointee.clearNotes.unsafelyUnwrapped(pointer)
}
/// The track's length in steps, including the tail of the last note.
/// In steps: where the last note ends.
public var length: UInt32 {
SequenceTrack.api.pointee.getLength.unsafelyUnwrapped(pointer)
}
/// The index of the first note at or after `step`.
/// The internal index of the first note at `step`.
public func indexForStep(_ step: UInt32) -> Int {
Int(SequenceTrack.api.pointee.getIndexForStep.unsafelyUnwrapped(pointer, step))
}
/// The note at `index`, or `nil` if the index is out of range.
public func note(at index: Int) -> (step: UInt32, length: UInt32,
note: MIDINote, velocity: Float)? {
var step: UInt32 = 0, length: UInt32 = 0
@@ -76,42 +73,37 @@ extension Sound {
return (step, length, note, velocity)
}
/// The number of control signals on the track.
public var controlSignalCount: Int {
Int(SequenceTrack.api.pointee.getControlSignalCount.unsafelyUnwrapped(pointer))
}
/// The control signal at `index`. Owned by the track.
public func controlSignal(at index: Int) -> ControlSignal? {
guard let signal = SequenceTrack.api.pointee.getControlSignal.unsafelyUnwrapped(
pointer, Int32(index)) else { return nil }
return ControlSignal(pointer: signal, isOwned: false)
}
/// The control signal for MIDI controller `controller`, optionally
/// creating it. Owned by the track.
/// If `create`, makes the signal for `controller` when it is missing.
public func signalForController(_ controller: Int, create: Bool = false) -> ControlSignal? {
guard let signal = SequenceTrack.api.pointee.getSignalForController.unsafelyUnwrapped(
pointer, Int32(controller), create ? 1 : 0) else { return nil }
return ControlSignal(pointer: signal, isOwned: false)
}
/// Removes all control signal events from the track.
public func clearControlEvents() {
SequenceTrack.api.pointee.clearControlEvents.unsafelyUnwrapped(pointer)
}
/// The maximum number of simultaneous notes in the track.
/// Max simultaneous notes; set only for tracks loaded from a MIDI file.
public var polyphony: Int {
Int(SequenceTrack.api.pointee.getPolyphony.unsafelyUnwrapped(pointer))
}
/// The number of notes currently playing.
/// Voices playing in the track's instrument.
public var activeVoiceCount: Int {
Int(SequenceTrack.api.pointee.activeVoiceCount.unsafelyUnwrapped(pointer))
}
/// Mutes or unmutes the track.
public func setMuted(_ muted: Bool) {
SequenceTrack.api.pointee.setMuted.unsafelyUnwrapped(pointer, muted ? 1 : 0)
}
@@ -1,7 +1,7 @@
internal import CPlaydate
extension Sound {
/// A synthesizer voice. Wraps `PDSynth`.
/// A synthesizer voice. Wraps `PDSynth`. Keeps samples and generators set on it alive.
public final class Synth: Source {
private static var api: UnsafePointer<playdate_sound_synth> { Playdate.synthAPI.unsafelyUnwrapped }
@@ -37,7 +37,7 @@ extension Sound {
}
}
/// Copies the synth (and its generator, if any).
/// An independently owned copy, including any generator.
public func copy() -> Synth {
Synth(pointer: Synth.api.pointee.copy.unsafelyUnwrapped(pointer).unsafelyUnwrapped,
isOwned: true)
@@ -49,15 +49,15 @@ extension Sound {
Synth.api.pointee.setWaveform.unsafelyUnwrapped(pointer, waveform.cValue)
}
/// Plays a sample instead of a waveform. A nonzero sustain range
/// loops that part of the sample while the note is held.
/// Plays `sample` (uncompressed PCM, not ADPCM). Frames `sustainStart..<sustainEnd`
/// loop while held; `sustainEnd` 0 with nonzero `sustainStart` means the sample's end.
public func setSample(_ sample: AudioSample, sustainStart: UInt32 = 0, sustainEnd: UInt32 = 0) {
retainedSample = sample
Synth.api.pointee.setSample.unsafelyUnwrapped(pointer, sample.pointer, sustainStart, sustainEnd)
}
/// Uses a wavetable for the synth. `log2size` is the base-2 log of
/// each waveform's size (e.g. 8 for 256 samples).
/// Plays `sample` (16-bit mono, uncompressed) as `columns` × `rows` cells of
/// 2^`log2size` samples; parameters 14 select the position.
public func setWavetable(_ sample: AudioSample, log2size: Int,
columns: Int, rows: Int) throws(PlaydateError) {
retainedSample = sample
@@ -67,7 +67,7 @@ extension Sound {
}
}
/// Provides audio via custom Swift callbacks.
/// `copy()` shares `generator`.
public func setGenerator(stereo: Bool, _ generator: Generator) {
let box = Unmanaged.passRetained(GeneratorBox(generator, stereo: stereo))
Synth.api.pointee.setGenerator.unsafelyUnwrapped(
@@ -75,9 +75,9 @@ extension Sound {
{ userdata, left, right, nsamples, rate, drate in
guard let userdata, let left else { return 0 }
let box = Unmanaged<GeneratorBox>.fromOpaque(userdata).takeUnretainedValue()
let leftBuffer = UnsafeMutableBufferPointer(start: left, count: Int(nsamples))
let rightBuffer = right.map { UnsafeMutableBufferPointer(start: $0, count: Int(nsamples)) }
return Int32(box.generator.render(leftBuffer, rightBuffer, rate, drate))
var leftSpan = UnsafeMutableBufferPointer(start: left, count: Int(nsamples)).mutableSpan
var rightSpan = UnsafeMutableBufferPointer(start: right, count: right == nil ? 0 : Int(nsamples)).mutableSpan
return Int32(box.generator.render(&leftSpan, &rightSpan, rate, drate))
},
{ userdata, note, velocity, length in
guard let userdata else { return }
@@ -108,45 +108,44 @@ extension Sound {
// MARK: Envelope
/// The envelope's attack time, in seconds.
/// In seconds.
public func setAttackTime(_ attack: Float) {
Synth.api.pointee.setAttackTime.unsafelyUnwrapped(pointer, attack)
}
/// The envelope's decay time, in seconds.
/// In seconds.
public func setDecayTime(_ decay: Float) {
Synth.api.pointee.setDecayTime.unsafelyUnwrapped(pointer, decay)
}
/// The envelope's sustain level, 0...1.
/// 0...1.
public func setSustainLevel(_ sustain: Float) {
Synth.api.pointee.setSustainLevel.unsafelyUnwrapped(pointer, sustain)
}
/// The envelope's release time, in seconds.
/// In seconds.
public func setReleaseTime(_ release: Float) {
Synth.api.pointee.setReleaseTime.unsafelyUnwrapped(pointer, release)
}
/// The synth's amplitude envelope. Owned by the synth.
/// The amplitude envelope; owned by the synth, valid only while it is alive.
public var envelope: Envelope? {
guard let envelope = Synth.api.pointee.getEnvelope.unsafelyUnwrapped(pointer) else { return nil }
return Envelope(pointer: envelope, isOwned: false)
}
/// Clears the synth's envelope so it plays at constant volume.
public func clearEnvelope() {
Synth.api.pointee.clearEnvelope.unsafelyUnwrapped(pointer)
}
// MARK: Modulation
// MARK: Pitch, modulation, and parameters
/// Transposes played notes by `halfSteps` (fractional values allowed).
/// Fractional half-steps allowed.
public func setTranspose(_ halfSteps: Float) {
Synth.api.pointee.setTranspose.unsafelyUnwrapped(pointer, halfSteps)
}
/// Modulates the synth's frequency.
/// 1 is an octave up, -1 an octave down.
public var frequencyModulator: SignalValue? {
get { SignalValue.wrap(Synth.api.pointee.getFrequencyModulator.unsafelyUnwrapped(pointer)) }
set {
@@ -155,7 +154,6 @@ extension Sound {
}
}
/// Modulates the synth's amplitude.
public var amplitudeModulator: SignalValue? {
get { SignalValue.wrap(Synth.api.pointee.getAmplitudeModulator.unsafelyUnwrapped(pointer)) }
set {
@@ -164,26 +162,25 @@ extension Sound {
}
}
/// The number of parameters the synth's generator supports.
/// The number of parameters the generator supports.
public var parameterCount: Int {
Int(Synth.api.pointee.getParameterCount.unsafelyUnwrapped(pointer))
}
/// Sets a generator parameter. Returns `false` if the parameter is
/// invalid.
/// `parameter` is 1-based. Returns `false` if it is invalid.
@discardableResult
public func setParameter(_ parameter: Int, value: Float) -> Bool {
Synth.api.pointee.setParameter.unsafelyUnwrapped(pointer, Int32(parameter), value) != 0
}
/// Modulates a generator parameter.
/// `parameter` is 1-based.
public func setParameterModulator(_ parameter: Int, _ modulator: SignalValue?) {
retain(modulator)
Synth.api.pointee.setParameterModulator.unsafelyUnwrapped(pointer, Int32(parameter),
modulator?.pointer)
}
/// The modulator installed on a generator parameter, if any.
/// `parameter` is 1-based.
public func parameterModulator(_ parameter: Int) -> SignalValue? {
SignalValue.wrap(Synth.api.pointee.getParameterModulator.unsafelyUnwrapped(pointer, Int32(parameter)))
}
@@ -196,26 +193,25 @@ extension Sound {
// MARK: Playing
/// Plays a note at `frequency` Hz. `length` is in seconds; `nil`
/// plays until `noteOff()`. `when` is the audio-clock time to start,
/// or 0 for immediately.
/// `frequency` in Hz; `length` in seconds, `nil` until `noteOff(when:)`;
/// `when` is an audio-clock time, 0 for now.
public func playNote(frequency: Float, velocity: Float = 1,
length: Float? = nil, when: UInt32 = 0) {
Synth.api.pointee.playNote.unsafelyUnwrapped(pointer, frequency, velocity, length ?? -1, when)
}
/// Plays a MIDI note, where 60 is middle C.
/// 60 is C4; fractional notes allowed. Other arguments as in `playNote`.
public func playMIDINote(_ note: MIDINote, velocity: Float = 1,
length: Float? = nil, when: UInt32 = 0) {
Synth.api.pointee.playMIDINote.unsafelyUnwrapped(pointer, note, velocity, length ?? -1, when)
}
/// Releases the playing note at time `when`, or immediately if 0.
/// Releases the note at audio-clock time `when`, or now if 0.
public func noteOff(when: UInt32 = 0) {
Synth.api.pointee.noteOff.unsafelyUnwrapped(pointer, when)
}
/// Stops the synth immediately, without playing the release phase.
/// Stops immediately, skipping the release phase.
public func stop() {
Synth.api.pointee.stop.unsafelyUnwrapped(pointer)
}
@@ -1,18 +1,19 @@
internal import CPlaydate
extension Sound.Synth {
/// The synth's waveform.
public enum Waveform: UInt32, Sendable {
/// Parameter 1 sets the pulse width.
case square = 0
case triangle = 1
case sine = 2
/// White noise.
case noise = 3
case sawtooth = 4
/// A Pocket Operator-style phase-distortion waveform.
/// Pocket Operator-style phase distortion.
case poPhase = 5
/// A Pocket Operator-style digital waveform.
/// Pocket Operator-style digital.
case poDigital = 6
/// A Pocket Operator-style VOSIM (voice simulation) waveform.
/// Pocket Operator-style VOSIM (voice simulation).
case poVosim = 7
var cValue: SoundWaveform { SoundWaveform(SoundWaveform.RawValue(rawValue)) }
@@ -1,24 +1,21 @@
extension Sound.Synth {
/// Custom generator callbacks. Samples are in signed Q8.24 format.
/// Custom generator callbacks, run on the audio render thread; return quickly.
/// Samples are signed Q8.24.
public struct Generator {
/// Renders up to 256 sample frames into `left` (and `right` for
/// stereo generators). `rate` is the per-frame phase step in
/// Q0.32 format and `drate` its per-frame change. Returns the
/// number of frames rendered.
public var render: (_ left: UnsafeMutableBufferPointer<Int32>,
_ right: UnsafeMutableBufferPointer<Int32>?,
/// Renders `left.count` frames into `left` and `right` (empty if mono). `rate` is the
/// per-frame Q0.32 phase step, `drate` its per-frame change. Returns frames rendered.
public var render: (_ left: inout MutableSpan<Int32>,
_ right: inout MutableSpan<Int32>,
_ rate: UInt32, _ drate: Int32) -> Int
/// Called when a note starts. `length` is -1 for indefinite notes.
/// `length` is in seconds, or -1 if indefinite.
public var noteOn: ((_ note: Sound.MIDINote, _ velocity: Float, _ length: Float) -> Void)?
/// Called when a note is released (`stop == false`) or stopped
/// (`stop == true`).
/// `stop` is `false` on release, `true` on stop.
public var release: ((_ stop: Bool) -> Void)?
/// Sets a generator parameter. Returns `true` if the parameter is
/// valid.
/// Called by `Synth.setParameter(_:value:)` or a modulator. Returns `true` if valid.
public var setParameter: ((_ parameter: Int, _ value: Float) -> Bool)?
public init(render: @escaping (_ left: UnsafeMutableBufferPointer<Int32>,
_ right: UnsafeMutableBufferPointer<Int32>?,
public init(render: @escaping (_ left: inout MutableSpan<Int32>,
_ right: inout MutableSpan<Int32>,
_ rate: UInt32, _ drate: Int32) -> Int,
noteOn: ((_ note: Sound.MIDINote, _ velocity: Float, _ length: Float) -> Void)? = nil,
release: ((_ stop: Bool) -> Void)? = nil,