Compare commits

..
4 Commits
Author SHA1 Message Date
james f992457cfe feat: add menu bar mode 2026-07-17 12:46:34 +02:00
james ad995b59ad feat: generate meeting titles automatically 2026-07-17 12:15:04 +02:00
james a13153b8fe fix: isolate system audio converter callbacks 2026-07-17 11:20:53 +02:00
james 0008bd3081 fix: own system audio buffers before conversion 2026-07-17 11:19:28 +02:00
9 changed files with 298 additions and 17 deletions
+4 -4
View File
@@ -276,7 +276,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 20;
CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = G9LVHZAJNX;
ENABLE_HARDENED_RUNTIME = YES;
@@ -290,7 +290,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.1.8;
MARKETING_VERSION = 1.1.11;
ONLY_ACTIVE_ARCH = NO;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
@@ -312,7 +312,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 20;
CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = G9LVHZAJNX;
ENABLE_HARDENED_RUNTIME = YES;
@@ -326,7 +326,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.1.8;
MARKETING_VERSION = 1.1.11;
ONLY_ACTIVE_ARCH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
+2
View File
@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSUIElement</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
+42 -6
View File
@@ -302,14 +302,14 @@ final class AudioManager: NSObject, ObservableObject {
private func startTapIO(_ tap: ProcessTap) throws {
guard var description = tap.tapStreamDescription,
let inputFormat = AVAudioFormat(streamDescription: &description),
let targetFormat = systemAudioFile?.processingFormat,
let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else {
let targetFormat = systemAudioFile?.processingFormat else {
throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported system audio format"])
}
try tap.run(on: tapQueue) { [weak self] _, inputData, _, _, _ in
guard let self else { return }
guard let self,
let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else { return }
self.processAudioBuffer(
{ AVAudioPCMBuffer(pcmFormat: inputFormat, bufferListNoCopy: inputData, deallocator: nil) },
{ self.copyAudioBuffer(from: inputData, format: inputFormat) },
converter: converter,
targetFormat: targetFormat,
source: .system
@@ -319,6 +319,42 @@ final class AudioManager: NSObject, ObservableObject {
Task { await self.restartSystemAudioTap() }
}
}
private func copyAudioBuffer(
from inputData: UnsafePointer<AudioBufferList>,
format: AVAudioFormat
) -> AVAudioPCMBuffer? {
guard let borrowedBuffer = AVAudioPCMBuffer(
pcmFormat: format,
bufferListNoCopy: inputData,
deallocator: nil
), borrowedBuffer.frameLength > 0,
let ownedBuffer = AVAudioPCMBuffer(
pcmFormat: format,
frameCapacity: borrowedBuffer.frameLength
) else { return nil }
ownedBuffer.frameLength = borrowedBuffer.frameLength
let sourceBuffers = UnsafeMutableAudioBufferListPointer(
UnsafeMutablePointer(mutating: inputData)
)
let destinationBuffers = UnsafeMutableAudioBufferListPointer(
ownedBuffer.mutableAudioBufferList
)
guard sourceBuffers.count == destinationBuffers.count else { return nil }
for index in 0..<sourceBuffers.count {
let source = sourceBuffers[index]
let destination = destinationBuffers[index]
let byteCount = Int(source.mDataByteSize)
guard byteCount <= Int(destination.mDataByteSize),
let sourceData = source.mData,
let destinationData = destination.mData else { return nil }
memcpy(destinationData, sourceData, byteCount)
destinationBuffers[index].mDataByteSize = source.mDataByteSize
}
return ownedBuffer
}
private func processAudioBuffer(
_ inputBufferProvider: () -> AVAudioPCMBuffer?,
@@ -326,8 +362,8 @@ final class AudioManager: NSObject, ObservableObject {
targetFormat: AVAudioFormat,
source: AudioSource
) {
// Keep callback-owned buffers alive until conversion finishes. Teardown
// takes this same lock before invalidating the Core Audio process tap.
// The system callback copies its borrowed Core Audio memory while this
// lock prevents teardown, then conversion operates on the owned copy.
audioFileLock.lock()
defer { audioFileLock.unlock() }
guard isAcceptingAudio,
+121 -1
View File
@@ -6,12 +6,14 @@
//
import SwiftUI
import AppKit
import Sparkle
import PostHog
@main
struct MeetingnotesApp: App {
private let updaterController: SPUStandardUpdaterController
@StateObject private var recordingSessionManager = RecordingSessionManager.shared
init() {
updaterController = SPUStandardUpdaterController(updaterDelegate: nil, userDriverDelegate: nil)
@@ -34,9 +36,10 @@ struct MeetingnotesApp: App {
}
var body: some Scene {
WindowGroup {
WindowGroup("Meetingnotes", id: "main") {
ContentView()
.frame(minWidth: 700, minHeight: 400)
.background(MainWindowConfigurator())
}
.windowResizability(.automatic)
.defaultSize(width: 1000, height: 600)
@@ -45,6 +48,123 @@ struct MeetingnotesApp: App {
CheckForUpdatesView(updater: updaterController.updater)
}
}
MenuBarExtra {
MeetingnotesMenu(
recordingSessionManager: recordingSessionManager,
updater: updaterController.updater
)
} label: {
Image(systemName: recordingSessionManager.isRecording ? "record.circle.fill" : "waveform")
.accessibilityLabel(recordingSessionManager.isRecording ? "Meetingnotes recording" : "Meetingnotes")
}
}
}
private struct MeetingnotesMenu: View {
@ObservedObject var recordingSessionManager: RecordingSessionManager
let updater: SPUUpdater
@Environment(\.openWindow) private var openWindow
var body: some View {
if recordingSessionManager.isRecording {
Label("Recording", systemImage: "record.circle.fill")
} else if recordingSessionManager.isProcessing {
Label("Processing audio", systemImage: "hourglass")
}
Button {
MainWindowController.shared.show(using: openWindow)
} label: {
Label("Open Meetingnotes", systemImage: "macwindow")
}
Divider()
Button {
updater.checkForUpdates()
} label: {
Label("Check for Updates...", systemImage: "arrow.triangle.2.circlepath")
}
.keyboardShortcut("u")
Button {
NSApplication.shared.terminate(nil)
} label: {
Label("Quit Meetingnotes", systemImage: "power")
}
.keyboardShortcut("q")
}
}
@MainActor
private final class MainWindowController: NSObject {
static let shared = MainWindowController()
private weak var window: NSWindow?
func configure(_ window: NSWindow) {
if self.window !== window {
if let previousWindow = self.window {
NotificationCenter.default.removeObserver(
self,
name: NSWindow.willMiniaturizeNotification,
object: previousWindow
)
}
self.window = window
NotificationCenter.default.addObserver(
self,
selector: #selector(windowWillMiniaturize(_:)),
name: NSWindow.willMiniaturizeNotification,
object: window
)
}
if let minimizeButton = window.standardWindowButton(.miniaturizeButton) {
minimizeButton.target = self
minimizeButton.action = #selector(hideWindow(_:))
}
}
func show(using openWindow: OpenWindowAction) {
if let window {
window.makeKeyAndOrderFront(nil)
} else {
openWindow(id: "main")
}
NSApplication.shared.activate(ignoringOtherApps: true)
}
@objc private func hideWindow(_ sender: NSButton) {
sender.window?.orderOut(nil)
}
@objc private func windowWillMiniaturize(_ notification: Notification) {
guard let window = notification.object as? NSWindow else { return }
DispatchQueue.main.async {
window.deminiaturize(nil)
window.orderOut(nil)
}
}
}
private struct MainWindowConfigurator: NSViewRepresentable {
func makeNSView(context: Context) -> NSView {
let view = NSView()
configureWindow(for: view)
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
configureWindow(for: nsView)
}
private func configureWindow(for view: NSView) {
DispatchQueue.main.async {
guard let window = view.window else { return }
MainWindowController.shared.configure(window)
}
}
}
+6 -2
View File
@@ -97,7 +97,11 @@ final class CoderAPIClient {
)
}
func streamChat(systemPrompt: String, model: String) -> AsyncThrowingStream<String, Error> {
func streamChat(
systemPrompt: String,
userPrompt: String = "Create the meeting notes now.",
model: String
) -> AsyncThrowingStream<String, Error> {
AsyncThrowingStream { continuation in
Task {
do {
@@ -113,7 +117,7 @@ final class CoderAPIClient {
"model": selectedModel,
"messages": [
["role": "system", "content": systemPrompt],
["role": "user", "content": "Create the meeting notes now."]
["role": "user", "content": userPrompt]
],
"stream": true
])
@@ -309,8 +309,27 @@ private final class LocalRecordingController {
generatedNotes = ""
}
}
var meetingChanged = false
if !generatedNotes.isEmpty {
meeting.generatedNotes = generatedNotes
meetingChanged = true
}
if meeting.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
do {
if let title = try await NotesGenerator.shared.generateMeetingTitle(
meeting: meeting,
generatedNotes: generatedNotes,
templateId: meeting.templateId
) {
meeting.title = title
meetingChanged = true
}
} catch {
// The recording and generated notes remain valid without a generated title.
print("Meeting title generation failed: \(error)")
}
}
if meetingChanged {
_ = LocalStorageManager.shared.saveMeeting(meeting)
NotificationCenter.default.post(name: .meetingSaved, object: meeting)
}
+83 -1
View File
@@ -105,6 +105,88 @@ class NotesGenerator {
}
}
}
/// Generates a short title after meeting notes have been created.
func generateMeetingTitle(
meeting: Meeting,
generatedNotes: String,
templateId: UUID?
) async throws -> String? {
let templates = LocalStorageManager.shared.loadTemplates()
let template = templateId.flatMap { id in
templates.first(where: { $0.id == id })
}
let meetingContext: String
if let template {
meetingContext = "\(template.title): \(template.context)"
} else {
meetingContext = "General meeting"
}
let trimmedNotes = generatedNotes.trimmingCharacters(in: .whitespacesAndNewlines)
let meetingContent = trimmedNotes.isEmpty ? meeting.formattedTranscript : trimmedNotes
guard !meetingContent.isEmpty else { return nil }
let systemPrompt = """
Create a concise, descriptive title for a recorded meeting from its context and content.
Return only the title in 3 to 8 words. Do not use quotation marks, Markdown, or a trailing period.
Avoid generic titles such as Meeting, Discussion, Meeting Notes, or Untitled Meeting.
"""
let userPrompt = """
Meeting context:
\(meetingContext)
Meeting content:
\(meetingContent)
"""
var generatedTitle = ""
let stream = CoderAPIClient.shared.streamChat(
systemPrompt: systemPrompt,
userPrompt: userPrompt,
model: UserDefaultsManager.shared.notesModel
)
for try await content in stream {
generatedTitle += content
}
return normalizedTitle(generatedTitle)
}
private func normalizedTitle(_ value: String) -> String? {
guard var title = value
.split(whereSeparator: \Character.isNewline)
.map(String.init)
.first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })?
.trimmingCharacters(in: .whitespacesAndNewlines) else {
return nil
}
while title.hasPrefix("#") {
title.removeFirst()
title = title.trimmingCharacters(in: .whitespaces)
}
if title.lowercased().hasPrefix("title:") {
title = String(title.dropFirst("title:".count))
.trimmingCharacters(in: .whitespacesAndNewlines)
}
title = title.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
if title.hasSuffix(".") {
title.removeLast()
}
if title.count > 80 {
let prefix = String(title.prefix(80))
if let lastSpace = prefix.lastIndex(of: " ") {
title = String(prefix[..<lastSpace])
} else {
title = prefix
}
}
title = title.trimmingCharacters(in: .whitespacesAndNewlines)
return title.isEmpty ? nil : title
}
/// Validates if the Coder service token is configured
/// - Returns: True if API key exists, false otherwise
@@ -115,4 +197,4 @@ class NotesGenerator {
}
return true
}
}
}
+19 -1
View File
@@ -12,7 +12,7 @@ extension Notification.Name {
enum MeetingViewTab: String, CaseIterable {
case myNotes = "My Notes"
case transcript = "Transcript"
case enhancedNotes = "Enhanced Notes"
case enhancedNotes = "Meeting Notes"
}
@@ -260,11 +260,29 @@ class MeetingViewModel: ObservableObject {
// Only save if there was no error
if !hasError {
await generateTitleIfNeeded()
saveMeeting()
}
isGeneratingNotes = false
}
private func generateTitleIfNeeded() async {
guard meeting.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
do {
if let title = try await NotesGenerator.shared.generateMeetingTitle(
meeting: meeting,
generatedNotes: meeting.generatedNotes,
templateId: selectedTemplateId
) {
meeting.title = title
}
} catch {
// A title is helpful but should never prevent completed notes from being saved.
print("Meeting title generation failed: \(error)")
}
}
func saveMeeting() {
if isDeleted { return }
+2 -2
View File
@@ -327,7 +327,7 @@ struct MeetingDetailContentView: View {
}
.buttonStyle(.plain)
.disabled(viewModel.meeting.transcript.isEmpty || viewModel.isGeneratingNotes || viewModel.isRecording || viewModel.isProcessing || viewModel.isStartingRecording)
.help("Generate enhanced notes using a template")
.help("Generate meeting notes using a template")
// Recording Button
Button(action: {
@@ -374,7 +374,7 @@ struct MeetingDetailContentView: View {
Spacer()
// Edit/Preview button (for My Notes and Enhanced Notes)
// Edit/Preview button (for My Notes and Meeting Notes)
if viewModel.selectedTab == .myNotes || viewModel.selectedTab == .enhancedNotes {
Button(action: {
isEditing.toggle()