Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 0 additions & 55 deletions .github/build.sh

This file was deleted.

27 changes: 0 additions & 27 deletions .github/workflows/build.yml

This file was deleted.

23 changes: 0 additions & 23 deletions .github/workflows/dependencies.yml

This file was deleted.

64 changes: 64 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
name: Docs

on:
push:
tags: ['*']
branches: [main]

jobs:
build:
runs-on: macos-latest
steps:
- uses: swift-actions/setup-swift@v2

- uses: actions/checkout@v4

- name: Restore .build
id: restore-build
uses: actions/cache/restore@v4
with:
path: .build
restore-keys: "swiftpm-docs-build-${{ runner.os }}-"
key: "swiftpm-docs-build-${{ runner.os }}-${{ github.event.pull_request.base.sha || github.event.after }}"

- name: Generate documentation
run: |
swift package --allow-writing-to-directory ./RealtimeAPI.doccarchive generate-documentation --target RealtimeAPI --disable-indexing --experimental-documentation-coverage --diagnostic-filter error --output-path ./RealtimeAPI.doccarchive
tar -cf RealtimeAPI.doccarchive.tar ./RealtimeAPI.doccarchive/data ./RealtimeAPI.doccarchive/index ./RealtimeAPI.doccarchive/metadata.json ./RealtimeAPI.doccarchive/documentation-coverage.json

- name: Cache .build
if: steps.restore-build.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: .build
key: "swiftpm-docs-build-${{ runner.os }}-${{ github.event.pull_request.base.sha || github.event.after }}"

- name: Update latest documentation
id: github-pages
if: github.ref_type != 'tag'
uses: actions/upload-artifact@v4
with:
retention-days: 1
name: github-pages
if-no-files-found: error
path: ./RealtimeAPI.doccarchive.tar

- name: Attach to release
if: github.ref_type == 'tag'
uses: softprops/action-gh-release@v2
with:
files: ./RealtimeAPI.doccarchive.tar
deploy:
needs: build
runs-on: ubuntu-latest
if: github.ref_type != 'tag'
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
61 changes: 61 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: Tests

on:
push:
branches: [main]
pull_request:

jobs:
tests:
strategy:
fail-fast: false
matrix:
swift: ["6.1", "main-snapshot"]
runs-on: macos-latest
steps:
- name: Install Swift toolchain
shell: bash
run: |
set -e

export SWIFTLY_BIN_DIR=${{ github.workspace }}/.swiftly/bin
export SWIFTLY_HOME_DIR=${{ github.workspace }}/.swiftly/share

echo "TOOLCHAINS=swift" >> $GITHUB_ENV
echo "$SWIFTLY_BIN_DIR" >> $GITHUB_PATH
echo "SWIFTLY_BIN_DIR=$SWIFTLY_BIN_DIR" >> $GITHUB_ENV
echo "SWIFTLY_HOME_DIR=$SWIFTLY_HOME_DIR" >> $GITHUB_ENV

export PATH=$SWIFTLY_BIN_DIR:$PATH

mkdir -p $SWIFTLY_BIN_DIR
mkdir -p $SWIFTLY_HOME_DIR

curl -O https://download.swift.org/swiftly/darwin/swiftly.pkg
installer -pkg swiftly.pkg -target CurrentUserHomeDirectory
cp ~/.swiftly/bin/swiftly $SWIFTLY_BIN_DIR
rm swiftly.pkg

swiftly init --no-modify-profile --quiet-shell-followup --assume-yes --skip-install --verbose
echo "swiftly version: $(swiftly --version)" >&2

swiftly install --assume-yes --use ${{ matrix.swift }}
- uses: actions/checkout@v4

- name: Restore .build
id: restore-build
uses: actions/cache/restore@v4
with:
path: .build
restore-keys: "swiftpm-tests-build-${{ runner.os }}-${{ matrix.swift }}-"
key: "swiftpm-tests-build-${{ runner.os }}-${{ matrix.swift }}-${{ github.event.pull_request.base.sha || github.event.after }}"

- name: Build
run: swift build

- name: Cache .build
if: steps.restore-build.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: .build
key: "swiftpm-tests-build-${{ runner.os }}-${{ matrix.swift }}-${{ github.event.pull_request.base.sha || github.event.after }}"
17 changes: 12 additions & 5 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,29 @@
import PackageDescription

let package = Package(
name: "OpenAIRealtime",
name: "RealtimeAPI",
platforms: [
.iOS(.v17),
.tvOS(.v17),
.macOS(.v14),
.watchOS(.v10),
.visionOS(.v1),
.macCatalyst(.v17),
],
products: [
.library(name: "OpenAIRealtime", type: .static, targets: ["OpenAIRealtime"]),
.library(name: "RealtimeAPI", targets: ["RealtimeAPI"]),
],
dependencies: [
.package(url: "https://github.com/stasel/WebRTC.git", branch: "latest"),
.package(url: "https://github.com/livekit/webrtc-xcframework.git", branch: "main"),
.package(url: "https://github.com/SwiftyLab/MetaCodable.git", .upToNextMajor(from: "1.0.0")),
],
targets: [
.target(name: "OpenAIRealtime", dependencies: ["WebRTC"], path: "./src"),
.target(name: "Core", dependencies: [
.product(name: "MetaCodable", package: "MetaCodable"),
.product(name: "HelperCoders", package: "MetaCodable"),
]),
.target(name: "WebSocket", dependencies: ["Core"]),
.target(name: "UI", dependencies: ["Core", "WebRTC"]),
.target(name: "RealtimeAPI", dependencies: ["Core", "WebSocket", "WebRTC", "UI"]),
.target(name: "WebRTC", dependencies: ["Core", .product(name: "LiveKitWebRTC", package: "webrtc-xcframework")]),
]
)
32 changes: 9 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,11 @@ You can build an iMessage-like app with built-in AI chat in less than 60 lines o

```swift
import SwiftUI
import OpenAIRealtime
import RealtimeAPI

struct ContentView: View {
@State private var newMessage: String = ""
@State private var conversation = Conversation(authToken: OPENAI_KEY)
@State private var conversation = try! Conversation()

var messages: [Item.Message] {
conversation.entries.compactMap { switch $0 {
Expand Down Expand Up @@ -84,7 +84,7 @@ struct ContentView: View {
}
.navigationTitle("Chat")
.navigationBarTitleDisplayMode(.inline)
.onAppear { try! conversation.startHandlingVoice() }
.task { try! await conversation..connect(ephemeralKey: YOUR_EPHEMERAL_KEY_HERE) }
}

func sendMessage() {
Expand All @@ -102,32 +102,24 @@ Or, if you just want a simple app that lets the user talk and the AI respond:

```swift
import SwiftUI
import OpenAIRealtime
import RealtimeAPI

struct ContentView: View {
@State private var conversation = Conversation(authToken: OPENAI_KEY)
@State private var conversation = try! Conversation()

var body: some View {
Text("Say something!")
.onAppear { try! conversation.startListening() }
.task { try! await conversation..connect(ephemeralKey: YOUR_EPHEMERAL_KEY_HERE) }
}
}
```

## Features

- [x] A simple interface for directly interacting with the API
- [x] Wrap the API in an interface that manages the conversation for you
- [x] Optionally handle recording the user's mic and sending it to the API
- [x] Optionally handle playing model responses as they stream in
- [x] Allow interrupting the model
- [ ] WebRTC support

## Architecture

### `Conversation`

The `Conversation` class provides a high-level interface for managing a conversation with the model. It wraps the `RealtimeAPI` class and handles the details of sending and receiving messages, as well as managing the conversation history. It can optionally also handle recording the user's mic and sending it to the API, as well as playing model responses as they stream in.
The `Conversation` class provides a high-level interface for managing a conversation with the model. It wraps the `RealtimeAPI` class and handles the details of sending and receiving messages, managing the conversation history, recording the user's mic, and playing model responses as they stream in.

#### Reading messages

Expand Down Expand Up @@ -166,12 +158,6 @@ try await conversation.whenConnected {
}
```

#### Handling voice conversations

The `Conversation` class can automatically handle 2-way voice conversations. Calling `startListening()` will start listening to the user's voice and sending it to the model, and playing back the model's responses. Calling `stopListening()` will stop listening, but continue playing back responses.

If you just want to play model responses, call `startHandlingVoice()`. To stop both listening and playing back responses, call `stopHandlingVoice()`.

#### Manually sending messages

To send a text message, call the `send(from: Item.ItemRole, text: String, response: Response.Config? = nil)` providing the role of the sender (`.user`, `.assistant`, or `.system`) and the contents of the message. You can optionally also provide a `Response.Config` object to customize the response, such as enabling or disabling function calls.
Expand All @@ -187,8 +173,8 @@ To manually send an event to the API, use the `send(event: RealtimeAPI.ClientEve
To interact with the API directly, create a new instance of `RealtimeAPI` providing one of the available connectors. There are helper methods that let you create an instance from an apiKey or a `URLRequest`, like so:

```swift
let api = RealtimeAPI.webSocket(authToken: YOUR_OPENAI_API_KEY, model: String = "gpt-4o-realtime-preview") // or RealtimeAPI.webSocket(connectingTo: URLRequest)
let api = RealtimeAPI.webRTC(authToken: YOUR_OPENAI_API_KEY, model: String = "gpt-4o-realtime-preview") // or RealtimeAPI.webRTC(connectingTo: URLRequest)
let api = RealtimeAPI.webRTC(ephemeralKey: YOUR_EPHEMERAL_KEY, model: .gptRealtime) // or RealtimeAPI.webRTC(connectingTo: URLRequest)
let api = RealtimeAPI.webSocket(authToken: YOUR_OPENAI_API_KEY, model: .gptRealtime) // or RealtimeAPI.webSocket(connectingTo: URLRequest)
```

You can listen for new events through the `events` property, like so:
Expand Down
8 changes: 8 additions & 0 deletions Sources/Core/Extensions/Result+async.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import Foundation

package extension Result {
init(catching body: () async throws(Failure) -> Success) async {
do { self = try .success(await body()) }
catch { self = .failure(error) }
}
}
22 changes: 22 additions & 0 deletions Sources/Core/Models/AudioData.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Foundation

public struct AudioData: Equatable, Hashable, Sendable {
public var data: Data
}

extension AudioData: Codable {
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()

guard let data = try Data(base64Encoded: container.decode(String.self)) else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid base64 string")
}
self.data = data
}

public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()

try container.encode(data.base64EncodedString())
}
}
Loading
Loading