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 ee17789..792082f 100755 Binary files a/Examples/HelloPlaydate/Source/pdex.elf and b/Examples/HelloPlaydate/Source/pdex.elf differ 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