feat: add MuteDeck recording integration

This commit is contained in:
2026-07-14 22:37:00 +02:00
parent 9d83f9e40f
commit 0c2d1ff1fe
9 changed files with 554 additions and 3 deletions
+1
View File
@@ -108,6 +108,7 @@ Implemented:
- Meeting search functionality
- Abilty to edit system prompt
- Select any compatible Coder model for transcription and note generation
- Automatic start and stop from MuteDeck through a compatible local API
- Auto updates
- Text formatting
- Different note templates
+3
View File
@@ -19,6 +19,9 @@ struct ContentView: View {
MeetingListView(settingsViewModel: settingsViewModel)
}
}
.task {
LocalAPIServer.shared.applyConfiguration()
}
}
}
@@ -24,6 +24,32 @@ class KeychainHelper {
func saveCoderAPIKey(_ apiKey: String) -> Bool {
return save(apiKey, forKey: "coderAPIKey")
}
func getOrCreateMuteDeckAPIToken() -> String {
if let token = get(forKey: "muteDeckAPIToken"), !token.isEmpty {
return token
}
return regenerateMuteDeckAPIToken()
}
func regenerateMuteDeckAPIToken() -> String {
var bytes = [UInt8](repeating: 0, count: 24)
let status = bytes.withUnsafeMutableBytes { buffer in
SecRandomCopyBytes(kSecRandomDefault, buffer.count, buffer.baseAddress!)
}
let randomPart: String
if status == errSecSuccess {
randomPart = Data(bytes).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
} else {
randomPart = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
}
let token = "trby_\(randomPart)"
_ = save(token, forKey: "muteDeckAPIToken")
return token
}
/// Saves a string value to the keychain
/// - Parameters:
@@ -10,6 +10,7 @@ class RecordingSessionManager: ObservableObject {
@Published var isRecording = false
@Published var isProcessing = false
@Published var activeMeetingId: UUID?
@Published var recordingStartedAt: Date?
@Published var errorMessage: String?
@Published var activeRecordingTranscriptChunksUpdated: [TranscriptChunk] = []
@@ -79,6 +80,7 @@ class RecordingSessionManager: ObservableObject {
}
activeMeetingId = meetingId
recordingStartedAt = Date()
audioManager.startRecording()
}
@@ -87,8 +89,10 @@ class RecordingSessionManager: ObservableObject {
guard let meetingId = activeMeetingId else {
audioManager.cancelRecording()
recordingStartedAt = nil
return []
}
recordingStartedAt = nil
let chunks = await audioManager.stopRecordingAndTranscribe()
activeRecordingTranscriptChunks = chunks
activeRecordingTranscriptChunksUpdated = chunks
@@ -101,6 +105,7 @@ class RecordingSessionManager: ObservableObject {
func cancelRecording() {
audioManager.cancelRecording()
activeMeetingId = nil
recordingStartedAt = nil
activeRecordingTranscriptChunks = []
}
@@ -144,4 +149,4 @@ class RecordingSessionManager: ObservableObject {
return []
}
}
}
}
@@ -21,6 +21,8 @@ class UserDefaultsManager {
static let coderBaseURL = "coderBaseURL"
static let notesModel = "notesModel"
static let transcriptionModel = "transcriptionModel"
static let muteDeckAPIEnabled = "muteDeckAPIEnabled"
static let muteDeckAPIPort = "muteDeckAPIPort"
}
// MARK: - User Blurb
@@ -79,4 +81,17 @@ class UserDefaultsManager {
get { userDefaults.string(forKey: Keys.transcriptionModel) ?? "groq/whisper-large-v3-turbo" }
set { userDefaults.set(newValue, forKey: Keys.transcriptionModel) }
}
var muteDeckAPIEnabled: Bool {
get { userDefaults.bool(forKey: Keys.muteDeckAPIEnabled) }
set { userDefaults.set(newValue, forKey: Keys.muteDeckAPIEnabled) }
}
var muteDeckAPIPort: Int {
get {
let stored = userDefaults.integer(forKey: Keys.muteDeckAPIPort)
return stored == 0 ? 9880 : stored
}
set { userDefaults.set(newValue, forKey: Keys.muteDeckAPIPort) }
}
}
+10
View File
@@ -44,6 +44,16 @@ struct Settings: Codable {
set { UserDefaultsManager.shared.transcriptionModel = newValue }
}
var muteDeckAPIEnabled: Bool {
get { UserDefaultsManager.shared.muteDeckAPIEnabled }
set { UserDefaultsManager.shared.muteDeckAPIEnabled = newValue }
}
var muteDeckAPIPort: Int {
get { UserDefaultsManager.shared.muteDeckAPIPort }
set { UserDefaultsManager.shared.muteDeckAPIPort = newValue }
}
// System prompt default loading
static func defaultSystemPrompt() -> String {
guard let path = Bundle.main.path(forResource: "DefaultSystemPrompt", ofType: "txt"),
+409
View File
@@ -0,0 +1,409 @@
import Foundation
import Network
private struct LocalAPIRequest {
let method: String
let path: String
let headers: [String: String]
}
private struct LocalAPIResponse {
let status: Int
let reason: String
let payload: [String: Any]
static func json(status: Int = 200, reason: String = "OK", _ payload: [String: Any]) -> LocalAPIResponse {
LocalAPIResponse(status: status, reason: reason, payload: payload)
}
func encoded() -> Data {
let body = (try? JSONSerialization.data(withJSONObject: payload)) ?? Data("{}".utf8)
let headers = [
"HTTP/1.1 \(status) \(reason)",
"Content-Type: application/json; charset=utf-8",
"Content-Length: \(body.count)",
"Connection: close",
"Cache-Control: no-store",
"",
""
].joined(separator: "\r\n")
var response = Data(headers.utf8)
response.append(body)
return response
}
}
private final class LocalAPIConnection {
private let connection: NWConnection
private let queue: DispatchQueue
private var buffer = Data()
init(connection: NWConnection, queue: DispatchQueue) {
self.connection = connection
self.queue = queue
}
func start() {
connection.start(queue: queue)
receive()
}
private func receive() {
connection.receive(minimumIncompleteLength: 1, maximumLength: 16_384) { data, _, isComplete, error in
if let data { buffer.append(data) }
if buffer.range(of: Data("\r\n\r\n".utf8)) != nil {
handleRequest()
return
}
if buffer.count > 64 * 1024 {
send(.json(status: 413, reason: "Payload Too Large", ["error": "request headers are too large"]))
return
}
if isComplete || error != nil {
connection.cancel()
return
}
receive()
}
}
private func handleRequest() {
guard let request = Self.parse(buffer) else {
send(.json(status: 400, reason: "Bad Request", ["error": "invalid HTTP request"]))
return
}
Task { @MainActor in
let response = LocalAPIRouter.handle(request)
send(response)
}
}
private func send(_ response: LocalAPIResponse) {
connection.send(content: response.encoded(), completion: .contentProcessed { [connection] _ in
connection.cancel()
})
}
private static func parse(_ data: Data) -> LocalAPIRequest? {
guard let text = String(data: data, encoding: .utf8),
let headerEnd = text.range(of: "\r\n\r\n") else {
return nil
}
let lines = text[..<headerEnd.lowerBound].components(separatedBy: "\r\n")
guard let requestLine = lines.first else { return nil }
let requestParts = requestLine.split(separator: " ", maxSplits: 2).map(String.init)
guard requestParts.count == 3 else { return nil }
let requestTarget = requestParts[1]
let path = URLComponents(string: requestTarget)?.path ?? requestTarget.split(separator: "?", maxSplits: 1).first.map(String.init) ?? requestTarget
var headers: [String: String] = [:]
for line in lines.dropFirst() {
guard let separator = line.firstIndex(of: ":") else { continue }
let key = line[..<separator].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let value = line[line.index(after: separator)...].trimmingCharacters(in: .whitespacesAndNewlines)
headers[key] = value
}
return LocalAPIRequest(method: requestParts[0].uppercased(), path: path, headers: headers)
}
}
@MainActor
private enum LocalAPIRouter {
static func handle(_ request: LocalAPIRequest) -> LocalAPIResponse {
let path = request.path.count > 1 ? request.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) : request.path
let normalizedPath = path.hasPrefix("/") ? path : "/" + path
if request.method == "GET", normalizedPath == "/api/info" {
return .json(infoPayload())
}
guard authorized(request) else {
return .json(status: 401, reason: "Unauthorized", ["error": "invalid or missing bearer token"])
}
let controller = LocalRecordingController.shared
switch (request.method, normalizedPath) {
case ("GET", "/api/recording/status"):
return .json(controller.statusPayload())
case ("POST", "/api/recording/start"):
do {
return .json(try controller.startRecording())
} catch {
return .json(status: 409, reason: "Conflict", [
"error": error.localizedDescription,
"state": "processing"
])
}
case ("POST", "/api/recording/stop"):
return .json(controller.stopRecording())
case ("POST", "/api/recording/cancel"):
return .json(controller.cancelRecording())
default:
return .json(status: 404, reason: "Not Found", ["error": "endpoint not found"])
}
}
private static func authorized(_ request: LocalAPIRequest) -> Bool {
guard let authorization = request.headers["authorization"],
authorization.lowercased().hasPrefix("bearer ") else {
return false
}
let supplied = String(authorization.dropFirst(7)).trimmingCharacters(in: .whitespacesAndNewlines)
return timingSafeEqual(supplied, KeychainHelper.shared.getOrCreateMuteDeckAPIToken())
}
private static func timingSafeEqual(_ lhs: String, _ rhs: String) -> Bool {
let left = Array(lhs.utf8)
let right = Array(rhs.utf8)
guard left.count == right.count else { return false }
var difference: UInt8 = 0
for index in left.indices { difference |= left[index] ^ right[index] }
return difference == 0
}
private static func infoPayload() -> [String: Any] {
// MuteDeck identifies compatible local API targets by the MeetingDebrief contract.
[
"status": "ok",
"name": "MeetingDebrief",
"implementation": "Meetingnotes",
"api_version": "1",
"apiVersion": "1",
"version": Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0",
"endpoints": [
"GET /api/info",
"GET /api/recording/status",
"POST /api/recording/start",
"POST /api/recording/stop",
"POST /api/recording/cancel"
]
]
}
}
private enum LocalRecordingError: LocalizedError {
case processing
case saveFailed
var errorDescription: String? {
switch self {
case .processing:
return "The previous meeting is still processing."
case .saveFailed:
return "Could not create a meeting for this recording."
}
}
}
@MainActor
private final class LocalRecordingController {
static let shared = LocalRecordingController()
private let recordingManager = RecordingSessionManager.shared
private var isStopping = false
private init() {}
func statusPayload() -> [String: Any] {
let state: String
if isStopping || recordingManager.isProcessing {
state = "processing"
} else if recordingManager.isRecording {
state = "recording"
} else if recordingManager.activeMeetingId != nil {
state = "starting"
} else {
state = "idle"
}
let isRecording = state == "recording" || state == "starting"
let elapsed = recordingManager.recordingStartedAt.map { max(0, Int(Date().timeIntervalSince($0))) } ?? 0
let sessionID: Any = recordingManager.activeMeetingId.map { $0.uuidString as Any } ?? NSNull()
return [
"success": true,
"status": state,
"state": state,
"recording": isRecording,
"is_recording": isRecording,
"isRecording": isRecording,
"paused": false,
"is_paused": false,
"isPaused": false,
"duration": elapsed,
"duration_seconds": elapsed,
"durationSeconds": elapsed,
"session_id": sessionID,
"sessionId": sessionID
]
}
func startRecording() throws -> [String: Any] {
if isStopping || recordingManager.isProcessing {
throw LocalRecordingError.processing
}
if recordingManager.activeMeetingId != nil {
return statusPayload()
}
let meeting = Meeting()
guard LocalStorageManager.shared.saveMeeting(meeting) else {
throw LocalRecordingError.saveFailed
}
NotificationCenter.default.post(name: .meetingSaved, object: meeting)
recordingManager.startRecording(for: meeting.id)
return statusPayload()
}
func stopRecording() -> [String: Any] {
guard !isStopping, recordingManager.activeMeetingId != nil else {
return statusPayload()
}
isStopping = true
Task { [weak self] in
await self?.finishRecording()
}
return statusPayload()
}
func cancelRecording() -> [String: Any] {
guard !isStopping else { return statusPayload() }
let meetingID = recordingManager.activeMeetingId
recordingManager.cancelRecording()
if let meetingID,
let meeting = LocalStorageManager.shared.loadMeetings().first(where: { $0.id == meetingID }) {
_ = LocalStorageManager.shared.deleteMeeting(meeting)
NotificationCenter.default.post(name: .meetingDeleted, object: meeting)
}
return statusPayload()
}
private func finishRecording() async {
let meetingID = recordingManager.activeMeetingId
let chunks = await recordingManager.stopRecording()
guard let meetingID,
var meeting = LocalStorageManager.shared.loadMeetings().first(where: { $0.id == meetingID }) else {
isStopping = false
return
}
meeting.transcriptChunks = chunks
let templates = LocalStorageManager.shared.loadTemplates()
if meeting.templateId == nil {
meeting.templateId = UserDefaultsManager.shared.selectedTemplateId
?? templates.first(where: { $0.title == "Standard Meeting" })?.id
?? templates.first?.id
}
_ = LocalStorageManager.shared.saveMeeting(meeting)
NotificationCenter.default.post(name: .meetingSaved, object: meeting)
if !meeting.formattedTranscript.isEmpty {
let stream = NotesGenerator.shared.generateNotesStream(
meeting: meeting,
userBlurb: UserDefaultsManager.shared.userBlurb,
systemPrompt: UserDefaultsManager.shared.systemPrompt,
templateId: meeting.templateId
)
var generatedNotes = ""
for await result in stream {
switch result {
case .content(let content):
generatedNotes += content
case .error:
generatedNotes = ""
}
}
if !generatedNotes.isEmpty {
meeting.generatedNotes = generatedNotes
_ = LocalStorageManager.shared.saveMeeting(meeting)
NotificationCenter.default.post(name: .meetingSaved, object: meeting)
}
}
isStopping = false
}
}
@MainActor
final class LocalAPIServer: ObservableObject {
static let shared = LocalAPIServer()
@Published private(set) var isRunning = false
@Published private(set) var errorMessage: String?
@Published private(set) var listeningPort: UInt16?
private let queue = DispatchQueue(label: "io.meetingnotes.local-api", qos: .userInitiated)
private var listener: NWListener?
private init() {}
var statusText: String {
if isRunning, let listeningPort {
return "Listening on 127.0.0.1:\(listeningPort)"
}
return errorMessage ?? "Stopped"
}
func applyConfiguration() {
guard UserDefaultsManager.shared.muteDeckAPIEnabled else {
stop()
return
}
let configuredPort = UserDefaultsManager.shared.muteDeckAPIPort
guard let port = UInt16(exactly: configuredPort), port > 0 else {
stop()
errorMessage = "Enter a port between 1 and 65535."
return
}
if isRunning, listeningPort == port { return }
start(port: port)
}
private func start(port: UInt16) {
stop()
do {
let parameters = NWParameters.tcp
parameters.requiredLocalEndpoint = .hostPort(
host: NWEndpoint.Host("127.0.0.1"),
port: NWEndpoint.Port(rawValue: port)!
)
let listener = try NWListener(using: parameters)
let queue = self.queue
listener.newConnectionHandler = { connection in
LocalAPIConnection(connection: connection, queue: queue).start()
}
listener.stateUpdateHandler = { [weak self] state in
Task { @MainActor [weak self] in
guard let self else { return }
switch state {
case .ready:
isRunning = true
listeningPort = port
errorMessage = nil
case .failed(let error):
isRunning = false
listeningPort = nil
errorMessage = "Local API failed: \(error.localizedDescription)"
case .cancelled:
isRunning = false
listeningPort = nil
default:
break
}
}
}
self.listener = listener
_ = KeychainHelper.shared.getOrCreateMuteDeckAPIToken()
listener.start(queue: queue)
} catch {
errorMessage = "Local API failed: \(error.localizedDescription)"
}
}
private func stop() {
listener?.cancel()
listener = nil
isRunning = false
listeningPort = nil
if !UserDefaultsManager.shared.muteDeckAPIEnabled {
errorMessage = nil
}
}
}
@@ -2,6 +2,7 @@ import Foundation
import SwiftUI
import PostHog
@MainActor
class SettingsViewModel: ObservableObject {
@Published var settings = Settings()
@Published var saveMessage = ""
@@ -10,8 +11,10 @@ class SettingsViewModel: ObservableObject {
@Published var coderModels: [CoderModel] = []
@Published var isLoadingModels = false
@Published var connectionMessage = ""
@Published var muteDeckAPIToken: String
init() {
muteDeckAPIToken = KeychainHelper.shared.getOrCreateMuteDeckAPIToken()
loadTemplates()
}
@@ -84,6 +87,7 @@ class SettingsViewModel: ObservableObject {
// Only save API key to keychain - other values are automatically saved to UserDefaults
// via computed properties when they're modified
let coderSaved = KeychainHelper.shared.saveCoderAPIKey(settings.coderAPIKey)
LocalAPIServer.shared.applyConfiguration()
if showMessage {
if coderSaved {
@@ -120,4 +124,12 @@ class SettingsViewModel: ObservableObject {
// This will cause ContentView to re-evaluate and show onboarding
NotificationCenter.default.post(name: Notification.Name("OnboardingReset"), object: nil)
}
}
func applyMuteDeckAPIConfiguration() {
LocalAPIServer.shared.applyConfiguration()
}
func regenerateMuteDeckAPIToken() {
muteDeckAPIToken = KeychainHelper.shared.regenerateMuteDeckAPIToken()
}
}
+71 -1
View File
@@ -2,7 +2,9 @@ import SwiftUI
struct SettingsView: View {
@ObservedObject var viewModel: SettingsViewModel
@StateObject private var localAPIServer = LocalAPIServer.shared
@State private var showingTemplateManager = false
@State private var confirmingTokenRegeneration = false
@Binding var navigationPath: NavigationPath
init(viewModel: SettingsViewModel, navigationPath: Binding<NavigationPath> = .constant(NavigationPath())) {
@@ -58,6 +60,65 @@ struct SettingsView: View {
.font(.caption)
.foregroundColor(.secondary)
}
VStack(alignment: .leading, spacing: 12) {
Toggle("Enable MuteDeck Integration", isOn: $viewModel.settings.muteDeckAPIEnabled)
.font(.headline)
.onChange(of: viewModel.settings.muteDeckAPIEnabled) { _, _ in
viewModel.applyMuteDeckAPIConfiguration()
}
if viewModel.settings.muteDeckAPIEnabled {
LabeledContent("Host") {
Text("127.0.0.1")
.textSelection(.enabled)
}
LabeledContent("Port") {
TextField("Port", value: $viewModel.settings.muteDeckAPIPort, format: .number)
.textFieldStyle(.roundedBorder)
.frame(width: 110)
.onSubmit {
viewModel.applyMuteDeckAPIConfiguration()
}
}
LabeledContent("API Token") {
HStack(spacing: 8) {
Text(viewModel.muteDeckAPIToken)
.font(.system(.body, design: .monospaced))
.lineLimit(1)
.textSelection(.enabled)
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(viewModel.muteDeckAPIToken, forType: .string)
} label: {
Image(systemName: "doc.on.doc")
}
.buttonStyle(.borderless)
.help("Copy API token")
Button {
confirmingTokenRegeneration = true
} label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
.help("Regenerate API token")
}
}
HStack(spacing: 8) {
Circle()
.fill(localAPIServer.isRunning ? Color.green : Color.orange)
.frame(width: 8, height: 8)
Text(localAPIServer.statusText)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
// Note Templates Section: only the Manage Templates button
VStack(alignment: .leading, spacing: 8) {
@@ -202,6 +263,7 @@ struct SettingsView: View {
.onAppear {
viewModel.loadTemplates()
viewModel.loadAPIKey()
viewModel.applyMuteDeckAPIConfiguration()
Task { await viewModel.refreshModels() }
}
.onDisappear {
@@ -214,6 +276,14 @@ struct SettingsView: View {
} message: {
Text(viewModel.saveMessage)
}
.confirmationDialog("Regenerate API token?", isPresented: $confirmingTokenRegeneration, titleVisibility: .visible) {
Button("Regenerate", role: .destructive) {
viewModel.regenerateMuteDeckAPIToken()
}
Button("Cancel", role: .cancel) { }
} message: {
Text("MuteDeck will need the new token before it can control recordings again.")
}
}
}
@@ -221,4 +291,4 @@ struct SettingsView: View {
NavigationStack {
SettingsView(viewModel: SettingsViewModel())
}
}
}