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,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,