From b435a6e8bd702115a66ac0d850f8840b8df0b389 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 25 Jul 2026 12:39:32 +0200 Subject: [PATCH] Restructured the source code in the Playdate bindings target. --- Examples/HelloPlaydate/Makefile | 8 +- Examples/HelloPlaydate/Source/pdex.elf | Bin 384420 -> 384788 bytes .../PlaydateKit/{ => Display}/Display.swift | 5 - Sources/PlaydateKit/File.swift | 216 ------ Sources/PlaydateKit/File/Classes/Handle.swift | 103 +++ .../File/Enumerations/SeekOrigin.swift | 8 + Sources/PlaydateKit/File/File.swift | 73 +++ .../PlaydateKit/File/Structures/Options.swift | 20 + .../PlaydateKit/File/Structures/Stat.swift | 8 + .../Classes/Bitmap.swift} | 69 -- .../Graphics/Classes/BitmapTable.swift | 57 ++ .../Classes/Font.swift} | 36 - .../Graphics/Classes/StreamPlayer.swift | 81 +++ .../Classes/TileMap.swift} | 5 - .../Graphics/Classes/VideoPlayer.swift | 77 +++ .../Graphics/Enumerations/BitmapFlip.swift | 14 + .../Graphics/Enumerations/Color.swift | 28 + .../Graphics/Enumerations/DrawMode.swift | 18 + .../Graphics/Enumerations/LineCapStyle.swift | 12 + .../Enumerations/PolygonFillRule.swift | 11 + .../Graphics/Enumerations/SolidColor.swift | 14 + .../Enumerations/StringEncoding.swift | 12 + .../Graphics/Enumerations/TextAlignment.swift | 12 + .../Enumerations/TextWrappingMode.swift | 12 + .../PlaydateKit/{ => Graphics}/Graphics.swift | 166 ----- .../Graphics/Structures/Bitmap.Data.swift | 11 + .../Graphics/Structures/FontPage.swift | 22 + .../Graphics/Structures/Glyph.swift | 15 + .../Graphics/Structures/Graphics.Rect.swift | 37 ++ .../Graphics/Structures/Pattern.swift | 18 + Sources/PlaydateKit/GraphicsVideo.swift | 160 ----- .../PlaydateKit/JSON/Classes/Encoder.swift | 111 ++++ .../PlaydateKit/JSON/Enumerations/Value.swift | 12 + Sources/PlaydateKit/{ => JSON}/JSON.swift | 130 +--- .../PlaydateKit/Lua/Aliases/CFunction.swift | 7 + .../Lua/Enumerations/ClassValue.swift | 8 + .../PlaydateKit/Lua/Enumerations/Kind.swift | 18 + Sources/PlaydateKit/{ => Lua}/Lua.swift | 68 +- .../PlaydateKit/Lua/Structures/UDObject.swift | 32 + Sources/PlaydateKit/Network.swift | 515 --------------- .../Network/Classes/HTTPConnection.swift | 246 +++++++ .../Network/Classes/TCPConnection.swift | 155 +++++ .../Network/Enumerations/NetError.swift | 30 + .../Network/Enumerations/WifiStatus.swift | 10 + Sources/PlaydateKit/Network/Network.swift | 73 +++ .../Playdate/Enumerations/AccessReply.swift | 6 + .../Playdate/Enumerations/SystemEvent.swift | 36 + .../Playdate.swift} | 65 -- .../Playdate/Structures/PlaydateError.swift | 12 + .../{ => Scoreboards}/Scoreboards.swift | 86 +-- .../Scoreboards/Structures/Board.swift | 14 + .../Scoreboards/Structures/BoardsList.swift | 21 + .../Scoreboards/Structures/Score.swift | 25 + .../Scoreboards/Structures/ScoresList.swift | 27 + .../PlaydateKit/Sound/Aliases/MIDINote.swift | 5 + .../Classes/Channel.swift} | 173 ----- .../Effect/Aliases/Effect.Processor.swift | 8 + .../Sound/Effect/Classes/BitCrusher.swift | 56 ++ .../Sound/Effect/Classes/DelayLine.swift | 40 ++ .../Sound/Effect/Classes/DelayLineTap.swift | 40 ++ .../Sound/Effect/Classes/Effect.swift | 59 ++ .../Sound/Effect/Classes/OnePoleFilter.swift | 35 + .../Sound/Effect/Classes/Overdrive.swift | 56 ++ .../Sound/Effect/Classes/RingModulator.swift | 34 + .../Sound/Effect/Classes/TwoPoleFilter.swift | 57 ++ .../Enumerations/TwoPoleFilter.Kind.swift | 15 + .../Sound/Enumerations/Format.swift | 20 + .../Sound/Enumerations/MicSource.swift | 8 + .../Sound/Signal/Classes/ControlSignal.swift | 44 ++ .../Sound/Signal/Classes/Envelope.swift | 73 +++ .../Sound/Signal/Classes/LFO.swift | 96 +++ .../Sound/Signal/Classes/Signal.swift | 73 +++ .../Sound/Signal/Classes/SignalValue.swift | 19 + .../Sound/Signal/Enumerations/LFO.Shape.swift | 17 + .../Signal/Structures/SignalCallbacks.swift | 25 + Sources/PlaydateKit/Sound/Sound.swift | 139 ++++ .../Source/Aliases/SoundCallbackSource.swift | 6 + .../Sound/Source/Classes/AudioSample.swift | 82 +++ .../Sound/Source/Classes/CallbackSource.swift | 41 ++ .../Sound/Source/Classes/FilePlayer.swift | 151 +++++ .../Sound/Source/Classes/SamplePlayer.swift | 102 +++ .../Sound/Source/Classes/Source.swift | 54 ++ .../Sound/Synth/Classes/Instrument.swift | 106 +++ .../Sound/Synth/Classes/Sequence.swift | 132 ++++ .../Sound/Synth/Classes/SequenceTrack.swift | 114 ++++ .../Sound/Synth/Classes/Synth.swift | 215 ++++++ .../Synth/Enumerations/Synth.Waveform.swift | 17 + .../Synth/Structures/Synth.Generator.swift | 32 + Sources/PlaydateKit/SoundEffect.swift | 393 ----------- Sources/PlaydateKit/SoundSignal.swift | 345 ---------- Sources/PlaydateKit/SoundSource.swift | 436 ------------- Sources/PlaydateKit/SoundSynth.swift | 614 ------------------ .../{ => Sprite/Classes}/Sprite.swift | 104 --- .../Enumerations/CollisionResponse.swift | 16 + .../Sprite/Structures/CollisionInfo.swift | 41 ++ .../Sprite/Structures/QueryInfo.swift | 23 + .../PlaydateKit/Sprite/Structures/Rect.swift | 22 + Sources/PlaydateKit/Support.swift | 9 - .../PlaydateKit/System/Classes/MenuItem.swift | 53 ++ .../System/Enumerations/Language.swift | 16 + .../System/Structures/Buttons.swift | 18 + .../System/Structures/DateTime.swift | 44 ++ .../PlaydateKit/System/Structures/Info.swift | 8 + .../System/Structures/Peripherals.swift | 13 + .../System/Structures/PowerStatus.swift | 13 + Sources/PlaydateKit/{ => System}/System.swift | 153 ----- 106 files changed, 3754 insertions(+), 3746 deletions(-) rename Sources/PlaydateKit/{ => Display}/Display.swift (96%) delete mode 100644 Sources/PlaydateKit/File.swift create mode 100644 Sources/PlaydateKit/File/Classes/Handle.swift create mode 100644 Sources/PlaydateKit/File/Enumerations/SeekOrigin.swift create mode 100644 Sources/PlaydateKit/File/File.swift create mode 100644 Sources/PlaydateKit/File/Structures/Options.swift create mode 100644 Sources/PlaydateKit/File/Structures/Stat.swift rename Sources/PlaydateKit/{GraphicsBitmap.swift => Graphics/Classes/Bitmap.swift} (70%) create mode 100644 Sources/PlaydateKit/Graphics/Classes/BitmapTable.swift rename Sources/PlaydateKit/{GraphicsFont.swift => Graphics/Classes/Font.swift} (74%) create mode 100644 Sources/PlaydateKit/Graphics/Classes/StreamPlayer.swift rename Sources/PlaydateKit/{GraphicsTileMap.swift => Graphics/Classes/TileMap.swift} (96%) create mode 100644 Sources/PlaydateKit/Graphics/Classes/VideoPlayer.swift create mode 100644 Sources/PlaydateKit/Graphics/Enumerations/BitmapFlip.swift create mode 100644 Sources/PlaydateKit/Graphics/Enumerations/Color.swift create mode 100644 Sources/PlaydateKit/Graphics/Enumerations/DrawMode.swift create mode 100644 Sources/PlaydateKit/Graphics/Enumerations/LineCapStyle.swift create mode 100644 Sources/PlaydateKit/Graphics/Enumerations/PolygonFillRule.swift create mode 100644 Sources/PlaydateKit/Graphics/Enumerations/SolidColor.swift create mode 100644 Sources/PlaydateKit/Graphics/Enumerations/StringEncoding.swift create mode 100644 Sources/PlaydateKit/Graphics/Enumerations/TextAlignment.swift create mode 100644 Sources/PlaydateKit/Graphics/Enumerations/TextWrappingMode.swift rename Sources/PlaydateKit/{ => Graphics}/Graphics.swift (65%) create mode 100644 Sources/PlaydateKit/Graphics/Structures/Bitmap.Data.swift create mode 100644 Sources/PlaydateKit/Graphics/Structures/FontPage.swift create mode 100644 Sources/PlaydateKit/Graphics/Structures/Glyph.swift create mode 100644 Sources/PlaydateKit/Graphics/Structures/Graphics.Rect.swift create mode 100644 Sources/PlaydateKit/Graphics/Structures/Pattern.swift delete mode 100644 Sources/PlaydateKit/GraphicsVideo.swift create mode 100644 Sources/PlaydateKit/JSON/Classes/Encoder.swift create mode 100644 Sources/PlaydateKit/JSON/Enumerations/Value.swift rename Sources/PlaydateKit/{ => JSON}/JSON.swift (61%) create mode 100644 Sources/PlaydateKit/Lua/Aliases/CFunction.swift create mode 100644 Sources/PlaydateKit/Lua/Enumerations/ClassValue.swift create mode 100644 Sources/PlaydateKit/Lua/Enumerations/Kind.swift rename Sources/PlaydateKit/{ => Lua}/Lua.swift (81%) create mode 100644 Sources/PlaydateKit/Lua/Structures/UDObject.swift delete mode 100644 Sources/PlaydateKit/Network.swift create mode 100644 Sources/PlaydateKit/Network/Classes/HTTPConnection.swift create mode 100644 Sources/PlaydateKit/Network/Classes/TCPConnection.swift create mode 100644 Sources/PlaydateKit/Network/Enumerations/NetError.swift create mode 100644 Sources/PlaydateKit/Network/Enumerations/WifiStatus.swift create mode 100644 Sources/PlaydateKit/Network/Network.swift create mode 100644 Sources/PlaydateKit/Playdate/Enumerations/AccessReply.swift create mode 100644 Sources/PlaydateKit/Playdate/Enumerations/SystemEvent.swift rename Sources/PlaydateKit/{PlaydateKit.swift => Playdate/Playdate.swift} (76%) create mode 100644 Sources/PlaydateKit/Playdate/Structures/PlaydateError.swift rename Sources/PlaydateKit/{ => Scoreboards}/Scoreboards.swift (59%) create mode 100644 Sources/PlaydateKit/Scoreboards/Structures/Board.swift create mode 100644 Sources/PlaydateKit/Scoreboards/Structures/BoardsList.swift create mode 100644 Sources/PlaydateKit/Scoreboards/Structures/Score.swift create mode 100644 Sources/PlaydateKit/Scoreboards/Structures/ScoresList.swift create mode 100644 Sources/PlaydateKit/Sound/Aliases/MIDINote.swift rename Sources/PlaydateKit/{Sound.swift => Sound/Classes/Channel.swift} (50%) create mode 100644 Sources/PlaydateKit/Sound/Effect/Aliases/Effect.Processor.swift create mode 100644 Sources/PlaydateKit/Sound/Effect/Classes/BitCrusher.swift create mode 100644 Sources/PlaydateKit/Sound/Effect/Classes/DelayLine.swift create mode 100644 Sources/PlaydateKit/Sound/Effect/Classes/DelayLineTap.swift create mode 100644 Sources/PlaydateKit/Sound/Effect/Classes/Effect.swift create mode 100644 Sources/PlaydateKit/Sound/Effect/Classes/OnePoleFilter.swift create mode 100644 Sources/PlaydateKit/Sound/Effect/Classes/Overdrive.swift create mode 100644 Sources/PlaydateKit/Sound/Effect/Classes/RingModulator.swift create mode 100644 Sources/PlaydateKit/Sound/Effect/Classes/TwoPoleFilter.swift create mode 100644 Sources/PlaydateKit/Sound/Effect/Enumerations/TwoPoleFilter.Kind.swift create mode 100644 Sources/PlaydateKit/Sound/Enumerations/Format.swift create mode 100644 Sources/PlaydateKit/Sound/Enumerations/MicSource.swift create mode 100644 Sources/PlaydateKit/Sound/Signal/Classes/ControlSignal.swift create mode 100644 Sources/PlaydateKit/Sound/Signal/Classes/Envelope.swift create mode 100644 Sources/PlaydateKit/Sound/Signal/Classes/LFO.swift create mode 100644 Sources/PlaydateKit/Sound/Signal/Classes/Signal.swift create mode 100644 Sources/PlaydateKit/Sound/Signal/Classes/SignalValue.swift create mode 100644 Sources/PlaydateKit/Sound/Signal/Enumerations/LFO.Shape.swift create mode 100644 Sources/PlaydateKit/Sound/Signal/Structures/SignalCallbacks.swift create mode 100644 Sources/PlaydateKit/Sound/Sound.swift create mode 100644 Sources/PlaydateKit/Sound/Source/Aliases/SoundCallbackSource.swift create mode 100644 Sources/PlaydateKit/Sound/Source/Classes/AudioSample.swift create mode 100644 Sources/PlaydateKit/Sound/Source/Classes/CallbackSource.swift create mode 100644 Sources/PlaydateKit/Sound/Source/Classes/FilePlayer.swift create mode 100644 Sources/PlaydateKit/Sound/Source/Classes/SamplePlayer.swift create mode 100644 Sources/PlaydateKit/Sound/Source/Classes/Source.swift create mode 100644 Sources/PlaydateKit/Sound/Synth/Classes/Instrument.swift create mode 100644 Sources/PlaydateKit/Sound/Synth/Classes/Sequence.swift create mode 100644 Sources/PlaydateKit/Sound/Synth/Classes/SequenceTrack.swift create mode 100644 Sources/PlaydateKit/Sound/Synth/Classes/Synth.swift create mode 100644 Sources/PlaydateKit/Sound/Synth/Enumerations/Synth.Waveform.swift create mode 100644 Sources/PlaydateKit/Sound/Synth/Structures/Synth.Generator.swift delete mode 100644 Sources/PlaydateKit/SoundEffect.swift delete mode 100644 Sources/PlaydateKit/SoundSignal.swift delete mode 100644 Sources/PlaydateKit/SoundSource.swift delete mode 100644 Sources/PlaydateKit/SoundSynth.swift rename Sources/PlaydateKit/{ => Sprite/Classes}/Sprite.swift (85%) create mode 100644 Sources/PlaydateKit/Sprite/Enumerations/CollisionResponse.swift create mode 100644 Sources/PlaydateKit/Sprite/Structures/CollisionInfo.swift create mode 100644 Sources/PlaydateKit/Sprite/Structures/QueryInfo.swift create mode 100644 Sources/PlaydateKit/Sprite/Structures/Rect.swift create mode 100644 Sources/PlaydateKit/System/Classes/MenuItem.swift create mode 100644 Sources/PlaydateKit/System/Enumerations/Language.swift create mode 100644 Sources/PlaydateKit/System/Structures/Buttons.swift create mode 100644 Sources/PlaydateKit/System/Structures/DateTime.swift create mode 100644 Sources/PlaydateKit/System/Structures/Info.swift create mode 100644 Sources/PlaydateKit/System/Structures/Peripherals.swift create mode 100644 Sources/PlaydateKit/System/Structures/PowerStatus.swift rename Sources/PlaydateKit/{ => System}/System.swift (71%) diff --git a/Examples/HelloPlaydate/Makefile b/Examples/HelloPlaydate/Makefile index 3f3abf3..f7cafec 100644 --- a/Examples/HelloPlaydate/Makefile +++ b/Examples/HelloPlaydate/Makefile @@ -3,11 +3,15 @@ PRODUCT := HelloPlaydate.pdx include $(REPO_ROOT)/Examples/swift.mk # MARK: - Build PlaydateKit Wrapper Swift Module -build/Modules/playdate_device.o: $(REPO_ROOT)/Sources/PlaydateKit/*.swift +# The wrapper sources are organized in nested domain folders, so collect +# them recursively (a plain *.swift glob would miss the subdirectories). +PLAYDATEKIT_SOURCES := $(shell find $(REPO_ROOT)/Sources/PlaydateKit -name '*.swift') + +build/Modules/playdate_device.o: $(PLAYDATEKIT_SOURCES) @mkdir -p build/Modules $(SWIFT_EXEC) $(SWIFT_FLAGS) $(SWIFT_FLAGS_DEVICE) -c $^ -emit-module -o $@ -build/Modules/playdate_simulator.o: $(REPO_ROOT)/Sources/PlaydateKit/*.swift +build/Modules/playdate_simulator.o: $(PLAYDATEKIT_SOURCES) @mkdir -p build/Modules $(SWIFT_EXEC) $(SWIFT_FLAGS) $(SWIFT_FLAGS_SIMULATOR) -c $^ -emit-module -o $@ diff --git a/Examples/HelloPlaydate/Source/pdex.elf b/Examples/HelloPlaydate/Source/pdex.elf index ee1778982b2fe74ed18457a5a613d91ec0019683..792082fdec4b19832097c50abcd6373b0efbb861 100755 GIT binary patch delta 104579 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�q|j;d2iM4*V6DY*8aL{v&_5 zGCE3(P8OrTQAZc;jIWf4Xc4hYL_D&LaQ>!5#EOXJBEo-#vOo22YKKv)+z)YL^hz=M z*I=W~JBq$MyB0>`p8>}PVRw9x_#2OKiV;SK*tVApl5o<-n zeKkV=-mbb4Q^e@?Vsx#Q>MqbfgQQlu3sS}Cjbe0Hb#&22?5ISfiHJ=iBEd4kIbMlK z7ZLA@h_}@U<3(Q?ohe3d5u*>Qql?xdTZz~sBHkAfzgkE9qC{kih^->Rca^dqik8BM zy5!Et5fLAXh>q3~&nOYOA|gXXjJJ-6QzFiYi0vZcE$fICN<^NB_*g__TShqdD-kzD z#10YhiyEQvz30P~(fMNZE-~6?wbjmWdQ+F&83iIDOGG@QMi}qFl+m`xqPGlt#ppP7 zbkWf;N{R3k5ub{P6_ydsH{la<&vBH|zGC!2G5VT1x@aAKQX>3C#32#k z{g%~!h$v58az6x$h%ZD$TgwP%8zmxGM0_bCMyU}R-z_>^8674@9}%P9R7V$WhBuUm zND*;VMC`GS*r`Nx7ZG2Jh@Y$@E-DdGBI3A+D6d)V45ue`$(<1`B2I{iHfn^Xf3r^+ z9VoWP$eQxM4S{6Z&*e+7bp=4BI2}&*r`Ss-)mDwCyLQOh|w3-(dIfh ze^erpM8sJU;hC!J2mRahVbm&j!6GsGoEY7**y!H$>j~k?=oB&ff*3ti9bL5e(Mm+B zh`1yo78nsWYO?vyrT=LW=N#3XuPejTXt-}B;{x@|V)#<3^Q56XJeXcvgSBKD?plph z{Ucqy%guHtv2aS?VEV*zWj1@Bv)dLmG-h)}%x064=SRQd+L7mp|J>jXqpoaWyKT4@ zbe>jcyE321*X;Cbuf}|?i}`%6%x8u+i-t#hrVgKJw*}HX9?{U7V)$ug_>PLDY=PRG z^Nz~SZ=sZu7_7 z?~38}HO9UPrau19Q#(Mz+g4DTXXcl((KngQ|9JEB|9q*A(h>?g9_DMa&pWDC3B>rq z_8|TaguY!WhAUmcTFga9HNP-wmgkS}X1gtzX2Dzhp|<>@qo$YtgHpDnM4PREnx5D8 eQd8-)@$R;V?`l6@bkuWn-(t7*(A+LLD*r#GfXG7t diff --git a/Sources/PlaydateKit/Display.swift b/Sources/PlaydateKit/Display/Display.swift similarity index 96% rename from Sources/PlaydateKit/Display.swift rename to Sources/PlaydateKit/Display/Display.swift index 849930a..e1381db 100644 --- a/Sources/PlaydateKit/Display.swift +++ b/Sources/PlaydateKit/Display/Display.swift @@ -1,8 +1,3 @@ -// -// Display.swift -// Wraps `playdate->display` (pd_api_display.h). -// - internal import CPlaydate /// The display API: resolution, refresh rate, scaling, and effects. diff --git a/Sources/PlaydateKit/File.swift b/Sources/PlaydateKit/File.swift deleted file mode 100644 index 89a8299..0000000 --- a/Sources/PlaydateKit/File.swift +++ /dev/null @@ -1,216 +0,0 @@ -// -// File.swift -// Wraps `playdate->file` (pd_api_file.h). -// -// Paths are relative to the game's Data directory (read/write) or the -// game's pdx (read-only), per the mode used to open them. -// - -internal import CPlaydate - -private var fileAPI: UnsafePointer { Playdate.fileAPI.unsafelyUnwrapped } - -/// The most recent file system error as a thrown error. -private func lastFileError() -> PlaydateError { - PlaydateError(cString: fileAPI.pointee.geterr.unsafelyUnwrapped()) -} - -/// The file API: access to the game's Data directory and pdx contents. -public enum File {} - -extension File { - // MARK: - Types - - /// How to open a file. - public struct Options: OptionSet, Sendable { - public let rawValue: UInt32 - public init(rawValue: UInt32) { self.rawValue = rawValue } - - /// Read from the game pdx, then the Data directory. - public static let read = Options(rawValue: UInt32(kFileRead.rawValue)) - /// Read from the Data directory only. - public static let readData = Options(rawValue: UInt32(kFileReadData.rawValue)) - /// Write to the Data directory, truncating an existing file. - public static let write = Options(rawValue: UInt32(kFileWrite.rawValue)) - /// Write to the Data directory, appending to an existing file. - public static let append = Options(rawValue: UInt32(kFileAppend.rawValue)) - - var cValue: FileOptions { FileOptions(FileOptions.RawValue(rawValue)) } - } - - /// Information about a file or directory, mirroring `FileStat`. - public struct Stat: Sendable { - public let isDirectory: Bool - public let size: UInt32 - public let modified: System.DateTime - } - - /// The origin used by `Handle.seek(to:from:)`. - public enum SeekOrigin: Int32, Sendable { - case start = 0 - case current = 1 - case end = 2 - } - - // MARK: - Directory operations - - /// Calls `each` with the name of every file in `path`. Subdirectory names - /// end in a slash. Throws if the directory does not exist. - public static func listFiles(at path: String, showHidden: Bool = false, - _ each: (String) -> Void) throws(PlaydateError) { - let result = withoutActuallyEscaping(each) { each in - var callback = each - return path.withPlaydateCString { cPath in - withUnsafeMutablePointer(to: &callback) { callbackPointer in - fileAPI.pointee.listfiles.unsafelyUnwrapped(cPath, { cName, userdata in - guard let cName, let userdata else { return } - let each = userdata.assumingMemoryBound(to: ((String) -> Void).self).pointee - each(String(playdateCString: cName)) - }, callbackPointer, showHidden ? 1 : 0) - } - } - } - if result != 0 { throw lastFileError() } - } - - /// Information about the file or directory at `path`. - public static func stat(_ path: String) throws(PlaydateError) -> Stat { - var stat = FileStat() - let result = path.withPlaydateCString { fileAPI.pointee.stat.unsafelyUnwrapped($0, &stat) } - if result != 0 { throw lastFileError() } - return Stat( - isDirectory: stat.isdir != 0, - size: stat.size, - modified: System.DateTime( - year: UInt16(stat.m_year), month: UInt8(stat.m_month), day: UInt8(stat.m_day), - hour: UInt8(stat.m_hour), minute: UInt8(stat.m_minute), second: UInt8(stat.m_second))) - } - - /// Creates a directory (and intermediate directories) in the Data directory. - public static func mkdir(_ path: String) throws(PlaydateError) { - let result = path.withPlaydateCString { fileAPI.pointee.mkdir.unsafelyUnwrapped($0) } - if result != 0 { throw lastFileError() } - } - - /// Deletes the file or directory at `path`. Directories require - /// `recursive` to be deleted with their contents. - public static func unlink(_ path: String, recursive: Bool = false) throws(PlaydateError) { - let result = path.withPlaydateCString { - fileAPI.pointee.unlink.unsafelyUnwrapped($0, recursive ? 1 : 0) - } - if result != 0 { throw lastFileError() } - } - - /// Renames (moves) a file in the Data directory, overwriting any existing - /// file at the destination. - public static func rename(from: String, to: String) throws(PlaydateError) { - let result = from.withPlaydateCString { cFrom in - to.withPlaydateCString { cTo in - fileAPI.pointee.rename.unsafelyUnwrapped(cFrom, cTo) - } - } - if result != 0 { throw lastFileError() } - } - - // MARK: - Open files - - /// An open file. Wraps `SDFile`. The file is closed on deinit if it has - /// not been closed explicitly. - public final class Handle { - let pointer: UnsafeMutableRawPointer - private var isClosed = false - - /// Opens the file at `path`. - public init(path: String, mode: Options) throws(PlaydateError) { - let pointer = path.withPlaydateCString { - fileAPI.pointee.open.unsafelyUnwrapped($0, mode.cValue) - } - guard let pointer else { throw lastFileError() } - self.pointer = pointer - } - - deinit { - if !isClosed { - _ = fileAPI.pointee.close.unsafelyUnwrapped(pointer) - } - } - - /// Closes the file. Further operations are invalid. - public func close() throws(PlaydateError) { - guard !isClosed else { return } - isClosed = true - if fileAPI.pointee.close.unsafelyUnwrapped(pointer) != 0 { throw lastFileError() } - } - - /// Reads up to `buffer.count` bytes into `buffer`. Returns the number - /// of bytes read; 0 indicates end of file. - public func read(into buffer: UnsafeMutableRawBufferPointer) throws(PlaydateError) -> Int { - let result = fileAPI.pointee.read.unsafelyUnwrapped( - pointer, buffer.baseAddress, UInt32(buffer.count)) - if result < 0 { throw lastFileError() } - return Int(result) - } - - /// Reads up to `length` bytes and returns them. - public func read(length: Int) throws(PlaydateError) -> [UInt8] { - var bytes = [UInt8](repeating: 0, count: length) - let result = bytes.withUnsafeMutableBytes { buffer in - fileAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress, UInt32(buffer.count)) - } - if result < 0 { throw lastFileError() } - bytes.removeLast(length - Int(result)) - return bytes - } - - /// Writes the buffer to the file. Returns the number of bytes written. - @discardableResult - public func write(_ buffer: UnsafeRawBufferPointer) throws(PlaydateError) -> Int { - let result = fileAPI.pointee.write.unsafelyUnwrapped( - pointer, buffer.baseAddress, UInt32(buffer.count)) - if result < 0 { throw lastFileError() } - return Int(result) - } - - /// Writes the bytes to the file. Returns the number of bytes written. - @discardableResult - public func write(_ bytes: [UInt8]) throws(PlaydateError) -> Int { - let result = bytes.withUnsafeBytes { buffer in - fileAPI.pointee.write.unsafelyUnwrapped(pointer, buffer.baseAddress, UInt32(buffer.count)) - } - if result < 0 { throw lastFileError() } - return Int(result) - } - - /// Writes the string's UTF-8 to the file. Returns the bytes written. - @discardableResult - public func write(_ string: String) throws(PlaydateError) -> Int { - let result = string.withPlaydateUTF8 { bytes, count in - fileAPI.pointee.write.unsafelyUnwrapped(pointer, bytes, UInt32(count)) - } - if result < 0 { throw lastFileError() } - return Int(result) - } - - /// Flushes buffered writes to disk. Returns the bytes written. - @discardableResult - public func flush() throws(PlaydateError) -> Int { - let result = fileAPI.pointee.flush.unsafelyUnwrapped(pointer) - if result < 0 { throw lastFileError() } - return Int(result) - } - - /// The current read/write offset. - public func tell() throws(PlaydateError) -> Int { - let result = fileAPI.pointee.tell.unsafelyUnwrapped(pointer) - if result < 0 { throw lastFileError() } - return Int(result) - } - - /// Moves the read/write offset to `offset` relative to `origin`. - public func seek(to offset: Int, from origin: SeekOrigin = .start) throws(PlaydateError) { - if fileAPI.pointee.seek.unsafelyUnwrapped(pointer, Int32(offset), origin.rawValue) != 0 { - throw lastFileError() - } - } - } -} diff --git a/Sources/PlaydateKit/File/Classes/Handle.swift b/Sources/PlaydateKit/File/Classes/Handle.swift new file mode 100644 index 0000000..692dfb5 --- /dev/null +++ b/Sources/PlaydateKit/File/Classes/Handle.swift @@ -0,0 +1,103 @@ +internal import CPlaydate + +extension File { + /// An open file. Wraps `SDFile`. The file is closed on deinit if it has + /// not been closed explicitly. + public final class Handle { + let pointer: UnsafeMutableRawPointer + private var isClosed = false + + /// Opens the file at `path`. + public init(path: String, mode: Options) throws(PlaydateError) { + let pointer = path.withPlaydateCString { + fileAPI.pointee.open.unsafelyUnwrapped($0, mode.cValue) + } + guard let pointer else { throw lastFileError() } + self.pointer = pointer + } + + deinit { + if !isClosed { + _ = fileAPI.pointee.close.unsafelyUnwrapped(pointer) + } + } + + /// Closes the file. Further operations are invalid. + public func close() throws(PlaydateError) { + guard !isClosed else { return } + isClosed = true + if fileAPI.pointee.close.unsafelyUnwrapped(pointer) != 0 { throw lastFileError() } + } + + /// Reads up to `buffer.count` bytes into `buffer`. Returns the number + /// of bytes read; 0 indicates end of file. + public func read(into buffer: UnsafeMutableRawBufferPointer) throws(PlaydateError) -> Int { + let result = fileAPI.pointee.read.unsafelyUnwrapped( + pointer, buffer.baseAddress, UInt32(buffer.count)) + if result < 0 { throw lastFileError() } + return Int(result) + } + + /// Reads up to `length` bytes and returns them. + public func read(length: Int) throws(PlaydateError) -> [UInt8] { + var bytes = [UInt8](repeating: 0, count: length) + let result = bytes.withUnsafeMutableBytes { buffer in + fileAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress, UInt32(buffer.count)) + } + if result < 0 { throw lastFileError() } + bytes.removeLast(length - Int(result)) + return bytes + } + + /// Writes the buffer to the file. Returns the number of bytes written. + @discardableResult + public func write(_ buffer: UnsafeRawBufferPointer) throws(PlaydateError) -> Int { + let result = fileAPI.pointee.write.unsafelyUnwrapped( + pointer, buffer.baseAddress, UInt32(buffer.count)) + if result < 0 { throw lastFileError() } + return Int(result) + } + + /// Writes the bytes to the file. Returns the number of bytes written. + @discardableResult + public func write(_ bytes: [UInt8]) throws(PlaydateError) -> Int { + let result = bytes.withUnsafeBytes { buffer in + fileAPI.pointee.write.unsafelyUnwrapped(pointer, buffer.baseAddress, UInt32(buffer.count)) + } + if result < 0 { throw lastFileError() } + return Int(result) + } + + /// Writes the string's UTF-8 to the file. Returns the bytes written. + @discardableResult + public func write(_ string: String) throws(PlaydateError) -> Int { + let result = string.withPlaydateUTF8 { bytes, count in + fileAPI.pointee.write.unsafelyUnwrapped(pointer, bytes, UInt32(count)) + } + if result < 0 { throw lastFileError() } + return Int(result) + } + + /// Flushes buffered writes to disk. Returns the bytes written. + @discardableResult + public func flush() throws(PlaydateError) -> Int { + let result = fileAPI.pointee.flush.unsafelyUnwrapped(pointer) + if result < 0 { throw lastFileError() } + return Int(result) + } + + /// The current read/write offset. + public func tell() throws(PlaydateError) -> Int { + let result = fileAPI.pointee.tell.unsafelyUnwrapped(pointer) + if result < 0 { throw lastFileError() } + return Int(result) + } + + /// Moves the read/write offset to `offset` relative to `origin`. + public func seek(to offset: Int, from origin: SeekOrigin = .start) throws(PlaydateError) { + if fileAPI.pointee.seek.unsafelyUnwrapped(pointer, Int32(offset), origin.rawValue) != 0 { + throw lastFileError() + } + } + } +} diff --git a/Sources/PlaydateKit/File/Enumerations/SeekOrigin.swift b/Sources/PlaydateKit/File/Enumerations/SeekOrigin.swift new file mode 100644 index 0000000..eb46596 --- /dev/null +++ b/Sources/PlaydateKit/File/Enumerations/SeekOrigin.swift @@ -0,0 +1,8 @@ +extension File { + /// The origin used by `Handle.seek(to:from:)`. + public enum SeekOrigin: Int32, Sendable { + case start = 0 + case current = 1 + case end = 2 + } +} diff --git a/Sources/PlaydateKit/File/File.swift b/Sources/PlaydateKit/File/File.swift new file mode 100644 index 0000000..41b4f19 --- /dev/null +++ b/Sources/PlaydateKit/File/File.swift @@ -0,0 +1,73 @@ +internal import CPlaydate + +var fileAPI: UnsafePointer { Playdate.fileAPI.unsafelyUnwrapped } + +/// The most recent file system error as a thrown error. +func lastFileError() -> PlaydateError { + PlaydateError(cString: fileAPI.pointee.geterr.unsafelyUnwrapped()) +} + +/// The file API: access to the game's Data directory and pdx contents. +public enum File {} + +extension File { + // MARK: - Directory operations + + /// Calls `each` with the name of every file in `path`. Subdirectory names + /// end in a slash. Throws if the directory does not exist. + public static func listFiles(at path: String, showHidden: Bool = false, + _ each: (String) -> Void) throws(PlaydateError) { + let result = withoutActuallyEscaping(each) { each in + var callback = each + return path.withPlaydateCString { cPath in + withUnsafeMutablePointer(to: &callback) { callbackPointer in + fileAPI.pointee.listfiles.unsafelyUnwrapped(cPath, { cName, userdata in + guard let cName, let userdata else { return } + let each = userdata.assumingMemoryBound(to: ((String) -> Void).self).pointee + each(String(playdateCString: cName)) + }, callbackPointer, showHidden ? 1 : 0) + } + } + } + if result != 0 { throw lastFileError() } + } + + /// Information about the file or directory at `path`. + public static func stat(_ path: String) throws(PlaydateError) -> Stat { + var stat = FileStat() + let result = path.withPlaydateCString { fileAPI.pointee.stat.unsafelyUnwrapped($0, &stat) } + if result != 0 { throw lastFileError() } + return Stat( + isDirectory: stat.isdir != 0, + size: stat.size, + modified: System.DateTime( + year: UInt16(stat.m_year), month: UInt8(stat.m_month), day: UInt8(stat.m_day), + hour: UInt8(stat.m_hour), minute: UInt8(stat.m_minute), second: UInt8(stat.m_second))) + } + + /// Creates a directory (and intermediate directories) in the Data directory. + public static func mkdir(_ path: String) throws(PlaydateError) { + let result = path.withPlaydateCString { fileAPI.pointee.mkdir.unsafelyUnwrapped($0) } + if result != 0 { throw lastFileError() } + } + + /// Deletes the file or directory at `path`. Directories require + /// `recursive` to be deleted with their contents. + public static func unlink(_ path: String, recursive: Bool = false) throws(PlaydateError) { + let result = path.withPlaydateCString { + fileAPI.pointee.unlink.unsafelyUnwrapped($0, recursive ? 1 : 0) + } + if result != 0 { throw lastFileError() } + } + + /// Renames (moves) a file in the Data directory, overwriting any existing + /// file at the destination. + public static func rename(from: String, to: String) throws(PlaydateError) { + let result = from.withPlaydateCString { cFrom in + to.withPlaydateCString { cTo in + fileAPI.pointee.rename.unsafelyUnwrapped(cFrom, cTo) + } + } + if result != 0 { throw lastFileError() } + } +} diff --git a/Sources/PlaydateKit/File/Structures/Options.swift b/Sources/PlaydateKit/File/Structures/Options.swift new file mode 100644 index 0000000..d23de7b --- /dev/null +++ b/Sources/PlaydateKit/File/Structures/Options.swift @@ -0,0 +1,20 @@ +internal import CPlaydate + +extension File { + /// How to open a file. + public struct Options: OptionSet, Sendable { + public let rawValue: UInt32 + public init(rawValue: UInt32) { self.rawValue = rawValue } + + /// Read from the game pdx, then the Data directory. + public static let read = Options(rawValue: UInt32(kFileRead.rawValue)) + /// Read from the Data directory only. + public static let readData = Options(rawValue: UInt32(kFileReadData.rawValue)) + /// Write to the Data directory, truncating an existing file. + public static let write = Options(rawValue: UInt32(kFileWrite.rawValue)) + /// Write to the Data directory, appending to an existing file. + public static let append = Options(rawValue: UInt32(kFileAppend.rawValue)) + + var cValue: FileOptions { FileOptions(FileOptions.RawValue(rawValue)) } + } +} diff --git a/Sources/PlaydateKit/File/Structures/Stat.swift b/Sources/PlaydateKit/File/Structures/Stat.swift new file mode 100644 index 0000000..3e484e4 --- /dev/null +++ b/Sources/PlaydateKit/File/Structures/Stat.swift @@ -0,0 +1,8 @@ +extension File { + /// Information about a file or directory, mirroring `FileStat`. + public struct Stat: Sendable { + public let isDirectory: Bool + public let size: UInt32 + public let modified: System.DateTime + } +} diff --git a/Sources/PlaydateKit/GraphicsBitmap.swift b/Sources/PlaydateKit/Graphics/Classes/Bitmap.swift similarity index 70% rename from Sources/PlaydateKit/GraphicsBitmap.swift rename to Sources/PlaydateKit/Graphics/Classes/Bitmap.swift index a036c51..fab07b4 100644 --- a/Sources/PlaydateKit/GraphicsBitmap.swift +++ b/Sources/PlaydateKit/Graphics/Classes/Bitmap.swift @@ -1,8 +1,3 @@ -// -// GraphicsBitmap.swift -// Bitmap and BitmapTable wrappers around LCDBitmap / LCDBitmapTable. -// - internal import CPlaydate extension Graphics { @@ -44,16 +39,6 @@ extension Graphics { // MARK: Properties - /// The bitmap's dimensions, row stride, and raw pixel/mask storage. - /// The pointers are owned by the bitmap. - public struct Data { - public let width: Int - public let height: Int - public let rowBytes: Int - public let mask: UnsafeMutablePointer? - public let data: UnsafeMutablePointer? - } - public var data: Data { var width: Int32 = 0, height: Int32 = 0, rowBytes: Int32 = 0 var mask: UnsafeMutablePointer? @@ -164,58 +149,4 @@ extension Graphics { Int32(width), Int32(height), flip.cValue) } } - - /// A collection of bitmaps loaded from an image table. Wraps `LCDBitmapTable`. - public final class BitmapTable { - let pointer: OpaquePointer - - init(pointer: OpaquePointer) { - self.pointer = pointer - } - - /// Allocates a table with room for `count` bitmaps of the given size. - public convenience init(count: Int, width: Int, height: Int) { - let pointer = gfx.pointee.newBitmapTable.unsafelyUnwrapped(Int32(count), Int32(width), Int32(height)) - self.init(pointer: pointer.unsafelyUnwrapped) - } - - /// Loads an image table from a file. - public convenience init(path: String) throws(PlaydateError) { - var error: UnsafePointer? - let pointer = path.withPlaydateCString { gfx.pointee.loadBitmapTable.unsafelyUnwrapped($0, &error) } - guard let pointer else { throw PlaydateError(cString: error) } - self.init(pointer: pointer) - } - - deinit { - gfx.pointee.freeBitmapTable.unsafelyUnwrapped(pointer) - } - - /// Replaces the table's contents with the image table at `path`. - public func load(path: String) throws(PlaydateError) { - var error: UnsafePointer? - path.withPlaydateCString { gfx.pointee.loadIntoBitmapTable.unsafelyUnwrapped($0, pointer, &error) } - if let error { throw PlaydateError(cString: error) } - } - - /// The bitmap at `index`, or `nil` if out of range. The bitmap - /// references storage owned by the table; keep the table alive while - /// using it. - public func bitmap(at index: Int) -> Bitmap? { - guard let bitmap = gfx.pointee.getTableBitmap.unsafelyUnwrapped(pointer, Int32(index)) else { - return nil - } - return Bitmap(pointer: bitmap, isOwned: false) - } - - /// The number of bitmaps in the table and the number of cells per row - /// of the source image. - public var info: (count: Int, cellsWide: Int) { - var count: Int32 = 0, width: Int32 = 0 - gfx.pointee.getBitmapTableInfo.unsafelyUnwrapped(pointer, &count, &width) - return (Int(count), Int(width)) - } - - public var count: Int { info.count } - } } diff --git a/Sources/PlaydateKit/Graphics/Classes/BitmapTable.swift b/Sources/PlaydateKit/Graphics/Classes/BitmapTable.swift new file mode 100644 index 0000000..cc7c73e --- /dev/null +++ b/Sources/PlaydateKit/Graphics/Classes/BitmapTable.swift @@ -0,0 +1,57 @@ +internal import CPlaydate + +extension Graphics { + /// A collection of bitmaps loaded from an image table. Wraps `LCDBitmapTable`. + public final class BitmapTable { + let pointer: OpaquePointer + + init(pointer: OpaquePointer) { + self.pointer = pointer + } + + /// Allocates a table with room for `count` bitmaps of the given size. + public convenience init(count: Int, width: Int, height: Int) { + let pointer = gfx.pointee.newBitmapTable.unsafelyUnwrapped(Int32(count), Int32(width), Int32(height)) + self.init(pointer: pointer.unsafelyUnwrapped) + } + + /// Loads an image table from a file. + public convenience init(path: String) throws(PlaydateError) { + var error: UnsafePointer? + let pointer = path.withPlaydateCString { gfx.pointee.loadBitmapTable.unsafelyUnwrapped($0, &error) } + guard let pointer else { throw PlaydateError(cString: error) } + self.init(pointer: pointer) + } + + deinit { + gfx.pointee.freeBitmapTable.unsafelyUnwrapped(pointer) + } + + /// Replaces the table's contents with the image table at `path`. + public func load(path: String) throws(PlaydateError) { + var error: UnsafePointer? + path.withPlaydateCString { gfx.pointee.loadIntoBitmapTable.unsafelyUnwrapped($0, pointer, &error) } + if let error { throw PlaydateError(cString: error) } + } + + /// The bitmap at `index`, or `nil` if out of range. The bitmap + /// references storage owned by the table; keep the table alive while + /// using it. + public func bitmap(at index: Int) -> Bitmap? { + guard let bitmap = gfx.pointee.getTableBitmap.unsafelyUnwrapped(pointer, Int32(index)) else { + return nil + } + return Bitmap(pointer: bitmap, isOwned: false) + } + + /// The number of bitmaps in the table and the number of cells per row + /// of the source image. + public var info: (count: Int, cellsWide: Int) { + var count: Int32 = 0, width: Int32 = 0 + gfx.pointee.getBitmapTableInfo.unsafelyUnwrapped(pointer, &count, &width) + return (Int(count), Int(width)) + } + + public var count: Int { info.count } + } +} diff --git a/Sources/PlaydateKit/GraphicsFont.swift b/Sources/PlaydateKit/Graphics/Classes/Font.swift similarity index 74% rename from Sources/PlaydateKit/GraphicsFont.swift rename to Sources/PlaydateKit/Graphics/Classes/Font.swift index 68936e6..c156789 100644 --- a/Sources/PlaydateKit/GraphicsFont.swift +++ b/Sources/PlaydateKit/Graphics/Classes/Font.swift @@ -1,8 +1,3 @@ -// -// GraphicsFont.swift -// Font, FontPage, and Glyph wrappers around LCDFont / LCDFontPage / LCDFontGlyph. -// - internal import CPlaydate extension Graphics { @@ -89,35 +84,4 @@ extension Graphics { Int(advance)) } } - - /// A page of glyphs within a font. Wraps `LCDFontPage`. - /// Keep the font alive while using its pages. - public struct FontPage { - let pointer: OpaquePointer - let font: Font - - /// The glyph for `codepoint` within this page, with its bitmap and advance. - public func glyph(for codepoint: UInt32) -> (glyph: Glyph, bitmap: Bitmap?, advance: Int)? { - var bitmap: OpaquePointer? - var advance: Int32 = 0 - guard let glyph = gfx.pointee.getPageGlyph.unsafelyUnwrapped(pointer, codepoint, &bitmap, &advance) else { - return nil - } - return (Glyph(pointer: glyph, font: font), - bitmap.map { Bitmap(pointer: $0, isOwned: false) }, - Int(advance)) - } - } - - /// A single glyph within a font. Wraps `LCDFontGlyph`. - /// Keep the font alive while using its glyphs. - public struct Glyph { - let pointer: OpaquePointer - let font: Font - - /// The kerning adjustment between this glyph and the next character. - public func kerning(glyphCode: UInt32, nextCode: UInt32) -> Int { - Int(gfx.pointee.getGlyphKerning.unsafelyUnwrapped(pointer, glyphCode, nextCode)) - } - } } diff --git a/Sources/PlaydateKit/Graphics/Classes/StreamPlayer.swift b/Sources/PlaydateKit/Graphics/Classes/StreamPlayer.swift new file mode 100644 index 0000000..007e6ae --- /dev/null +++ b/Sources/PlaydateKit/Graphics/Classes/StreamPlayer.swift @@ -0,0 +1,81 @@ +internal import CPlaydate + +private var streamAPI: UnsafePointer { Playdate.videoStreamAPI.unsafelyUnwrapped } + +extension Graphics { + /// Streams video (and audio) from a file or network connection. + /// Wraps `LCDStreamPlayer`. + public final class StreamPlayer { + let pointer: OpaquePointer + /// Retains the active source so it outlives the stream. + private var retainedSource: AnyObject? + + public init() { + pointer = streamAPI.pointee.newPlayer.unsafelyUnwrapped().unsafelyUnwrapped + } + + deinit { + streamAPI.pointee.freePlayer.unsafelyUnwrapped(pointer) + } + + /// Sets the sizes of the stream's video and audio buffers, in bytes. + public func setBufferSize(video: Int, audio: Int) { + streamAPI.pointee.setBufferSize.unsafelyUnwrapped(pointer, Int32(video), Int32(audio)) + } + + /// Streams from an open file. + public func setFile(_ file: File.Handle) { + retainedSource = file + streamAPI.pointee.setFile.unsafelyUnwrapped(pointer, file.pointer) + } + + /// Streams from an HTTP connection. + public func setHTTPConnection(_ connection: Network.HTTPConnection) { + retainedSource = connection + streamAPI.pointee.setHTTPConnection.unsafelyUnwrapped(pointer, connection.pointer) + } + + /// Streams from a TCP connection. + public func setTCPConnection(_ connection: Network.TCPConnection) { + retainedSource = connection + streamAPI.pointee.setTCPConnection.unsafelyUnwrapped(pointer, connection.pointer) + } + + /// The player used for the stream's audio track. Owned by the stream. + /// The same wrapper is returned on every access, so callbacks + /// registered on it stay valid for the stream's lifetime. + public var filePlayer: Sound.FilePlayer? { + guard let player = streamAPI.pointee.getFilePlayer.unsafelyUnwrapped(pointer) else { return nil } + if let cached = cachedFilePlayer, cached.pointer == player { + return cached + } + let wrapper = Sound.FilePlayer(pointer: player, isOwned: false) + cachedFilePlayer = wrapper + return wrapper + } + + private var cachedFilePlayer: Sound.FilePlayer? + + /// The player used for the stream's video track. Owned by the stream. + public var videoPlayer: VideoPlayer? { + guard let player = streamAPI.pointee.getVideoPlayer.unsafelyUnwrapped(pointer) else { return nil } + return VideoPlayer(pointer: player, isOwned: false) + } + + /// Advances the stream. Returns `true` if a frame was drawn. + @discardableResult + public func update() -> Bool { + streamAPI.pointee.update.unsafelyUnwrapped(pointer) + } + + /// The number of video frames currently buffered. + public var bufferedFrameCount: Int { + Int(streamAPI.pointee.getBufferedFrameCount.unsafelyUnwrapped(pointer)) + } + + /// The total number of bytes read from the source. + public var bytesRead: UInt32 { + streamAPI.pointee.getBytesRead.unsafelyUnwrapped(pointer) + } + } +} diff --git a/Sources/PlaydateKit/GraphicsTileMap.swift b/Sources/PlaydateKit/Graphics/Classes/TileMap.swift similarity index 96% rename from Sources/PlaydateKit/GraphicsTileMap.swift rename to Sources/PlaydateKit/Graphics/Classes/TileMap.swift index a1ec4da..ef04c4a 100644 --- a/Sources/PlaydateKit/GraphicsTileMap.swift +++ b/Sources/PlaydateKit/Graphics/Classes/TileMap.swift @@ -1,8 +1,3 @@ -// -// GraphicsTileMap.swift -// TileMap wrapper around LCDTileMap (playdate->graphics->tilemap). -// - internal import CPlaydate private var tilemapAPI: UnsafePointer { Playdate.tilemapAPI.unsafelyUnwrapped } diff --git a/Sources/PlaydateKit/Graphics/Classes/VideoPlayer.swift b/Sources/PlaydateKit/Graphics/Classes/VideoPlayer.swift new file mode 100644 index 0000000..48896a7 --- /dev/null +++ b/Sources/PlaydateKit/Graphics/Classes/VideoPlayer.swift @@ -0,0 +1,77 @@ +internal import CPlaydate + +private var videoAPI: UnsafePointer { Playdate.videoAPI.unsafelyUnwrapped } + +extension Graphics { + /// Plays .pdv video files. Wraps `LCDVideoPlayer`. + public final class VideoPlayer { + let pointer: OpaquePointer + let isOwned: Bool + /// Retains the render context bitmap while the player uses it. + private var retainedContext: Bitmap? + + init(pointer: OpaquePointer, isOwned: Bool) { + self.pointer = pointer + self.isOwned = isOwned + } + + /// Opens the .pdv file at `path`. + public convenience init(path: String) throws(PlaydateError) { + let pointer = path.withPlaydateCString { videoAPI.pointee.loadVideo.unsafelyUnwrapped($0) } + guard let pointer else { + throw PlaydateError(message: "unable to load video: \(path)") + } + self.init(pointer: pointer, isOwned: true) + } + + deinit { + if isOwned { + videoAPI.pointee.freePlayer.unsafelyUnwrapped(pointer) + } + } + + /// Sets the bitmap the video renders into. + public func setContext(_ context: Bitmap) throws(PlaydateError) { + guard videoAPI.pointee.setContext.unsafelyUnwrapped(pointer, context.pointer) != 0 else { + throw PlaydateError(message: error ?? "unable to set video context") + } + retainedContext = context + } + + /// The bitmap the video renders into. + public var context: Bitmap? { + guard let context = videoAPI.pointee.getContext.unsafelyUnwrapped(pointer) else { return nil } + return Bitmap(pointer: context, isOwned: false) + } + + /// Renders directly into the display framebuffer. + public func useScreenContext() { + retainedContext = nil + videoAPI.pointee.useScreenContext.unsafelyUnwrapped(pointer) + } + + /// Renders frame `frame` into the current context. + public func renderFrame(_ frame: Int) throws(PlaydateError) { + guard videoAPI.pointee.renderFrame.unsafelyUnwrapped(pointer, Int32(frame)) != 0 else { + // Static message: the caller knows the frame it passed, and + // interpolating it would pull integer formatting machinery + // into the device binary. + throw PlaydateError(message: error ?? "unable to render frame") + } + } + + /// The most recent error message, if any. + public var error: String? { + String(playdateCString: videoAPI.pointee.getError.unsafelyUnwrapped(pointer)) + } + + /// The video's dimensions, frame rate, frame count, and current frame. + public var info: (width: Int, height: Int, frameRate: Float, frameCount: Int, currentFrame: Int) { + var width: Int32 = 0, height: Int32 = 0, frameCount: Int32 = 0, currentFrame: Int32 = 0 + var frameRate: Float = 0 + videoAPI.pointee.getInfo.unsafelyUnwrapped(pointer, &width, &height, &frameRate, + &frameCount, ¤tFrame) + return (Int(width), Int(height), frameRate, Int(frameCount), Int(currentFrame)) + } + } +} diff --git a/Sources/PlaydateKit/Graphics/Enumerations/BitmapFlip.swift b/Sources/PlaydateKit/Graphics/Enumerations/BitmapFlip.swift new file mode 100644 index 0000000..f1dbd33 --- /dev/null +++ b/Sources/PlaydateKit/Graphics/Enumerations/BitmapFlip.swift @@ -0,0 +1,14 @@ +internal import CPlaydate + +extension Graphics { + /// Mirroring applied when drawing a bitmap. + public enum BitmapFlip: UInt32, Sendable { + case unflipped = 0 + case flippedX = 1 + case flippedY = 2 + case flippedXY = 3 + + init(_ flip: LCDBitmapFlip) { self = BitmapFlip(rawValue: UInt32(flip.rawValue)) ?? .unflipped } + var cValue: LCDBitmapFlip { LCDBitmapFlip(LCDBitmapFlip.RawValue(rawValue)) } + } +} diff --git a/Sources/PlaydateKit/Graphics/Enumerations/Color.swift b/Sources/PlaydateKit/Graphics/Enumerations/Color.swift new file mode 100644 index 0000000..5512494 --- /dev/null +++ b/Sources/PlaydateKit/Graphics/Enumerations/Color.swift @@ -0,0 +1,28 @@ +internal import CPlaydate + +extension Graphics { + /// A drawing color: solid or an 8×8 pattern. + public enum Color: Sendable { + case black + case white + case clear + case xor + case pattern(Pattern) + + /// Materializes the `LCDColor` for the duration of `body`. Pattern + /// colors pass a pointer to a temporary, so the value must not be + /// stored beyond the call. + func withLCDColor(_ body: (LCDColor) -> Result) -> Result { + switch self { + case .black: return body(LCDColor(kColorBlack.rawValue)) + case .white: return body(LCDColor(kColorWhite.rawValue)) + case .clear: return body(LCDColor(kColorClear.rawValue)) + case .xor: return body(LCDColor(kColorXOR.rawValue)) + case .pattern(let pattern): + return withUnsafeBytes(of: pattern.bytes) { buffer in + body(LCDColor(UInt(bitPattern: buffer.baseAddress))) + } + } + } + } +} diff --git a/Sources/PlaydateKit/Graphics/Enumerations/DrawMode.swift b/Sources/PlaydateKit/Graphics/Enumerations/DrawMode.swift new file mode 100644 index 0000000..92dfe4b --- /dev/null +++ b/Sources/PlaydateKit/Graphics/Enumerations/DrawMode.swift @@ -0,0 +1,18 @@ +internal import CPlaydate + +extension Graphics { + /// How source pixels combine with the destination when drawing. + public enum DrawMode: UInt32, Sendable { + case copy = 0 + case whiteTransparent = 1 + case blackTransparent = 2 + case fillWhite = 3 + case fillBlack = 4 + case xor = 5 + case nxor = 6 + case inverted = 7 + + init(_ mode: LCDBitmapDrawMode) { self = DrawMode(rawValue: UInt32(mode.rawValue)) ?? .copy } + var cValue: LCDBitmapDrawMode { LCDBitmapDrawMode(LCDBitmapDrawMode.RawValue(rawValue)) } + } +} diff --git a/Sources/PlaydateKit/Graphics/Enumerations/LineCapStyle.swift b/Sources/PlaydateKit/Graphics/Enumerations/LineCapStyle.swift new file mode 100644 index 0000000..c520a67 --- /dev/null +++ b/Sources/PlaydateKit/Graphics/Enumerations/LineCapStyle.swift @@ -0,0 +1,12 @@ +internal import CPlaydate + +extension Graphics { + /// The end cap style used when drawing lines. + public enum LineCapStyle: UInt32, Sendable { + case butt = 0 + case square = 1 + case round = 2 + + var cValue: LCDLineCapStyle { LCDLineCapStyle(LCDLineCapStyle.RawValue(rawValue)) } + } +} diff --git a/Sources/PlaydateKit/Graphics/Enumerations/PolygonFillRule.swift b/Sources/PlaydateKit/Graphics/Enumerations/PolygonFillRule.swift new file mode 100644 index 0000000..bd57d33 --- /dev/null +++ b/Sources/PlaydateKit/Graphics/Enumerations/PolygonFillRule.swift @@ -0,0 +1,11 @@ +internal import CPlaydate + +extension Graphics { + /// The winding rule used by `fillPolygon`. + public enum PolygonFillRule: UInt32, Sendable { + case nonZero = 0 + case evenOdd = 1 + + var cValue: LCDPolygonFillRule { LCDPolygonFillRule(LCDPolygonFillRule.RawValue(rawValue)) } + } +} diff --git a/Sources/PlaydateKit/Graphics/Enumerations/SolidColor.swift b/Sources/PlaydateKit/Graphics/Enumerations/SolidColor.swift new file mode 100644 index 0000000..afe17b5 --- /dev/null +++ b/Sources/PlaydateKit/Graphics/Enumerations/SolidColor.swift @@ -0,0 +1,14 @@ +internal import CPlaydate + +extension Graphics { + /// A solid color, for APIs that cannot take a pattern. + public enum SolidColor: UInt32, Sendable { + case black = 0 + case white = 1 + case clear = 2 + case xor = 3 + + init(_ color: LCDSolidColor) { self = SolidColor(rawValue: UInt32(color.rawValue)) ?? .clear } + var cValue: LCDSolidColor { LCDSolidColor(LCDSolidColor.RawValue(rawValue)) } + } +} diff --git a/Sources/PlaydateKit/Graphics/Enumerations/StringEncoding.swift b/Sources/PlaydateKit/Graphics/Enumerations/StringEncoding.swift new file mode 100644 index 0000000..2eb9433 --- /dev/null +++ b/Sources/PlaydateKit/Graphics/Enumerations/StringEncoding.swift @@ -0,0 +1,12 @@ +internal import CPlaydate + +extension Graphics { + /// The encoding of text passed to the text functions. + public enum StringEncoding: UInt32, Sendable { + case ascii = 0 + case utf8 = 1 + case utf16LittleEndian = 2 + + var cValue: PDStringEncoding { PDStringEncoding(PDStringEncoding.RawValue(rawValue)) } + } +} diff --git a/Sources/PlaydateKit/Graphics/Enumerations/TextAlignment.swift b/Sources/PlaydateKit/Graphics/Enumerations/TextAlignment.swift new file mode 100644 index 0000000..01299fc --- /dev/null +++ b/Sources/PlaydateKit/Graphics/Enumerations/TextAlignment.swift @@ -0,0 +1,12 @@ +internal import CPlaydate + +extension Graphics { + /// Horizontal alignment for `drawText(in:)`. + public enum TextAlignment: UInt32, Sendable { + case left = 0 + case center = 1 + case right = 2 + + var cValue: PDTextAlignment { PDTextAlignment(PDTextAlignment.RawValue(rawValue)) } + } +} diff --git a/Sources/PlaydateKit/Graphics/Enumerations/TextWrappingMode.swift b/Sources/PlaydateKit/Graphics/Enumerations/TextWrappingMode.swift new file mode 100644 index 0000000..1a9c02f --- /dev/null +++ b/Sources/PlaydateKit/Graphics/Enumerations/TextWrappingMode.swift @@ -0,0 +1,12 @@ +internal import CPlaydate + +extension Graphics { + /// How text wraps in `drawText(in:)`. + public enum TextWrappingMode: UInt32, Sendable { + case clip = 0 + case character = 1 + case word = 2 + + var cValue: PDTextWrappingMode { PDTextWrappingMode(PDTextWrappingMode.RawValue(rawValue)) } + } +} diff --git a/Sources/PlaydateKit/Graphics.swift b/Sources/PlaydateKit/Graphics/Graphics.swift similarity index 65% rename from Sources/PlaydateKit/Graphics.swift rename to Sources/PlaydateKit/Graphics/Graphics.swift index 982f307..c361738 100644 --- a/Sources/PlaydateKit/Graphics.swift +++ b/Sources/PlaydateKit/Graphics/Graphics.swift @@ -1,10 +1,3 @@ -// -// Graphics.swift -// Wraps `playdate->graphics` (pd_api_gfx.h): drawing state, shapes, text, -// and raw framebuffer access. Bitmap, font, tilemap, and video wrappers live -// in their own files. -// - internal import CPlaydate /// The graphics API: drawing, bitmaps, fonts, tilemaps, and video. @@ -22,165 +15,6 @@ extension Graphics { /// The stride of a framebuffer row in bytes (`LCD_ROWSIZE`). public static let rowSize = 52 - // MARK: - Types - - /// An 8×8 two-color pattern: 8 rows of image data followed by 8 rows of mask. - public struct Pattern: Sendable { - public var bytes: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, - UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8) - - public init(bytes: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, - UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8)) { - self.bytes = bytes - } - - /// Creates an opaque pattern from 8 rows of image data. - public init(rows r: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8)) { - bytes = (r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff) - } - } - - /// A drawing color: solid or an 8×8 pattern. - public enum Color: Sendable { - case black - case white - case clear - case xor - case pattern(Pattern) - - /// Materializes the `LCDColor` for the duration of `body`. Pattern - /// colors pass a pointer to a temporary, so the value must not be - /// stored beyond the call. - func withLCDColor(_ body: (LCDColor) -> Result) -> Result { - switch self { - case .black: return body(LCDColor(kColorBlack.rawValue)) - case .white: return body(LCDColor(kColorWhite.rawValue)) - case .clear: return body(LCDColor(kColorClear.rawValue)) - case .xor: return body(LCDColor(kColorXOR.rawValue)) - case .pattern(let pattern): - return withUnsafeBytes(of: pattern.bytes) { buffer in - body(LCDColor(UInt(bitPattern: buffer.baseAddress))) - } - } - } - } - - /// A solid color, for APIs that cannot take a pattern. - public enum SolidColor: UInt32, Sendable { - case black = 0 - case white = 1 - case clear = 2 - case xor = 3 - - init(_ color: LCDSolidColor) { self = SolidColor(rawValue: UInt32(color.rawValue)) ?? .clear } - var cValue: LCDSolidColor { LCDSolidColor(LCDSolidColor.RawValue(rawValue)) } - } - - /// How source pixels combine with the destination when drawing. - public enum DrawMode: UInt32, Sendable { - case copy = 0 - case whiteTransparent = 1 - case blackTransparent = 2 - case fillWhite = 3 - case fillBlack = 4 - case xor = 5 - case nxor = 6 - case inverted = 7 - - init(_ mode: LCDBitmapDrawMode) { self = DrawMode(rawValue: UInt32(mode.rawValue)) ?? .copy } - var cValue: LCDBitmapDrawMode { LCDBitmapDrawMode(LCDBitmapDrawMode.RawValue(rawValue)) } - } - - /// Mirroring applied when drawing a bitmap. - public enum BitmapFlip: UInt32, Sendable { - case unflipped = 0 - case flippedX = 1 - case flippedY = 2 - case flippedXY = 3 - - init(_ flip: LCDBitmapFlip) { self = BitmapFlip(rawValue: UInt32(flip.rawValue)) ?? .unflipped } - var cValue: LCDBitmapFlip { LCDBitmapFlip(LCDBitmapFlip.RawValue(rawValue)) } - } - - /// The end cap style used when drawing lines. - public enum LineCapStyle: UInt32, Sendable { - case butt = 0 - case square = 1 - case round = 2 - - var cValue: LCDLineCapStyle { LCDLineCapStyle(LCDLineCapStyle.RawValue(rawValue)) } - } - - /// The encoding of text passed to the text functions. - public enum StringEncoding: UInt32, Sendable { - case ascii = 0 - case utf8 = 1 - case utf16LittleEndian = 2 - - var cValue: PDStringEncoding { PDStringEncoding(PDStringEncoding.RawValue(rawValue)) } - } - - /// The winding rule used by `fillPolygon`. - public enum PolygonFillRule: UInt32, Sendable { - case nonZero = 0 - case evenOdd = 1 - - var cValue: LCDPolygonFillRule { LCDPolygonFillRule(LCDPolygonFillRule.RawValue(rawValue)) } - } - - /// How text wraps in `drawText(in:)`. - public enum TextWrappingMode: UInt32, Sendable { - case clip = 0 - case character = 1 - case word = 2 - - var cValue: PDTextWrappingMode { PDTextWrappingMode(PDTextWrappingMode.RawValue(rawValue)) } - } - - /// Horizontal alignment for `drawText(in:)`. - public enum TextAlignment: UInt32, Sendable { - case left = 0 - case center = 1 - case right = 2 - - var cValue: PDTextAlignment { PDTextAlignment(PDTextAlignment.RawValue(rawValue)) } - } - - /// An integer rectangle mirroring `LCDRect`. `right` and `bottom` are - /// not inclusive. - public struct Rect: Sendable { - public var left: Int - public var right: Int - public var top: Int - public var bottom: Int - - public init(left: Int, right: Int, top: Int, bottom: Int) { - self.left = left - self.right = right - self.top = top - self.bottom = bottom - } - - public init(x: Int, y: Int, width: Int, height: Int) { - self.init(left: x, right: x + width, top: y, bottom: y + height) - } - - init(_ rect: LCDRect) { - self.init(left: Int(rect.left), right: Int(rect.right), - top: Int(rect.top), bottom: Int(rect.bottom)) - } - - var cValue: LCDRect { - LCDRect(left: Int32(left), right: Int32(right), - top: Int32(top), bottom: Int32(bottom)) - } - - public func translated(dx: Int, dy: Int) -> Rect { - Rect(left: left + dx, right: right + dx, top: top + dy, bottom: bottom + dy) - } - } - // MARK: - Drawing state /// Clears the entire display, filling it with `color`. diff --git a/Sources/PlaydateKit/Graphics/Structures/Bitmap.Data.swift b/Sources/PlaydateKit/Graphics/Structures/Bitmap.Data.swift new file mode 100644 index 0000000..9d99caf --- /dev/null +++ b/Sources/PlaydateKit/Graphics/Structures/Bitmap.Data.swift @@ -0,0 +1,11 @@ +extension Graphics.Bitmap { + /// The bitmap's dimensions, row stride, and raw pixel/mask storage. + /// The pointers are owned by the bitmap. + public struct Data { + public let width: Int + public let height: Int + public let rowBytes: Int + public let mask: UnsafeMutablePointer? + public let data: UnsafeMutablePointer? + } +} diff --git a/Sources/PlaydateKit/Graphics/Structures/FontPage.swift b/Sources/PlaydateKit/Graphics/Structures/FontPage.swift new file mode 100644 index 0000000..657d1cb --- /dev/null +++ b/Sources/PlaydateKit/Graphics/Structures/FontPage.swift @@ -0,0 +1,22 @@ +internal import CPlaydate + +extension Graphics { + /// A page of glyphs within a font. Wraps `LCDFontPage`. + /// Keep the font alive while using its pages. + public struct FontPage { + let pointer: OpaquePointer + let font: Font + + /// The glyph for `codepoint` within this page, with its bitmap and advance. + public func glyph(for codepoint: UInt32) -> (glyph: Glyph, bitmap: Bitmap?, advance: Int)? { + var bitmap: OpaquePointer? + var advance: Int32 = 0 + guard let glyph = gfx.pointee.getPageGlyph.unsafelyUnwrapped(pointer, codepoint, &bitmap, &advance) else { + return nil + } + return (Glyph(pointer: glyph, font: font), + bitmap.map { Bitmap(pointer: $0, isOwned: false) }, + Int(advance)) + } + } +} diff --git a/Sources/PlaydateKit/Graphics/Structures/Glyph.swift b/Sources/PlaydateKit/Graphics/Structures/Glyph.swift new file mode 100644 index 0000000..e169011 --- /dev/null +++ b/Sources/PlaydateKit/Graphics/Structures/Glyph.swift @@ -0,0 +1,15 @@ +internal import CPlaydate + +extension Graphics { + /// A single glyph within a font. Wraps `LCDFontGlyph`. + /// Keep the font alive while using its glyphs. + public struct Glyph { + let pointer: OpaquePointer + let font: Font + + /// The kerning adjustment between this glyph and the next character. + public func kerning(glyphCode: UInt32, nextCode: UInt32) -> Int { + Int(gfx.pointee.getGlyphKerning.unsafelyUnwrapped(pointer, glyphCode, nextCode)) + } + } +} diff --git a/Sources/PlaydateKit/Graphics/Structures/Graphics.Rect.swift b/Sources/PlaydateKit/Graphics/Structures/Graphics.Rect.swift new file mode 100644 index 0000000..3d6d75b --- /dev/null +++ b/Sources/PlaydateKit/Graphics/Structures/Graphics.Rect.swift @@ -0,0 +1,37 @@ +internal import CPlaydate + +extension Graphics { + /// An integer rectangle mirroring `LCDRect`. `right` and `bottom` are + /// not inclusive. + public struct Rect: Sendable { + public var left: Int + public var right: Int + public var top: Int + public var bottom: Int + + public init(left: Int, right: Int, top: Int, bottom: Int) { + self.left = left + self.right = right + self.top = top + self.bottom = bottom + } + + public init(x: Int, y: Int, width: Int, height: Int) { + self.init(left: x, right: x + width, top: y, bottom: y + height) + } + + init(_ rect: LCDRect) { + self.init(left: Int(rect.left), right: Int(rect.right), + top: Int(rect.top), bottom: Int(rect.bottom)) + } + + var cValue: LCDRect { + LCDRect(left: Int32(left), right: Int32(right), + top: Int32(top), bottom: Int32(bottom)) + } + + public func translated(dx: Int, dy: Int) -> Rect { + Rect(left: left + dx, right: right + dx, top: top + dy, bottom: bottom + dy) + } + } +} diff --git a/Sources/PlaydateKit/Graphics/Structures/Pattern.swift b/Sources/PlaydateKit/Graphics/Structures/Pattern.swift new file mode 100644 index 0000000..84ddf49 --- /dev/null +++ b/Sources/PlaydateKit/Graphics/Structures/Pattern.swift @@ -0,0 +1,18 @@ +extension Graphics { + /// An 8×8 two-color pattern: 8 rows of image data followed by 8 rows of mask. + public struct Pattern: Sendable { + public var bytes: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8) + + public init(bytes: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8)) { + self.bytes = bytes + } + + /// Creates an opaque pattern from 8 rows of image data. + public init(rows r: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8)) { + bytes = (r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff) + } + } +} diff --git a/Sources/PlaydateKit/GraphicsVideo.swift b/Sources/PlaydateKit/GraphicsVideo.swift deleted file mode 100644 index eb71767..0000000 --- a/Sources/PlaydateKit/GraphicsVideo.swift +++ /dev/null @@ -1,160 +0,0 @@ -// -// GraphicsVideo.swift -// VideoPlayer and StreamPlayer wrappers around LCDVideoPlayer / -// LCDStreamPlayer (playdate->graphics->video / ->videostream). -// - -internal import CPlaydate - -private var videoAPI: UnsafePointer { Playdate.videoAPI.unsafelyUnwrapped } -private var streamAPI: UnsafePointer { Playdate.videoStreamAPI.unsafelyUnwrapped } - -extension Graphics { - /// Plays .pdv video files. Wraps `LCDVideoPlayer`. - public final class VideoPlayer { - let pointer: OpaquePointer - let isOwned: Bool - /// Retains the render context bitmap while the player uses it. - private var retainedContext: Bitmap? - - init(pointer: OpaquePointer, isOwned: Bool) { - self.pointer = pointer - self.isOwned = isOwned - } - - /// Opens the .pdv file at `path`. - public convenience init(path: String) throws(PlaydateError) { - let pointer = path.withPlaydateCString { videoAPI.pointee.loadVideo.unsafelyUnwrapped($0) } - guard let pointer else { - throw PlaydateError(message: "unable to load video: \(path)") - } - self.init(pointer: pointer, isOwned: true) - } - - deinit { - if isOwned { - videoAPI.pointee.freePlayer.unsafelyUnwrapped(pointer) - } - } - - /// Sets the bitmap the video renders into. - public func setContext(_ context: Bitmap) throws(PlaydateError) { - guard videoAPI.pointee.setContext.unsafelyUnwrapped(pointer, context.pointer) != 0 else { - throw PlaydateError(message: error ?? "unable to set video context") - } - retainedContext = context - } - - /// The bitmap the video renders into. - public var context: Bitmap? { - guard let context = videoAPI.pointee.getContext.unsafelyUnwrapped(pointer) else { return nil } - return Bitmap(pointer: context, isOwned: false) - } - - /// Renders directly into the display framebuffer. - public func useScreenContext() { - retainedContext = nil - videoAPI.pointee.useScreenContext.unsafelyUnwrapped(pointer) - } - - /// Renders frame `frame` into the current context. - public func renderFrame(_ frame: Int) throws(PlaydateError) { - guard videoAPI.pointee.renderFrame.unsafelyUnwrapped(pointer, Int32(frame)) != 0 else { - // Static message: the caller knows the frame it passed, and - // interpolating it would pull integer formatting machinery - // into the device binary. - throw PlaydateError(message: error ?? "unable to render frame") - } - } - - /// The most recent error message, if any. - public var error: String? { - String(playdateCString: videoAPI.pointee.getError.unsafelyUnwrapped(pointer)) - } - - /// The video's dimensions, frame rate, frame count, and current frame. - public var info: (width: Int, height: Int, frameRate: Float, frameCount: Int, currentFrame: Int) { - var width: Int32 = 0, height: Int32 = 0, frameCount: Int32 = 0, currentFrame: Int32 = 0 - var frameRate: Float = 0 - videoAPI.pointee.getInfo.unsafelyUnwrapped(pointer, &width, &height, &frameRate, - &frameCount, ¤tFrame) - return (Int(width), Int(height), frameRate, Int(frameCount), Int(currentFrame)) - } - } - - /// Streams video (and audio) from a file or network connection. - /// Wraps `LCDStreamPlayer`. - public final class StreamPlayer { - let pointer: OpaquePointer - /// Retains the active source so it outlives the stream. - private var retainedSource: AnyObject? - - public init() { - pointer = streamAPI.pointee.newPlayer.unsafelyUnwrapped().unsafelyUnwrapped - } - - deinit { - streamAPI.pointee.freePlayer.unsafelyUnwrapped(pointer) - } - - /// Sets the sizes of the stream's video and audio buffers, in bytes. - public func setBufferSize(video: Int, audio: Int) { - streamAPI.pointee.setBufferSize.unsafelyUnwrapped(pointer, Int32(video), Int32(audio)) - } - - /// Streams from an open file. - public func setFile(_ file: File.Handle) { - retainedSource = file - streamAPI.pointee.setFile.unsafelyUnwrapped(pointer, file.pointer) - } - - /// Streams from an HTTP connection. - public func setHTTPConnection(_ connection: Network.HTTPConnection) { - retainedSource = connection - streamAPI.pointee.setHTTPConnection.unsafelyUnwrapped(pointer, connection.pointer) - } - - /// Streams from a TCP connection. - public func setTCPConnection(_ connection: Network.TCPConnection) { - retainedSource = connection - streamAPI.pointee.setTCPConnection.unsafelyUnwrapped(pointer, connection.pointer) - } - - /// The player used for the stream's audio track. Owned by the stream. - /// The same wrapper is returned on every access, so callbacks - /// registered on it stay valid for the stream's lifetime. - public var filePlayer: Sound.FilePlayer? { - guard let player = streamAPI.pointee.getFilePlayer.unsafelyUnwrapped(pointer) else { return nil } - if let cached = cachedFilePlayer, cached.pointer == player { - return cached - } - let wrapper = Sound.FilePlayer(pointer: player, isOwned: false) - cachedFilePlayer = wrapper - return wrapper - } - - private var cachedFilePlayer: Sound.FilePlayer? - - /// The player used for the stream's video track. Owned by the stream. - public var videoPlayer: VideoPlayer? { - guard let player = streamAPI.pointee.getVideoPlayer.unsafelyUnwrapped(pointer) else { return nil } - return VideoPlayer(pointer: player, isOwned: false) - } - - /// Advances the stream. Returns `true` if a frame was drawn. - @discardableResult - public func update() -> Bool { - streamAPI.pointee.update.unsafelyUnwrapped(pointer) - } - - /// The number of video frames currently buffered. - public var bufferedFrameCount: Int { - Int(streamAPI.pointee.getBufferedFrameCount.unsafelyUnwrapped(pointer)) - } - - /// The total number of bytes read from the source. - public var bytesRead: UInt32 { - streamAPI.pointee.getBytesRead.unsafelyUnwrapped(pointer) - } - } -} diff --git a/Sources/PlaydateKit/JSON/Classes/Encoder.swift b/Sources/PlaydateKit/JSON/Classes/Encoder.swift new file mode 100644 index 0000000..2aef1e5 --- /dev/null +++ b/Sources/PlaydateKit/JSON/Classes/Encoder.swift @@ -0,0 +1,111 @@ +internal import CPlaydate + +extension JSON { + /// A streaming JSON encoder writing into a string. Wraps `json_encoder`. + public final class Encoder { + private final class Output { + var text = "" + } + + private var encoder = json_encoder() + private let output = Output() + + public init(pretty: Bool = false) { + jsonAPI.pointee.initEncoder.unsafelyUnwrapped(&encoder, { userdata, string, length in + guard let userdata, let string else { return } + let output = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + let bytes = UnsafeRawBufferPointer(start: string, count: Int(length)) + output.text += String(decoding: bytes, as: UTF8.self) + }, Unmanaged.passUnretained(output).toOpaque(), pretty ? 1 : 0) + } + + /// The JSON produced so far. + public var json: String { output.text } + + public func startArray() { + withUnsafeMutablePointer(to: &encoder) { $0.pointee.startArray.unsafelyUnwrapped($0) } + } + + /// Call before writing each array element. + public func addArrayMember() { + withUnsafeMutablePointer(to: &encoder) { $0.pointee.addArrayMember.unsafelyUnwrapped($0) } + } + + public func endArray() { + withUnsafeMutablePointer(to: &encoder) { $0.pointee.endArray.unsafelyUnwrapped($0) } + } + + public func startTable() { + withUnsafeMutablePointer(to: &encoder) { $0.pointee.startTable.unsafelyUnwrapped($0) } + } + + /// Call before writing each table value. + public func addTableMember(name: String) { + name.withPlaydateCString { cName in + withUnsafeMutablePointer(to: &encoder) { + $0.pointee.addTableMember.unsafelyUnwrapped($0, cName, Int32(name.utf8.count)) + } + } + } + + public func endTable() { + withUnsafeMutablePointer(to: &encoder) { $0.pointee.endTable.unsafelyUnwrapped($0) } + } + + public func writeNull() { + withUnsafeMutablePointer(to: &encoder) { $0.pointee.writeNull.unsafelyUnwrapped($0) } + } + + public func writeBool(_ value: Bool) { + withUnsafeMutablePointer(to: &encoder) { + (value ? $0.pointee.writeTrue : $0.pointee.writeFalse).unsafelyUnwrapped($0) + } + } + + public func writeInt(_ value: Int) { + withUnsafeMutablePointer(to: &encoder) { $0.pointee.writeInt.unsafelyUnwrapped($0, Int32(value)) } + } + + public func writeDouble(_ value: Double) { + withUnsafeMutablePointer(to: &encoder) { $0.pointee.writeDouble.unsafelyUnwrapped($0, value) } + } + + public func writeString(_ value: String) { + value.withPlaydateCString { cString in + withUnsafeMutablePointer(to: &encoder) { + $0.pointee.writeString.unsafelyUnwrapped($0, cString, Int32(value.utf8.count)) + } + } + } + + /// Writes a complete `Value` tree. + public func write(_ value: Value) { + switch value { + case .null: + writeNull() + case .bool(let bool): + writeBool(bool) + case .int(let int): + writeInt(int) + case .float(let float): + writeDouble(Double(float)) + case .string(let string): + writeString(string) + case .array(let items): + startArray() + for item in items { + addArrayMember() + write(item) + } + endArray() + case .table(let entries): + startTable() + for (key, entry) in entries { + addTableMember(name: key) + write(entry) + } + endTable() + } + } + } +} diff --git a/Sources/PlaydateKit/JSON/Enumerations/Value.swift b/Sources/PlaydateKit/JSON/Enumerations/Value.swift new file mode 100644 index 0000000..7938136 --- /dev/null +++ b/Sources/PlaydateKit/JSON/Enumerations/Value.swift @@ -0,0 +1,12 @@ +extension JSON { + /// A decoded JSON value. + public indirect enum Value { + case null + case bool(Bool) + case int(Int) + case float(Float) + case string(String) + case array([Value]) + case table([String: Value]) + } +} diff --git a/Sources/PlaydateKit/JSON.swift b/Sources/PlaydateKit/JSON/JSON.swift similarity index 61% rename from Sources/PlaydateKit/JSON.swift rename to Sources/PlaydateKit/JSON/JSON.swift index 2590a66..c24323a 100644 --- a/Sources/PlaydateKit/JSON.swift +++ b/Sources/PlaydateKit/JSON/JSON.swift @@ -1,31 +1,11 @@ -// -// JSON.swift -// Wraps `playdate->json` (pd_api_json.h). -// -// The C decoder is callback-based; this wrapper drives it to build a -// complete `Value` tree. The encoder is exposed both as a streaming -// `Encoder` and as a one-shot `encode(_:)` of a `Value`. -// - internal import CPlaydate -private var jsonAPI: UnsafePointer { Playdate.jsonAPI.unsafelyUnwrapped } +var jsonAPI: UnsafePointer { Playdate.jsonAPI.unsafelyUnwrapped } /// The JSON API: decoding to and encoding from a `Value` tree. public enum JSON {} extension JSON { - /// A decoded JSON value. - public indirect enum Value { - case null - case bool(Bool) - case int(Int) - case float(Float) - case string(String) - case array([Value]) - case table([String: Value]) - } - // MARK: - Decoding private final class ValueBox { @@ -178,114 +158,6 @@ extension JSON { // MARK: - Encoding - /// A streaming JSON encoder writing into a string. Wraps `json_encoder`. - public final class Encoder { - private final class Output { - var text = "" - } - - private var encoder = json_encoder() - private let output = Output() - - public init(pretty: Bool = false) { - jsonAPI.pointee.initEncoder.unsafelyUnwrapped(&encoder, { userdata, string, length in - guard let userdata, let string else { return } - let output = Unmanaged.fromOpaque(userdata).takeUnretainedValue() - let bytes = UnsafeRawBufferPointer(start: string, count: Int(length)) - output.text += String(decoding: bytes, as: UTF8.self) - }, Unmanaged.passUnretained(output).toOpaque(), pretty ? 1 : 0) - } - - /// The JSON produced so far. - public var json: String { output.text } - - public func startArray() { - withUnsafeMutablePointer(to: &encoder) { $0.pointee.startArray.unsafelyUnwrapped($0) } - } - - /// Call before writing each array element. - public func addArrayMember() { - withUnsafeMutablePointer(to: &encoder) { $0.pointee.addArrayMember.unsafelyUnwrapped($0) } - } - - public func endArray() { - withUnsafeMutablePointer(to: &encoder) { $0.pointee.endArray.unsafelyUnwrapped($0) } - } - - public func startTable() { - withUnsafeMutablePointer(to: &encoder) { $0.pointee.startTable.unsafelyUnwrapped($0) } - } - - /// Call before writing each table value. - public func addTableMember(name: String) { - name.withPlaydateCString { cName in - withUnsafeMutablePointer(to: &encoder) { - $0.pointee.addTableMember.unsafelyUnwrapped($0, cName, Int32(name.utf8.count)) - } - } - } - - public func endTable() { - withUnsafeMutablePointer(to: &encoder) { $0.pointee.endTable.unsafelyUnwrapped($0) } - } - - public func writeNull() { - withUnsafeMutablePointer(to: &encoder) { $0.pointee.writeNull.unsafelyUnwrapped($0) } - } - - public func writeBool(_ value: Bool) { - withUnsafeMutablePointer(to: &encoder) { - (value ? $0.pointee.writeTrue : $0.pointee.writeFalse).unsafelyUnwrapped($0) - } - } - - public func writeInt(_ value: Int) { - withUnsafeMutablePointer(to: &encoder) { $0.pointee.writeInt.unsafelyUnwrapped($0, Int32(value)) } - } - - public func writeDouble(_ value: Double) { - withUnsafeMutablePointer(to: &encoder) { $0.pointee.writeDouble.unsafelyUnwrapped($0, value) } - } - - public func writeString(_ value: String) { - value.withPlaydateCString { cString in - withUnsafeMutablePointer(to: &encoder) { - $0.pointee.writeString.unsafelyUnwrapped($0, cString, Int32(value.utf8.count)) - } - } - } - - /// Writes a complete `Value` tree. - public func write(_ value: Value) { - switch value { - case .null: - writeNull() - case .bool(let bool): - writeBool(bool) - case .int(let int): - writeInt(int) - case .float(let float): - writeDouble(Double(float)) - case .string(let string): - writeString(string) - case .array(let items): - startArray() - for item in items { - addArrayMember() - write(item) - } - endArray() - case .table(let entries): - startTable() - for (key, entry) in entries { - addTableMember(name: key) - write(entry) - } - endTable() - } - } - } - /// Encodes a `Value` tree as a JSON string. public static func encode(_ value: Value, pretty: Bool = false) -> String { let encoder = Encoder(pretty: pretty) diff --git a/Sources/PlaydateKit/Lua/Aliases/CFunction.swift b/Sources/PlaydateKit/Lua/Aliases/CFunction.swift new file mode 100644 index 0000000..aa4a6c2 --- /dev/null +++ b/Sources/PlaydateKit/Lua/Aliases/CFunction.swift @@ -0,0 +1,7 @@ +public import CPlaydate + +extension Lua { + /// A function callable from Lua. Returns the number of values it pushed + /// onto the stack. + public typealias CFunction = lua_CFunction +} diff --git a/Sources/PlaydateKit/Lua/Enumerations/ClassValue.swift b/Sources/PlaydateKit/Lua/Enumerations/ClassValue.swift new file mode 100644 index 0000000..2db98f7 --- /dev/null +++ b/Sources/PlaydateKit/Lua/Enumerations/ClassValue.swift @@ -0,0 +1,8 @@ +extension Lua { + /// A constant published on a registered class. + public enum ClassValue { + case int(name: String, value: UInt32) + case float(name: String, value: Float) + case string(name: String, value: String) + } +} diff --git a/Sources/PlaydateKit/Lua/Enumerations/Kind.swift b/Sources/PlaydateKit/Lua/Enumerations/Kind.swift new file mode 100644 index 0000000..5e9940c --- /dev/null +++ b/Sources/PlaydateKit/Lua/Enumerations/Kind.swift @@ -0,0 +1,18 @@ +internal import CPlaydate + +extension Lua { + /// The type of a value on the Lua stack. + public enum Kind: UInt32, Sendable { + case `nil` = 0 + case bool = 1 + case int = 2 + case float = 3 + case string = 4 + case table = 5 + case function = 6 + case thread = 7 + case object = 8 + + init(_ type: LuaType) { self = Kind(rawValue: UInt32(type.rawValue)) ?? .nil } + } +} diff --git a/Sources/PlaydateKit/Lua.swift b/Sources/PlaydateKit/Lua/Lua.swift similarity index 81% rename from Sources/PlaydateKit/Lua.swift rename to Sources/PlaydateKit/Lua/Lua.swift index d99f810..7cdcc49 100644 --- a/Sources/PlaydateKit/Lua.swift +++ b/Sources/PlaydateKit/Lua/Lua.swift @@ -1,47 +1,12 @@ -// -// Lua.swift -// Wraps `playdate->lua` (pd_api_lua.h). -// -// Lua callbacks are C function pointers without userdata, so functions -// registered here must be `@convention(c)` (the `CFunction` typealias), not -// capturing closures. -// +internal import CPlaydate -public import CPlaydate - -private var luaAPI: UnsafePointer { Playdate.luaAPI.unsafelyUnwrapped } +var luaAPI: UnsafePointer { Playdate.luaAPI.unsafelyUnwrapped } /// The Lua bridge: registering C functions and classes, and exchanging /// values with Lua code. public enum Lua {} extension Lua { - /// A function callable from Lua. Returns the number of values it pushed - /// onto the stack. - public typealias CFunction = lua_CFunction - - /// The type of a value on the Lua stack. - public enum Kind: UInt32, Sendable { - case `nil` = 0 - case bool = 1 - case int = 2 - case float = 3 - case string = 4 - case table = 5 - case function = 6 - case thread = 7 - case object = 8 - - init(_ type: LuaType) { self = Kind(rawValue: UInt32(type.rawValue)) ?? .nil } - } - - /// A constant published on a registered class. - public enum ClassValue { - case int(name: String, value: UInt32) - case float(name: String, value: Float) - case string(name: String, value: String) - } - /// Buffers passed to `registerClass`/`addFunction`; the OS may keep /// referencing them, so they are retained for the life of the game. nonisolated(unsafe) private static var retainedBuffers: [UnsafeMutableRawPointer] = [] @@ -253,35 +218,6 @@ extension Lua { return UDObject(pointer: pointer) } - /// A handle to a Lua-owned object. Wraps `LuaUDObject`. - public struct UDObject { - let pointer: OpaquePointer - - /// Prevents the object from being garbage-collected until `release()`. - @discardableResult - public func retain() -> UDObject { - UDObject(pointer: luaAPI.pointee.retainObject.unsafelyUnwrapped(pointer).unsafelyUnwrapped) - } - - public func release() { - luaAPI.pointee.releaseObject.unsafelyUnwrapped(pointer) - } - - /// Pops the value on top of the stack and stores it in the object's - /// user-value `slot` (1-based). - public func setUserValue(slot: UInt32) { - luaAPI.pointee.setUserValue.unsafelyUnwrapped(pointer, slot) - } - - /// Pushes the value in user-value `slot` onto the stack and returns - /// its stack position, or `nil` if there is none. - @discardableResult - public func getUserValue(slot: UInt32) -> Int? { - let position = luaAPI.pointee.getUserValue.unsafelyUnwrapped(pointer, slot) - return position == 0 ? nil : Int(position) - } - } - // MARK: - Calling Lua /// Calls the Lua function `name`. Push the arguments onto the stack diff --git a/Sources/PlaydateKit/Lua/Structures/UDObject.swift b/Sources/PlaydateKit/Lua/Structures/UDObject.swift new file mode 100644 index 0000000..afd73a7 --- /dev/null +++ b/Sources/PlaydateKit/Lua/Structures/UDObject.swift @@ -0,0 +1,32 @@ +internal import CPlaydate + +extension Lua { + /// A handle to a Lua-owned object. Wraps `LuaUDObject`. + public struct UDObject { + let pointer: OpaquePointer + + /// Prevents the object from being garbage-collected until `release()`. + @discardableResult + public func retain() -> UDObject { + UDObject(pointer: luaAPI.pointee.retainObject.unsafelyUnwrapped(pointer).unsafelyUnwrapped) + } + + public func release() { + luaAPI.pointee.releaseObject.unsafelyUnwrapped(pointer) + } + + /// Pops the value on top of the stack and stores it in the object's + /// user-value `slot` (1-based). + public func setUserValue(slot: UInt32) { + luaAPI.pointee.setUserValue.unsafelyUnwrapped(pointer, slot) + } + + /// Pushes the value in user-value `slot` onto the stack and returns + /// its stack position, or `nil` if there is none. + @discardableResult + public func getUserValue(slot: UInt32) -> Int? { + let position = luaAPI.pointee.getUserValue.unsafelyUnwrapped(pointer, slot) + return position == 0 ? nil : Int(position) + } + } +} diff --git a/Sources/PlaydateKit/Network.swift b/Sources/PlaydateKit/Network.swift deleted file mode 100644 index 9865382..0000000 --- a/Sources/PlaydateKit/Network.swift +++ /dev/null @@ -1,515 +0,0 @@ -// -// Network.swift -// Wraps `playdate->network` (pd_api_network.h): HTTP and TCP connections. -// -// The binding stores a back-reference to each connection wrapper in the -// underlying object's userdata slot so callbacks can recover the wrapper; -// the C userdata slot is therefore reserved by the binding. -// - -internal import CPlaydate - -private var networkAPI: UnsafePointer { Playdate.networkAPI.unsafelyUnwrapped } -private var httpAPI: UnsafePointer { Playdate.httpAPI.unsafelyUnwrapped } -private var tcpAPI: UnsafePointer { Playdate.tcpAPI.unsafelyUnwrapped } - -/// The network API: wifi status, HTTP, and TCP. -public enum Network {} - -extension Network { - /// A network error code (`PDNetErr`). - public enum NetError: Int32, Swift.Error, Sendable { - case noDevice = -1 - case busy = -2 - case writeError = -3 - case writeBusy = -4 - case writeTimeout = -5 - case readError = -6 - case readBusy = -7 - case readTimeout = -8 - case readOverflow = -9 - case frameError = -10 - case badResponse = -11 - case errorResponse = -12 - case resetTimeout = -13 - case bufferTooSmall = -14 - case unexpectedResponse = -15 - case notConnectedToAP = -16 - case notImplemented = -17 - case connectionClosed = -18 - case unknown = 1 - - init(_ error: PDNetErr) { - self = NetError(rawValue: Int32(error.rawValue)) ?? .unknown - } - } - - /// Throws unless `error` is `NET_OK`. - static func check(_ error: PDNetErr) throws(NetError) { - if error != NET_OK { - throw NetError(error) - } - } - - /// Converts an error code to `nil` (OK) or a `NetError`. - static func optionalError(_ error: PDNetErr) -> NetError? { - error == NET_OK ? nil : NetError(error) - } - - /// The device's wifi status. - public enum WifiStatus: UInt32, Sendable { - case notConnected = 0 - case connected = 1 - /// A connection was attempted but no configured access point was - /// available. - case notAvailable = 2 - } - - public static var status: WifiStatus { - WifiStatus(rawValue: UInt32(networkAPI.pointee.getStatus.unsafelyUnwrapped().rawValue)) ?? .notConnected - } - - /// Turns the wifi radio on or off. The completion receives `nil` on - /// success. Completions of overlapping calls are delivered in call order. - public static func setEnabled(_ enabled: Bool, completion: ((NetError?) -> Void)? = nil) { - if let completion { - setEnabledCompletions.append(completion) - networkAPI.pointee.setEnabled.unsafelyUnwrapped(enabled, { error in - guard !Network.setEnabledCompletions.isEmpty else { return } - let completion = Network.setEnabledCompletions.removeFirst() - completion(Network.optionalError(error)) - }) - } else { - networkAPI.pointee.setEnabled.unsafelyUnwrapped(enabled, nil) - } - } - - nonisolated(unsafe) private static var setEnabledCompletions: [(NetError?) -> Void] = [] - - /// Requests permission to connect to `server`. Shared by HTTP and TCP. - fileprivate static func requestAccess( - rawRequest: (UnsafePointer?, Int32, Bool, UnsafePointer?, - (@convention(c) (Bool, UnsafeMutableRawPointer?) -> Void)?, - UnsafeMutableRawPointer?) -> accessReply, - server: String, port: Int, useSSL: Bool, purpose: String?, - completion: @escaping (Bool) -> Void) -> AccessReply { - final class Box { - let body: (Bool) -> Void - init(_ body: @escaping (Bool) -> Void) { self.body = body } - } - let box = Unmanaged.passRetained(Box(completion)) - let trampoline: @convention(c) (Bool, UnsafeMutableRawPointer?) -> Void = { allowed, userdata in - guard let userdata else { return } - Unmanaged.fromOpaque(userdata).takeRetainedValue().body(allowed) - } - let reply = server.withPlaydateCString { cServer in - if let purpose { - return purpose.withPlaydateCString { cPurpose in - rawRequest(cServer, Int32(port), useSSL, cPurpose, trampoline, box.toOpaque()) - } - } else { - return rawRequest(cServer, Int32(port), useSSL, nil, trampoline, box.toOpaque()) - } - } - if reply != kAccessAsk { - // The callback will not be invoked; balance the retain. - box.release() - } - return AccessReply(rawValue: UInt32(reply.rawValue)) ?? .ask - } - - // MARK: - HTTP - - /// An HTTP connection to a server. Wraps `HTTPConnection`. - public final class HTTPConnection { - let pointer: OpaquePointer - - var headerReceivedCallback: ((HTTPConnection, _ key: String, _ value: String) -> Void)? - var headersReadCallback: ((HTTPConnection) -> Void)? - var responseCallback: ((HTTPConnection) -> Void)? - var requestCompleteCallback: ((HTTPConnection) -> Void)? - var connectionClosedCallback: ((HTTPConnection) -> Void)? - - /// Requests permission to connect to `server`. If the reply is - /// `.ask`, the completion is called later with the user's answer. - @discardableResult - public static func requestAccess(server: String, port: Int = 443, useSSL: Bool = true, - purpose: String? = nil, - completion: @escaping (Bool) -> Void) -> AccessReply { - Network.requestAccess( - rawRequest: { httpAPI.pointee.requestAccess.unsafelyUnwrapped($0, $1, $2, $3, $4, $5) }, - server: server, port: port, useSSL: useSSL, purpose: purpose, - completion: completion) - } - - /// Opens a connection to `server`. Fails if access has not been - /// granted. - public init?(server: String, port: Int = 443, useSSL: Bool = true) { - let pointer = server.withPlaydateCString { - httpAPI.pointee.newConnection.unsafelyUnwrapped($0, Int32(port), useSSL) - } - guard let pointer else { return nil } - self.pointer = pointer - httpAPI.pointee.setUserdata.unsafelyUnwrapped(pointer, Unmanaged.passUnretained(self).toOpaque()) - } - - deinit { - httpAPI.pointee.setUserdata.unsafelyUnwrapped(pointer, nil) - httpAPI.pointee.release.unsafelyUnwrapped(pointer) - } - - private static func wrapper(for pointer: OpaquePointer?) -> HTTPConnection? { - guard let pointer, - let userdata = httpAPI.pointee.getUserdata.unsafelyUnwrapped(pointer) else { return nil } - return Unmanaged.fromOpaque(userdata).takeUnretainedValue() - } - - // MARK: Configuration - - /// The time to wait for the connection to open, in milliseconds. - public func setConnectTimeout(milliseconds: Int) { - httpAPI.pointee.setConnectTimeout.unsafelyUnwrapped(pointer, Int32(milliseconds)) - } - - /// Whether to keep the connection open after a request completes. - public func setKeepAlive(_ keepAlive: Bool) { - httpAPI.pointee.setKeepAlive.unsafelyUnwrapped(pointer, keepAlive) - } - - /// Adds a `Range: bytes=start-end` header to future requests. - public func setByteRange(start: Int, end: Int) { - httpAPI.pointee.setByteRange.unsafelyUnwrapped(pointer, Int32(start), Int32(end)) - } - - /// The time to wait for incoming data, in milliseconds. - public func setReadTimeout(milliseconds: Int) { - httpAPI.pointee.setReadTimeout.unsafelyUnwrapped(pointer, Int32(milliseconds)) - } - - /// The size of the connection's read buffer, in bytes. - public func setReadBufferSize(bytes: Int) { - httpAPI.pointee.setReadBufferSize.unsafelyUnwrapped(pointer, Int32(bytes)) - } - - // MARK: Requests - - /// Sends a GET request for `path`. `headers` are raw header lines - /// (e.g. "Accept: text/html\r\n"). - public func get(path: String, headers: String = "") throws(NetError) { - let error = path.withPlaydateCString { cPath in - headers.withPlaydateCString { cHeaders in - httpAPI.pointee.get.unsafelyUnwrapped(pointer, cPath, cHeaders, headers.utf8.count) - } - } - try Network.check(error) - } - - /// Sends a POST request for `path` with the given body. - public func post(path: String, headers: String = "", body: [UInt8]) throws(NetError) { - let error = path.withPlaydateCString { cPath in - headers.withPlaydateCString { cHeaders in - body.withUnsafeBytes { bodyBuffer in - httpAPI.pointee.post.unsafelyUnwrapped( - pointer, cPath, cHeaders, headers.utf8.count, - bodyBuffer.baseAddress?.assumingMemoryBound(to: CChar.self), - bodyBuffer.count) - } - } - } - try Network.check(error) - } - - /// Sends a request with an arbitrary HTTP method. - public func query(method: String, path: String, headers: String = "", - body: [UInt8] = []) throws(NetError) { - let error = method.withPlaydateCString { cMethod in - path.withPlaydateCString { cPath in - headers.withPlaydateCString { cHeaders in - body.withUnsafeBytes { bodyBuffer in - httpAPI.pointee.query.unsafelyUnwrapped( - pointer, cMethod, cPath, cHeaders, headers.utf8.count, - bodyBuffer.baseAddress?.assumingMemoryBound(to: CChar.self), - bodyBuffer.count) - } - } - } - } - try Network.check(error) - } - - // MARK: Response - - /// The last error on the connection, if any. - public var error: NetError? { - Network.optionalError(httpAPI.pointee.getError.unsafelyUnwrapped(pointer)) - } - - /// The number of bytes read of the current response, and the total - /// expected (0 if the response has no Content-Length). - public var progress: (read: Int, total: Int) { - var read: Int32 = 0, total: Int32 = 0 - httpAPI.pointee.getProgress.unsafelyUnwrapped(pointer, &read, &total) - return (Int(read), Int(total)) - } - - /// The HTTP status code of the response. - public var responseStatus: Int { - Int(httpAPI.pointee.getResponseStatus.unsafelyUnwrapped(pointer)) - } - - /// The number of response bytes available to read. - public var bytesAvailable: Int { - Int(httpAPI.pointee.getBytesAvailable.unsafelyUnwrapped(pointer)) - } - - /// Reads up to `buffer.count` response bytes. Returns the number of - /// bytes read. - public func read(into buffer: UnsafeMutableRawBufferPointer) throws(NetError) -> Int { - let result = httpAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress, - UInt32(buffer.count)) - if result < 0 { - throw NetError(rawValue: result) ?? .unknown - } - return Int(result) - } - - /// Reads up to `length` available response bytes. - public func read(length: Int) throws(NetError) -> [UInt8] { - var bytes = [UInt8](repeating: 0, count: length) - let result = bytes.withUnsafeMutableBytes { buffer in - httpAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress, UInt32(buffer.count)) - } - if result < 0 { - throw NetError(rawValue: result) ?? .unknown - } - bytes.removeLast(length - Int(result)) - return bytes - } - - /// Closes the connection. - public func close() { - httpAPI.pointee.close.unsafelyUnwrapped(pointer) - } - - // MARK: Callbacks - - /// Called for each header line as it arrives. - public func setHeaderReceivedCallback(_ callback: ((HTTPConnection, _ key: String, _ value: String) -> Void)?) { - headerReceivedCallback = callback - if callback != nil { - httpAPI.pointee.setHeaderReceivedCallback.unsafelyUnwrapped(pointer, { connection, key, value in - guard let wrapper = HTTPConnection.wrapper(for: connection), - let key = String(playdateCString: key), - let value = String(playdateCString: value) else { return } - wrapper.headerReceivedCallback?(wrapper, key, value) - }) - } else { - httpAPI.pointee.setHeaderReceivedCallback.unsafelyUnwrapped(pointer, nil) - } - } - - /// Called when all headers have been read. - public func setHeadersReadCallback(_ callback: ((HTTPConnection) -> Void)?) { - headersReadCallback = callback - if callback != nil { - httpAPI.pointee.setHeadersReadCallback.unsafelyUnwrapped(pointer, { connection in - guard let wrapper = HTTPConnection.wrapper(for: connection) else { return } - wrapper.headersReadCallback?(wrapper) - }) - } else { - httpAPI.pointee.setHeadersReadCallback.unsafelyUnwrapped(pointer, nil) - } - } - - /// Called when response data is available to read. - public func setResponseCallback(_ callback: ((HTTPConnection) -> Void)?) { - responseCallback = callback - if callback != nil { - httpAPI.pointee.setResponseCallback.unsafelyUnwrapped(pointer, { connection in - guard let wrapper = HTTPConnection.wrapper(for: connection) else { return } - wrapper.responseCallback?(wrapper) - }) - } else { - httpAPI.pointee.setResponseCallback.unsafelyUnwrapped(pointer, nil) - } - } - - /// Called when the request finishes. - public func setRequestCompleteCallback(_ callback: ((HTTPConnection) -> Void)?) { - requestCompleteCallback = callback - if callback != nil { - httpAPI.pointee.setRequestCompleteCallback.unsafelyUnwrapped(pointer, { connection in - guard let wrapper = HTTPConnection.wrapper(for: connection) else { return } - wrapper.requestCompleteCallback?(wrapper) - }) - } else { - httpAPI.pointee.setRequestCompleteCallback.unsafelyUnwrapped(pointer, nil) - } - } - - /// Called when the connection closes. - public func setConnectionClosedCallback(_ callback: ((HTTPConnection) -> Void)?) { - connectionClosedCallback = callback - if callback != nil { - httpAPI.pointee.setConnectionClosedCallback.unsafelyUnwrapped(pointer, { connection in - guard let wrapper = HTTPConnection.wrapper(for: connection) else { return } - wrapper.connectionClosedCallback?(wrapper) - }) - } else { - httpAPI.pointee.setConnectionClosedCallback.unsafelyUnwrapped(pointer, nil) - } - } - } - - // MARK: - TCP - - /// A TCP connection to a server. Wraps `TCPConnection`. - public final class TCPConnection { - let pointer: OpaquePointer - - var openCompletion: ((TCPConnection, NetError?) -> Void)? - var connectionClosedCallback: ((TCPConnection, NetError?) -> Void)? - - /// Requests permission to connect to `server`. If the reply is - /// `.ask`, the completion is called later with the user's answer. - @discardableResult - public static func requestAccess(server: String, port: Int, useSSL: Bool = true, - purpose: String? = nil, - completion: @escaping (Bool) -> Void) -> AccessReply { - Network.requestAccess( - rawRequest: { tcpAPI.pointee.requestAccess.unsafelyUnwrapped($0, $1, $2, $3, $4, $5) }, - server: server, port: port, useSSL: useSSL, purpose: purpose, - completion: completion) - } - - /// Creates a connection to `server`. Fails if access has not been - /// granted. Call `open(_:)` to connect. - public init?(server: String, port: Int, useSSL: Bool = true) { - let pointer = server.withPlaydateCString { - tcpAPI.pointee.newConnection.unsafelyUnwrapped($0, Int32(port), useSSL) - } - guard let pointer else { return nil } - self.pointer = pointer - tcpAPI.pointee.setUserdata.unsafelyUnwrapped(pointer, Unmanaged.passUnretained(self).toOpaque()) - } - - deinit { - tcpAPI.pointee.setUserdata.unsafelyUnwrapped(pointer, nil) - tcpAPI.pointee.release.unsafelyUnwrapped(pointer) - } - - private static func wrapper(for pointer: OpaquePointer?) -> TCPConnection? { - guard let pointer, - let userdata = tcpAPI.pointee.getUserdata.unsafelyUnwrapped(pointer) else { return nil } - return Unmanaged.fromOpaque(userdata).takeUnretainedValue() - } - - /// The last error on the connection, if any. - public var error: NetError? { - Network.optionalError(tcpAPI.pointee.getError.unsafelyUnwrapped(pointer)) - } - - /// The time to wait for the connection to open, in milliseconds. - public func setConnectTimeout(milliseconds: Int) { - tcpAPI.pointee.setConnectTimeout.unsafelyUnwrapped(pointer, Int32(milliseconds)) - } - - /// Opens the connection. The completion receives `nil` on success. - public func open(_ completion: @escaping (TCPConnection, NetError?) -> Void) throws(NetError) { - openCompletion = completion - let error = tcpAPI.pointee.open.unsafelyUnwrapped(pointer, { connection, error, _ in - guard let wrapper = TCPConnection.wrapper(for: connection) else { return } - let completion = wrapper.openCompletion - wrapper.openCompletion = nil - completion?(wrapper, Network.optionalError(error)) - }, nil) - try Network.check(error) - } - - /// Closes the connection. - public func close() throws(NetError) { - try Network.check(tcpAPI.pointee.close.unsafelyUnwrapped(pointer)) - } - - /// Called when the connection closes, with the reason if it closed - /// due to an error. - public func setConnectionClosedCallback(_ callback: ((TCPConnection, NetError?) -> Void)?) { - connectionClosedCallback = callback - if callback != nil { - tcpAPI.pointee.setConnectionClosedCallback.unsafelyUnwrapped(pointer, { connection, error in - guard let wrapper = TCPConnection.wrapper(for: connection) else { return } - wrapper.connectionClosedCallback?(wrapper, Network.optionalError(error)) - }) - } else { - tcpAPI.pointee.setConnectionClosedCallback.unsafelyUnwrapped(pointer, nil) - } - } - - /// The time to wait for incoming data, in milliseconds. - public func setReadTimeout(milliseconds: Int) { - tcpAPI.pointee.setReadTimeout.unsafelyUnwrapped(pointer, Int32(milliseconds)) - } - - /// The size of the connection's read buffer, in bytes. - public func setReadBufferSize(bytes: Int) { - tcpAPI.pointee.setReadBufferSize.unsafelyUnwrapped(pointer, Int32(bytes)) - } - - /// The number of bytes available to read. - public var bytesAvailable: Int { - Int(tcpAPI.pointee.getBytesAvailable.unsafelyUnwrapped(pointer)) - } - - /// The number of written bytes not yet sent on the wire. - public var sentBytesPending: Int { - Int(tcpAPI.pointee.getSentBytesPending.unsafelyUnwrapped(pointer)) - } - - /// Reads up to `buffer.count` bytes, waiting up to the read timeout. - /// Returns the number of bytes read. - public func read(into buffer: UnsafeMutableRawBufferPointer) throws(NetError) -> Int { - let result = tcpAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress, buffer.count) - if result < 0 { - throw NetError(rawValue: result) ?? .unknown - } - return Int(result) - } - - /// Reads up to `length` bytes, waiting up to the read timeout. - public func read(length: Int) throws(NetError) -> [UInt8] { - var bytes = [UInt8](repeating: 0, count: length) - let result = bytes.withUnsafeMutableBytes { buffer in - tcpAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress, buffer.count) - } - if result < 0 { - throw NetError(rawValue: result) ?? .unknown - } - bytes.removeLast(length - Int(result)) - return bytes - } - - /// Writes the buffer to the connection. Returns the number of bytes - /// accepted. - @discardableResult - public func write(_ buffer: UnsafeRawBufferPointer) throws(NetError) -> Int { - let result = tcpAPI.pointee.write.unsafelyUnwrapped(pointer, buffer.baseAddress, buffer.count) - if result < 0 { - throw NetError(rawValue: result) ?? .unknown - } - return Int(result) - } - - /// Writes the bytes to the connection. Returns the number of bytes - /// accepted. - @discardableResult - public func write(_ bytes: [UInt8]) throws(NetError) -> Int { - let result = bytes.withUnsafeBytes { buffer in - tcpAPI.pointee.write.unsafelyUnwrapped(pointer, buffer.baseAddress, buffer.count) - } - if result < 0 { - throw NetError(rawValue: result) ?? .unknown - } - return Int(result) - } - } -} diff --git a/Sources/PlaydateKit/Network/Classes/HTTPConnection.swift b/Sources/PlaydateKit/Network/Classes/HTTPConnection.swift new file mode 100644 index 0000000..cf31564 --- /dev/null +++ b/Sources/PlaydateKit/Network/Classes/HTTPConnection.swift @@ -0,0 +1,246 @@ +internal import CPlaydate + +private var httpAPI: UnsafePointer { Playdate.httpAPI.unsafelyUnwrapped } + +extension Network { + /// An HTTP connection to a server. Wraps `HTTPConnection`. + public final class HTTPConnection { + let pointer: OpaquePointer + + var headerReceivedCallback: ((HTTPConnection, _ key: String, _ value: String) -> Void)? + var headersReadCallback: ((HTTPConnection) -> Void)? + var responseCallback: ((HTTPConnection) -> Void)? + var requestCompleteCallback: ((HTTPConnection) -> Void)? + var connectionClosedCallback: ((HTTPConnection) -> Void)? + + /// Requests permission to connect to `server`. If the reply is + /// `.ask`, the completion is called later with the user's answer. + @discardableResult + public static func requestAccess(server: String, port: Int = 443, useSSL: Bool = true, + purpose: String? = nil, + completion: @escaping (Bool) -> Void) -> AccessReply { + Network.requestAccess( + rawRequest: { httpAPI.pointee.requestAccess.unsafelyUnwrapped($0, $1, $2, $3, $4, $5) }, + server: server, port: port, useSSL: useSSL, purpose: purpose, + completion: completion) + } + + /// Opens a connection to `server`. Fails if access has not been + /// granted. + public init?(server: String, port: Int = 443, useSSL: Bool = true) { + let pointer = server.withPlaydateCString { + httpAPI.pointee.newConnection.unsafelyUnwrapped($0, Int32(port), useSSL) + } + guard let pointer else { return nil } + self.pointer = pointer + httpAPI.pointee.setUserdata.unsafelyUnwrapped(pointer, Unmanaged.passUnretained(self).toOpaque()) + } + + deinit { + httpAPI.pointee.setUserdata.unsafelyUnwrapped(pointer, nil) + httpAPI.pointee.release.unsafelyUnwrapped(pointer) + } + + private static func wrapper(for pointer: OpaquePointer?) -> HTTPConnection? { + guard let pointer, + let userdata = httpAPI.pointee.getUserdata.unsafelyUnwrapped(pointer) else { return nil } + return Unmanaged.fromOpaque(userdata).takeUnretainedValue() + } + + // MARK: Configuration + + /// The time to wait for the connection to open, in milliseconds. + public func setConnectTimeout(milliseconds: Int) { + httpAPI.pointee.setConnectTimeout.unsafelyUnwrapped(pointer, Int32(milliseconds)) + } + + /// Whether to keep the connection open after a request completes. + public func setKeepAlive(_ keepAlive: Bool) { + httpAPI.pointee.setKeepAlive.unsafelyUnwrapped(pointer, keepAlive) + } + + /// Adds a `Range: bytes=start-end` header to future requests. + public func setByteRange(start: Int, end: Int) { + httpAPI.pointee.setByteRange.unsafelyUnwrapped(pointer, Int32(start), Int32(end)) + } + + /// The time to wait for incoming data, in milliseconds. + public func setReadTimeout(milliseconds: Int) { + httpAPI.pointee.setReadTimeout.unsafelyUnwrapped(pointer, Int32(milliseconds)) + } + + /// The size of the connection's read buffer, in bytes. + public func setReadBufferSize(bytes: Int) { + httpAPI.pointee.setReadBufferSize.unsafelyUnwrapped(pointer, Int32(bytes)) + } + + // MARK: Requests + + /// Sends a GET request for `path`. `headers` are raw header lines + /// (e.g. "Accept: text/html\r\n"). + public func get(path: String, headers: String = "") throws(NetError) { + let error = path.withPlaydateCString { cPath in + headers.withPlaydateCString { cHeaders in + httpAPI.pointee.get.unsafelyUnwrapped(pointer, cPath, cHeaders, headers.utf8.count) + } + } + try Network.check(error) + } + + /// Sends a POST request for `path` with the given body. + public func post(path: String, headers: String = "", body: [UInt8]) throws(NetError) { + let error = path.withPlaydateCString { cPath in + headers.withPlaydateCString { cHeaders in + body.withUnsafeBytes { bodyBuffer in + httpAPI.pointee.post.unsafelyUnwrapped( + pointer, cPath, cHeaders, headers.utf8.count, + bodyBuffer.baseAddress?.assumingMemoryBound(to: CChar.self), + bodyBuffer.count) + } + } + } + try Network.check(error) + } + + /// Sends a request with an arbitrary HTTP method. + public func query(method: String, path: String, headers: String = "", + body: [UInt8] = []) throws(NetError) { + let error = method.withPlaydateCString { cMethod in + path.withPlaydateCString { cPath in + headers.withPlaydateCString { cHeaders in + body.withUnsafeBytes { bodyBuffer in + httpAPI.pointee.query.unsafelyUnwrapped( + pointer, cMethod, cPath, cHeaders, headers.utf8.count, + bodyBuffer.baseAddress?.assumingMemoryBound(to: CChar.self), + bodyBuffer.count) + } + } + } + } + try Network.check(error) + } + + // MARK: Response + + /// The last error on the connection, if any. + public var error: NetError? { + Network.optionalError(httpAPI.pointee.getError.unsafelyUnwrapped(pointer)) + } + + /// The number of bytes read of the current response, and the total + /// expected (0 if the response has no Content-Length). + public var progress: (read: Int, total: Int) { + var read: Int32 = 0, total: Int32 = 0 + httpAPI.pointee.getProgress.unsafelyUnwrapped(pointer, &read, &total) + return (Int(read), Int(total)) + } + + /// The HTTP status code of the response. + public var responseStatus: Int { + Int(httpAPI.pointee.getResponseStatus.unsafelyUnwrapped(pointer)) + } + + /// The number of response bytes available to read. + public var bytesAvailable: Int { + Int(httpAPI.pointee.getBytesAvailable.unsafelyUnwrapped(pointer)) + } + + /// Reads up to `buffer.count` response bytes. Returns the number of + /// bytes read. + public func read(into buffer: UnsafeMutableRawBufferPointer) throws(NetError) -> Int { + let result = httpAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress, + UInt32(buffer.count)) + if result < 0 { + throw NetError(rawValue: result) ?? .unknown + } + return Int(result) + } + + /// Reads up to `length` available response bytes. + public func read(length: Int) throws(NetError) -> [UInt8] { + var bytes = [UInt8](repeating: 0, count: length) + let result = bytes.withUnsafeMutableBytes { buffer in + httpAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress, UInt32(buffer.count)) + } + if result < 0 { + throw NetError(rawValue: result) ?? .unknown + } + bytes.removeLast(length - Int(result)) + return bytes + } + + /// Closes the connection. + public func close() { + httpAPI.pointee.close.unsafelyUnwrapped(pointer) + } + + // MARK: Callbacks + + /// Called for each header line as it arrives. + public func setHeaderReceivedCallback(_ callback: ((HTTPConnection, _ key: String, _ value: String) -> Void)?) { + headerReceivedCallback = callback + if callback != nil { + httpAPI.pointee.setHeaderReceivedCallback.unsafelyUnwrapped(pointer, { connection, key, value in + guard let wrapper = HTTPConnection.wrapper(for: connection), + let key = String(playdateCString: key), + let value = String(playdateCString: value) else { return } + wrapper.headerReceivedCallback?(wrapper, key, value) + }) + } else { + httpAPI.pointee.setHeaderReceivedCallback.unsafelyUnwrapped(pointer, nil) + } + } + + /// Called when all headers have been read. + public func setHeadersReadCallback(_ callback: ((HTTPConnection) -> Void)?) { + headersReadCallback = callback + if callback != nil { + httpAPI.pointee.setHeadersReadCallback.unsafelyUnwrapped(pointer, { connection in + guard let wrapper = HTTPConnection.wrapper(for: connection) else { return } + wrapper.headersReadCallback?(wrapper) + }) + } else { + httpAPI.pointee.setHeadersReadCallback.unsafelyUnwrapped(pointer, nil) + } + } + + /// Called when response data is available to read. + public func setResponseCallback(_ callback: ((HTTPConnection) -> Void)?) { + responseCallback = callback + if callback != nil { + httpAPI.pointee.setResponseCallback.unsafelyUnwrapped(pointer, { connection in + guard let wrapper = HTTPConnection.wrapper(for: connection) else { return } + wrapper.responseCallback?(wrapper) + }) + } else { + httpAPI.pointee.setResponseCallback.unsafelyUnwrapped(pointer, nil) + } + } + + /// Called when the request finishes. + public func setRequestCompleteCallback(_ callback: ((HTTPConnection) -> Void)?) { + requestCompleteCallback = callback + if callback != nil { + httpAPI.pointee.setRequestCompleteCallback.unsafelyUnwrapped(pointer, { connection in + guard let wrapper = HTTPConnection.wrapper(for: connection) else { return } + wrapper.requestCompleteCallback?(wrapper) + }) + } else { + httpAPI.pointee.setRequestCompleteCallback.unsafelyUnwrapped(pointer, nil) + } + } + + /// Called when the connection closes. + public func setConnectionClosedCallback(_ callback: ((HTTPConnection) -> Void)?) { + connectionClosedCallback = callback + if callback != nil { + httpAPI.pointee.setConnectionClosedCallback.unsafelyUnwrapped(pointer, { connection in + guard let wrapper = HTTPConnection.wrapper(for: connection) else { return } + wrapper.connectionClosedCallback?(wrapper) + }) + } else { + httpAPI.pointee.setConnectionClosedCallback.unsafelyUnwrapped(pointer, nil) + } + } + } +} diff --git a/Sources/PlaydateKit/Network/Classes/TCPConnection.swift b/Sources/PlaydateKit/Network/Classes/TCPConnection.swift new file mode 100644 index 0000000..999c1c5 --- /dev/null +++ b/Sources/PlaydateKit/Network/Classes/TCPConnection.swift @@ -0,0 +1,155 @@ +internal import CPlaydate + +private var tcpAPI: UnsafePointer { Playdate.tcpAPI.unsafelyUnwrapped } + +extension Network { + /// A TCP connection to a server. Wraps `TCPConnection`. + public final class TCPConnection { + let pointer: OpaquePointer + + var openCompletion: ((TCPConnection, NetError?) -> Void)? + var connectionClosedCallback: ((TCPConnection, NetError?) -> Void)? + + /// Requests permission to connect to `server`. If the reply is + /// `.ask`, the completion is called later with the user's answer. + @discardableResult + public static func requestAccess(server: String, port: Int, useSSL: Bool = true, + purpose: String? = nil, + completion: @escaping (Bool) -> Void) -> AccessReply { + Network.requestAccess( + rawRequest: { tcpAPI.pointee.requestAccess.unsafelyUnwrapped($0, $1, $2, $3, $4, $5) }, + server: server, port: port, useSSL: useSSL, purpose: purpose, + completion: completion) + } + + /// Creates a connection to `server`. Fails if access has not been + /// granted. Call `open(_:)` to connect. + public init?(server: String, port: Int, useSSL: Bool = true) { + let pointer = server.withPlaydateCString { + tcpAPI.pointee.newConnection.unsafelyUnwrapped($0, Int32(port), useSSL) + } + guard let pointer else { return nil } + self.pointer = pointer + tcpAPI.pointee.setUserdata.unsafelyUnwrapped(pointer, Unmanaged.passUnretained(self).toOpaque()) + } + + deinit { + tcpAPI.pointee.setUserdata.unsafelyUnwrapped(pointer, nil) + tcpAPI.pointee.release.unsafelyUnwrapped(pointer) + } + + private static func wrapper(for pointer: OpaquePointer?) -> TCPConnection? { + guard let pointer, + let userdata = tcpAPI.pointee.getUserdata.unsafelyUnwrapped(pointer) else { return nil } + return Unmanaged.fromOpaque(userdata).takeUnretainedValue() + } + + /// The last error on the connection, if any. + public var error: NetError? { + Network.optionalError(tcpAPI.pointee.getError.unsafelyUnwrapped(pointer)) + } + + /// The time to wait for the connection to open, in milliseconds. + public func setConnectTimeout(milliseconds: Int) { + tcpAPI.pointee.setConnectTimeout.unsafelyUnwrapped(pointer, Int32(milliseconds)) + } + + /// Opens the connection. The completion receives `nil` on success. + public func open(_ completion: @escaping (TCPConnection, NetError?) -> Void) throws(NetError) { + openCompletion = completion + let error = tcpAPI.pointee.open.unsafelyUnwrapped(pointer, { connection, error, _ in + guard let wrapper = TCPConnection.wrapper(for: connection) else { return } + let completion = wrapper.openCompletion + wrapper.openCompletion = nil + completion?(wrapper, Network.optionalError(error)) + }, nil) + try Network.check(error) + } + + /// Closes the connection. + public func close() throws(NetError) { + try Network.check(tcpAPI.pointee.close.unsafelyUnwrapped(pointer)) + } + + /// Called when the connection closes, with the reason if it closed + /// due to an error. + public func setConnectionClosedCallback(_ callback: ((TCPConnection, NetError?) -> Void)?) { + connectionClosedCallback = callback + if callback != nil { + tcpAPI.pointee.setConnectionClosedCallback.unsafelyUnwrapped(pointer, { connection, error in + guard let wrapper = TCPConnection.wrapper(for: connection) else { return } + wrapper.connectionClosedCallback?(wrapper, Network.optionalError(error)) + }) + } else { + tcpAPI.pointee.setConnectionClosedCallback.unsafelyUnwrapped(pointer, nil) + } + } + + /// The time to wait for incoming data, in milliseconds. + public func setReadTimeout(milliseconds: Int) { + tcpAPI.pointee.setReadTimeout.unsafelyUnwrapped(pointer, Int32(milliseconds)) + } + + /// The size of the connection's read buffer, in bytes. + public func setReadBufferSize(bytes: Int) { + tcpAPI.pointee.setReadBufferSize.unsafelyUnwrapped(pointer, Int32(bytes)) + } + + /// The number of bytes available to read. + public var bytesAvailable: Int { + Int(tcpAPI.pointee.getBytesAvailable.unsafelyUnwrapped(pointer)) + } + + /// The number of written bytes not yet sent on the wire. + public var sentBytesPending: Int { + Int(tcpAPI.pointee.getSentBytesPending.unsafelyUnwrapped(pointer)) + } + + /// Reads up to `buffer.count` bytes, waiting up to the read timeout. + /// Returns the number of bytes read. + public func read(into buffer: UnsafeMutableRawBufferPointer) throws(NetError) -> Int { + let result = tcpAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress, buffer.count) + if result < 0 { + throw NetError(rawValue: result) ?? .unknown + } + return Int(result) + } + + /// Reads up to `length` bytes, waiting up to the read timeout. + public func read(length: Int) throws(NetError) -> [UInt8] { + var bytes = [UInt8](repeating: 0, count: length) + let result = bytes.withUnsafeMutableBytes { buffer in + tcpAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress, buffer.count) + } + if result < 0 { + throw NetError(rawValue: result) ?? .unknown + } + bytes.removeLast(length - Int(result)) + return bytes + } + + /// Writes the buffer to the connection. Returns the number of bytes + /// accepted. + @discardableResult + public func write(_ buffer: UnsafeRawBufferPointer) throws(NetError) -> Int { + let result = tcpAPI.pointee.write.unsafelyUnwrapped(pointer, buffer.baseAddress, buffer.count) + if result < 0 { + throw NetError(rawValue: result) ?? .unknown + } + return Int(result) + } + + /// Writes the bytes to the connection. Returns the number of bytes + /// accepted. + @discardableResult + public func write(_ bytes: [UInt8]) throws(NetError) -> Int { + let result = bytes.withUnsafeBytes { buffer in + tcpAPI.pointee.write.unsafelyUnwrapped(pointer, buffer.baseAddress, buffer.count) + } + if result < 0 { + throw NetError(rawValue: result) ?? .unknown + } + return Int(result) + } + } +} diff --git a/Sources/PlaydateKit/Network/Enumerations/NetError.swift b/Sources/PlaydateKit/Network/Enumerations/NetError.swift new file mode 100644 index 0000000..6307d90 --- /dev/null +++ b/Sources/PlaydateKit/Network/Enumerations/NetError.swift @@ -0,0 +1,30 @@ +internal import CPlaydate + +extension Network { + /// A network error code (`PDNetErr`). + public enum NetError: Int32, Swift.Error, Sendable { + case noDevice = -1 + case busy = -2 + case writeError = -3 + case writeBusy = -4 + case writeTimeout = -5 + case readError = -6 + case readBusy = -7 + case readTimeout = -8 + case readOverflow = -9 + case frameError = -10 + case badResponse = -11 + case errorResponse = -12 + case resetTimeout = -13 + case bufferTooSmall = -14 + case unexpectedResponse = -15 + case notConnectedToAP = -16 + case notImplemented = -17 + case connectionClosed = -18 + case unknown = 1 + + init(_ error: PDNetErr) { + self = NetError(rawValue: Int32(error.rawValue)) ?? .unknown + } + } +} diff --git a/Sources/PlaydateKit/Network/Enumerations/WifiStatus.swift b/Sources/PlaydateKit/Network/Enumerations/WifiStatus.swift new file mode 100644 index 0000000..f3217c8 --- /dev/null +++ b/Sources/PlaydateKit/Network/Enumerations/WifiStatus.swift @@ -0,0 +1,10 @@ +extension Network { + /// The device's wifi status. + public enum WifiStatus: UInt32, Sendable { + case notConnected = 0 + case connected = 1 + /// A connection was attempted but no configured access point was + /// available. + case notAvailable = 2 + } +} diff --git a/Sources/PlaydateKit/Network/Network.swift b/Sources/PlaydateKit/Network/Network.swift new file mode 100644 index 0000000..2318e0c --- /dev/null +++ b/Sources/PlaydateKit/Network/Network.swift @@ -0,0 +1,73 @@ +internal import CPlaydate + +private var networkAPI: UnsafePointer { Playdate.networkAPI.unsafelyUnwrapped } + +/// The network API: wifi status, HTTP, and TCP. +public enum Network {} + +extension Network { + /// Throws unless `error` is `NET_OK`. + static func check(_ error: PDNetErr) throws(NetError) { + if error != NET_OK { + throw NetError(error) + } + } + + /// Converts an error code to `nil` (OK) or a `NetError`. + static func optionalError(_ error: PDNetErr) -> NetError? { + error == NET_OK ? nil : NetError(error) + } + + public static var status: WifiStatus { + WifiStatus(rawValue: UInt32(networkAPI.pointee.getStatus.unsafelyUnwrapped().rawValue)) ?? .notConnected + } + + /// Turns the wifi radio on or off. The completion receives `nil` on + /// success. Completions of overlapping calls are delivered in call order. + public static func setEnabled(_ enabled: Bool, completion: ((NetError?) -> Void)? = nil) { + if let completion { + setEnabledCompletions.append(completion) + networkAPI.pointee.setEnabled.unsafelyUnwrapped(enabled, { error in + guard !Network.setEnabledCompletions.isEmpty else { return } + let completion = Network.setEnabledCompletions.removeFirst() + completion(Network.optionalError(error)) + }) + } else { + networkAPI.pointee.setEnabled.unsafelyUnwrapped(enabled, nil) + } + } + + nonisolated(unsafe) private static var setEnabledCompletions: [(NetError?) -> Void] = [] + + /// Requests permission to connect to `server`. Shared by HTTP and TCP. + static func requestAccess( + rawRequest: (UnsafePointer?, Int32, Bool, UnsafePointer?, + (@convention(c) (Bool, UnsafeMutableRawPointer?) -> Void)?, + UnsafeMutableRawPointer?) -> accessReply, + server: String, port: Int, useSSL: Bool, purpose: String?, + completion: @escaping (Bool) -> Void) -> AccessReply { + final class Box { + let body: (Bool) -> Void + init(_ body: @escaping (Bool) -> Void) { self.body = body } + } + let box = Unmanaged.passRetained(Box(completion)) + let trampoline: @convention(c) (Bool, UnsafeMutableRawPointer?) -> Void = { allowed, userdata in + guard let userdata else { return } + Unmanaged.fromOpaque(userdata).takeRetainedValue().body(allowed) + } + let reply = server.withPlaydateCString { cServer in + if let purpose { + return purpose.withPlaydateCString { cPurpose in + rawRequest(cServer, Int32(port), useSSL, cPurpose, trampoline, box.toOpaque()) + } + } else { + return rawRequest(cServer, Int32(port), useSSL, nil, trampoline, box.toOpaque()) + } + } + if reply != kAccessAsk { + // The callback will not be invoked; balance the retain. + box.release() + } + return AccessReply(rawValue: UInt32(reply.rawValue)) ?? .ask + } +} diff --git a/Sources/PlaydateKit/Playdate/Enumerations/AccessReply.swift b/Sources/PlaydateKit/Playdate/Enumerations/AccessReply.swift new file mode 100644 index 0000000..7eca6bd --- /dev/null +++ b/Sources/PlaydateKit/Playdate/Enumerations/AccessReply.swift @@ -0,0 +1,6 @@ +/// The user's answer to a permission request (microphone, network). +public enum AccessReply: UInt32, Sendable { + case ask = 0 + case deny = 1 + case allow = 2 +} diff --git a/Sources/PlaydateKit/Playdate/Enumerations/SystemEvent.swift b/Sources/PlaydateKit/Playdate/Enumerations/SystemEvent.swift new file mode 100644 index 0000000..a74bcbd --- /dev/null +++ b/Sources/PlaydateKit/Playdate/Enumerations/SystemEvent.swift @@ -0,0 +1,36 @@ +public import CPlaydate + +/// A Swift view of `PDSystemEvent` with the key code folded into the +/// key events. +public enum SystemEvent { + case initialize + case initializeLua + case lock + case unlock + case pause + case resume + case terminate + case keyPressed(keyCode: UInt32) + case keyReleased(keyCode: UInt32) + case lowPower + case mirrorStarted + case mirrorEnded + + public init?(event: PDSystemEvent, argument: UInt32) { + switch event { + case kEventInit: self = .initialize + case kEventInitLua: self = .initializeLua + case kEventLock: self = .lock + case kEventUnlock: self = .unlock + case kEventPause: self = .pause + case kEventResume: self = .resume + case kEventTerminate: self = .terminate + case kEventKeyPressed: self = .keyPressed(keyCode: argument) + case kEventKeyReleased: self = .keyReleased(keyCode: argument) + case kEventLowPower: self = .lowPower + case kEventMirrorStarted: self = .mirrorStarted + case kEventMirrorEnded: self = .mirrorEnded + default: return nil + } + } +} diff --git a/Sources/PlaydateKit/PlaydateKit.swift b/Sources/PlaydateKit/Playdate/Playdate.swift similarity index 76% rename from Sources/PlaydateKit/PlaydateKit.swift rename to Sources/PlaydateKit/Playdate/Playdate.swift index 3736cef..4d18dd3 100644 --- a/Sources/PlaydateKit/PlaydateKit.swift +++ b/Sources/PlaydateKit/Playdate/Playdate.swift @@ -1,13 +1,3 @@ -// -// PlaydateKit.swift -// Swift bindings to the Playdate C API. -// -// The C API is delivered as a `PlaydateAPI` struct of function pointers that -// the firmware hands to the game's `eventHandler` entry point. Call -// `Playdate.initialize(with:)` from that entry point before using any other -// API in this module. -// - public import CPlaydate /// The raw C API bootstrap. Everything else in this module (System, @@ -111,58 +101,3 @@ public enum Playdate { tcpAPI = networkAPI?.pointee.tcp } } - -/// An error reported by the Playdate OS. -public struct PlaydateError: Swift.Error, Sendable { - public let message: String - - init(message: String) { - self.message = message - } - - init(cString: UnsafePointer?) { - self.init(message: String(playdateCString: cString) ?? "unknown error") - } -} - -/// The user's answer to a permission request (microphone, network). -public enum AccessReply: UInt32, Sendable { - case ask = 0 - case deny = 1 - case allow = 2 -} - -/// A Swift view of `PDSystemEvent` with the key code folded into the -/// key events. -public enum SystemEvent { - case initialize - case initializeLua - case lock - case unlock - case pause - case resume - case terminate - case keyPressed(keyCode: UInt32) - case keyReleased(keyCode: UInt32) - case lowPower - case mirrorStarted - case mirrorEnded - - public init?(event: PDSystemEvent, argument: UInt32) { - switch event { - case kEventInit: self = .initialize - case kEventInitLua: self = .initializeLua - case kEventLock: self = .lock - case kEventUnlock: self = .unlock - case kEventPause: self = .pause - case kEventResume: self = .resume - case kEventTerminate: self = .terminate - case kEventKeyPressed: self = .keyPressed(keyCode: argument) - case kEventKeyReleased: self = .keyReleased(keyCode: argument) - case kEventLowPower: self = .lowPower - case kEventMirrorStarted: self = .mirrorStarted - case kEventMirrorEnded: self = .mirrorEnded - default: return nil - } - } -} diff --git a/Sources/PlaydateKit/Playdate/Structures/PlaydateError.swift b/Sources/PlaydateKit/Playdate/Structures/PlaydateError.swift new file mode 100644 index 0000000..d1a720a --- /dev/null +++ b/Sources/PlaydateKit/Playdate/Structures/PlaydateError.swift @@ -0,0 +1,12 @@ +/// An error reported by the Playdate OS. +public struct PlaydateError: Swift.Error, Sendable { + public let message: String + + init(message: String) { + self.message = message + } + + init(cString: UnsafePointer?) { + self.init(message: String(playdateCString: cString) ?? "unknown error") + } +} diff --git a/Sources/PlaydateKit/Scoreboards.swift b/Sources/PlaydateKit/Scoreboards/Scoreboards.swift similarity index 59% rename from Sources/PlaydateKit/Scoreboards.swift rename to Sources/PlaydateKit/Scoreboards/Scoreboards.swift index 8356748..8ebdbdb 100644 --- a/Sources/PlaydateKit/Scoreboards.swift +++ b/Sources/PlaydateKit/Scoreboards/Scoreboards.swift @@ -1,95 +1,11 @@ -// -// Scoreboards.swift -// Wraps `playdate->scoreboards` (pd_api_scoreboards.h). -// -// The C callbacks carry no userdata, so one completion per operation kind -// is tracked at a time; starting a second request of the same kind before -// the first completes replaces the stored completion. -// - internal import CPlaydate -private var scoreboardsAPI: UnsafePointer { Playdate.scoreboardsAPI.unsafelyUnwrapped } +var scoreboardsAPI: UnsafePointer { Playdate.scoreboardsAPI.unsafelyUnwrapped } /// The scoreboards API for games with online leaderboards. public enum Scoreboards {} extension Scoreboards { - /// A score on a board. - public struct Score { - public let rank: UInt32 - public let value: UInt32 - public let player: String - public let boardID: String? - - init(_ score: PDScore) { - rank = score.rank - value = score.value - player = String(playdateCString: score.player) ?? "" - boardID = String(playdateCString: score.boardID) - } - - init(_ score: PDListScore, boardID: String?) { - rank = score.rank - value = score.value - player = String(playdateCString: score.player) ?? "" - self.boardID = boardID - } - } - - /// The scores on a board. - public struct ScoresList { - public let boardID: String - public let lastUpdated: UInt32 - public let playerIncluded: Bool - public let limit: UInt32 - public let scores: [Score] - - init(_ list: PDScoresList) { - boardID = String(playdateCString: list.boardID) ?? "" - lastUpdated = list.lastUpdated - playerIncluded = list.playerIncluded != 0 - limit = list.limit - var scores = [Score]() - if let entries = list.scores { - scores.reserveCapacity(Int(list.count)) - for index in 0..) -> Void)? nonisolated(unsafe) private static var personalBestCompletion: ((Result) -> Void)? nonisolated(unsafe) private static var boardsCompletion: ((Result) -> Void)? diff --git a/Sources/PlaydateKit/Scoreboards/Structures/Board.swift b/Sources/PlaydateKit/Scoreboards/Structures/Board.swift new file mode 100644 index 0000000..c1461b9 --- /dev/null +++ b/Sources/PlaydateKit/Scoreboards/Structures/Board.swift @@ -0,0 +1,14 @@ +internal import CPlaydate + +extension Scoreboards { + /// A board belonging to the game. + public struct Board { + public let boardID: String + public let name: String + + init(_ board: PDBoard) { + boardID = String(playdateCString: board.boardID) ?? "" + name = String(playdateCString: board.name) ?? "" + } + } +} diff --git a/Sources/PlaydateKit/Scoreboards/Structures/BoardsList.swift b/Sources/PlaydateKit/Scoreboards/Structures/BoardsList.swift new file mode 100644 index 0000000..3ac0515 --- /dev/null +++ b/Sources/PlaydateKit/Scoreboards/Structures/BoardsList.swift @@ -0,0 +1,21 @@ +internal import CPlaydate + +extension Scoreboards { + /// The game's boards. + public struct BoardsList { + public let lastUpdated: UInt32 + public let boards: [Board] + + init(_ list: PDBoardsList) { + lastUpdated = list.lastUpdated + var boards = [Board]() + if let entries = list.boards { + boards.reserveCapacity(Int(list.count)) + for index in 0..sound` (pd_api_sound.h): the namespace, top-level audio -// functions, and SoundChannel. Sources, signals, synths, and effects live in -// their own files. -// - internal import CPlaydate -var snd: UnsafePointer { Playdate.soundAPI.unsafelyUnwrapped } - -/// The sound API: channels, players, synths, sequences, and effects. -public enum Sound {} - extension Sound { - /// A note as a MIDI note number, where 60 is middle C. Fractional values - /// are valid. - public typealias MIDINote = Float - - /// Middle C (`NOTE_C4`). - public static let noteC4: MIDINote = 60 - - /// The number of audio frames rendered per system audio cycle - /// (`AUDIO_FRAMES_PER_CYCLE`). - public static let audioFramesPerCycle = 512 - - /// Converts a MIDI note to a frequency in Hz. - public static func frequency(forNote note: MIDINote) -> Float { - pd_noteToFrequency(note) - } - - /// Converts a frequency in Hz to a MIDI note. - public static func note(forFrequency frequency: Float) -> MIDINote { - pd_frequencyToNote(frequency) - } - - /// The format of sample data. - public enum Format: UInt32, Sendable { - case mono8bit = 0 - case stereo8bit = 1 - case mono16bit = 2 - case stereo16bit = 3 - case monoADPCM = 4 - case stereoADPCM = 5 - - init(_ format: SoundFormat) { self = Format(rawValue: UInt32(format.rawValue)) ?? .mono16bit } - var cValue: SoundFormat { SoundFormat(SoundFormat.RawValue(rawValue)) } - - public var isStereo: Bool { rawValue & 1 != 0 } - public var is16bit: Bool { rawValue >= 2 && rawValue < 4 } - public var bytesPerFrame: Int { Int(SoundFormat_bytesPerFrame(cValue)) } - } - - /// The microphone used when recording. - public enum MicSource: UInt32, Sendable { - case autodetect = 0 - case internalMic = 1 - case headset = 2 - } - - /// The most recent sound error as a thrown error. - static func lastError() -> PlaydateError { - PlaydateError(cString: snd.pointee.getError.unsafelyUnwrapped()) - } - - // MARK: - Top-level functions - - /// The audio engine's current time, in frames (44,100 per second). - public static var currentTime: UInt32 { - snd.pointee.getCurrentTime.unsafelyUnwrapped() - } - - /// The most recent audio error message, if any. - public static var error: String? { - String(playdateCString: snd.pointee.getError.unsafelyUnwrapped()) - } - - /// Removes a source from its channel. - @discardableResult - public static func removeSource(_ source: Source) -> Bool { - let removed = snd.pointee.removeSource.unsafelyUnwrapped(source.pointer) != 0 - CallbackSource.release(source) - 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. - @discardableResult - public static func setMicCallback(source: MicSource = .autodetect, - _ callback: ((UnsafeMutableBufferPointer) -> Bool)?) -> Bool { - micCallback = callback - if callback != nil { - return snd.pointee.setMicCallback.unsafelyUnwrapped({ _, buffer, length in - let samples = UnsafeMutableBufferPointer(start: buffer, count: Int(length)) - return Sound.micCallback?(samples) == true ? 1 : 0 - }, nil, CPlaydate.MicSource(CPlaydate.MicSource.RawValue(source.rawValue))) != 0 - } else { - return snd.pointee.setMicCallback.unsafelyUnwrapped(nil, nil, CPlaydate.MicSource(CPlaydate.MicSource.RawValue(source.rawValue))) != 0 - } - } - - nonisolated(unsafe) private static var micCallback: ((UnsafeMutableBufferPointer) -> 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`). - @discardableResult - public static func requestMicAccess(purpose: String? = nil, - _ completion: @escaping (Bool) -> Void) -> AccessReply { - final class Box { let body: (Bool) -> Void; init(_ body: @escaping (Bool) -> Void) { self.body = body } } - let box = Unmanaged.passRetained(Box(completion)) - let trampoline: @convention(c) (Bool, UnsafeMutableRawPointer?) -> Void = { allowed, userdata in - guard let userdata else { return } - let box = Unmanaged.fromOpaque(userdata).takeRetainedValue() - box.body(allowed) - } - let reply: accessReply - if let purpose { - reply = purpose.withPlaydateCString { - snd.pointee.requestMicAccess.unsafelyUnwrapped($0, trampoline, box.toOpaque()) - } - } else { - reply = snd.pointee.requestMicAccess.unsafelyUnwrapped(nil, trampoline, box.toOpaque()) - } - if reply != kAccessAsk { - // The callback will not be invoked; balance the retain. - box.release() - } - return AccessReply(rawValue: UInt32(reply.rawValue)) ?? .ask - } - - /// The current headphone and headset-microphone state. - public static var headphoneState: (headphone: Bool, headsetMic: Bool) { - var headphone: Int32 = 0, headsetMic: Int32 = 0 - snd.pointee.getHeadphoneState.unsafelyUnwrapped(&headphone, &headsetMic, nil) - return (headphone != 0, headsetMic != 0) - } - - /// Installs a callback invoked when the headphone or headset-mic state - /// changes. - public static func setHeadphoneChangeCallback(_ callback: ((_ headphone: Bool, _ headsetMic: Bool) -> Void)?) { - headphoneChangeCallback = callback - if callback != nil { - snd.pointee.getHeadphoneState.unsafelyUnwrapped(nil, nil, { headphone, mic in - Sound.headphoneChangeCallback?(headphone != 0, mic != 0) - }) - } else { - snd.pointee.getHeadphoneState.unsafelyUnwrapped(nil, nil, nil) - } - } - - 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. - 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`. - public static func addSource(stereo: Bool, - _ callback: @escaping CallbackSource.Callback) -> CallbackSource { - let source = CallbackSource(callback: callback) - let pointer = snd.pointee.addSource.unsafelyUnwrapped( - CallbackSource.trampoline, source.contextPointer, stereo ? 1 : 0) - source.adopt(pointer: pointer.unsafelyUnwrapped) - return source - } - - // MARK: - Channels - /// A mixer channel holding sources and effects. Wraps `SoundChannel`. public final class Channel { private static var api: UnsafePointer { Playdate.channelAPI.unsafelyUnwrapped } diff --git a/Sources/PlaydateKit/Sound/Effect/Aliases/Effect.Processor.swift b/Sources/PlaydateKit/Sound/Effect/Aliases/Effect.Processor.swift new file mode 100644 index 0000000..3d7cc8a --- /dev/null +++ b/Sources/PlaydateKit/Sound/Effect/Aliases/Effect.Processor.swift @@ -0,0 +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, + _ right: UnsafeMutableBufferPointer?, + _ bufferActive: Bool) -> Bool +} diff --git a/Sources/PlaydateKit/Sound/Effect/Classes/BitCrusher.swift b/Sources/PlaydateKit/Sound/Effect/Classes/BitCrusher.swift new file mode 100644 index 0000000..bb14230 --- /dev/null +++ b/Sources/PlaydateKit/Sound/Effect/Classes/BitCrusher.swift @@ -0,0 +1,56 @@ +internal import CPlaydate + +extension Sound { + /// A bit-crushing and downsampling effect. Wraps `BitCrusher`. + public final class BitCrusher: Effect { + private static var api: UnsafePointer { Playdate.bitCrusherAPI.unsafelyUnwrapped } + + private var retainedModulators: [SignalValue] = [] + + public init() { + super.init(pointer: BitCrusher.api.pointee.newBitCrusher.unsafelyUnwrapped().unsafelyUnwrapped, + isOwned: true) + } + + deinit { + if isOwned { + BitCrusher.api.pointee.freeBitCrusher.unsafelyUnwrapped(pointer) + } + } + + /// When `true`, `setDepth` values map exponentially to bit depth. + public func setExponential(_ flag: Bool) { + BitCrusher.api.pointee.setExponential.unsafelyUnwrapped(pointer, flag) + } + + /// The amount of crushing, 0 (none) to 1 (quantized to 1 bit). + public func setDepth(_ depth: Float) { + BitCrusher.api.pointee.setDepth.unsafelyUnwrapped(pointer, depth) + } + + public var depthModulator: SignalValue? { + get { SignalValue.wrap(BitCrusher.api.pointee.getDepthModulator.unsafelyUnwrapped(pointer)) } + set { + retain(newValue) + BitCrusher.api.pointee.setDepthModulator.unsafelyUnwrapped(pointer, newValue?.pointer) + } + } + + /// The amount of downsampling, 0 (none) to 1 (every sample repeated). + public func setDownsampling(_ downsampling: Float) { + BitCrusher.api.pointee.setDownsampling.unsafelyUnwrapped(pointer, downsampling) + } + + public var downsamplingModulator: SignalValue? { + get { SignalValue.wrap(BitCrusher.api.pointee.getDownsamplingModulator.unsafelyUnwrapped(pointer)) } + set { + retain(newValue) + BitCrusher.api.pointee.setDownsamplingModulator.unsafelyUnwrapped(pointer, newValue?.pointer) + } + } + + private func retain(_ modulator: SignalValue?) { + if let modulator { retainedModulators.append(modulator) } + } + } +} diff --git a/Sources/PlaydateKit/Sound/Effect/Classes/DelayLine.swift b/Sources/PlaydateKit/Sound/Effect/Classes/DelayLine.swift new file mode 100644 index 0000000..420cdbc --- /dev/null +++ b/Sources/PlaydateKit/Sound/Effect/Classes/DelayLine.swift @@ -0,0 +1,40 @@ +internal import CPlaydate + +extension Sound { + /// A delay line effect. Wraps `DelayLine`. + public final class DelayLine: Effect { + private static var api: UnsafePointer { Playdate.delayLineAPI.unsafelyUnwrapped } + + /// Creates a delay line holding `length` frames. + public init(length: Int, stereo: Bool = false) { + super.init(pointer: DelayLine.api.pointee.newDelayLine.unsafelyUnwrapped( + Int32(length), stereo ? 1 : 0).unsafelyUnwrapped, isOwned: true) + } + + deinit { + if isOwned { + DelayLine.api.pointee.freeDelayLine.unsafelyUnwrapped(pointer) + } + } + + /// Changes the delay length. Cannot be larger than the line's + /// original length. + public func setLength(frames: Int) { + DelayLine.api.pointee.setLength.unsafelyUnwrapped(pointer, Int32(frames)) + } + + /// The feedback level, 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. + public func addTap(delay: Int) -> DelayLineTap? { + guard let tap = DelayLine.api.pointee.addTap.unsafelyUnwrapped(pointer, Int32(delay)) else { + return nil + } + return DelayLineTap(pointer: tap, delayLine: self) + } + } +} diff --git a/Sources/PlaydateKit/Sound/Effect/Classes/DelayLineTap.swift b/Sources/PlaydateKit/Sound/Effect/Classes/DelayLineTap.swift new file mode 100644 index 0000000..b272e04 --- /dev/null +++ b/Sources/PlaydateKit/Sound/Effect/Classes/DelayLineTap.swift @@ -0,0 +1,40 @@ +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`. + public final class DelayLineTap: Source { + private static var api: UnsafePointer { Playdate.delayLineAPI.unsafelyUnwrapped } + + /// The delay line is retained so the tap stays valid. + private let delayLine: DelayLine + private var retainedDelayModulator: SignalValue? + + init(pointer: OpaquePointer, delayLine: DelayLine) { + self.delayLine = delayLine + super.init(pointer: pointer, isOwned: true) + } + + deinit { + DelayLineTap.api.pointee.freeTap.unsafelyUnwrapped(pointer) + } + + /// The tap's position in the delay line, in frames. + public func setDelay(frames: Int) { + DelayLineTap.api.pointee.setTapDelay.unsafelyUnwrapped(pointer, Int32(frames)) + } + + public var delayModulator: SignalValue? { + get { SignalValue.wrap(DelayLineTap.api.pointee.getTapDelayModulator.unsafelyUnwrapped(pointer)) } + set { + retainedDelayModulator = newValue + DelayLineTap.api.pointee.setTapDelayModulator.unsafelyUnwrapped(pointer, newValue?.pointer) + } + } + + /// For stereo delay lines: swaps the left and right channels. + public func setChannelsFlipped(_ flipped: Bool) { + DelayLineTap.api.pointee.setTapChannelsFlipped.unsafelyUnwrapped(pointer, flipped ? 1 : 0) + } + } +} diff --git a/Sources/PlaydateKit/Sound/Effect/Classes/Effect.swift b/Sources/PlaydateKit/Sound/Effect/Classes/Effect.swift new file mode 100644 index 0000000..3df534d --- /dev/null +++ b/Sources/PlaydateKit/Sound/Effect/Classes/Effect.swift @@ -0,0 +1,59 @@ +internal import CPlaydate + +private var effectAPI: UnsafePointer { Playdate.effectAPI.unsafelyUnwrapped } + +extension Sound { + /// An effect that processes a channel's audio: the base class of the + /// built-in effects. Wraps `SoundEffect`. + public class Effect { + let pointer: OpaquePointer + let isOwned: Bool + private var retainedMixModulator: SignalValue? + private var processorBox: Unmanaged? + + final class ProcessorBox { + let processor: Processor + init(_ processor: @escaping Processor) { self.processor = processor } + } + + init(pointer: OpaquePointer, isOwned: Bool) { + self.pointer = pointer + self.isOwned = isOwned + } + + /// Creates an effect that processes audio with a Swift callback. + public init(processor: @escaping Processor) { + let box = Unmanaged.passRetained(ProcessorBox(processor)) + processorBox = box + pointer = effectAPI.pointee.newEffect.unsafelyUnwrapped({ effect, left, right, nsamples, bufactive in + guard let effect, let left, + let userdata = effectAPI.pointee.getUserdata.unsafelyUnwrapped(effect) else { return 0 } + let box = Unmanaged.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 + }, box.toOpaque()).unsafelyUnwrapped + isOwned = true + } + + deinit { + if isOwned { + effectAPI.pointee.freeEffect.unsafelyUnwrapped(pointer) + } + processorBox?.release() + } + + /// The wet/dry mix: 1 is fully processed, 0 fully dry. + public func setMix(_ level: Float) { + effectAPI.pointee.setMix.unsafelyUnwrapped(pointer, level) + } + + public var mixModulator: SignalValue? { + get { SignalValue.wrap(effectAPI.pointee.getMixModulator.unsafelyUnwrapped(pointer)) } + set { + retainedMixModulator = newValue + effectAPI.pointee.setMixModulator.unsafelyUnwrapped(pointer, newValue?.pointer) + } + } + } +} diff --git a/Sources/PlaydateKit/Sound/Effect/Classes/OnePoleFilter.swift b/Sources/PlaydateKit/Sound/Effect/Classes/OnePoleFilter.swift new file mode 100644 index 0000000..11187b4 --- /dev/null +++ b/Sources/PlaydateKit/Sound/Effect/Classes/OnePoleFilter.swift @@ -0,0 +1,35 @@ +internal import CPlaydate + +extension Sound { + /// A one-pole low/high-pass filter. Wraps `OnePoleFilter`. + public final class OnePoleFilter: Effect { + private static var api: UnsafePointer { Playdate.onePoleFilterAPI.unsafelyUnwrapped } + + private var retainedParameterModulator: SignalValue? + + public init() { + super.init(pointer: OnePoleFilter.api.pointee.newFilter.unsafelyUnwrapped().unsafelyUnwrapped, + isOwned: true) + } + + deinit { + if isOwned { + OnePoleFilter.api.pointee.freeFilter.unsafelyUnwrapped(pointer) + } + } + + /// The filter's cutoff: -1 to 1, where values above 0 are low-pass + /// and values below 0 high-pass. + public func setParameter(_ parameter: Float) { + OnePoleFilter.api.pointee.setParameter.unsafelyUnwrapped(pointer, parameter) + } + + public var parameterModulator: SignalValue? { + get { SignalValue.wrap(OnePoleFilter.api.pointee.getParameterModulator.unsafelyUnwrapped(pointer)) } + set { + retainedParameterModulator = newValue + OnePoleFilter.api.pointee.setParameterModulator.unsafelyUnwrapped(pointer, newValue?.pointer) + } + } + } +} diff --git a/Sources/PlaydateKit/Sound/Effect/Classes/Overdrive.swift b/Sources/PlaydateKit/Sound/Effect/Classes/Overdrive.swift new file mode 100644 index 0000000..c887166 --- /dev/null +++ b/Sources/PlaydateKit/Sound/Effect/Classes/Overdrive.swift @@ -0,0 +1,56 @@ +internal import CPlaydate + +extension Sound { + /// An overdrive/distortion effect. Wraps `Overdrive`. + public final class Overdrive: Effect { + private static var api: UnsafePointer { Playdate.overdriveAPI.unsafelyUnwrapped } + + private var retainedModulators: [SignalValue] = [] + + public init() { + super.init(pointer: Overdrive.api.pointee.newOverdrive.unsafelyUnwrapped().unsafelyUnwrapped, + isOwned: true) + } + + deinit { + if isOwned { + Overdrive.api.pointee.freeOverdrive.unsafelyUnwrapped(pointer) + } + } + + /// The input gain applied before clipping. + public func setGain(_ gain: Float) { + Overdrive.api.pointee.setGain.unsafelyUnwrapped(pointer, gain) + } + + /// The level where the amplified input clips. + public func setLimit(_ limit: Float) { + Overdrive.api.pointee.setLimit.unsafelyUnwrapped(pointer, limit) + } + + public var limitModulator: SignalValue? { + get { SignalValue.wrap(Overdrive.api.pointee.getLimitModulator.unsafelyUnwrapped(pointer)) } + set { + retain(newValue) + Overdrive.api.pointee.setLimitModulator.unsafelyUnwrapped(pointer, newValue?.pointer) + } + } + + /// A DC offset applied to the input, making the clipping asymmetric. + public func setOffset(_ offset: Float) { + Overdrive.api.pointee.setOffset.unsafelyUnwrapped(pointer, offset) + } + + public var offsetModulator: SignalValue? { + get { SignalValue.wrap(Overdrive.api.pointee.getOffsetModulator.unsafelyUnwrapped(pointer)) } + set { + retain(newValue) + Overdrive.api.pointee.setOffsetModulator.unsafelyUnwrapped(pointer, newValue?.pointer) + } + } + + private func retain(_ modulator: SignalValue?) { + if let modulator { retainedModulators.append(modulator) } + } + } +} diff --git a/Sources/PlaydateKit/Sound/Effect/Classes/RingModulator.swift b/Sources/PlaydateKit/Sound/Effect/Classes/RingModulator.swift new file mode 100644 index 0000000..dcaac75 --- /dev/null +++ b/Sources/PlaydateKit/Sound/Effect/Classes/RingModulator.swift @@ -0,0 +1,34 @@ +internal import CPlaydate + +extension Sound { + /// A ring modulator effect. Wraps `RingModulator`. + public final class RingModulator: Effect { + private static var api: UnsafePointer { Playdate.ringModulatorAPI.unsafelyUnwrapped } + + private var retainedFrequencyModulator: SignalValue? + + public init() { + super.init(pointer: RingModulator.api.pointee.newRingmod.unsafelyUnwrapped().unsafelyUnwrapped, + isOwned: true) + } + + deinit { + if isOwned { + RingModulator.api.pointee.freeRingmod.unsafelyUnwrapped(pointer) + } + } + + /// The modulation frequency, in Hz. + public func setFrequency(_ frequency: Float) { + RingModulator.api.pointee.setFrequency.unsafelyUnwrapped(pointer, frequency) + } + + public var frequencyModulator: SignalValue? { + get { SignalValue.wrap(RingModulator.api.pointee.getFrequencyModulator.unsafelyUnwrapped(pointer)) } + set { + retainedFrequencyModulator = newValue + RingModulator.api.pointee.setFrequencyModulator.unsafelyUnwrapped(pointer, newValue?.pointer) + } + } + } +} diff --git a/Sources/PlaydateKit/Sound/Effect/Classes/TwoPoleFilter.swift b/Sources/PlaydateKit/Sound/Effect/Classes/TwoPoleFilter.swift new file mode 100644 index 0000000..4216e97 --- /dev/null +++ b/Sources/PlaydateKit/Sound/Effect/Classes/TwoPoleFilter.swift @@ -0,0 +1,57 @@ +internal import CPlaydate + +extension Sound { + /// A two-pole IIR filter. Wraps `TwoPoleFilter`. + public final class TwoPoleFilter: Effect { + private static var api: UnsafePointer { Playdate.twoPoleFilterAPI.unsafelyUnwrapped } + + private var retainedFrequencyModulator: SignalValue? + private var retainedResonanceModulator: SignalValue? + + public init(kind: Kind = .lowPass) { + super.init(pointer: TwoPoleFilter.api.pointee.newFilter.unsafelyUnwrapped().unsafelyUnwrapped, + isOwned: true) + setKind(kind) + } + + deinit { + if isOwned { + TwoPoleFilter.api.pointee.freeFilter.unsafelyUnwrapped(pointer) + } + } + + public func setKind(_ kind: Kind) { + TwoPoleFilter.api.pointee.setType.unsafelyUnwrapped(pointer, kind.cValue) + } + + /// The center/corner frequency, in Hz. + public func setFrequency(_ frequency: Float) { + TwoPoleFilter.api.pointee.setFrequency.unsafelyUnwrapped(pointer, frequency) + } + + public var frequencyModulator: SignalValue? { + get { SignalValue.wrap(TwoPoleFilter.api.pointee.getFrequencyModulator.unsafelyUnwrapped(pointer)) } + set { + retainedFrequencyModulator = newValue + TwoPoleFilter.api.pointee.setFrequencyModulator.unsafelyUnwrapped(pointer, newValue?.pointer) + } + } + + /// The gain, used by PEQ and shelf filters. + public func setGain(_ gain: Float) { + TwoPoleFilter.api.pointee.setGain.unsafelyUnwrapped(pointer, gain) + } + + public func setResonance(_ resonance: Float) { + TwoPoleFilter.api.pointee.setResonance.unsafelyUnwrapped(pointer, resonance) + } + + public var resonanceModulator: SignalValue? { + get { SignalValue.wrap(TwoPoleFilter.api.pointee.getResonanceModulator.unsafelyUnwrapped(pointer)) } + set { + retainedResonanceModulator = newValue + TwoPoleFilter.api.pointee.setResonanceModulator.unsafelyUnwrapped(pointer, newValue?.pointer) + } + } + } +} diff --git a/Sources/PlaydateKit/Sound/Effect/Enumerations/TwoPoleFilter.Kind.swift b/Sources/PlaydateKit/Sound/Effect/Enumerations/TwoPoleFilter.Kind.swift new file mode 100644 index 0000000..96b4362 --- /dev/null +++ b/Sources/PlaydateKit/Sound/Effect/Enumerations/TwoPoleFilter.Kind.swift @@ -0,0 +1,15 @@ +internal import CPlaydate + +extension Sound.TwoPoleFilter { + public enum Kind: UInt32, Sendable { + case lowPass = 0 + case highPass = 1 + case bandPass = 2 + case notch = 3 + case peq = 4 + case lowShelf = 5 + case highShelf = 6 + + var cValue: TwoPoleFilterType { TwoPoleFilterType(TwoPoleFilterType.RawValue(rawValue)) } + } +} diff --git a/Sources/PlaydateKit/Sound/Enumerations/Format.swift b/Sources/PlaydateKit/Sound/Enumerations/Format.swift new file mode 100644 index 0000000..787dcb3 --- /dev/null +++ b/Sources/PlaydateKit/Sound/Enumerations/Format.swift @@ -0,0 +1,20 @@ +internal import CPlaydate + +extension Sound { + /// The format of sample data. + public enum Format: UInt32, Sendable { + case mono8bit = 0 + case stereo8bit = 1 + case mono16bit = 2 + case stereo16bit = 3 + case monoADPCM = 4 + case stereoADPCM = 5 + + init(_ format: SoundFormat) { self = Format(rawValue: UInt32(format.rawValue)) ?? .mono16bit } + var cValue: SoundFormat { SoundFormat(SoundFormat.RawValue(rawValue)) } + + public var isStereo: Bool { rawValue & 1 != 0 } + public var is16bit: Bool { rawValue >= 2 && rawValue < 4 } + public var bytesPerFrame: Int { Int(SoundFormat_bytesPerFrame(cValue)) } + } +} diff --git a/Sources/PlaydateKit/Sound/Enumerations/MicSource.swift b/Sources/PlaydateKit/Sound/Enumerations/MicSource.swift new file mode 100644 index 0000000..b6c03c9 --- /dev/null +++ b/Sources/PlaydateKit/Sound/Enumerations/MicSource.swift @@ -0,0 +1,8 @@ +extension Sound { + /// The microphone used when recording. + public enum MicSource: UInt32, Sendable { + case autodetect = 0 + case internalMic = 1 + case headset = 2 + } +} diff --git a/Sources/PlaydateKit/Sound/Signal/Classes/ControlSignal.swift b/Sources/PlaydateKit/Sound/Signal/Classes/ControlSignal.swift new file mode 100644 index 0000000..27b58b1 --- /dev/null +++ b/Sources/PlaydateKit/Sound/Signal/Classes/ControlSignal.swift @@ -0,0 +1,44 @@ +internal import CPlaydate + +extension Sound { + /// A signal whose values are set on a sequence timeline. Wraps + /// `ControlSignal`. + public final class ControlSignal: SignalValue { + private static var api: UnsafePointer { Playdate.controlSignalAPI.unsafelyUnwrapped } + + public init() { + let pointer = ControlSignal.api.pointee.newSignal.unsafelyUnwrapped() + super.init(pointer: pointer.unsafelyUnwrapped, isOwned: true) + } + + override init(pointer: OpaquePointer, isOwned: Bool) { + super.init(pointer: pointer, isOwned: isOwned) + } + + deinit { + if isOwned { + ControlSignal.api.pointee.freeSignal.unsafelyUnwrapped(pointer) + } + } + + 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. + public func addEvent(step: Int, value: Float, interpolate: Bool = false) { + ControlSignal.api.pointee.addEvent.unsafelyUnwrapped(pointer, Int32(step), value, + interpolate ? 1 : 0) + } + + public func removeEvent(step: Int) { + ControlSignal.api.pointee.removeEvent.unsafelyUnwrapped(pointer, Int32(step)) + } + + /// The MIDI controller number for signals loaded from a MIDI file. + public var midiControllerNumber: Int { + Int(ControlSignal.api.pointee.getMIDIControllerNumber.unsafelyUnwrapped(pointer)) + } + } +} diff --git a/Sources/PlaydateKit/Sound/Signal/Classes/Envelope.swift b/Sources/PlaydateKit/Sound/Signal/Classes/Envelope.swift new file mode 100644 index 0000000..09be698 --- /dev/null +++ b/Sources/PlaydateKit/Sound/Signal/Classes/Envelope.swift @@ -0,0 +1,73 @@ +internal import CPlaydate + +extension Sound { + /// An ADSR envelope signal. Wraps `PDSynthEnvelope`. + public final class Envelope: SignalValue { + private static var api: UnsafePointer { Playdate.envelopeAPI.unsafelyUnwrapped } + + /// Creates an envelope with the given attack and decay times + /// (seconds), sustain level (0...1), and release time (seconds). + 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) + } + + override init(pointer: OpaquePointer, isOwned: Bool) { + super.init(pointer: pointer, isOwned: isOwned) + } + + deinit { + if isOwned { + Envelope.api.pointee.freeEnvelope.unsafelyUnwrapped(pointer) + } + } + + public func setAttack(_ attack: Float) { + Envelope.api.pointee.setAttack.unsafelyUnwrapped(pointer, attack) + } + + public func setDecay(_ decay: Float) { + Envelope.api.pointee.setDecay.unsafelyUnwrapped(pointer, decay) + } + + public func setSustain(_ sustain: Float) { + Envelope.api.pointee.setSustain.unsafelyUnwrapped(pointer, sustain) + } + + 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. + 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. + 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. + public func setCurvature(_ amount: Float) { + Envelope.api.pointee.setCurvature.unsafelyUnwrapped(pointer, amount) + } + + /// How much note velocity scales the envelope's output. + 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). + public func setRateScaling(_ scaling: Float, start: MIDINote, end: MIDINote) { + Envelope.api.pointee.setRateScaling.unsafelyUnwrapped(pointer, scaling, start, end) + } + + public var value: Float { + Envelope.api.pointee.getValue.unsafelyUnwrapped(pointer) + } + } +} diff --git a/Sources/PlaydateKit/Sound/Signal/Classes/LFO.swift b/Sources/PlaydateKit/Sound/Signal/Classes/LFO.swift new file mode 100644 index 0000000..eaeddba --- /dev/null +++ b/Sources/PlaydateKit/Sound/Signal/Classes/LFO.swift @@ -0,0 +1,96 @@ +internal import CPlaydate + +extension Sound { + /// A low-frequency oscillator signal. Wraps `PDSynthLFO`. + public final class LFO: SignalValue { + private static var api: UnsafePointer { Playdate.lfoAPI.unsafelyUnwrapped } + + var function: ((LFO) -> Float)? + + public init(shape: Shape = .sine) { + let pointer = LFO.api.pointee.newLFO.unsafelyUnwrapped(shape.cValue) + super.init(pointer: pointer.unsafelyUnwrapped, isOwned: true) + } + + deinit { + if isOwned { + LFO.api.pointee.freeLFO.unsafelyUnwrapped(pointer) + } + } + + public func setShape(_ shape: Shape) { + LFO.api.pointee.setType.unsafelyUnwrapped(pointer, shape.cValue) + } + + /// The LFO rate, in cycles per second. + public func setRate(_ rate: Float) { + LFO.api.pointee.setRate.unsafelyUnwrapped(pointer, rate) + } + + /// The current phase, 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. + 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. + 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. + public func setArpeggiation(_ steps: [Float]) { + var steps = steps + steps.withUnsafeMutableBufferPointer { buffer in + LFO.api.pointee.setArpeggiation.unsafelyUnwrapped(pointer, Int32(buffer.count), + buffer.baseAddress) + } + } + + /// For `.function` LFOs: the Swift function providing the value. If + /// `interpolate` is `true`, values are interpolated between calls. + public func setFunction(interpolate: Bool = false, _ function: @escaping (LFO) -> Float) { + self.function = function + LFO.api.pointee.setFunction.unsafelyUnwrapped(pointer, { _, userdata in + guard let userdata else { return 0 } + let lfo = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + return lfo.function?(lfo) ?? 0 + }, Unmanaged.passUnretained(self).toOpaque(), interpolate ? 1 : 0) + } + + /// Waits `holdoff` seconds after a note starts, then ramps the LFO + /// depth up 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. + public func setRetrigger(_ flag: Bool) { + LFO.api.pointee.setRetrigger.unsafelyUnwrapped(pointer, flag ? 1 : 0) + } + + /// When `true`, the LFO runs globally instead of per-note. + public func setGlobal(_ global: Bool) { + LFO.api.pointee.setGlobal.unsafelyUnwrapped(pointer, global ? 1 : 0) + } + + /// Seeds the random number generator used by `.sampleAndHold` LFOs. + public func setRandomSeed(_ seed: UInt16) { + LFO.api.pointee.setRandomSeed.unsafelyUnwrapped(pointer, seed) + } + + public var value: Float { + LFO.api.pointee.getValue.unsafelyUnwrapped(pointer) + } + } +} diff --git a/Sources/PlaydateKit/Sound/Signal/Classes/Signal.swift b/Sources/PlaydateKit/Sound/Signal/Classes/Signal.swift new file mode 100644 index 0000000..6d5a371 --- /dev/null +++ b/Sources/PlaydateKit/Sound/Signal/Classes/Signal.swift @@ -0,0 +1,73 @@ +internal import CPlaydate + +extension Sound { + /// A signal object; also provides custom signals driven by Swift + /// callbacks. Wraps `PDSynthSignal`. + public final class Signal: SignalValue { + private static var api: UnsafePointer { Playdate.signalAPI.unsafelyUnwrapped } + + private final class Box { + let callbacks: Callbacks + init(_ callbacks: Callbacks) { self.callbacks = callbacks } + } + + /// Creates a signal driven by the given callbacks. + public init(callbacks: Callbacks) { + let box = Unmanaged.passRetained(Box(callbacks)) + let pointer = Signal.api.pointee.newSignal.unsafelyUnwrapped( + { userdata, ioFrames, interpolationValue in + guard let userdata else { return 0 } + let box = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + return box.callbacks.step(ioFrames, interpolationValue) + }, + { userdata, note, velocity, length in + guard let userdata else { return } + let box = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + box.callbacks.noteOn?(note, velocity, length) + }, + { userdata, stopped, offset in + guard let userdata else { return } + let box = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + box.callbacks.noteOff?(stopped != 0, Int(offset)) + }, + { userdata in + guard let userdata else { return } + Unmanaged.fromOpaque(userdata).release() + }, + box.toOpaque()) + super.init(pointer: pointer.unsafelyUnwrapped, isOwned: true) + } + + /// Creates a plain signal object wrapping an existing signal value, + /// so it can be scaled and offset. + public init(value: SignalValue) { + let pointer = Signal.api.pointee.newSignalForValue.unsafelyUnwrapped(value.pointer) + super.init(pointer: pointer.unsafelyUnwrapped, isOwned: true) + } + + override init(pointer: OpaquePointer, isOwned: Bool) { + super.init(pointer: pointer, isOwned: isOwned) + } + + deinit { + if isOwned { + Signal.api.pointee.freeSignal.unsafelyUnwrapped(pointer) + } + } + + /// The signal's current value. + public var value: Float { + Signal.api.pointee.getValue.unsafelyUnwrapped(pointer) + } + + /// Scales the signal's output. + 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) + } + } +} diff --git a/Sources/PlaydateKit/Sound/Signal/Classes/SignalValue.swift b/Sources/PlaydateKit/Sound/Signal/Classes/SignalValue.swift new file mode 100644 index 0000000..323b1ca --- /dev/null +++ b/Sources/PlaydateKit/Sound/Signal/Classes/SignalValue.swift @@ -0,0 +1,19 @@ +extension Sound { + /// A value that can modulate a parameter. The base class of `Signal`, + /// `LFO`, `Envelope`, and `ControlSignal`. Wraps `PDSynthSignalValue`. + public class SignalValue { + let pointer: OpaquePointer + let isOwned: Bool + + init(pointer: OpaquePointer, isOwned: Bool) { + self.pointer = pointer + self.isOwned = isOwned + } + + /// Wraps a signal value pointer returned by the OS (not owned). + static func wrap(_ pointer: OpaquePointer?) -> SignalValue? { + guard let pointer else { return nil } + return SignalValue(pointer: pointer, isOwned: false) + } + } +} diff --git a/Sources/PlaydateKit/Sound/Signal/Enumerations/LFO.Shape.swift b/Sources/PlaydateKit/Sound/Signal/Enumerations/LFO.Shape.swift new file mode 100644 index 0000000..bb57635 --- /dev/null +++ b/Sources/PlaydateKit/Sound/Signal/Enumerations/LFO.Shape.swift @@ -0,0 +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 + case sampleAndHold = 3 + case sawtoothUp = 4 + case sawtoothDown = 5 + case arpeggiator = 6 + case function = 7 + + var cValue: LFOType { LFOType(LFOType.RawValue(rawValue)) } + } +} diff --git a/Sources/PlaydateKit/Sound/Signal/Structures/SignalCallbacks.swift b/Sources/PlaydateKit/Sound/Signal/Structures/SignalCallbacks.swift new file mode 100644 index 0000000..416a2a8 --- /dev/null +++ b/Sources/PlaydateKit/Sound/Signal/Structures/SignalCallbacks.swift @@ -0,0 +1,25 @@ +extension Sound.Signal { + /// Custom signal callbacks. + 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`. + public var step: (_ ioFrames: UnsafeMutablePointer?, + _ interpolationValue: UnsafeMutablePointer?) -> Float + /// Called on note-on events. `length` is -1 for indefinite notes. + 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. + public var noteOff: ((_ stopped: Bool, _ offset: Int) -> Void)? + + public init(step: @escaping (_ ioFrames: UnsafeMutablePointer?, + _ interpolationValue: UnsafeMutablePointer?) -> Float, + noteOn: ((_ note: Sound.MIDINote, _ velocity: Float, _ length: Float) -> Void)? = nil, + noteOff: ((_ stopped: Bool, _ offset: Int) -> Void)? = nil) { + self.step = step + self.noteOn = noteOn + self.noteOff = noteOff + } + } +} diff --git a/Sources/PlaydateKit/Sound/Sound.swift b/Sources/PlaydateKit/Sound/Sound.swift new file mode 100644 index 0000000..1dc274b --- /dev/null +++ b/Sources/PlaydateKit/Sound/Sound.swift @@ -0,0 +1,139 @@ +internal import CPlaydate + +var snd: UnsafePointer { Playdate.soundAPI.unsafelyUnwrapped } + +/// The sound API: channels, players, synths, sequences, and effects. +public enum Sound {} + +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`). + public static let audioFramesPerCycle = 512 + + /// Converts a MIDI note to a frequency in Hz. + public static func frequency(forNote note: MIDINote) -> Float { + pd_noteToFrequency(note) + } + + /// Converts a frequency in Hz to a MIDI note. + public static func note(forFrequency frequency: Float) -> MIDINote { + pd_frequencyToNote(frequency) + } + + /// The most recent sound error as a thrown error. + static func lastError() -> PlaydateError { + PlaydateError(cString: snd.pointee.getError.unsafelyUnwrapped()) + } + + // MARK: - Top-level functions + + /// The audio engine's current time, in frames (44,100 per second). + public static var currentTime: UInt32 { + snd.pointee.getCurrentTime.unsafelyUnwrapped() + } + + /// The most recent audio error message, if any. + public static var error: String? { + String(playdateCString: snd.pointee.getError.unsafelyUnwrapped()) + } + + /// Removes a source from its channel. + @discardableResult + public static func removeSource(_ source: Source) -> Bool { + let removed = snd.pointee.removeSource.unsafelyUnwrapped(source.pointer) != 0 + CallbackSource.release(source) + 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. + @discardableResult + public static func setMicCallback(source: MicSource = .autodetect, + _ callback: ((UnsafeMutableBufferPointer) -> Bool)?) -> Bool { + micCallback = callback + if callback != nil { + return snd.pointee.setMicCallback.unsafelyUnwrapped({ _, buffer, length in + let samples = UnsafeMutableBufferPointer(start: buffer, count: Int(length)) + return Sound.micCallback?(samples) == true ? 1 : 0 + }, nil, CPlaydate.MicSource(CPlaydate.MicSource.RawValue(source.rawValue))) != 0 + } else { + return snd.pointee.setMicCallback.unsafelyUnwrapped(nil, nil, CPlaydate.MicSource(CPlaydate.MicSource.RawValue(source.rawValue))) != 0 + } + } + + nonisolated(unsafe) private static var micCallback: ((UnsafeMutableBufferPointer) -> 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`). + @discardableResult + public static func requestMicAccess(purpose: String? = nil, + _ completion: @escaping (Bool) -> Void) -> AccessReply { + final class Box { let body: (Bool) -> Void; init(_ body: @escaping (Bool) -> Void) { self.body = body } } + let box = Unmanaged.passRetained(Box(completion)) + let trampoline: @convention(c) (Bool, UnsafeMutableRawPointer?) -> Void = { allowed, userdata in + guard let userdata else { return } + let box = Unmanaged.fromOpaque(userdata).takeRetainedValue() + box.body(allowed) + } + let reply: accessReply + if let purpose { + reply = purpose.withPlaydateCString { + snd.pointee.requestMicAccess.unsafelyUnwrapped($0, trampoline, box.toOpaque()) + } + } else { + reply = snd.pointee.requestMicAccess.unsafelyUnwrapped(nil, trampoline, box.toOpaque()) + } + if reply != kAccessAsk { + // The callback will not be invoked; balance the retain. + box.release() + } + return AccessReply(rawValue: UInt32(reply.rawValue)) ?? .ask + } + + /// The current headphone and headset-microphone state. + public static var headphoneState: (headphone: Bool, headsetMic: Bool) { + var headphone: Int32 = 0, headsetMic: Int32 = 0 + snd.pointee.getHeadphoneState.unsafelyUnwrapped(&headphone, &headsetMic, nil) + return (headphone != 0, headsetMic != 0) + } + + /// Installs a callback invoked when the headphone or headset-mic state + /// changes. + public static func setHeadphoneChangeCallback(_ callback: ((_ headphone: Bool, _ headsetMic: Bool) -> Void)?) { + headphoneChangeCallback = callback + if callback != nil { + snd.pointee.getHeadphoneState.unsafelyUnwrapped(nil, nil, { headphone, mic in + Sound.headphoneChangeCallback?(headphone != 0, mic != 0) + }) + } else { + snd.pointee.getHeadphoneState.unsafelyUnwrapped(nil, nil, nil) + } + } + + 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. + 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`. + public static func addSource(stereo: Bool, + _ callback: @escaping CallbackSource.Callback) -> CallbackSource { + let source = CallbackSource(callback: callback) + let pointer = snd.pointee.addSource.unsafelyUnwrapped( + CallbackSource.trampoline, source.contextPointer, stereo ? 1 : 0) + source.adopt(pointer: pointer.unsafelyUnwrapped) + return source + } +} diff --git a/Sources/PlaydateKit/Sound/Source/Aliases/SoundCallbackSource.swift b/Sources/PlaydateKit/Sound/Source/Aliases/SoundCallbackSource.swift new file mode 100644 index 0000000..a899ebb --- /dev/null +++ b/Sources/PlaydateKit/Sound/Source/Aliases/SoundCallbackSource.swift @@ -0,0 +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, + _ right: UnsafeMutableBufferPointer?) -> Bool +} diff --git a/Sources/PlaydateKit/Sound/Source/Classes/AudioSample.swift b/Sources/PlaydateKit/Sound/Source/Classes/AudioSample.swift new file mode 100644 index 0000000..e39d583 --- /dev/null +++ b/Sources/PlaydateKit/Sound/Source/Classes/AudioSample.swift @@ -0,0 +1,82 @@ +internal import CPlaydate + +extension Sound { + /// Audio data loaded into memory. Wraps `AudioSample`. + public final class AudioSample { + private static var api: UnsafePointer { Playdate.sampleAPI.unsafelyUnwrapped } + + let pointer: OpaquePointer + let isOwned: Bool + + init(pointer: OpaquePointer, isOwned: Bool) { + self.pointer = pointer + self.isOwned = isOwned + } + + /// Allocates a sample buffer with room for `byteCount` bytes. + public convenience init(byteCount: Int) { + self.init(pointer: AudioSample.api.pointee.newSampleBuffer.unsafelyUnwrapped( + Int32(byteCount)).unsafelyUnwrapped, isOwned: true) + } + + /// 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) } + 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. + public convenience init?(data: UnsafeMutablePointer, format: Format, + sampleRate: UInt32, byteCount: Int, freeWhenDone: Bool) { + guard let pointer = AudioSample.api.pointee.newSampleFromData.unsafelyUnwrapped( + data, format.cValue, sampleRate, Int32(byteCount), freeWhenDone ? 1 : 0) else { + return nil + } + self.init(pointer: pointer, isOwned: true) + } + + deinit { + if isOwned { + AudioSample.api.pointee.freeSample.unsafelyUnwrapped(pointer) + } + } + + /// Loads the file at `path` into this sample's buffer. + public func load(path: String) throws(PlaydateError) { + let loaded = path.withPlaydateCString { + AudioSample.api.pointee.loadIntoSample.unsafelyUnwrapped(pointer, $0) != 0 + } + if !loaded { + throw PlaydateError(message: "unable to load sample: \(path)") + } + } + + /// The sample's raw data, format, and rate. + public var data: (data: UnsafeMutablePointer?, format: Format, + sampleRate: UInt32, byteLength: UInt32) { + var data: UnsafeMutablePointer? + var format = kSound16bitMono + var sampleRate: UInt32 = 0, byteLength: UInt32 = 0 + AudioSample.api.pointee.getData.unsafelyUnwrapped(pointer, &data, &format, &sampleRate, &byteLength) + return (data, Format(format), sampleRate, byteLength) + } + + /// The sample's 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. + @discardableResult + public func decompress() -> Bool { + AudioSample.api.pointee.decompress.unsafelyUnwrapped(pointer) != 0 + } + } +} diff --git a/Sources/PlaydateKit/Sound/Source/Classes/CallbackSource.swift b/Sources/PlaydateKit/Sound/Source/Classes/CallbackSource.swift new file mode 100644 index 0000000..0d16202 --- /dev/null +++ b/Sources/PlaydateKit/Sound/Source/Classes/CallbackSource.swift @@ -0,0 +1,41 @@ +extension Sound { + /// A source that produces audio by calling back into Swift. + 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. + nonisolated(unsafe) static var live: [CallbackSource] = [] + + /// Releases the registration added by `adopt(pointer:)`. + static func release(_ source: Source) { + live.removeAll { $0 === source } + } + + init(callback: @escaping Callback) { + self.callback = callback + super.init(pointer: nil, isOwned: false) + } + + var contextPointer: UnsafeMutableRawPointer { + Unmanaged.passUnretained(self).toOpaque() + } + + static let trampoline: @convention(c) (UnsafeMutableRawPointer?, UnsafeMutablePointer?, + UnsafeMutablePointer?, Int32) -> Int32 = { context, left, right, length in + guard let context, let left else { return 0 } + let source = Unmanaged.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 + } + + /// Attaches the C object created for this source. + func adopt(pointer: OpaquePointer) { + self.pointer = pointer + CallbackSource.live.append(self) + } + } +} diff --git a/Sources/PlaydateKit/Sound/Source/Classes/FilePlayer.swift b/Sources/PlaydateKit/Sound/Source/Classes/FilePlayer.swift new file mode 100644 index 0000000..5bc36a0 --- /dev/null +++ b/Sources/PlaydateKit/Sound/Source/Classes/FilePlayer.swift @@ -0,0 +1,151 @@ +internal import CPlaydate + +extension Sound { + /// Streams audio from a file. Wraps `FilePlayer`. + public final class FilePlayer: Source { + private static var api: UnsafePointer { Playdate.filePlayerAPI.unsafelyUnwrapped } + + var loopCallback: ((FilePlayer) -> Void)? + var fadeCallback: ((FilePlayer) -> Void)? + var mp3DataSource: ((UnsafeMutableBufferPointer) -> Int)? + private var retainedRateModulator: SignalValue? + + override init(pointer: OpaquePointer?, isOwned: Bool) { + super.init(pointer: pointer, isOwned: isOwned) + } + + public convenience init() { + self.init(pointer: FilePlayer.api.pointee.newPlayer.unsafelyUnwrapped().unsafelyUnwrapped, + 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) + } + + deinit { + if isOwned { + FilePlayer.api.pointee.freePlayer.unsafelyUnwrapped(pointer) + } + } + + /// Prepares the player to stream the file at `path`. + public func load(path: String) throws(PlaydateError) { + let loaded = path.withPlaydateCString { + FilePlayer.api.pointee.loadIntoPlayer.unsafelyUnwrapped(pointer, $0) != 0 + } + if !loaded { + throw PlaydateError(message: "unable to load audio file: \(path)") + } + } + + /// Sets the length of the stream buffer, 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. + @discardableResult + public func play(repeat repeatCount: Int = 1) -> Bool { + FilePlayer.api.pointee.play.unsafelyUnwrapped(pointer, Int32(repeatCount)) != 0 + } + + public func pause() { + FilePlayer.api.pointee.pause.unsafelyUnwrapped(pointer) + } + + public func stop() { + FilePlayer.api.pointee.stop.unsafelyUnwrapped(pointer) + } + + /// The file's length in seconds. + public var length: Float { + FilePlayer.api.pointee.getLength.unsafelyUnwrapped(pointer) + } + + /// The 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. + 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. + 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. + public var didUnderrun: Bool { + FilePlayer.api.pointee.didUnderrun.unsafelyUnwrapped(pointer) != 0 + } + + /// Stops playback (instead of looping the buffer) on underrun. + public func setStopOnUnderrun(_ flag: Bool) { + FilePlayer.api.pointee.setStopOnUnderrun.unsafelyUnwrapped(pointer, flag ? 1 : 0) + } + + /// Sets a function called every time playback loops. + public func setLoopCallback(_ callback: ((FilePlayer) -> Void)?) { + loopCallback = callback + if callback != nil { + FilePlayer.api.pointee.setLoopCallback.unsafelyUnwrapped(pointer, { _, userdata in + guard let userdata else { return } + let player = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + player.loopCallback?(player) + }, Unmanaged.passUnretained(self).toOpaque()) + } else { + FilePlayer.api.pointee.setLoopCallback.unsafelyUnwrapped(pointer, nil, nil) + } + } + + /// Fades the volume to the given levels over `length` sample frames, + /// then calls `completion`. + public func fadeVolume(left: Float, right: Float, length: Int32, + completion: ((FilePlayer) -> Void)? = nil) { + fadeCallback = completion + if completion != nil { + FilePlayer.api.pointee.fadeVolume.unsafelyUnwrapped(pointer, left, right, length, { _, userdata in + guard let userdata else { return } + let player = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + player.fadeCallback?(player) + }, Unmanaged.passUnretained(self).toOpaque()) + } else { + FilePlayer.api.pointee.fadeVolume.unsafelyUnwrapped(pointer, left, right, length, nil, nil) + } + } + + /// 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. + public func setMP3StreamSource(bufferLength: Float, + _ dataSource: @escaping (UnsafeMutableBufferPointer) -> Int) { + mp3DataSource = dataSource + FilePlayer.api.pointee.setMP3StreamSource.unsafelyUnwrapped(pointer, { data, bytes, userdata in + guard let userdata, let data else { return 0 } + let player = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + let buffer = UnsafeMutableBufferPointer(start: data, count: Int(bytes)) + return Int32(player.mp3DataSource?(buffer) ?? 0) + }, Unmanaged.passUnretained(self).toOpaque(), bufferLength) + } + + /// Modulates the playback rate. + public var rateModulator: SignalValue? { + get { SignalValue.wrap(FilePlayer.api.pointee.getRateModulator.unsafelyUnwrapped(pointer)) } + set { + retainedRateModulator = newValue + FilePlayer.api.pointee.setRateModulator.unsafelyUnwrapped(pointer, newValue?.pointer) + } + } + } +} diff --git a/Sources/PlaydateKit/Sound/Source/Classes/SamplePlayer.swift b/Sources/PlaydateKit/Sound/Source/Classes/SamplePlayer.swift new file mode 100644 index 0000000..2a4a758 --- /dev/null +++ b/Sources/PlaydateKit/Sound/Source/Classes/SamplePlayer.swift @@ -0,0 +1,102 @@ +internal import CPlaydate + +extension Sound { + /// Plays an `AudioSample` from memory. Wraps `SamplePlayer`. + public final class SamplePlayer: Source { + private static var api: UnsafePointer { Playdate.samplePlayerAPI.unsafelyUnwrapped } + + var loopCallback: ((SamplePlayer) -> Void)? + private var retainedSample: AudioSample? + private var retainedRateModulator: SignalValue? + + override init(pointer: OpaquePointer?, isOwned: Bool) { + super.init(pointer: pointer, isOwned: isOwned) + } + + public convenience init() { + self.init(pointer: SamplePlayer.api.pointee.newPlayer.unsafelyUnwrapped().unsafelyUnwrapped, + isOwned: true) + } + + /// Creates a player for the sample at `path`. + public convenience init(path: String) throws(PlaydateError) { + self.init() + sample = try AudioSample(path: path) + } + + deinit { + if isOwned { + SamplePlayer.api.pointee.freePlayer.unsafelyUnwrapped(pointer) + } + } + + /// The sample to play. + public var sample: AudioSample? { + get { retainedSample } + set { + retainedSample = newValue + SamplePlayer.api.pointee.setSample.unsafelyUnwrapped(pointer, newValue?.pointer) + } + } + + /// Starts playback at `rate`, looping `repeat` times; 0 loops + /// endlessly, -1 loops ping-pong. + @discardableResult + public func play(repeat repeatCount: Int = 1, rate: Float = 1) -> Bool { + SamplePlayer.api.pointee.play.unsafelyUnwrapped(pointer, Int32(repeatCount), rate) != 0 + } + + public func stop() { + SamplePlayer.api.pointee.stop.unsafelyUnwrapped(pointer) + } + + public func setPaused(_ paused: Bool) { + SamplePlayer.api.pointee.setPaused.unsafelyUnwrapped(pointer, paused ? 1 : 0) + } + + /// The sample's length in seconds. + public var length: Float { + SamplePlayer.api.pointee.getLength.unsafelyUnwrapped(pointer) + } + + /// The 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. + 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. + 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. + public func setLoopCallback(_ callback: ((SamplePlayer) -> Void)?) { + loopCallback = callback + if callback != nil { + SamplePlayer.api.pointee.setLoopCallback.unsafelyUnwrapped(pointer, { _, userdata in + guard let userdata else { return } + let player = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + player.loopCallback?(player) + }, Unmanaged.passUnretained(self).toOpaque()) + } else { + SamplePlayer.api.pointee.setLoopCallback.unsafelyUnwrapped(pointer, nil, nil) + } + } + + /// Modulates the playback rate. + public var rateModulator: SignalValue? { + get { SignalValue.wrap(SamplePlayer.api.pointee.getRateModulator.unsafelyUnwrapped(pointer)) } + set { + retainedRateModulator = newValue + SamplePlayer.api.pointee.setRateModulator.unsafelyUnwrapped(pointer, newValue?.pointer) + } + } + } +} diff --git a/Sources/PlaydateKit/Sound/Source/Classes/Source.swift b/Sources/PlaydateKit/Sound/Source/Classes/Source.swift new file mode 100644 index 0000000..07d6a9a --- /dev/null +++ b/Sources/PlaydateKit/Sound/Source/Classes/Source.swift @@ -0,0 +1,54 @@ +internal import CPlaydate + +extension Sound { + /// A source of audio: the base class of `FilePlayer`, `SamplePlayer`, + /// `Synth`, `DelayLineTap`, and `CallbackSource`. Wraps `SoundSource`. + public class Source { + private static var api: UnsafePointer { Playdate.sourceAPI.unsafelyUnwrapped } + + /// The underlying C object. Set once, immediately after creation. + var pointer: OpaquePointer! + let isOwned: Bool + var finishCallback: ((Source) -> Void)? + + init(pointer: OpaquePointer?, isOwned: Bool) { + self.pointer = pointer + self.isOwned = isOwned + } + + /// Sets the playback volume for the left and right channels, 0...1. + public func setVolume(left: Float, right: Float) { + Source.api.pointee.setVolume.unsafelyUnwrapped(pointer, left, right) + } + + /// Sets the playback volume of both channels. + public func setVolume(_ volume: Float) { + setVolume(left: volume, right: volume) + } + + /// The playback volume of the left and right channels. + public var volume: (left: Float, right: Float) { + var left: Float = 0, right: Float = 0 + Source.api.pointee.getVolume.unsafelyUnwrapped(pointer, &left, &right) + return (left, right) + } + + public var isPlaying: Bool { + Source.api.pointee.isPlaying.unsafelyUnwrapped(pointer) != 0 + } + + /// Sets a function called when the source finishes playing. + public func setFinishCallback(_ callback: ((Source) -> Void)?) { + finishCallback = callback + if callback != nil { + Source.api.pointee.setFinishCallback.unsafelyUnwrapped(pointer, { _, userdata in + guard let userdata else { return } + let source = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + source.finishCallback?(source) + }, Unmanaged.passUnretained(self).toOpaque()) + } else { + Source.api.pointee.setFinishCallback.unsafelyUnwrapped(pointer, nil, nil) + } + } + } +} diff --git a/Sources/PlaydateKit/Sound/Synth/Classes/Instrument.swift b/Sources/PlaydateKit/Sound/Synth/Classes/Instrument.swift new file mode 100644 index 0000000..e63ee14 --- /dev/null +++ b/Sources/PlaydateKit/Sound/Synth/Classes/Instrument.swift @@ -0,0 +1,106 @@ +internal import CPlaydate + +extension Sound { + /// A bank of synth voices for playing a sequence track. Wraps + /// `PDSynthInstrument`. + public final class Instrument { + private static var api: UnsafePointer { Playdate.instrumentAPI.unsafelyUnwrapped } + + let pointer: OpaquePointer + let isOwned: Bool + private var retainedVoices: [Synth] = [] + + init(pointer: OpaquePointer, isOwned: Bool) { + self.pointer = pointer + self.isOwned = isOwned + } + + public convenience init() { + self.init(pointer: Instrument.api.pointee.newInstrument.unsafelyUnwrapped().unsafelyUnwrapped, + isOwned: true) + } + + deinit { + if isOwned { + Instrument.api.pointee.freeInstrument.unsafelyUnwrapped(pointer) + } + } + + /// Adds a voice to the instrument, handling notes in + /// `rangeStart...rangeEnd` (0...127 handles all notes), transposed by + /// `transpose` half-steps. + @discardableResult + public func addVoice(_ synth: Synth, rangeStart: MIDINote = 0, rangeEnd: MIDINote = 127, + transpose: Float = 0) -> Bool { + let added = Instrument.api.pointee.addVoice.unsafelyUnwrapped( + pointer, synth.pointer, rangeStart, rangeEnd, transpose) != 0 + if added, !retainedVoices.contains(where: { $0 === synth }) { + retainedVoices.append(synth) + } + return added + } + + /// Plays a note at `frequency` Hz on an available voice. Returns the + /// synth used, if any. + @discardableResult + public func playNote(frequency: Float, velocity: Float = 1, + length: Float? = nil, when: UInt32 = 0) -> Synth? { + let synth = Instrument.api.pointee.playNote.unsafelyUnwrapped( + pointer, frequency, velocity, length ?? -1, when) + return voice(for: synth) + } + + /// Plays a MIDI note on an available voice. Returns the synth used. + @discardableResult + public func playMIDINote(_ note: MIDINote, velocity: Float = 1, + length: Float? = nil, when: UInt32 = 0) -> Synth? { + let synth = Instrument.api.pointee.playMIDINote.unsafelyUnwrapped( + pointer, note, velocity, length ?? -1, when) + return voice(for: synth) + } + + private func voice(for pointer: OpaquePointer?) -> Synth? { + guard let pointer else { return nil } + if let voice = retainedVoices.first(where: { $0.pointer == pointer }) { + return voice + } + return Synth(pointer: pointer, isOwned: false) + } + + /// Bends played notes by `bend` × the pitch bend range. + public func setPitchBend(_ bend: Float) { + Instrument.api.pointee.setPitchBend.unsafelyUnwrapped(pointer, bend) + } + + public func setPitchBendRange(halfSteps: Float) { + Instrument.api.pointee.setPitchBendRange.unsafelyUnwrapped(pointer, halfSteps) + } + + public func setTranspose(halfSteps: Float) { + Instrument.api.pointee.setTranspose.unsafelyUnwrapped(pointer, halfSteps) + } + + /// Releases the voice playing `note` at time `when` (0 = now). + public func noteOff(_ note: MIDINote, when: UInt32 = 0) { + Instrument.api.pointee.noteOff.unsafelyUnwrapped(pointer, note, when) + } + + public func allNotesOff(when: UInt32 = 0) { + Instrument.api.pointee.allNotesOff.unsafelyUnwrapped(pointer, when) + } + + public func setVolume(left: Float, right: Float) { + Instrument.api.pointee.setVolume.unsafelyUnwrapped(pointer, left, right) + } + + public var volume: (left: Float, right: Float) { + var left: Float = 0, right: Float = 0 + Instrument.api.pointee.getVolume.unsafelyUnwrapped(pointer, &left, &right) + return (left, right) + } + + public var activeVoiceCount: Int { + Int(Instrument.api.pointee.activeVoiceCount.unsafelyUnwrapped(pointer)) + } + } +} diff --git a/Sources/PlaydateKit/Sound/Synth/Classes/Sequence.swift b/Sources/PlaydateKit/Sound/Synth/Classes/Sequence.swift new file mode 100644 index 0000000..f29e57f --- /dev/null +++ b/Sources/PlaydateKit/Sound/Synth/Classes/Sequence.swift @@ -0,0 +1,132 @@ +internal import CPlaydate + +extension Sound { + /// A collection of tracks with tempo and loop control, playable from a + /// MIDI file. Wraps `SoundSequence`. + public final class Sequence { + private static var api: UnsafePointer { Playdate.sequenceAPI.unsafelyUnwrapped } + + let pointer: OpaquePointer + private var retainedTracks: [SequenceTrack] = [] + var finishCallback: ((Sequence) -> Void)? + + public init() { + pointer = Sequence.api.pointee.newSequence.unsafelyUnwrapped().unsafelyUnwrapped + } + + /// Creates a sequence and loads the MIDI file at `path`. + public convenience init(midiFilePath: String) throws(PlaydateError) { + self.init() + try loadMIDIFile(path: midiFilePath) + } + + deinit { + Sequence.api.pointee.freeSequence.unsafelyUnwrapped(pointer) + } + + public func loadMIDIFile(path: String) throws(PlaydateError) { + let loaded = path.withPlaydateCString { + Sequence.api.pointee.loadMIDIFile.unsafelyUnwrapped(pointer, $0) != 0 + } + if !loaded { + throw PlaydateError(message: "unable to load MIDI file: \(path)") + } + } + + /// Starts playback. `completion` is called when the sequence finishes. + public func play(completion: ((Sequence) -> Void)? = nil) { + finishCallback = completion + if completion != nil { + Sequence.api.pointee.play.unsafelyUnwrapped(pointer, { _, userdata in + guard let userdata else { return } + let sequence = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + sequence.finishCallback?(sequence) + }, Unmanaged.passUnretained(self).toOpaque()) + } else { + Sequence.api.pointee.play.unsafelyUnwrapped(pointer, nil, nil) + } + } + + public func stop() { + Sequence.api.pointee.stop.unsafelyUnwrapped(pointer) + } + + public var isPlaying: Bool { + Sequence.api.pointee.isPlaying.unsafelyUnwrapped(pointer) != 0 + } + + /// The playback position, in samples. + 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. + 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. + public var length: UInt32 { + Sequence.api.pointee.getLength.unsafelyUnwrapped(pointer) + } + + /// Loops the range `loopStart.. SequenceTrack { + let track = SequenceTrack( + pointer: Sequence.api.pointee.addTrack.unsafelyUnwrapped(pointer).unsafelyUnwrapped, + isOwned: false) + retainedTracks.append(track) + 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) + } + 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) + } + } +} diff --git a/Sources/PlaydateKit/Sound/Synth/Classes/SequenceTrack.swift b/Sources/PlaydateKit/Sound/Synth/Classes/SequenceTrack.swift new file mode 100644 index 0000000..07826e0 --- /dev/null +++ b/Sources/PlaydateKit/Sound/Synth/Classes/SequenceTrack.swift @@ -0,0 +1,114 @@ +internal import CPlaydate + +extension Sound { + /// A track of notes played by an instrument. Wraps `SequenceTrack`. + public final class SequenceTrack { + private static var api: UnsafePointer { Playdate.trackAPI.unsafelyUnwrapped } + + let pointer: OpaquePointer + let isOwned: Bool + private var retainedInstrument: Instrument? + + init(pointer: OpaquePointer, isOwned: Bool) { + self.pointer = pointer + self.isOwned = isOwned + } + + public convenience init() { + self.init(pointer: SequenceTrack.api.pointee.newTrack.unsafelyUnwrapped().unsafelyUnwrapped, + isOwned: true) + } + + deinit { + if isOwned { + SequenceTrack.api.pointee.freeTrack.unsafelyUnwrapped(pointer) + } + } + + /// The instrument that plays this track's notes. + public var instrument: Instrument? { + get { + if let retainedInstrument { return retainedInstrument } + guard let instrument = SequenceTrack.api.pointee.getInstrument.unsafelyUnwrapped(pointer) else { + return nil + } + return Instrument(pointer: instrument, isOwned: false) + } + set { + retainedInstrument = newValue + SequenceTrack.api.pointee.setInstrument.unsafelyUnwrapped(pointer, newValue?.pointer) + } + } + + /// Adds a note starting at `step`, lasting `length` steps. + public func addNote(step: UInt32, length: UInt32, note: MIDINote, velocity: Float = 1) { + SequenceTrack.api.pointee.addNoteEvent.unsafelyUnwrapped(pointer, step, length, note, velocity) + } + + public func removeNote(step: UInt32, note: MIDINote) { + SequenceTrack.api.pointee.removeNoteEvent.unsafelyUnwrapped(pointer, step, note) + } + + public func clearNotes() { + SequenceTrack.api.pointee.clearNotes.unsafelyUnwrapped(pointer) + } + + /// The track's length in steps, including the tail of the last note. + public var length: UInt32 { + SequenceTrack.api.pointee.getLength.unsafelyUnwrapped(pointer) + } + + /// The index of the first note at or after `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 + var note: MIDINote = 0 + var velocity: Float = 0 + guard SequenceTrack.api.pointee.getNoteAtIndex.unsafelyUnwrapped( + pointer, Int32(index), &step, &length, ¬e, &velocity) != 0 else { return nil } + 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. + 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) + } + + public func clearControlEvents() { + SequenceTrack.api.pointee.clearControlEvents.unsafelyUnwrapped(pointer) + } + + /// The maximum number of simultaneous notes in the track. + public var polyphony: Int { + Int(SequenceTrack.api.pointee.getPolyphony.unsafelyUnwrapped(pointer)) + } + + public var activeVoiceCount: Int { + Int(SequenceTrack.api.pointee.activeVoiceCount.unsafelyUnwrapped(pointer)) + } + + public func setMuted(_ muted: Bool) { + SequenceTrack.api.pointee.setMuted.unsafelyUnwrapped(pointer, muted ? 1 : 0) + } + } +} diff --git a/Sources/PlaydateKit/Sound/Synth/Classes/Synth.swift b/Sources/PlaydateKit/Sound/Synth/Classes/Synth.swift new file mode 100644 index 0000000..2c3ef26 --- /dev/null +++ b/Sources/PlaydateKit/Sound/Synth/Classes/Synth.swift @@ -0,0 +1,215 @@ +internal import CPlaydate + +extension Sound { + /// A synthesizer voice. Wraps `PDSynth`. + public final class Synth: Source { + private static var api: UnsafePointer { Playdate.synthAPI.unsafelyUnwrapped } + + private final class GeneratorBox { + let generator: Generator + let stereo: Bool + init(_ generator: Generator, stereo: Bool) { + self.generator = generator + self.stereo = stereo + } + } + + private var retainedSample: AudioSample? + private var retainedModulators: [SignalValue] = [] + + override init(pointer: OpaquePointer?, isOwned: Bool) { + super.init(pointer: pointer, isOwned: isOwned) + } + + public convenience init() { + self.init(pointer: Synth.api.pointee.newSynth.unsafelyUnwrapped().unsafelyUnwrapped, + isOwned: true) + } + + public convenience init(waveform: Waveform) { + self.init() + setWaveform(waveform) + } + + deinit { + if isOwned { + Synth.api.pointee.freeSynth.unsafelyUnwrapped(pointer) + } + } + + /// Copies the synth (and its generator, if any). + public func copy() -> Synth { + Synth(pointer: Synth.api.pointee.copy.unsafelyUnwrapped(pointer).unsafelyUnwrapped, + isOwned: true) + } + + // MARK: Sound generation + + public func setWaveform(_ waveform: Waveform) { + 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. + 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). + public func setWavetable(_ sample: AudioSample, log2size: Int, + columns: Int, rows: Int) throws(PlaydateError) { + retainedSample = sample + guard Synth.api.pointee.setWavetable.unsafelyUnwrapped( + pointer, sample.pointer, Int32(log2size), Int32(columns), Int32(rows)) != 0 else { + throw PlaydateError(message: "invalid wavetable dimensions") + } + } + + /// Provides audio via custom Swift callbacks. + public func setGenerator(stereo: Bool, _ generator: Generator) { + let box = Unmanaged.passRetained(GeneratorBox(generator, stereo: stereo)) + Synth.api.pointee.setGenerator.unsafelyUnwrapped( + pointer, stereo ? 1 : 0, + { userdata, left, right, nsamples, rate, drate in + guard let userdata, let left else { return 0 } + let box = Unmanaged.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)) + }, + { userdata, note, velocity, length in + guard let userdata else { return } + let box = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + box.generator.noteOn?(note, velocity, length) + }, + { userdata, stop in + guard let userdata else { return } + let box = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + box.generator.release?(stop != 0) + }, + { userdata, parameter, value in + guard let userdata else { return 0 } + let box = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + return box.generator.setParameter?(Int(parameter), value) == true ? 1 : 0 + }, + { userdata in + guard let userdata else { return } + Unmanaged.fromOpaque(userdata).release() + }, + { userdata in + guard let userdata else { return nil } + let box = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + return Unmanaged.passRetained(GeneratorBox(box.generator, stereo: box.stereo)).toOpaque() + }, + box.toOpaque()) + } + + // MARK: Envelope + + public func setAttackTime(_ attack: Float) { + Synth.api.pointee.setAttackTime.unsafelyUnwrapped(pointer, attack) + } + + public func setDecayTime(_ decay: Float) { + Synth.api.pointee.setDecayTime.unsafelyUnwrapped(pointer, decay) + } + + public func setSustainLevel(_ sustain: Float) { + Synth.api.pointee.setSustainLevel.unsafelyUnwrapped(pointer, sustain) + } + + public func setReleaseTime(_ release: Float) { + Synth.api.pointee.setReleaseTime.unsafelyUnwrapped(pointer, release) + } + + /// The synth's amplitude envelope. Owned by the synth. + 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 + + /// Transposes played notes by `halfSteps` (fractional values allowed). + public func setTranspose(_ halfSteps: Float) { + Synth.api.pointee.setTranspose.unsafelyUnwrapped(pointer, halfSteps) + } + + public var frequencyModulator: SignalValue? { + get { SignalValue.wrap(Synth.api.pointee.getFrequencyModulator.unsafelyUnwrapped(pointer)) } + set { + retain(newValue) + Synth.api.pointee.setFrequencyModulator.unsafelyUnwrapped(pointer, newValue?.pointer) + } + } + + public var amplitudeModulator: SignalValue? { + get { SignalValue.wrap(Synth.api.pointee.getAmplitudeModulator.unsafelyUnwrapped(pointer)) } + set { + retain(newValue) + Synth.api.pointee.setAmplitudeModulator.unsafelyUnwrapped(pointer, newValue?.pointer) + } + } + + /// The number of parameters the synth's generator supports. + public var parameterCount: Int { + Int(Synth.api.pointee.getParameterCount.unsafelyUnwrapped(pointer)) + } + + /// Sets a generator parameter. Returns `false` if the parameter is + /// invalid. + @discardableResult + public func setParameter(_ parameter: Int, value: Float) -> Bool { + Synth.api.pointee.setParameter.unsafelyUnwrapped(pointer, Int32(parameter), value) != 0 + } + + public func setParameterModulator(_ parameter: Int, _ modulator: SignalValue?) { + retain(modulator) + Synth.api.pointee.setParameterModulator.unsafelyUnwrapped(pointer, Int32(parameter), + modulator?.pointer) + } + + public func parameterModulator(_ parameter: Int) -> SignalValue? { + SignalValue.wrap(Synth.api.pointee.getParameterModulator.unsafelyUnwrapped(pointer, Int32(parameter))) + } + + private func retain(_ modulator: SignalValue?) { + if let modulator, !retainedModulators.contains(where: { $0 === modulator }) { + retainedModulators.append(modulator) + } + } + + // 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. + 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. + 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. + public func noteOff(when: UInt32 = 0) { + Synth.api.pointee.noteOff.unsafelyUnwrapped(pointer, when) + } + + /// Stops the synth immediately, without playing the release phase. + public func stop() { + Synth.api.pointee.stop.unsafelyUnwrapped(pointer) + } + } +} diff --git a/Sources/PlaydateKit/Sound/Synth/Enumerations/Synth.Waveform.swift b/Sources/PlaydateKit/Sound/Synth/Enumerations/Synth.Waveform.swift new file mode 100644 index 0000000..e78929f --- /dev/null +++ b/Sources/PlaydateKit/Sound/Synth/Enumerations/Synth.Waveform.swift @@ -0,0 +1,17 @@ +internal import CPlaydate + +extension Sound.Synth { + /// The synth's waveform. + public enum Waveform: UInt32, Sendable { + case square = 0 + case triangle = 1 + case sine = 2 + case noise = 3 + case sawtooth = 4 + case poPhase = 5 + case poDigital = 6 + case poVosim = 7 + + var cValue: SoundWaveform { SoundWaveform(SoundWaveform.RawValue(rawValue)) } + } +} diff --git a/Sources/PlaydateKit/Sound/Synth/Structures/Synth.Generator.swift b/Sources/PlaydateKit/Sound/Synth/Structures/Synth.Generator.swift new file mode 100644 index 0000000..492579d --- /dev/null +++ b/Sources/PlaydateKit/Sound/Synth/Structures/Synth.Generator.swift @@ -0,0 +1,32 @@ +extension Sound.Synth { + /// Custom generator callbacks. Samples are in signed Q8.24 format. + 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, + _ right: UnsafeMutableBufferPointer?, + _ rate: UInt32, _ drate: Int32) -> Int + /// Called when a note starts. `length` is -1 for indefinite notes. + public var noteOn: ((_ note: Sound.MIDINote, _ velocity: Float, _ length: Float) -> Void)? + /// Called when a note is released (`stop == false`) or stopped + /// (`stop == true`). + public var release: ((_ stop: Bool) -> Void)? + /// Sets a generator parameter. Returns `true` if the parameter is + /// valid. + public var setParameter: ((_ parameter: Int, _ value: Float) -> Bool)? + + public init(render: @escaping (_ left: UnsafeMutableBufferPointer, + _ right: UnsafeMutableBufferPointer?, + _ rate: UInt32, _ drate: Int32) -> Int, + noteOn: ((_ note: Sound.MIDINote, _ velocity: Float, _ length: Float) -> Void)? = nil, + release: ((_ stop: Bool) -> Void)? = nil, + setParameter: ((_ parameter: Int, _ value: Float) -> Bool)? = nil) { + self.render = render + self.noteOn = noteOn + self.release = release + self.setParameter = setParameter + } + } +} diff --git a/Sources/PlaydateKit/SoundEffect.swift b/Sources/PlaydateKit/SoundEffect.swift deleted file mode 100644 index 21fc5f0..0000000 --- a/Sources/PlaydateKit/SoundEffect.swift +++ /dev/null @@ -1,393 +0,0 @@ -// -// SoundEffect.swift -// SoundEffect wrappers: filters, bitcrusher, ring modulator, delay line, -// and overdrive. -// - -internal import CPlaydate - -private var effectAPI: UnsafePointer { Playdate.effectAPI.unsafelyUnwrapped } - -extension Sound { - /// An effect that processes a channel's audio: the base class of the - /// built-in effects. Wraps `SoundEffect`. - public class 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, - _ right: UnsafeMutableBufferPointer?, - _ bufferActive: Bool) -> Bool - - let pointer: OpaquePointer - let isOwned: Bool - private var retainedMixModulator: SignalValue? - private var processorBox: Unmanaged? - - final class ProcessorBox { - let processor: Processor - init(_ processor: @escaping Processor) { self.processor = processor } - } - - init(pointer: OpaquePointer, isOwned: Bool) { - self.pointer = pointer - self.isOwned = isOwned - } - - /// Creates an effect that processes audio with a Swift callback. - public init(processor: @escaping Processor) { - let box = Unmanaged.passRetained(ProcessorBox(processor)) - processorBox = box - pointer = effectAPI.pointee.newEffect.unsafelyUnwrapped({ effect, left, right, nsamples, bufactive in - guard let effect, let left, - let userdata = effectAPI.pointee.getUserdata.unsafelyUnwrapped(effect) else { return 0 } - let box = Unmanaged.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 - }, box.toOpaque()).unsafelyUnwrapped - isOwned = true - } - - deinit { - if isOwned { - effectAPI.pointee.freeEffect.unsafelyUnwrapped(pointer) - } - processorBox?.release() - } - - /// The wet/dry mix: 1 is fully processed, 0 fully dry. - public func setMix(_ level: Float) { - effectAPI.pointee.setMix.unsafelyUnwrapped(pointer, level) - } - - public var mixModulator: SignalValue? { - get { SignalValue.wrap(effectAPI.pointee.getMixModulator.unsafelyUnwrapped(pointer)) } - set { - retainedMixModulator = newValue - effectAPI.pointee.setMixModulator.unsafelyUnwrapped(pointer, newValue?.pointer) - } - } - } - - // MARK: - Two-pole filter - - /// A two-pole IIR filter. Wraps `TwoPoleFilter`. - public final class TwoPoleFilter: Effect { - private static var api: UnsafePointer { Playdate.twoPoleFilterAPI.unsafelyUnwrapped } - - public enum Kind: UInt32, Sendable { - case lowPass = 0 - case highPass = 1 - case bandPass = 2 - case notch = 3 - case peq = 4 - case lowShelf = 5 - case highShelf = 6 - - var cValue: TwoPoleFilterType { TwoPoleFilterType(TwoPoleFilterType.RawValue(rawValue)) } - } - - private var retainedFrequencyModulator: SignalValue? - private var retainedResonanceModulator: SignalValue? - - public init(kind: Kind = .lowPass) { - super.init(pointer: TwoPoleFilter.api.pointee.newFilter.unsafelyUnwrapped().unsafelyUnwrapped, - isOwned: true) - setKind(kind) - } - - deinit { - if isOwned { - TwoPoleFilter.api.pointee.freeFilter.unsafelyUnwrapped(pointer) - } - } - - public func setKind(_ kind: Kind) { - TwoPoleFilter.api.pointee.setType.unsafelyUnwrapped(pointer, kind.cValue) - } - - /// The center/corner frequency, in Hz. - public func setFrequency(_ frequency: Float) { - TwoPoleFilter.api.pointee.setFrequency.unsafelyUnwrapped(pointer, frequency) - } - - public var frequencyModulator: SignalValue? { - get { SignalValue.wrap(TwoPoleFilter.api.pointee.getFrequencyModulator.unsafelyUnwrapped(pointer)) } - set { - retainedFrequencyModulator = newValue - TwoPoleFilter.api.pointee.setFrequencyModulator.unsafelyUnwrapped(pointer, newValue?.pointer) - } - } - - /// The gain, used by PEQ and shelf filters. - public func setGain(_ gain: Float) { - TwoPoleFilter.api.pointee.setGain.unsafelyUnwrapped(pointer, gain) - } - - public func setResonance(_ resonance: Float) { - TwoPoleFilter.api.pointee.setResonance.unsafelyUnwrapped(pointer, resonance) - } - - public var resonanceModulator: SignalValue? { - get { SignalValue.wrap(TwoPoleFilter.api.pointee.getResonanceModulator.unsafelyUnwrapped(pointer)) } - set { - retainedResonanceModulator = newValue - TwoPoleFilter.api.pointee.setResonanceModulator.unsafelyUnwrapped(pointer, newValue?.pointer) - } - } - } - - // MARK: - One-pole filter - - /// A one-pole low/high-pass filter. Wraps `OnePoleFilter`. - public final class OnePoleFilter: Effect { - private static var api: UnsafePointer { Playdate.onePoleFilterAPI.unsafelyUnwrapped } - - private var retainedParameterModulator: SignalValue? - - public init() { - super.init(pointer: OnePoleFilter.api.pointee.newFilter.unsafelyUnwrapped().unsafelyUnwrapped, - isOwned: true) - } - - deinit { - if isOwned { - OnePoleFilter.api.pointee.freeFilter.unsafelyUnwrapped(pointer) - } - } - - /// The filter's cutoff: -1 to 1, where values above 0 are low-pass - /// and values below 0 high-pass. - public func setParameter(_ parameter: Float) { - OnePoleFilter.api.pointee.setParameter.unsafelyUnwrapped(pointer, parameter) - } - - public var parameterModulator: SignalValue? { - get { SignalValue.wrap(OnePoleFilter.api.pointee.getParameterModulator.unsafelyUnwrapped(pointer)) } - set { - retainedParameterModulator = newValue - OnePoleFilter.api.pointee.setParameterModulator.unsafelyUnwrapped(pointer, newValue?.pointer) - } - } - } - - // MARK: - Bit crusher - - /// A bit-crushing and downsampling effect. Wraps `BitCrusher`. - public final class BitCrusher: Effect { - private static var api: UnsafePointer { Playdate.bitCrusherAPI.unsafelyUnwrapped } - - private var retainedModulators: [SignalValue] = [] - - public init() { - super.init(pointer: BitCrusher.api.pointee.newBitCrusher.unsafelyUnwrapped().unsafelyUnwrapped, - isOwned: true) - } - - deinit { - if isOwned { - BitCrusher.api.pointee.freeBitCrusher.unsafelyUnwrapped(pointer) - } - } - - /// When `true`, `setDepth` values map exponentially to bit depth. - public func setExponential(_ flag: Bool) { - BitCrusher.api.pointee.setExponential.unsafelyUnwrapped(pointer, flag) - } - - /// The amount of crushing, 0 (none) to 1 (quantized to 1 bit). - public func setDepth(_ depth: Float) { - BitCrusher.api.pointee.setDepth.unsafelyUnwrapped(pointer, depth) - } - - public var depthModulator: SignalValue? { - get { SignalValue.wrap(BitCrusher.api.pointee.getDepthModulator.unsafelyUnwrapped(pointer)) } - set { - retain(newValue) - BitCrusher.api.pointee.setDepthModulator.unsafelyUnwrapped(pointer, newValue?.pointer) - } - } - - /// The amount of downsampling, 0 (none) to 1 (every sample repeated). - public func setDownsampling(_ downsampling: Float) { - BitCrusher.api.pointee.setDownsampling.unsafelyUnwrapped(pointer, downsampling) - } - - public var downsamplingModulator: SignalValue? { - get { SignalValue.wrap(BitCrusher.api.pointee.getDownsamplingModulator.unsafelyUnwrapped(pointer)) } - set { - retain(newValue) - BitCrusher.api.pointee.setDownsamplingModulator.unsafelyUnwrapped(pointer, newValue?.pointer) - } - } - - private func retain(_ modulator: SignalValue?) { - if let modulator { retainedModulators.append(modulator) } - } - } - - // MARK: - Ring modulator - - /// A ring modulator effect. Wraps `RingModulator`. - public final class RingModulator: Effect { - private static var api: UnsafePointer { Playdate.ringModulatorAPI.unsafelyUnwrapped } - - private var retainedFrequencyModulator: SignalValue? - - public init() { - super.init(pointer: RingModulator.api.pointee.newRingmod.unsafelyUnwrapped().unsafelyUnwrapped, - isOwned: true) - } - - deinit { - if isOwned { - RingModulator.api.pointee.freeRingmod.unsafelyUnwrapped(pointer) - } - } - - /// The modulation frequency, in Hz. - public func setFrequency(_ frequency: Float) { - RingModulator.api.pointee.setFrequency.unsafelyUnwrapped(pointer, frequency) - } - - public var frequencyModulator: SignalValue? { - get { SignalValue.wrap(RingModulator.api.pointee.getFrequencyModulator.unsafelyUnwrapped(pointer)) } - set { - retainedFrequencyModulator = newValue - RingModulator.api.pointee.setFrequencyModulator.unsafelyUnwrapped(pointer, newValue?.pointer) - } - } - } - - // MARK: - Delay line - - /// A tap into a delay line; produces audio and can be added to a channel - /// as a source. Wraps `DelayLineTap`. - public final class DelayLineTap: Source { - private static var api: UnsafePointer { Playdate.delayLineAPI.unsafelyUnwrapped } - - /// The delay line is retained so the tap stays valid. - private let delayLine: DelayLine - private var retainedDelayModulator: SignalValue? - - init(pointer: OpaquePointer, delayLine: DelayLine) { - self.delayLine = delayLine - super.init(pointer: pointer, isOwned: true) - } - - deinit { - DelayLineTap.api.pointee.freeTap.unsafelyUnwrapped(pointer) - } - - /// The tap's position in the delay line, in frames. - public func setDelay(frames: Int) { - DelayLineTap.api.pointee.setTapDelay.unsafelyUnwrapped(pointer, Int32(frames)) - } - - public var delayModulator: SignalValue? { - get { SignalValue.wrap(DelayLineTap.api.pointee.getTapDelayModulator.unsafelyUnwrapped(pointer)) } - set { - retainedDelayModulator = newValue - DelayLineTap.api.pointee.setTapDelayModulator.unsafelyUnwrapped(pointer, newValue?.pointer) - } - } - - /// For stereo delay lines: swaps the left and right channels. - public func setChannelsFlipped(_ flipped: Bool) { - DelayLineTap.api.pointee.setTapChannelsFlipped.unsafelyUnwrapped(pointer, flipped ? 1 : 0) - } - } - - /// A delay line effect. Wraps `DelayLine`. - public final class DelayLine: Effect { - private static var api: UnsafePointer { Playdate.delayLineAPI.unsafelyUnwrapped } - - /// Creates a delay line holding `length` frames. - public init(length: Int, stereo: Bool = false) { - super.init(pointer: DelayLine.api.pointee.newDelayLine.unsafelyUnwrapped( - Int32(length), stereo ? 1 : 0).unsafelyUnwrapped, isOwned: true) - } - - deinit { - if isOwned { - DelayLine.api.pointee.freeDelayLine.unsafelyUnwrapped(pointer) - } - } - - /// Changes the delay length. Cannot be larger than the line's - /// original length. - public func setLength(frames: Int) { - DelayLine.api.pointee.setLength.unsafelyUnwrapped(pointer, Int32(frames)) - } - - /// The feedback level, 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. - public func addTap(delay: Int) -> DelayLineTap? { - guard let tap = DelayLine.api.pointee.addTap.unsafelyUnwrapped(pointer, Int32(delay)) else { - return nil - } - return DelayLineTap(pointer: tap, delayLine: self) - } - } - - // MARK: - Overdrive - - /// An overdrive/distortion effect. Wraps `Overdrive`. - public final class Overdrive: Effect { - private static var api: UnsafePointer { Playdate.overdriveAPI.unsafelyUnwrapped } - - private var retainedModulators: [SignalValue] = [] - - public init() { - super.init(pointer: Overdrive.api.pointee.newOverdrive.unsafelyUnwrapped().unsafelyUnwrapped, - isOwned: true) - } - - deinit { - if isOwned { - Overdrive.api.pointee.freeOverdrive.unsafelyUnwrapped(pointer) - } - } - - /// The input gain applied before clipping. - public func setGain(_ gain: Float) { - Overdrive.api.pointee.setGain.unsafelyUnwrapped(pointer, gain) - } - - /// The level where the amplified input clips. - public func setLimit(_ limit: Float) { - Overdrive.api.pointee.setLimit.unsafelyUnwrapped(pointer, limit) - } - - public var limitModulator: SignalValue? { - get { SignalValue.wrap(Overdrive.api.pointee.getLimitModulator.unsafelyUnwrapped(pointer)) } - set { - retain(newValue) - Overdrive.api.pointee.setLimitModulator.unsafelyUnwrapped(pointer, newValue?.pointer) - } - } - - /// A DC offset applied to the input, making the clipping asymmetric. - public func setOffset(_ offset: Float) { - Overdrive.api.pointee.setOffset.unsafelyUnwrapped(pointer, offset) - } - - public var offsetModulator: SignalValue? { - get { SignalValue.wrap(Overdrive.api.pointee.getOffsetModulator.unsafelyUnwrapped(pointer)) } - set { - retain(newValue) - Overdrive.api.pointee.setOffsetModulator.unsafelyUnwrapped(pointer, newValue?.pointer) - } - } - - private func retain(_ modulator: SignalValue?) { - if let modulator { retainedModulators.append(modulator) } - } - } -} diff --git a/Sources/PlaydateKit/SoundSignal.swift b/Sources/PlaydateKit/SoundSignal.swift deleted file mode 100644 index 58e042b..0000000 --- a/Sources/PlaydateKit/SoundSignal.swift +++ /dev/null @@ -1,345 +0,0 @@ -// -// SoundSignal.swift -// Signal wrappers: PDSynthSignalValue, PDSynthSignal, PDSynthLFO, -// PDSynthEnvelope, and ControlSignal. -// - -internal import CPlaydate - -extension Sound { - /// A value that can modulate a parameter. The base class of `Signal`, - /// `LFO`, `Envelope`, and `ControlSignal`. Wraps `PDSynthSignalValue`. - public class SignalValue { - let pointer: OpaquePointer - let isOwned: Bool - - init(pointer: OpaquePointer, isOwned: Bool) { - self.pointer = pointer - self.isOwned = isOwned - } - - /// Wraps a signal value pointer returned by the OS (not owned). - static func wrap(_ pointer: OpaquePointer?) -> SignalValue? { - guard let pointer else { return nil } - return SignalValue(pointer: pointer, isOwned: false) - } - } - - /// A signal object; also provides custom signals driven by Swift - /// callbacks. Wraps `PDSynthSignal`. - public final class Signal: SignalValue { - private static var api: UnsafePointer { Playdate.signalAPI.unsafelyUnwrapped } - - /// Custom signal callbacks. - 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`. - public var step: (_ ioFrames: UnsafeMutablePointer?, - _ interpolationValue: UnsafeMutablePointer?) -> Float - /// Called on note-on events. `length` is -1 for indefinite notes. - public var noteOn: ((_ note: 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. - public var noteOff: ((_ stopped: Bool, _ offset: Int) -> Void)? - - public init(step: @escaping (_ ioFrames: UnsafeMutablePointer?, - _ interpolationValue: UnsafeMutablePointer?) -> Float, - noteOn: ((_ note: MIDINote, _ velocity: Float, _ length: Float) -> Void)? = nil, - noteOff: ((_ stopped: Bool, _ offset: Int) -> Void)? = nil) { - self.step = step - self.noteOn = noteOn - self.noteOff = noteOff - } - } - - private final class Box { - let callbacks: Callbacks - init(_ callbacks: Callbacks) { self.callbacks = callbacks } - } - - /// Creates a signal driven by the given callbacks. - public init(callbacks: Callbacks) { - let box = Unmanaged.passRetained(Box(callbacks)) - let pointer = Signal.api.pointee.newSignal.unsafelyUnwrapped( - { userdata, ioFrames, interpolationValue in - guard let userdata else { return 0 } - let box = Unmanaged.fromOpaque(userdata).takeUnretainedValue() - return box.callbacks.step(ioFrames, interpolationValue) - }, - { userdata, note, velocity, length in - guard let userdata else { return } - let box = Unmanaged.fromOpaque(userdata).takeUnretainedValue() - box.callbacks.noteOn?(note, velocity, length) - }, - { userdata, stopped, offset in - guard let userdata else { return } - let box = Unmanaged.fromOpaque(userdata).takeUnretainedValue() - box.callbacks.noteOff?(stopped != 0, Int(offset)) - }, - { userdata in - guard let userdata else { return } - Unmanaged.fromOpaque(userdata).release() - }, - box.toOpaque()) - super.init(pointer: pointer.unsafelyUnwrapped, isOwned: true) - } - - /// Creates a plain signal object wrapping an existing signal value, - /// so it can be scaled and offset. - public init(value: SignalValue) { - let pointer = Signal.api.pointee.newSignalForValue.unsafelyUnwrapped(value.pointer) - super.init(pointer: pointer.unsafelyUnwrapped, isOwned: true) - } - - override init(pointer: OpaquePointer, isOwned: Bool) { - super.init(pointer: pointer, isOwned: isOwned) - } - - deinit { - if isOwned { - Signal.api.pointee.freeSignal.unsafelyUnwrapped(pointer) - } - } - - /// The signal's current value. - public var value: Float { - Signal.api.pointee.getValue.unsafelyUnwrapped(pointer) - } - - /// Scales the signal's output. - 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) - } - } - - // MARK: - LFO - - /// A low-frequency oscillator signal. Wraps `PDSynthLFO`. - public final class LFO: SignalValue { - private static var api: UnsafePointer { Playdate.lfoAPI.unsafelyUnwrapped } - - /// The oscillator's waveform. - public enum Shape: UInt32, Sendable { - case square = 0 - case triangle = 1 - case sine = 2 - case sampleAndHold = 3 - case sawtoothUp = 4 - case sawtoothDown = 5 - case arpeggiator = 6 - case function = 7 - - var cValue: LFOType { LFOType(LFOType.RawValue(rawValue)) } - } - - var function: ((LFO) -> Float)? - - public init(shape: Shape = .sine) { - let pointer = LFO.api.pointee.newLFO.unsafelyUnwrapped(shape.cValue) - super.init(pointer: pointer.unsafelyUnwrapped, isOwned: true) - } - - deinit { - if isOwned { - LFO.api.pointee.freeLFO.unsafelyUnwrapped(pointer) - } - } - - public func setShape(_ shape: Shape) { - LFO.api.pointee.setType.unsafelyUnwrapped(pointer, shape.cValue) - } - - /// The LFO rate, in cycles per second. - public func setRate(_ rate: Float) { - LFO.api.pointee.setRate.unsafelyUnwrapped(pointer, rate) - } - - /// The current phase, 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. - 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. - 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. - public func setArpeggiation(_ steps: [Float]) { - var steps = steps - steps.withUnsafeMutableBufferPointer { buffer in - LFO.api.pointee.setArpeggiation.unsafelyUnwrapped(pointer, Int32(buffer.count), - buffer.baseAddress) - } - } - - /// For `.function` LFOs: the Swift function providing the value. If - /// `interpolate` is `true`, values are interpolated between calls. - public func setFunction(interpolate: Bool = false, _ function: @escaping (LFO) -> Float) { - self.function = function - LFO.api.pointee.setFunction.unsafelyUnwrapped(pointer, { _, userdata in - guard let userdata else { return 0 } - let lfo = Unmanaged.fromOpaque(userdata).takeUnretainedValue() - return lfo.function?(lfo) ?? 0 - }, Unmanaged.passUnretained(self).toOpaque(), interpolate ? 1 : 0) - } - - /// Waits `holdoff` seconds after a note starts, then ramps the LFO - /// depth up 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. - public func setRetrigger(_ flag: Bool) { - LFO.api.pointee.setRetrigger.unsafelyUnwrapped(pointer, flag ? 1 : 0) - } - - /// When `true`, the LFO runs globally instead of per-note. - public func setGlobal(_ global: Bool) { - LFO.api.pointee.setGlobal.unsafelyUnwrapped(pointer, global ? 1 : 0) - } - - /// Seeds the random number generator used by `.sampleAndHold` LFOs. - public func setRandomSeed(_ seed: UInt16) { - LFO.api.pointee.setRandomSeed.unsafelyUnwrapped(pointer, seed) - } - - public var value: Float { - LFO.api.pointee.getValue.unsafelyUnwrapped(pointer) - } - } - - // MARK: - Envelope - - /// An ADSR envelope signal. Wraps `PDSynthEnvelope`. - public final class Envelope: SignalValue { - private static var api: UnsafePointer { Playdate.envelopeAPI.unsafelyUnwrapped } - - /// Creates an envelope with the given attack and decay times - /// (seconds), sustain level (0...1), and release time (seconds). - 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) - } - - override init(pointer: OpaquePointer, isOwned: Bool) { - super.init(pointer: pointer, isOwned: isOwned) - } - - deinit { - if isOwned { - Envelope.api.pointee.freeEnvelope.unsafelyUnwrapped(pointer) - } - } - - public func setAttack(_ attack: Float) { - Envelope.api.pointee.setAttack.unsafelyUnwrapped(pointer, attack) - } - - public func setDecay(_ decay: Float) { - Envelope.api.pointee.setDecay.unsafelyUnwrapped(pointer, decay) - } - - public func setSustain(_ sustain: Float) { - Envelope.api.pointee.setSustain.unsafelyUnwrapped(pointer, sustain) - } - - 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. - 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. - 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. - public func setCurvature(_ amount: Float) { - Envelope.api.pointee.setCurvature.unsafelyUnwrapped(pointer, amount) - } - - /// How much note velocity scales the envelope's output. - 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). - public func setRateScaling(_ scaling: Float, start: MIDINote, end: MIDINote) { - Envelope.api.pointee.setRateScaling.unsafelyUnwrapped(pointer, scaling, start, end) - } - - public var value: Float { - Envelope.api.pointee.getValue.unsafelyUnwrapped(pointer) - } - } - - // MARK: - ControlSignal - - /// A signal whose values are set on a sequence timeline. Wraps - /// `ControlSignal`. - public final class ControlSignal: SignalValue { - private static var api: UnsafePointer { Playdate.controlSignalAPI.unsafelyUnwrapped } - - public init() { - let pointer = ControlSignal.api.pointee.newSignal.unsafelyUnwrapped() - super.init(pointer: pointer.unsafelyUnwrapped, isOwned: true) - } - - override init(pointer: OpaquePointer, isOwned: Bool) { - super.init(pointer: pointer, isOwned: isOwned) - } - - deinit { - if isOwned { - ControlSignal.api.pointee.freeSignal.unsafelyUnwrapped(pointer) - } - } - - 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. - public func addEvent(step: Int, value: Float, interpolate: Bool = false) { - ControlSignal.api.pointee.addEvent.unsafelyUnwrapped(pointer, Int32(step), value, - interpolate ? 1 : 0) - } - - public func removeEvent(step: Int) { - ControlSignal.api.pointee.removeEvent.unsafelyUnwrapped(pointer, Int32(step)) - } - - /// The MIDI controller number for signals loaded from a MIDI file. - public var midiControllerNumber: Int { - Int(ControlSignal.api.pointee.getMIDIControllerNumber.unsafelyUnwrapped(pointer)) - } - } -} diff --git a/Sources/PlaydateKit/SoundSource.swift b/Sources/PlaydateKit/SoundSource.swift deleted file mode 100644 index e484c2b..0000000 --- a/Sources/PlaydateKit/SoundSource.swift +++ /dev/null @@ -1,436 +0,0 @@ -// -// SoundSource.swift -// SoundSource, FilePlayer, AudioSample, and SamplePlayer wrappers. -// - -internal import CPlaydate - -extension Sound { - /// A source of audio: the base class of `FilePlayer`, `SamplePlayer`, - /// `Synth`, `DelayLineTap`, and `CallbackSource`. Wraps `SoundSource`. - public class Source { - private static var api: UnsafePointer { Playdate.sourceAPI.unsafelyUnwrapped } - - /// The underlying C object. Set once, immediately after creation. - var pointer: OpaquePointer! - let isOwned: Bool - var finishCallback: ((Source) -> Void)? - - init(pointer: OpaquePointer?, isOwned: Bool) { - self.pointer = pointer - self.isOwned = isOwned - } - - /// Sets the playback volume for the left and right channels, 0...1. - public func setVolume(left: Float, right: Float) { - Source.api.pointee.setVolume.unsafelyUnwrapped(pointer, left, right) - } - - /// Sets the playback volume of both channels. - public func setVolume(_ volume: Float) { - setVolume(left: volume, right: volume) - } - - /// The playback volume of the left and right channels. - public var volume: (left: Float, right: Float) { - var left: Float = 0, right: Float = 0 - Source.api.pointee.getVolume.unsafelyUnwrapped(pointer, &left, &right) - return (left, right) - } - - public var isPlaying: Bool { - Source.api.pointee.isPlaying.unsafelyUnwrapped(pointer) != 0 - } - - /// Sets a function called when the source finishes playing. - public func setFinishCallback(_ callback: ((Source) -> Void)?) { - finishCallback = callback - if callback != nil { - Source.api.pointee.setFinishCallback.unsafelyUnwrapped(pointer, { _, userdata in - guard let userdata else { return } - let source = Unmanaged.fromOpaque(userdata).takeUnretainedValue() - source.finishCallback?(source) - }, Unmanaged.passUnretained(self).toOpaque()) - } else { - Source.api.pointee.setFinishCallback.unsafelyUnwrapped(pointer, nil, nil) - } - } - } - - /// A source that produces audio by calling back into Swift. - public final class CallbackSource: Source { - /// Fills the sample buffers and returns `true` if output was - /// produced. `right` is non-nil only for stereo sources. - public typealias Callback = (_ left: UnsafeMutableBufferPointer, - _ right: UnsafeMutableBufferPointer?) -> Bool - - 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. - nonisolated(unsafe) static var live: [CallbackSource] = [] - - /// Releases the registration added by `adopt(pointer:)`. - static func release(_ source: Source) { - live.removeAll { $0 === source } - } - - init(callback: @escaping Callback) { - self.callback = callback - super.init(pointer: nil, isOwned: false) - } - - var contextPointer: UnsafeMutableRawPointer { - Unmanaged.passUnretained(self).toOpaque() - } - - static let trampoline: @convention(c) (UnsafeMutableRawPointer?, UnsafeMutablePointer?, - UnsafeMutablePointer?, Int32) -> Int32 = { context, left, right, length in - guard let context, let left else { return 0 } - let source = Unmanaged.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 - } - - /// Attaches the C object created for this source. - func adopt(pointer: OpaquePointer) { - self.pointer = pointer - CallbackSource.live.append(self) - } - } - - // MARK: - FilePlayer - - /// Streams audio from a file. Wraps `FilePlayer`. - public final class FilePlayer: Source { - private static var api: UnsafePointer { Playdate.filePlayerAPI.unsafelyUnwrapped } - - var loopCallback: ((FilePlayer) -> Void)? - var fadeCallback: ((FilePlayer) -> Void)? - var mp3DataSource: ((UnsafeMutableBufferPointer) -> Int)? - private var retainedRateModulator: SignalValue? - - override init(pointer: OpaquePointer?, isOwned: Bool) { - super.init(pointer: pointer, isOwned: isOwned) - } - - public convenience init() { - self.init(pointer: FilePlayer.api.pointee.newPlayer.unsafelyUnwrapped().unsafelyUnwrapped, - 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) - } - - deinit { - if isOwned { - FilePlayer.api.pointee.freePlayer.unsafelyUnwrapped(pointer) - } - } - - /// Prepares the player to stream the file at `path`. - public func load(path: String) throws(PlaydateError) { - let loaded = path.withPlaydateCString { - FilePlayer.api.pointee.loadIntoPlayer.unsafelyUnwrapped(pointer, $0) != 0 - } - if !loaded { - throw PlaydateError(message: "unable to load audio file: \(path)") - } - } - - /// Sets the length of the stream buffer, 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. - @discardableResult - public func play(repeat repeatCount: Int = 1) -> Bool { - FilePlayer.api.pointee.play.unsafelyUnwrapped(pointer, Int32(repeatCount)) != 0 - } - - public func pause() { - FilePlayer.api.pointee.pause.unsafelyUnwrapped(pointer) - } - - public func stop() { - FilePlayer.api.pointee.stop.unsafelyUnwrapped(pointer) - } - - /// The file's length in seconds. - public var length: Float { - FilePlayer.api.pointee.getLength.unsafelyUnwrapped(pointer) - } - - /// The 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. - 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. - 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. - public var didUnderrun: Bool { - FilePlayer.api.pointee.didUnderrun.unsafelyUnwrapped(pointer) != 0 - } - - /// Stops playback (instead of looping the buffer) on underrun. - public func setStopOnUnderrun(_ flag: Bool) { - FilePlayer.api.pointee.setStopOnUnderrun.unsafelyUnwrapped(pointer, flag ? 1 : 0) - } - - /// Sets a function called every time playback loops. - public func setLoopCallback(_ callback: ((FilePlayer) -> Void)?) { - loopCallback = callback - if callback != nil { - FilePlayer.api.pointee.setLoopCallback.unsafelyUnwrapped(pointer, { _, userdata in - guard let userdata else { return } - let player = Unmanaged.fromOpaque(userdata).takeUnretainedValue() - player.loopCallback?(player) - }, Unmanaged.passUnretained(self).toOpaque()) - } else { - FilePlayer.api.pointee.setLoopCallback.unsafelyUnwrapped(pointer, nil, nil) - } - } - - /// Fades the volume to the given levels over `length` sample frames, - /// then calls `completion`. - public func fadeVolume(left: Float, right: Float, length: Int32, - completion: ((FilePlayer) -> Void)? = nil) { - fadeCallback = completion - if completion != nil { - FilePlayer.api.pointee.fadeVolume.unsafelyUnwrapped(pointer, left, right, length, { _, userdata in - guard let userdata else { return } - let player = Unmanaged.fromOpaque(userdata).takeUnretainedValue() - player.fadeCallback?(player) - }, Unmanaged.passUnretained(self).toOpaque()) - } else { - FilePlayer.api.pointee.fadeVolume.unsafelyUnwrapped(pointer, left, right, length, nil, nil) - } - } - - /// 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. - public func setMP3StreamSource(bufferLength: Float, - _ dataSource: @escaping (UnsafeMutableBufferPointer) -> Int) { - mp3DataSource = dataSource - FilePlayer.api.pointee.setMP3StreamSource.unsafelyUnwrapped(pointer, { data, bytes, userdata in - guard let userdata, let data else { return 0 } - let player = Unmanaged.fromOpaque(userdata).takeUnretainedValue() - let buffer = UnsafeMutableBufferPointer(start: data, count: Int(bytes)) - return Int32(player.mp3DataSource?(buffer) ?? 0) - }, Unmanaged.passUnretained(self).toOpaque(), bufferLength) - } - - /// Modulates the playback rate. - public var rateModulator: SignalValue? { - get { SignalValue.wrap(FilePlayer.api.pointee.getRateModulator.unsafelyUnwrapped(pointer)) } - set { - retainedRateModulator = newValue - FilePlayer.api.pointee.setRateModulator.unsafelyUnwrapped(pointer, newValue?.pointer) - } - } - } - - // MARK: - AudioSample - - /// Audio data loaded into memory. Wraps `AudioSample`. - public final class AudioSample { - private static var api: UnsafePointer { Playdate.sampleAPI.unsafelyUnwrapped } - - let pointer: OpaquePointer - let isOwned: Bool - - init(pointer: OpaquePointer, isOwned: Bool) { - self.pointer = pointer - self.isOwned = isOwned - } - - /// Allocates a sample buffer with room for `byteCount` bytes. - public convenience init(byteCount: Int) { - self.init(pointer: AudioSample.api.pointee.newSampleBuffer.unsafelyUnwrapped( - Int32(byteCount)).unsafelyUnwrapped, isOwned: true) - } - - /// 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) } - 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. - public convenience init?(data: UnsafeMutablePointer, format: Format, - sampleRate: UInt32, byteCount: Int, freeWhenDone: Bool) { - guard let pointer = AudioSample.api.pointee.newSampleFromData.unsafelyUnwrapped( - data, format.cValue, sampleRate, Int32(byteCount), freeWhenDone ? 1 : 0) else { - return nil - } - self.init(pointer: pointer, isOwned: true) - } - - deinit { - if isOwned { - AudioSample.api.pointee.freeSample.unsafelyUnwrapped(pointer) - } - } - - /// Loads the file at `path` into this sample's buffer. - public func load(path: String) throws(PlaydateError) { - let loaded = path.withPlaydateCString { - AudioSample.api.pointee.loadIntoSample.unsafelyUnwrapped(pointer, $0) != 0 - } - if !loaded { - throw PlaydateError(message: "unable to load sample: \(path)") - } - } - - /// The sample's raw data, format, and rate. - public var data: (data: UnsafeMutablePointer?, format: Format, - sampleRate: UInt32, byteLength: UInt32) { - var data: UnsafeMutablePointer? - var format = kSound16bitMono - var sampleRate: UInt32 = 0, byteLength: UInt32 = 0 - AudioSample.api.pointee.getData.unsafelyUnwrapped(pointer, &data, &format, &sampleRate, &byteLength) - return (data, Format(format), sampleRate, byteLength) - } - - /// The sample's 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. - @discardableResult - public func decompress() -> Bool { - AudioSample.api.pointee.decompress.unsafelyUnwrapped(pointer) != 0 - } - } - - // MARK: - SamplePlayer - - /// Plays an `AudioSample` from memory. Wraps `SamplePlayer`. - public final class SamplePlayer: Source { - private static var api: UnsafePointer { Playdate.samplePlayerAPI.unsafelyUnwrapped } - - var loopCallback: ((SamplePlayer) -> Void)? - private var retainedSample: AudioSample? - private var retainedRateModulator: SignalValue? - - override init(pointer: OpaquePointer?, isOwned: Bool) { - super.init(pointer: pointer, isOwned: isOwned) - } - - public convenience init() { - self.init(pointer: SamplePlayer.api.pointee.newPlayer.unsafelyUnwrapped().unsafelyUnwrapped, - isOwned: true) - } - - /// Creates a player for the sample at `path`. - public convenience init(path: String) throws(PlaydateError) { - self.init() - sample = try AudioSample(path: path) - } - - deinit { - if isOwned { - SamplePlayer.api.pointee.freePlayer.unsafelyUnwrapped(pointer) - } - } - - /// The sample to play. - public var sample: AudioSample? { - get { retainedSample } - set { - retainedSample = newValue - SamplePlayer.api.pointee.setSample.unsafelyUnwrapped(pointer, newValue?.pointer) - } - } - - /// Starts playback at `rate`, looping `repeat` times; 0 loops - /// endlessly, -1 loops ping-pong. - @discardableResult - public func play(repeat repeatCount: Int = 1, rate: Float = 1) -> Bool { - SamplePlayer.api.pointee.play.unsafelyUnwrapped(pointer, Int32(repeatCount), rate) != 0 - } - - public func stop() { - SamplePlayer.api.pointee.stop.unsafelyUnwrapped(pointer) - } - - public func setPaused(_ paused: Bool) { - SamplePlayer.api.pointee.setPaused.unsafelyUnwrapped(pointer, paused ? 1 : 0) - } - - /// The sample's length in seconds. - public var length: Float { - SamplePlayer.api.pointee.getLength.unsafelyUnwrapped(pointer) - } - - /// The 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. - 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. - 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. - public func setLoopCallback(_ callback: ((SamplePlayer) -> Void)?) { - loopCallback = callback - if callback != nil { - SamplePlayer.api.pointee.setLoopCallback.unsafelyUnwrapped(pointer, { _, userdata in - guard let userdata else { return } - let player = Unmanaged.fromOpaque(userdata).takeUnretainedValue() - player.loopCallback?(player) - }, Unmanaged.passUnretained(self).toOpaque()) - } else { - SamplePlayer.api.pointee.setLoopCallback.unsafelyUnwrapped(pointer, nil, nil) - } - } - - /// Modulates the playback rate. - public var rateModulator: SignalValue? { - get { SignalValue.wrap(SamplePlayer.api.pointee.getRateModulator.unsafelyUnwrapped(pointer)) } - set { - retainedRateModulator = newValue - SamplePlayer.api.pointee.setRateModulator.unsafelyUnwrapped(pointer, newValue?.pointer) - } - } - } -} diff --git a/Sources/PlaydateKit/SoundSynth.swift b/Sources/PlaydateKit/SoundSynth.swift deleted file mode 100644 index 4811ad9..0000000 --- a/Sources/PlaydateKit/SoundSynth.swift +++ /dev/null @@ -1,614 +0,0 @@ -// -// SoundSynth.swift -// Synth, Instrument, SequenceTrack, and Sequence wrappers. -// - -internal import CPlaydate - -extension Sound { - /// A synthesizer voice. Wraps `PDSynth`. - public final class Synth: Source { - private static var api: UnsafePointer { Playdate.synthAPI.unsafelyUnwrapped } - - /// The synth's waveform. - public enum Waveform: UInt32, Sendable { - case square = 0 - case triangle = 1 - case sine = 2 - case noise = 3 - case sawtooth = 4 - case poPhase = 5 - case poDigital = 6 - case poVosim = 7 - - var cValue: SoundWaveform { SoundWaveform(SoundWaveform.RawValue(rawValue)) } - } - - /// Custom generator callbacks. Samples are in signed Q8.24 format. - 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, - _ right: UnsafeMutableBufferPointer?, - _ rate: UInt32, _ drate: Int32) -> Int - /// Called when a note starts. `length` is -1 for indefinite notes. - public var noteOn: ((_ note: MIDINote, _ velocity: Float, _ length: Float) -> Void)? - /// Called when a note is released (`stop == false`) or stopped - /// (`stop == true`). - public var release: ((_ stop: Bool) -> Void)? - /// Sets a generator parameter. Returns `true` if the parameter is - /// valid. - public var setParameter: ((_ parameter: Int, _ value: Float) -> Bool)? - - public init(render: @escaping (_ left: UnsafeMutableBufferPointer, - _ right: UnsafeMutableBufferPointer?, - _ rate: UInt32, _ drate: Int32) -> Int, - noteOn: ((_ note: MIDINote, _ velocity: Float, _ length: Float) -> Void)? = nil, - release: ((_ stop: Bool) -> Void)? = nil, - setParameter: ((_ parameter: Int, _ value: Float) -> Bool)? = nil) { - self.render = render - self.noteOn = noteOn - self.release = release - self.setParameter = setParameter - } - } - - private final class GeneratorBox { - let generator: Generator - let stereo: Bool - init(_ generator: Generator, stereo: Bool) { - self.generator = generator - self.stereo = stereo - } - } - - private var retainedSample: AudioSample? - private var retainedModulators: [SignalValue] = [] - - override init(pointer: OpaquePointer?, isOwned: Bool) { - super.init(pointer: pointer, isOwned: isOwned) - } - - public convenience init() { - self.init(pointer: Synth.api.pointee.newSynth.unsafelyUnwrapped().unsafelyUnwrapped, - isOwned: true) - } - - public convenience init(waveform: Waveform) { - self.init() - setWaveform(waveform) - } - - deinit { - if isOwned { - Synth.api.pointee.freeSynth.unsafelyUnwrapped(pointer) - } - } - - /// Copies the synth (and its generator, if any). - public func copy() -> Synth { - Synth(pointer: Synth.api.pointee.copy.unsafelyUnwrapped(pointer).unsafelyUnwrapped, - isOwned: true) - } - - // MARK: Sound generation - - public func setWaveform(_ waveform: Waveform) { - 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. - 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). - public func setWavetable(_ sample: AudioSample, log2size: Int, - columns: Int, rows: Int) throws(PlaydateError) { - retainedSample = sample - guard Synth.api.pointee.setWavetable.unsafelyUnwrapped( - pointer, sample.pointer, Int32(log2size), Int32(columns), Int32(rows)) != 0 else { - throw PlaydateError(message: "invalid wavetable dimensions") - } - } - - /// Provides audio via custom Swift callbacks. - public func setGenerator(stereo: Bool, _ generator: Generator) { - let box = Unmanaged.passRetained(GeneratorBox(generator, stereo: stereo)) - Synth.api.pointee.setGenerator.unsafelyUnwrapped( - pointer, stereo ? 1 : 0, - { userdata, left, right, nsamples, rate, drate in - guard let userdata, let left else { return 0 } - let box = Unmanaged.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)) - }, - { userdata, note, velocity, length in - guard let userdata else { return } - let box = Unmanaged.fromOpaque(userdata).takeUnretainedValue() - box.generator.noteOn?(note, velocity, length) - }, - { userdata, stop in - guard let userdata else { return } - let box = Unmanaged.fromOpaque(userdata).takeUnretainedValue() - box.generator.release?(stop != 0) - }, - { userdata, parameter, value in - guard let userdata else { return 0 } - let box = Unmanaged.fromOpaque(userdata).takeUnretainedValue() - return box.generator.setParameter?(Int(parameter), value) == true ? 1 : 0 - }, - { userdata in - guard let userdata else { return } - Unmanaged.fromOpaque(userdata).release() - }, - { userdata in - guard let userdata else { return nil } - let box = Unmanaged.fromOpaque(userdata).takeUnretainedValue() - return Unmanaged.passRetained(GeneratorBox(box.generator, stereo: box.stereo)).toOpaque() - }, - box.toOpaque()) - } - - // MARK: Envelope - - public func setAttackTime(_ attack: Float) { - Synth.api.pointee.setAttackTime.unsafelyUnwrapped(pointer, attack) - } - - public func setDecayTime(_ decay: Float) { - Synth.api.pointee.setDecayTime.unsafelyUnwrapped(pointer, decay) - } - - public func setSustainLevel(_ sustain: Float) { - Synth.api.pointee.setSustainLevel.unsafelyUnwrapped(pointer, sustain) - } - - public func setReleaseTime(_ release: Float) { - Synth.api.pointee.setReleaseTime.unsafelyUnwrapped(pointer, release) - } - - /// The synth's amplitude envelope. Owned by the synth. - 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 - - /// Transposes played notes by `halfSteps` (fractional values allowed). - public func setTranspose(_ halfSteps: Float) { - Synth.api.pointee.setTranspose.unsafelyUnwrapped(pointer, halfSteps) - } - - public var frequencyModulator: SignalValue? { - get { SignalValue.wrap(Synth.api.pointee.getFrequencyModulator.unsafelyUnwrapped(pointer)) } - set { - retain(newValue) - Synth.api.pointee.setFrequencyModulator.unsafelyUnwrapped(pointer, newValue?.pointer) - } - } - - public var amplitudeModulator: SignalValue? { - get { SignalValue.wrap(Synth.api.pointee.getAmplitudeModulator.unsafelyUnwrapped(pointer)) } - set { - retain(newValue) - Synth.api.pointee.setAmplitudeModulator.unsafelyUnwrapped(pointer, newValue?.pointer) - } - } - - /// The number of parameters the synth's generator supports. - public var parameterCount: Int { - Int(Synth.api.pointee.getParameterCount.unsafelyUnwrapped(pointer)) - } - - /// Sets a generator parameter. Returns `false` if the parameter is - /// invalid. - @discardableResult - public func setParameter(_ parameter: Int, value: Float) -> Bool { - Synth.api.pointee.setParameter.unsafelyUnwrapped(pointer, Int32(parameter), value) != 0 - } - - public func setParameterModulator(_ parameter: Int, _ modulator: SignalValue?) { - retain(modulator) - Synth.api.pointee.setParameterModulator.unsafelyUnwrapped(pointer, Int32(parameter), - modulator?.pointer) - } - - public func parameterModulator(_ parameter: Int) -> SignalValue? { - SignalValue.wrap(Synth.api.pointee.getParameterModulator.unsafelyUnwrapped(pointer, Int32(parameter))) - } - - private func retain(_ modulator: SignalValue?) { - if let modulator, !retainedModulators.contains(where: { $0 === modulator }) { - retainedModulators.append(modulator) - } - } - - // 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. - 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. - 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. - public func noteOff(when: UInt32 = 0) { - Synth.api.pointee.noteOff.unsafelyUnwrapped(pointer, when) - } - - /// Stops the synth immediately, without playing the release phase. - public func stop() { - Synth.api.pointee.stop.unsafelyUnwrapped(pointer) - } - } - - // MARK: - Instrument - - /// A bank of synth voices for playing a sequence track. Wraps - /// `PDSynthInstrument`. - public final class Instrument { - private static var api: UnsafePointer { Playdate.instrumentAPI.unsafelyUnwrapped } - - let pointer: OpaquePointer - let isOwned: Bool - private var retainedVoices: [Synth] = [] - - init(pointer: OpaquePointer, isOwned: Bool) { - self.pointer = pointer - self.isOwned = isOwned - } - - public convenience init() { - self.init(pointer: Instrument.api.pointee.newInstrument.unsafelyUnwrapped().unsafelyUnwrapped, - isOwned: true) - } - - deinit { - if isOwned { - Instrument.api.pointee.freeInstrument.unsafelyUnwrapped(pointer) - } - } - - /// Adds a voice to the instrument, handling notes in - /// `rangeStart...rangeEnd` (0...127 handles all notes), transposed by - /// `transpose` half-steps. - @discardableResult - public func addVoice(_ synth: Synth, rangeStart: MIDINote = 0, rangeEnd: MIDINote = 127, - transpose: Float = 0) -> Bool { - let added = Instrument.api.pointee.addVoice.unsafelyUnwrapped( - pointer, synth.pointer, rangeStart, rangeEnd, transpose) != 0 - if added, !retainedVoices.contains(where: { $0 === synth }) { - retainedVoices.append(synth) - } - return added - } - - /// Plays a note at `frequency` Hz on an available voice. Returns the - /// synth used, if any. - @discardableResult - public func playNote(frequency: Float, velocity: Float = 1, - length: Float? = nil, when: UInt32 = 0) -> Synth? { - let synth = Instrument.api.pointee.playNote.unsafelyUnwrapped( - pointer, frequency, velocity, length ?? -1, when) - return voice(for: synth) - } - - /// Plays a MIDI note on an available voice. Returns the synth used. - @discardableResult - public func playMIDINote(_ note: MIDINote, velocity: Float = 1, - length: Float? = nil, when: UInt32 = 0) -> Synth? { - let synth = Instrument.api.pointee.playMIDINote.unsafelyUnwrapped( - pointer, note, velocity, length ?? -1, when) - return voice(for: synth) - } - - private func voice(for pointer: OpaquePointer?) -> Synth? { - guard let pointer else { return nil } - if let voice = retainedVoices.first(where: { $0.pointer == pointer }) { - return voice - } - return Synth(pointer: pointer, isOwned: false) - } - - /// Bends played notes by `bend` × the pitch bend range. - public func setPitchBend(_ bend: Float) { - Instrument.api.pointee.setPitchBend.unsafelyUnwrapped(pointer, bend) - } - - public func setPitchBendRange(halfSteps: Float) { - Instrument.api.pointee.setPitchBendRange.unsafelyUnwrapped(pointer, halfSteps) - } - - public func setTranspose(halfSteps: Float) { - Instrument.api.pointee.setTranspose.unsafelyUnwrapped(pointer, halfSteps) - } - - /// Releases the voice playing `note` at time `when` (0 = now). - public func noteOff(_ note: MIDINote, when: UInt32 = 0) { - Instrument.api.pointee.noteOff.unsafelyUnwrapped(pointer, note, when) - } - - public func allNotesOff(when: UInt32 = 0) { - Instrument.api.pointee.allNotesOff.unsafelyUnwrapped(pointer, when) - } - - public func setVolume(left: Float, right: Float) { - Instrument.api.pointee.setVolume.unsafelyUnwrapped(pointer, left, right) - } - - public var volume: (left: Float, right: Float) { - var left: Float = 0, right: Float = 0 - Instrument.api.pointee.getVolume.unsafelyUnwrapped(pointer, &left, &right) - return (left, right) - } - - public var activeVoiceCount: Int { - Int(Instrument.api.pointee.activeVoiceCount.unsafelyUnwrapped(pointer)) - } - } - - // MARK: - SequenceTrack - - /// A track of notes played by an instrument. Wraps `SequenceTrack`. - public final class SequenceTrack { - private static var api: UnsafePointer { Playdate.trackAPI.unsafelyUnwrapped } - - let pointer: OpaquePointer - let isOwned: Bool - private var retainedInstrument: Instrument? - - init(pointer: OpaquePointer, isOwned: Bool) { - self.pointer = pointer - self.isOwned = isOwned - } - - public convenience init() { - self.init(pointer: SequenceTrack.api.pointee.newTrack.unsafelyUnwrapped().unsafelyUnwrapped, - isOwned: true) - } - - deinit { - if isOwned { - SequenceTrack.api.pointee.freeTrack.unsafelyUnwrapped(pointer) - } - } - - /// The instrument that plays this track's notes. - public var instrument: Instrument? { - get { - if let retainedInstrument { return retainedInstrument } - guard let instrument = SequenceTrack.api.pointee.getInstrument.unsafelyUnwrapped(pointer) else { - return nil - } - return Instrument(pointer: instrument, isOwned: false) - } - set { - retainedInstrument = newValue - SequenceTrack.api.pointee.setInstrument.unsafelyUnwrapped(pointer, newValue?.pointer) - } - } - - /// Adds a note starting at `step`, lasting `length` steps. - public func addNote(step: UInt32, length: UInt32, note: MIDINote, velocity: Float = 1) { - SequenceTrack.api.pointee.addNoteEvent.unsafelyUnwrapped(pointer, step, length, note, velocity) - } - - public func removeNote(step: UInt32, note: MIDINote) { - SequenceTrack.api.pointee.removeNoteEvent.unsafelyUnwrapped(pointer, step, note) - } - - public func clearNotes() { - SequenceTrack.api.pointee.clearNotes.unsafelyUnwrapped(pointer) - } - - /// The track's length in steps, including the tail of the last note. - public var length: UInt32 { - SequenceTrack.api.pointee.getLength.unsafelyUnwrapped(pointer) - } - - /// The index of the first note at or after `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 - var note: MIDINote = 0 - var velocity: Float = 0 - guard SequenceTrack.api.pointee.getNoteAtIndex.unsafelyUnwrapped( - pointer, Int32(index), &step, &length, ¬e, &velocity) != 0 else { return nil } - 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. - 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) - } - - public func clearControlEvents() { - SequenceTrack.api.pointee.clearControlEvents.unsafelyUnwrapped(pointer) - } - - /// The maximum number of simultaneous notes in the track. - public var polyphony: Int { - Int(SequenceTrack.api.pointee.getPolyphony.unsafelyUnwrapped(pointer)) - } - - public var activeVoiceCount: Int { - Int(SequenceTrack.api.pointee.activeVoiceCount.unsafelyUnwrapped(pointer)) - } - - public func setMuted(_ muted: Bool) { - SequenceTrack.api.pointee.setMuted.unsafelyUnwrapped(pointer, muted ? 1 : 0) - } - } - - // MARK: - Sequence - - /// A collection of tracks with tempo and loop control, playable from a - /// MIDI file. Wraps `SoundSequence`. - public final class Sequence { - private static var api: UnsafePointer { Playdate.sequenceAPI.unsafelyUnwrapped } - - let pointer: OpaquePointer - private var retainedTracks: [SequenceTrack] = [] - var finishCallback: ((Sequence) -> Void)? - - public init() { - pointer = Sequence.api.pointee.newSequence.unsafelyUnwrapped().unsafelyUnwrapped - } - - /// Creates a sequence and loads the MIDI file at `path`. - public convenience init(midiFilePath: String) throws(PlaydateError) { - self.init() - try loadMIDIFile(path: midiFilePath) - } - - deinit { - Sequence.api.pointee.freeSequence.unsafelyUnwrapped(pointer) - } - - public func loadMIDIFile(path: String) throws(PlaydateError) { - let loaded = path.withPlaydateCString { - Sequence.api.pointee.loadMIDIFile.unsafelyUnwrapped(pointer, $0) != 0 - } - if !loaded { - throw PlaydateError(message: "unable to load MIDI file: \(path)") - } - } - - /// Starts playback. `completion` is called when the sequence finishes. - public func play(completion: ((Sequence) -> Void)? = nil) { - finishCallback = completion - if completion != nil { - Sequence.api.pointee.play.unsafelyUnwrapped(pointer, { _, userdata in - guard let userdata else { return } - let sequence = Unmanaged.fromOpaque(userdata).takeUnretainedValue() - sequence.finishCallback?(sequence) - }, Unmanaged.passUnretained(self).toOpaque()) - } else { - Sequence.api.pointee.play.unsafelyUnwrapped(pointer, nil, nil) - } - } - - public func stop() { - Sequence.api.pointee.stop.unsafelyUnwrapped(pointer) - } - - public var isPlaying: Bool { - Sequence.api.pointee.isPlaying.unsafelyUnwrapped(pointer) != 0 - } - - /// The playback position, in samples. - 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. - 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. - public var length: UInt32 { - Sequence.api.pointee.getLength.unsafelyUnwrapped(pointer) - } - - /// Loops the range `loopStart.. SequenceTrack { - let track = SequenceTrack( - pointer: Sequence.api.pointee.addTrack.unsafelyUnwrapped(pointer).unsafelyUnwrapped, - isOwned: false) - retainedTracks.append(track) - 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) - } - 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) - } - } -} diff --git a/Sources/PlaydateKit/Sprite.swift b/Sources/PlaydateKit/Sprite/Classes/Sprite.swift similarity index 85% rename from Sources/PlaydateKit/Sprite.swift rename to Sources/PlaydateKit/Sprite/Classes/Sprite.swift index f89bb8e..aad29cd 100644 --- a/Sources/PlaydateKit/Sprite.swift +++ b/Sources/PlaydateKit/Sprite/Classes/Sprite.swift @@ -1,38 +1,7 @@ -// -// Sprite.swift -// Wraps `playdate->sprite` (pd_api_sprite.h). -// -// The binding stores a back-reference to each `Sprite` wrapper in the -// underlying `LCDSprite`'s userdata slot, so callbacks and queries can -// recover the wrapper. Do not mix these wrappers with C code that sets its -// own sprite userdata; use `Sprite.userdata` for per-sprite storage instead. -// - internal import CPlaydate private var spriteAPI: UnsafePointer { Playdate.spriteAPI.unsafelyUnwrapped } -/// A floating-point rectangle mirroring `PDRect`. -public struct Rect: Sendable { - public var x: Float - public var y: Float - public var width: Float - public var height: Float - - public init(x: Float, y: Float, width: Float, height: Float) { - self.x = x - self.y = y - self.width = width - self.height = height - } - - init(_ rect: PDRect) { - self.init(x: rect.x, y: rect.y, width: rect.width, height: rect.height) - } - - var cValue: PDRect { PDRect(x: x, y: y, width: width, height: height) } -} - /// A sprite: a drawable object with position, z-order, and collision /// support. Wraps `LCDSprite`. Static members wrap the global sprite /// system functions. @@ -102,79 +71,6 @@ public final class Sprite { return copy } - // MARK: - Types - - /// How a sprite reacts when a collision occurs. - public enum CollisionResponse: UInt32, Sendable { - case slide = 0 - case freeze = 1 - case overlap = 2 - case bounce = 3 - - init(_ response: SpriteCollisionResponseType) { - self = CollisionResponse(rawValue: UInt32(response.rawValue)) ?? .freeze - } - var cValue: SpriteCollisionResponseType { SpriteCollisionResponseType(SpriteCollisionResponseType.RawValue(rawValue)) } - } - - /// Information about a single collision, mirroring `SpriteCollisionInfo`. - public struct CollisionInfo { - /// The sprite being moved. - public let sprite: Sprite - /// The sprite it collided with. - public let other: Sprite - /// The collision response used. - public let response: CollisionResponse - /// `true` if the sprites were overlapping when the collision - /// started; `false` if the sprite tunneled through. - public let overlaps: Bool - /// How far along the movement (0...1) the collision occurred. - public let ti: Float - /// The difference between the requested and actual positions. - public let move: (x: Float, y: Float) - /// The collision normal (each component -1, 0, or 1). - public let normal: (x: Int, y: Int) - /// Where the sprite started touching `other`. - public let touch: (x: Float, y: Float) - /// The sprite's rect at the moment of the touch. - public let spriteRect: Rect - /// `other`'s rect at the moment of the touch. - public let otherRect: Rect - - init(_ info: SpriteCollisionInfo) { - sprite = Sprite.wrapper(for: info.sprite) - other = Sprite.wrapper(for: info.other) - response = CollisionResponse(info.responseType) - overlaps = info.overlaps != 0 - ti = info.ti - move = (info.move.x, info.move.y) - normal = (Int(info.normal.x), Int(info.normal.y)) - touch = (info.touch.x, info.touch.y) - spriteRect = Rect(info.spriteRect) - otherRect = Rect(info.otherRect) - } - } - - /// Information about a sprite intersected by a line segment, - /// mirroring `SpriteQueryInfo`. - public struct QueryInfo { - public let sprite: Sprite - /// How far along the segment (0...1) the segment enters the sprite. - public let ti1: Float - /// How far along the segment (0...1) the segment exits the sprite. - public let ti2: Float - public let entryPoint: (x: Float, y: Float) - public let exitPoint: (x: Float, y: Float) - - init(_ info: SpriteQueryInfo) { - sprite = Sprite.wrapper(for: info.sprite) - ti1 = info.ti1 - ti2 = info.ti2 - entryPoint = (info.entryPoint.x, info.entryPoint.y) - exitPoint = (info.exitPoint.x, info.exitPoint.y) - } - } - // MARK: - Display list /// Sprites currently added to the display list, kept alive here. diff --git a/Sources/PlaydateKit/Sprite/Enumerations/CollisionResponse.swift b/Sources/PlaydateKit/Sprite/Enumerations/CollisionResponse.swift new file mode 100644 index 0000000..a0ec242 --- /dev/null +++ b/Sources/PlaydateKit/Sprite/Enumerations/CollisionResponse.swift @@ -0,0 +1,16 @@ +internal import CPlaydate + +extension Sprite { + /// How a sprite reacts when a collision occurs. + public enum CollisionResponse: UInt32, Sendable { + case slide = 0 + case freeze = 1 + case overlap = 2 + case bounce = 3 + + init(_ response: SpriteCollisionResponseType) { + self = CollisionResponse(rawValue: UInt32(response.rawValue)) ?? .freeze + } + var cValue: SpriteCollisionResponseType { SpriteCollisionResponseType(SpriteCollisionResponseType.RawValue(rawValue)) } + } +} diff --git a/Sources/PlaydateKit/Sprite/Structures/CollisionInfo.swift b/Sources/PlaydateKit/Sprite/Structures/CollisionInfo.swift new file mode 100644 index 0000000..7e72780 --- /dev/null +++ b/Sources/PlaydateKit/Sprite/Structures/CollisionInfo.swift @@ -0,0 +1,41 @@ +internal import CPlaydate + +extension Sprite { + /// Information about a single collision, mirroring `SpriteCollisionInfo`. + public struct CollisionInfo { + /// The sprite being moved. + public let sprite: Sprite + /// The sprite it collided with. + public let other: Sprite + /// The collision response used. + public let response: CollisionResponse + /// `true` if the sprites were overlapping when the collision + /// started; `false` if the sprite tunneled through. + public let overlaps: Bool + /// How far along the movement (0...1) the collision occurred. + public let ti: Float + /// The difference between the requested and actual positions. + public let move: (x: Float, y: Float) + /// The collision normal (each component -1, 0, or 1). + public let normal: (x: Int, y: Int) + /// Where the sprite started touching `other`. + public let touch: (x: Float, y: Float) + /// The sprite's rect at the moment of the touch. + public let spriteRect: Rect + /// `other`'s rect at the moment of the touch. + public let otherRect: Rect + + init(_ info: SpriteCollisionInfo) { + sprite = Sprite.wrapper(for: info.sprite) + other = Sprite.wrapper(for: info.other) + response = CollisionResponse(info.responseType) + overlaps = info.overlaps != 0 + ti = info.ti + move = (info.move.x, info.move.y) + normal = (Int(info.normal.x), Int(info.normal.y)) + touch = (info.touch.x, info.touch.y) + spriteRect = Rect(info.spriteRect) + otherRect = Rect(info.otherRect) + } + } +} diff --git a/Sources/PlaydateKit/Sprite/Structures/QueryInfo.swift b/Sources/PlaydateKit/Sprite/Structures/QueryInfo.swift new file mode 100644 index 0000000..1ada11d --- /dev/null +++ b/Sources/PlaydateKit/Sprite/Structures/QueryInfo.swift @@ -0,0 +1,23 @@ +internal import CPlaydate + +extension Sprite { + /// Information about a sprite intersected by a line segment, + /// mirroring `SpriteQueryInfo`. + public struct QueryInfo { + public let sprite: Sprite + /// How far along the segment (0...1) the segment enters the sprite. + public let ti1: Float + /// How far along the segment (0...1) the segment exits the sprite. + public let ti2: Float + public let entryPoint: (x: Float, y: Float) + public let exitPoint: (x: Float, y: Float) + + init(_ info: SpriteQueryInfo) { + sprite = Sprite.wrapper(for: info.sprite) + ti1 = info.ti1 + ti2 = info.ti2 + entryPoint = (info.entryPoint.x, info.entryPoint.y) + exitPoint = (info.exitPoint.x, info.exitPoint.y) + } + } +} diff --git a/Sources/PlaydateKit/Sprite/Structures/Rect.swift b/Sources/PlaydateKit/Sprite/Structures/Rect.swift new file mode 100644 index 0000000..d01f8b0 --- /dev/null +++ b/Sources/PlaydateKit/Sprite/Structures/Rect.swift @@ -0,0 +1,22 @@ +internal import CPlaydate + +/// A floating-point rectangle mirroring `PDRect`. +public struct Rect: Sendable { + public var x: Float + public var y: Float + public var width: Float + public var height: Float + + public init(x: Float, y: Float, width: Float, height: Float) { + self.x = x + self.y = y + self.width = width + self.height = height + } + + init(_ rect: PDRect) { + self.init(x: rect.x, y: rect.y, width: rect.width, height: rect.height) + } + + var cValue: PDRect { PDRect(x: x, y: y, width: width, height: height) } +} diff --git a/Sources/PlaydateKit/Support.swift b/Sources/PlaydateKit/Support.swift index 661d671..3e074a5 100644 --- a/Sources/PlaydateKit/Support.swift +++ b/Sources/PlaydateKit/Support.swift @@ -1,12 +1,3 @@ -// -// Support.swift -// Internal helpers shared by the wrappers. -// -// C-string conversions are implemented manually (rather than with -// `String(cString:)` / `withCString`) so the module stays within the -// Embedded Swift subset used for device builds. -// - internal import CPlaydate extension String { diff --git a/Sources/PlaydateKit/System/Classes/MenuItem.swift b/Sources/PlaydateKit/System/Classes/MenuItem.swift new file mode 100644 index 0000000..98a3113 --- /dev/null +++ b/Sources/PlaydateKit/System/Classes/MenuItem.swift @@ -0,0 +1,53 @@ +internal import CPlaydate + +extension System { + /// An item added to the system menu. Keep no more than three items at once. + public final class MenuItem { + let pointer: OpaquePointer + var onSelect: (MenuItem) -> Void + /// Retains C strings passed to the OS for option titles. + private var retainedOptionTitles: [UnsafeMutablePointer] = [] + + init?(pointer: OpaquePointer?, + retainedOptionTitles: [UnsafeMutablePointer] = [], + onSelect: @escaping (MenuItem) -> Void) { + guard let pointer else { + for title in retainedOptionTitles { title.deallocate() } + return nil + } + self.pointer = pointer + self.retainedOptionTitles = retainedOptionTitles + self.onSelect = onSelect + } + + /// The menu item's title. + public var title: String { + get { + String(playdateCString: Playdate.systemAPI.pointee.getMenuItemTitle.unsafelyUnwrapped(pointer)) ?? "" + } + set { + newValue.withPlaydateCString { + Playdate.systemAPI.pointee.setMenuItemTitle.unsafelyUnwrapped(pointer, $0) + } + } + } + + /// For checkmark items this is 0 or 1; for option items it is the + /// index of the selected option. + public var value: Int { + get { Int(Playdate.systemAPI.pointee.getMenuItemValue.unsafelyUnwrapped(pointer)) } + set { Playdate.systemAPI.pointee.setMenuItemValue.unsafelyUnwrapped(pointer, Int32(newValue)) } + } + + /// Convenience view of `value` for checkmark items. + public var isChecked: Bool { + get { value != 0 } + set { value = newValue ? 1 : 0 } + } + + func deallocateRetainedTitles() { + for title in retainedOptionTitles { title.deallocate() } + retainedOptionTitles = [] + } + } +} diff --git a/Sources/PlaydateKit/System/Enumerations/Language.swift b/Sources/PlaydateKit/System/Enumerations/Language.swift new file mode 100644 index 0000000..d9b86c1 --- /dev/null +++ b/Sources/PlaydateKit/System/Enumerations/Language.swift @@ -0,0 +1,16 @@ +internal import CPlaydate + +extension System { + /// The system language. + public enum Language: UInt32, Sendable { + case english = 0 + case japanese = 1 + /// Only meaningful as an argument to `localizedText(forKey:language:)`. + case system = 2 + + init(_ language: PDLanguage) { + self = Language(rawValue: UInt32(language.rawValue)) ?? .english + } + var cValue: PDLanguage { PDLanguage(PDLanguage.RawValue(rawValue)) } + } +} diff --git a/Sources/PlaydateKit/System/Structures/Buttons.swift b/Sources/PlaydateKit/System/Structures/Buttons.swift new file mode 100644 index 0000000..eb07f3e --- /dev/null +++ b/Sources/PlaydateKit/System/Structures/Buttons.swift @@ -0,0 +1,18 @@ +internal import CPlaydate + +extension System { + /// The state of the d-pad and face buttons, as an option set. + public struct Buttons: OptionSet, Sendable { + public let rawValue: UInt32 + public init(rawValue: UInt32) { self.rawValue = rawValue } + init(_ buttons: PDButtons) { self.rawValue = UInt32(buttons.rawValue) } + var cValue: PDButtons { PDButtons(PDButtons.RawValue(rawValue)) } + + public static let left = Buttons(kButtonLeft) + public static let right = Buttons(kButtonRight) + public static let up = Buttons(kButtonUp) + public static let down = Buttons(kButtonDown) + public static let b = Buttons(kButtonB) + public static let a = Buttons(kButtonA) + } +} diff --git a/Sources/PlaydateKit/System/Structures/DateTime.swift b/Sources/PlaydateKit/System/Structures/DateTime.swift new file mode 100644 index 0000000..7394aa4 --- /dev/null +++ b/Sources/PlaydateKit/System/Structures/DateTime.swift @@ -0,0 +1,44 @@ +internal import CPlaydate + +extension System { + /// A calendar date and time, mirroring `PDDateTime`. + public struct DateTime: Sendable { + public var year: UInt16 + /// 1...12 + public var month: UInt8 + /// 1...31 + public var day: UInt8 + /// 1 = Monday ... 7 = Sunday + public var weekday: UInt8 + /// 0...23 + public var hour: UInt8 + public var minute: UInt8 + public var second: UInt8 + + public init(year: UInt16, month: UInt8, day: UInt8, weekday: UInt8 = 0, + hour: UInt8, minute: UInt8, second: UInt8) { + self.year = year + self.month = month + self.day = day + self.weekday = weekday + self.hour = hour + self.minute = minute + self.second = second + } + + init(_ dateTime: PDDateTime) { + year = dateTime.year + month = dateTime.month + day = dateTime.day + weekday = dateTime.weekday + hour = dateTime.hour + minute = dateTime.minute + second = dateTime.second + } + + var cValue: PDDateTime { + PDDateTime(year: year, month: month, day: day, weekday: weekday, + hour: hour, minute: minute, second: second) + } + } +} diff --git a/Sources/PlaydateKit/System/Structures/Info.swift b/Sources/PlaydateKit/System/Structures/Info.swift new file mode 100644 index 0000000..ce0aa1b --- /dev/null +++ b/Sources/PlaydateKit/System/Structures/Info.swift @@ -0,0 +1,8 @@ +extension System { + /// OS, language, and pdx version information, mirroring `PDInfo`. + public struct Info: Sendable { + public let osVersion: UInt32 + public let language: Language + public let pdxVersion: UInt32 + } +} diff --git a/Sources/PlaydateKit/System/Structures/Peripherals.swift b/Sources/PlaydateKit/System/Structures/Peripherals.swift new file mode 100644 index 0000000..e0752dc --- /dev/null +++ b/Sources/PlaydateKit/System/Structures/Peripherals.swift @@ -0,0 +1,13 @@ +internal import CPlaydate + +extension System { + /// Peripherals that can be enabled with `setPeripheralsEnabled(_:)`. + public struct Peripherals: OptionSet, Sendable { + public let rawValue: UInt32 + public init(rawValue: UInt32) { self.rawValue = rawValue } + + public static let none = Peripherals([]) + public static let accelerometer = Peripherals(rawValue: UInt32(kAccelerometer.rawValue)) + public static let all = Peripherals(rawValue: UInt32(kAllPeripherals.rawValue)) + } +} diff --git a/Sources/PlaydateKit/System/Structures/PowerStatus.swift b/Sources/PlaydateKit/System/Structures/PowerStatus.swift new file mode 100644 index 0000000..1ddb178 --- /dev/null +++ b/Sources/PlaydateKit/System/Structures/PowerStatus.swift @@ -0,0 +1,13 @@ +internal import CPlaydate + +extension System { + /// Battery and power supply state. + public struct PowerStatus: OptionSet, Sendable { + public let rawValue: UInt32 + public init(rawValue: UInt32) { self.rawValue = rawValue } + + public static let charging = PowerStatus(rawValue: UInt32(kPDPowerStatusCharging.rawValue)) + public static let usb = PowerStatus(rawValue: UInt32(kPDPowerStatusUsb.rawValue)) + public static let screws = PowerStatus(rawValue: UInt32(kPDPowerStatusScrews.rawValue)) + } +} diff --git a/Sources/PlaydateKit/System.swift b/Sources/PlaydateKit/System/System.swift similarity index 71% rename from Sources/PlaydateKit/System.swift rename to Sources/PlaydateKit/System/System.swift index 7eab3fe..a3d9989 100644 --- a/Sources/PlaydateKit/System.swift +++ b/Sources/PlaydateKit/System/System.swift @@ -1,8 +1,3 @@ -// -// System.swift -// Wraps `playdate->system` (pd_api_sys.h). -// - internal import CPlaydate /// The system API: logging, input, time, menu items, and device state. @@ -11,104 +6,6 @@ public enum System {} extension System { private static var api: UnsafePointer { Playdate.systemAPI.unsafelyUnwrapped } - // MARK: - Types - - /// The state of the d-pad and face buttons, as an option set. - public struct Buttons: OptionSet, Sendable { - public let rawValue: UInt32 - public init(rawValue: UInt32) { self.rawValue = rawValue } - init(_ buttons: PDButtons) { self.rawValue = UInt32(buttons.rawValue) } - var cValue: PDButtons { PDButtons(PDButtons.RawValue(rawValue)) } - - public static let left = Buttons(kButtonLeft) - public static let right = Buttons(kButtonRight) - public static let up = Buttons(kButtonUp) - public static let down = Buttons(kButtonDown) - public static let b = Buttons(kButtonB) - public static let a = Buttons(kButtonA) - } - - /// Peripherals that can be enabled with `setPeripheralsEnabled(_:)`. - public struct Peripherals: OptionSet, Sendable { - public let rawValue: UInt32 - public init(rawValue: UInt32) { self.rawValue = rawValue } - - public static let none = Peripherals([]) - public static let accelerometer = Peripherals(rawValue: UInt32(kAccelerometer.rawValue)) - public static let all = Peripherals(rawValue: UInt32(kAllPeripherals.rawValue)) - } - - /// The system language. - public enum Language: UInt32, Sendable { - case english = 0 - case japanese = 1 - /// Only meaningful as an argument to `localizedText(forKey:language:)`. - case system = 2 - - init(_ language: PDLanguage) { - self = Language(rawValue: UInt32(language.rawValue)) ?? .english - } - var cValue: PDLanguage { PDLanguage(PDLanguage.RawValue(rawValue)) } - } - - /// A calendar date and time, mirroring `PDDateTime`. - public struct DateTime: Sendable { - public var year: UInt16 - /// 1...12 - public var month: UInt8 - /// 1...31 - public var day: UInt8 - /// 1 = Monday ... 7 = Sunday - public var weekday: UInt8 - /// 0...23 - public var hour: UInt8 - public var minute: UInt8 - public var second: UInt8 - - public init(year: UInt16, month: UInt8, day: UInt8, weekday: UInt8 = 0, - hour: UInt8, minute: UInt8, second: UInt8) { - self.year = year - self.month = month - self.day = day - self.weekday = weekday - self.hour = hour - self.minute = minute - self.second = second - } - - init(_ dateTime: PDDateTime) { - year = dateTime.year - month = dateTime.month - day = dateTime.day - weekday = dateTime.weekday - hour = dateTime.hour - minute = dateTime.minute - second = dateTime.second - } - - var cValue: PDDateTime { - PDDateTime(year: year, month: month, day: day, weekday: weekday, - hour: hour, minute: minute, second: second) - } - } - - /// Battery and power supply state. - public struct PowerStatus: OptionSet, Sendable { - public let rawValue: UInt32 - public init(rawValue: UInt32) { self.rawValue = rawValue } - - public static let charging = PowerStatus(rawValue: UInt32(kPDPowerStatusCharging.rawValue)) - public static let usb = PowerStatus(rawValue: UInt32(kPDPowerStatusUsb.rawValue)) - public static let screws = PowerStatus(rawValue: UInt32(kPDPowerStatusScrews.rawValue)) - } - - /// OS, language, and pdx version information, mirroring `PDInfo`. - public struct Info: Sendable { - public let osVersion: UInt32 - public let language: Language - public let pdxVersion: UInt32 - } - // MARK: - Memory /// The system allocator. Pass `nil` to allocate, `size` 0 to free. @@ -289,56 +186,6 @@ extension System { // MARK: - System menu - /// An item added to the system menu. Keep no more than three items at once. - public final class MenuItem { - let pointer: OpaquePointer - var onSelect: (MenuItem) -> Void - /// Retains C strings passed to the OS for option titles. - private var retainedOptionTitles: [UnsafeMutablePointer] = [] - - fileprivate init?(pointer: OpaquePointer?, - retainedOptionTitles: [UnsafeMutablePointer] = [], - onSelect: @escaping (MenuItem) -> Void) { - guard let pointer else { - for title in retainedOptionTitles { title.deallocate() } - return nil - } - self.pointer = pointer - self.retainedOptionTitles = retainedOptionTitles - self.onSelect = onSelect - } - - /// The menu item's title. - public var title: String { - get { - String(playdateCString: Playdate.systemAPI.pointee.getMenuItemTitle.unsafelyUnwrapped(pointer)) ?? "" - } - set { - newValue.withPlaydateCString { - Playdate.systemAPI.pointee.setMenuItemTitle.unsafelyUnwrapped(pointer, $0) - } - } - } - - /// For checkmark items this is 0 or 1; for option items it is the - /// index of the selected option. - public var value: Int { - get { Int(Playdate.systemAPI.pointee.getMenuItemValue.unsafelyUnwrapped(pointer)) } - set { Playdate.systemAPI.pointee.setMenuItemValue.unsafelyUnwrapped(pointer, Int32(newValue)) } - } - - /// Convenience view of `value` for checkmark items. - public var isChecked: Bool { - get { value != 0 } - set { value = newValue ? 1 : 0 } - } - - fileprivate func deallocateRetainedTitles() { - for title in retainedOptionTitles { title.deallocate() } - retainedOptionTitles = [] - } - } - nonisolated(unsafe) private static var liveMenuItems: [MenuItem] = [] private static let menuItemTrampoline: @convention(c) (UnsafeMutableRawPointer?) -> Void = { userdata in