Compare commits

...
5 Commits
5 changed files with 98 additions and 50 deletions
+4 -4
View File
@@ -276,7 +276,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 17; CURRENT_PROJECT_VERSION = 21;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = G9LVHZAJNX; DEVELOPMENT_TEAM = G9LVHZAJNX;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -290,7 +290,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.1.5; MARKETING_VERSION = 1.1.9;
ONLY_ACTIVE_ARCH = NO; ONLY_ACTIVE_ARCH = NO;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes; PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
@@ -312,7 +312,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 17; CURRENT_PROJECT_VERSION = 21;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = G9LVHZAJNX; DEVELOPMENT_TEAM = G9LVHZAJNX;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -326,7 +326,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.1.5; MARKETING_VERSION = 1.1.9;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes; PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
+63 -17
View File
@@ -183,9 +183,13 @@ final class AudioManager: NSObject, ObservableObject {
throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported microphone format"]) throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported microphone format"])
} }
inputNode.installTap(onBus: 0, bufferSize: 1024, format: inputFormat) { [weak self] buffer, _ in inputNode.installTap(onBus: 0, bufferSize: 1024, format: inputFormat) { [weak self] buffer, _ in
guard let self, buffer.frameLength > 0 else { return } guard let self else { return }
self.updateAudioLevel(buffer, source: .mic) self.processAudioBuffer(
self.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .mic) { buffer },
converter: converter,
targetFormat: targetFormat,
source: .mic
)
} }
audioEngine.prepare() audioEngine.prepare()
try audioEngine.start() try audioEngine.start()
@@ -298,28 +302,75 @@ final class AudioManager: NSObject, ObservableObject {
private func startTapIO(_ tap: ProcessTap) throws { private func startTapIO(_ tap: ProcessTap) throws {
guard var description = tap.tapStreamDescription, guard var description = tap.tapStreamDescription,
let inputFormat = AVAudioFormat(streamDescription: &description), let inputFormat = AVAudioFormat(streamDescription: &description),
let targetFormat = systemAudioFile?.processingFormat, let targetFormat = systemAudioFile?.processingFormat else {
let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else {
throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported system audio format"]) throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported system audio format"])
} }
try tap.run(on: tapQueue) { [weak self] _, inputData, _, _, _ in try tap.run(on: tapQueue) { [weak self] _, inputData, _, _, _ in
guard let self, guard let self,
let buffer = AVAudioPCMBuffer(pcmFormat: inputFormat, bufferListNoCopy: inputData, deallocator: nil), let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else { return }
buffer.frameLength > 0 else { return } self.processAudioBuffer(
self.updateAudioLevel(buffer, source: .system) { self.copyAudioBuffer(from: inputData, format: inputFormat) },
self.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .system) converter: converter,
targetFormat: targetFormat,
source: .system
)
} invalidationHandler: { [weak self] _ in } invalidationHandler: { [weak self] _ in
guard let self, !self.isRestartingSystemTap, self.isRecording else { return } guard let self, !self.isRestartingSystemTap, self.isRecording else { return }
Task { await self.restartSystemAudioTap() } 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( private func processAudioBuffer(
_ inputBuffer: AVAudioPCMBuffer, _ inputBufferProvider: () -> AVAudioPCMBuffer?,
converter: AVAudioConverter, converter: AVAudioConverter,
targetFormat: AVAudioFormat, targetFormat: AVAudioFormat,
source: AudioSource source: AudioSource
) { ) {
// 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,
let inputBuffer = inputBufferProvider(),
inputBuffer.frameLength > 0 else { return }
updateAudioLevel(inputBuffer, source: source)
let ratio = targetFormat.sampleRate / inputBuffer.format.sampleRate let ratio = targetFormat.sampleRate / inputBuffer.format.sampleRate
let capacity = max(1, AVAudioFrameCount(ceil(Double(inputBuffer.frameLength) * ratio))) let capacity = max(1, AVAudioFrameCount(ceil(Double(inputBuffer.frameLength) * ratio)))
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else { return } guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else { return }
@@ -336,11 +387,6 @@ final class AudioManager: NSObject, ObservableObject {
} }
guard status != .error, conversionError == nil, outputBuffer.frameLength > 0 else { return } guard status != .error, conversionError == nil, outputBuffer.frameLength > 0 else { return }
audioFileLock.lock()
defer { audioFileLock.unlock() }
guard isAcceptingAudio else {
return
}
do { do {
switch source { switch source {
case .mic: case .mic:
@@ -378,8 +424,8 @@ final class AudioManager: NSObject, ObservableObject {
pendingMicRestart = nil pendingMicRestart = nil
AudioLevelManager.shared.updateRecordingState(false) AudioLevelManager.shared.updateRecordingState(false)
// Stop new writes and wait for any callback already writing before // Stop new callbacks and wait for any active conversion/write before
// AVAudioFile is finalized and released. // invalidating callback-owned buffers or finalizing AVAudioFile.
audioFileLock.lock() audioFileLock.lock()
isAcceptingAudio = false isAcceptingAudio = false
audioFileLock.unlock() audioFileLock.unlock()
@@ -54,15 +54,6 @@ class MeetingViewModel: ObservableObject {
private let recordingSessionManager = RecordingSessionManager.shared private let recordingSessionManager = RecordingSessionManager.shared
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var isNewMeeting = false
// Computed property to check if meeting is empty
var isEmpty: Bool {
return meeting.transcriptChunks.isEmpty &&
meeting.userNotes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
meeting.generatedNotes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
meeting.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
init(meeting: Meeting = Meeting()) { init(meeting: Meeting = Meeting()) {
// Load the latest version of the meeting from storage if it exists // Load the latest version of the meeting from storage if it exists
@@ -76,9 +67,6 @@ class MeetingViewModel: ObservableObject {
// Detect if this is a new meeting based on content, not storage existence
isNewMeeting = isEmpty
// Set initial tab based on notes existence // Set initial tab based on notes existence
if !self.meeting.generatedNotes.isEmpty { if !self.meeting.generatedNotes.isEmpty {
selectedTab = .enhancedNotes selectedTab = .enhancedNotes
@@ -333,12 +321,4 @@ class MeetingViewModel: ObservableObject {
} }
} }
func deleteIfEmpty() {
if isEmpty && !isRecording && !isProcessing {
print("🗑️ Auto-deleting empty meeting")
deleteMeeting()
} else {
saveMeeting()
}
}
} }
+3 -2
View File
@@ -445,8 +445,9 @@ struct MeetingDetailContentView: View {
Text("Are you sure you want to delete this meeting? This action cannot be undone.") Text("Are you sure you want to delete this meeting? This action cannot be undone.")
} }
.onDisappear { .onDisappear {
// Auto-delete empty meetings when leaving, otherwise save // A failed recording may still be empty. Keep it until the user
viewModel.deleteIfEmpty() // explicitly deletes it so app updates cannot erase history.
viewModel.saveMeeting()
} }
} }
+28 -7
View File
@@ -33,9 +33,16 @@ struct TemplateEditView: View {
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
TextField("Meeting Context", text: $template.context, axis: .vertical) TextEditor(text: $template.context)
.textFieldStyle(.roundedBorder) .scrollContentBackground(.hidden)
.lineLimit(4...10) .padding(8)
.frame(height: 140)
.background(Color.secondary.opacity(0.06))
.overlay {
RoundedRectangle(cornerRadius: 6)
.stroke(Color.secondary.opacity(0.25), lineWidth: 1)
}
.clipShape(RoundedRectangle(cornerRadius: 6))
} }
// Sections // Sections
@@ -75,9 +82,16 @@ struct TemplateEditView: View {
TextField("Section Title", text: $section.title) TextField("Section Title", text: $section.title)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
TextField("Section Description", text: $section.description, axis: .vertical) TextEditor(text: $section.description)
.textFieldStyle(.roundedBorder) .scrollContentBackground(.hidden)
.lineLimit(3...8) .padding(8)
.frame(height: 90)
.background(Color.secondary.opacity(0.06))
.overlay {
RoundedRectangle(cornerRadius: 6)
.stroke(Color.secondary.opacity(0.25), lineWidth: 1)
}
.clipShape(RoundedRectangle(cornerRadius: 6))
} }
Button { Button {
@@ -113,12 +127,19 @@ struct TemplateEditView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.top) .padding(.top)
.disabled(template.title.isEmpty || template.sections.isEmpty) .disabled(!canSave)
} }
.padding(24) .padding(24)
} }
.navigationTitle("Edit Template") .navigationTitle("Edit Template")
} }
private var canSave: Bool {
let hasTitle = !template.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
let hasInstructions = !template.context.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| !template.sections.isEmpty
return hasTitle && hasInstructions
}
} }
#Preview { #Preview {