Compare commits

..
11 Commits
16 changed files with 635 additions and 114 deletions
+12 -6
View File
@@ -20,6 +20,9 @@ jobs:
runs-on: macos-15 runs-on: macos-15
env: env:
VERSION: ${{ inputs.version }} VERSION: ${{ inputs.version }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
@@ -31,7 +34,7 @@ jobs:
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: | run: |
for variable in APPLE_CERTIFICATE_P12 APPLE_CERTIFICATE_PASSWORD SPARKLE_PRIVATE_KEY; do for variable in APPLE_CERTIFICATE_P12 APPLE_CERTIFICATE_PASSWORD APPLE_ID APPLE_TEAM_ID APPLE_APP_PASSWORD SPARKLE_PRIVATE_KEY; do
if [[ -z "${!variable:-}" ]]; then if [[ -z "${!variable:-}" ]]; then
echo "Missing GitHub Actions secret: $variable" >&2 echo "Missing GitHub Actions secret: $variable" >&2
exit 1 exit 1
@@ -53,7 +56,7 @@ jobs:
fi fi
echo "SIGNING_IDENTITY=$signing_identity" >> "$GITHUB_ENV" echo "SIGNING_IDENTITY=$signing_identity" >> "$GITHUB_ENV"
- name: Build and sign release - name: Build, sign, and notarize release
timeout-minutes: 30 timeout-minutes: 30
run: scripts/package_release.sh run: scripts/package_release.sh
@@ -70,13 +73,16 @@ jobs:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
run: | run: |
tag="v$VERSION" tag="v$VERSION"
if git rev-parse "$tag" >/dev/null 2>&1; then if gh release view "$tag" >/dev/null 2>&1; then
echo "Tag already exists: $tag" >&2 gh release upload "$tag" \
exit 1 "$RUNNER_TEMP/meetingnotes-release/release/Meetingnotes-$VERSION.zip" \
fi "$RUNNER_TEMP/meetingnotes-release/release/appcast.xml" \
--clobber
else
gh release create "$tag" \ gh release create "$tag" \
"$RUNNER_TEMP/meetingnotes-release/release/Meetingnotes-$VERSION.zip" \ "$RUNNER_TEMP/meetingnotes-release/release/Meetingnotes-$VERSION.zip" \
"$RUNNER_TEMP/meetingnotes-release/release/appcast.xml" \ "$RUNNER_TEMP/meetingnotes-release/release/appcast.xml" \
--target "$GITHUB_SHA" \ --target "$GITHUB_SHA" \
--title "Meetingnotes $VERSION" \ --title "Meetingnotes $VERSION" \
--generate-notes --generate-notes
fi
+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 = 24; CURRENT_PROJECT_VERSION = 34;
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.12; MARKETING_VERSION = 1.1.22;
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 = 24; CURRENT_PROJECT_VERSION = 34;
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.12; MARKETING_VERSION = 1.1.22;
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;
+174 -71
View File
@@ -35,19 +35,16 @@ final class AudioManager: NSObject, ObservableObject {
private var audioEngine = AVAudioEngine() private var audioEngine = AVAudioEngine()
private var sessionID = UUID() private var sessionID = UUID()
private var meetingID = UUID()
private var processTap: ProcessTap? private var processTap: ProcessTap?
private let audioProcessController = AudioProcessController()
private let permission = AudioRecordingPermission() private let permission = AudioRecordingPermission()
private let tapQueue = DispatchQueue(label: "io.meetingnotes.audiotap", qos: .userInitiated) private let tapQueue = DispatchQueue(label: "io.meetingnotes.audiotap", qos: .userInitiated)
private let audioFileLock = NSLock() private let audioFileLock = NSLock()
private var isTapActive = false private var isTapActive = false
private var isRestartingSystemTap = false
private var isAcceptingAudio = false private var isAcceptingAudio = false
private var micRetryCount = 0 private var micRetryCount = 0
private var pendingMicRestart: DispatchWorkItem? private var pendingMicRestart: DispatchWorkItem?
private let maxMicRetries = 3 private let maxMicRetries = 3
private var cancellables = Set<AnyCancellable>()
private var micAudioFile: AVAudioFile? private var micAudioFile: AVAudioFile?
private var systemAudioFile: AVAudioFile? private var systemAudioFile: AVAudioFile?
private var micAudioURL: URL? private var micAudioURL: URL?
@@ -57,25 +54,18 @@ final class AudioManager: NSObject, ObservableObject {
private override init() { private override init() {
super.init() super.init()
observeAudioEngine() observeAudioEngine()
audioProcessController.activate()
NSWorkspace.shared.publisher(for: \.runningApplications)
.debounce(for: .seconds(1), scheduler: RunLoop.main)
.sink { [weak self] _ in
guard let self, self.isTapActive else { return }
Task { await self.restartSystemAudioTapIfNeeded() }
}
.store(in: &cancellables)
} }
deinit { deinit {
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
} }
func startRecording() { func startRecording(for meetingID: UUID) {
errorMessage = nil errorMessage = nil
lastRecoveryAudioFolderName = nil lastRecoveryAudioFolderName = nil
cancelCapture(removeFiles: true) cancelCapture(removeFiles: true)
sessionID = UUID() sessionID = UUID()
self.meetingID = meetingID
recordingStartedAt = Date() recordingStartedAt = Date()
do { do {
try prepareAudioFiles() try prepareAudioFiles()
@@ -88,7 +78,7 @@ final class AudioManager: NSObject, ObservableObject {
} }
func stopRecordingAndTranscribe() async -> [TranscriptChunk] { func stopRecordingAndTranscribe() async -> [TranscriptChunk] {
let completedSessionID = sessionID let completedMeetingID = meetingID
let captureStartedAt = recordingStartedAt let captureStartedAt = recordingStartedAt
let files = stopCaptureAndCloseFiles() let files = stopCaptureAndCloseFiles()
isProcessing = true isProcessing = true
@@ -96,9 +86,21 @@ final class AudioManager: NSObject, ObservableObject {
isProcessing = false isProcessing = false
} }
repairHalfDurationSystemWAVIfNeeded(in: files)
if let mismatch = captureDurationMismatch(in: files) {
let completedFiles = files.compactMap { $0 }
let audioFolder = preserveAudioFiles(completedFiles, meetingID: completedMeetingID)
lastRecoveryAudioFolderName = audioFolder?.lastPathComponent
let recoveryMessage = audioFolder == nil
? " The audio remains in the app's temporary folder."
: " Audio was kept so it can be recovered."
errorMessage = "System audio timing was invalid (\(mismatch)). Transcription was stopped to avoid an out-of-order result." + recoveryMessage
return transcriptChunks.filter(\.isFinal)
}
let model = UserDefaultsManager.shared.transcriptionModel let model = UserDefaultsManager.shared.transcriptionModel
async let micResult = transcribe(files[0], model: model) async let micResult = transcribe(files[0], model: model, diarization: false)
async let systemResult = transcribe(files[1], model: model) async let systemResult = transcribe(files[1], model: model, diarization: true)
let (micTranscription, systemTranscription) = await (micResult, systemResult) let (micTranscription, systemTranscription) = await (micResult, systemResult)
let results = [micTranscription, systemTranscription] let results = [micTranscription, systemTranscription]
@@ -109,16 +111,17 @@ final class AudioManager: NSObject, ObservableObject {
) )
transcriptChunks = updated transcriptChunks = updated
let completedFiles = files.compactMap { $0 } let completedFiles = files.compactMap { $0 }
if failures.isEmpty { let audioFolder = preserveAudioFiles(completedFiles, meetingID: completedMeetingID)
lastRecoveryAudioFolderName = nil lastRecoveryAudioFolderName = audioFolder?.lastPathComponent
removeAudioFiles(completedFiles) if !failures.isEmpty {
} else { let retentionDays = UserDefaultsManager.shared.audioRetentionDays
let recoveryFolder = preserveAudioFiles(completedFiles, sessionID: completedSessionID) let retentionUnit = retentionDays == 1 ? "day" : "days"
lastRecoveryAudioFolderName = recoveryFolder?.lastPathComponent let recoveryMessage = audioFolder == nil
let recoveryMessage = recoveryFolder == nil
? " The audio remains in the app's temporary folder." ? " The audio remains in the app's temporary folder."
: " Audio was saved in Documents/Meetingnotes-Recovery/\(completedSessionID.uuidString)." : " Audio was kept for \(retentionDays) \(retentionUnit). Use Show Audio Folder in the Meetingnotes menu to find it."
errorMessage = "Transcription failed for " + failures.joined(separator: "; ") + recoveryMessage errorMessage = "Transcription failed for " + failures.joined(separator: "; ") + recoveryMessage
} else if audioFolder == nil, !completedFiles.isEmpty {
errorMessage = "The transcript completed, but Meetingnotes could not move the audio into its retention folder."
} }
return updated return updated
} }
@@ -134,8 +137,8 @@ final class AudioManager: NSObject, ObservableObject {
let model = UserDefaultsManager.shared.transcriptionModel let model = UserDefaultsManager.shared.transcriptionModel
let micURL = recoveryFiles.first(where: { $0.source == .mic })?.url let micURL = recoveryFiles.first(where: { $0.source == .mic })?.url
let systemURL = recoveryFiles.first(where: { $0.source == .system })?.url let systemURL = recoveryFiles.first(where: { $0.source == .system })?.url
async let micResult = transcribe(micURL, model: model) async let micResult = transcribe(micURL, model: model, diarization: false)
async let systemResult = transcribe(systemURL, model: model) async let systemResult = transcribe(systemURL, model: model, diarization: true)
let (micTranscription, systemTranscription) = await (micResult, systemResult) let (micTranscription, systemTranscription) = await (micResult, systemResult)
let results = [micTranscription, systemTranscription] let results = [micTranscription, systemTranscription]
let (chunks, failures) = buildTranscriptChunks( let (chunks, failures) = buildTranscriptChunks(
@@ -158,10 +161,19 @@ final class AudioManager: NSObject, ObservableObject {
lastRecoveryAudioFolderName = nil lastRecoveryAudioFolderName = nil
} }
private func transcribe(_ fileURL: URL?, model: String) async -> Result<CoderAPIClient.Transcription, Error>? { private func transcribe(
_ fileURL: URL?,
model: String,
diarization: Bool
) async -> Result<CoderAPIClient.Transcription, Error>? {
guard let fileURL else { return nil } guard let fileURL else { return nil }
do { do {
return .success(try await CoderAPIClient.shared.transcribe(fileURL: fileURL, model: model)) return .success(try await CoderAPIClient.shared.transcribe(
fileURL: fileURL,
model: model,
diarization: diarization,
maxSpeakerCount: 4
))
} catch { } catch {
return .failure(error) return .failure(error)
} }
@@ -190,6 +202,7 @@ final class AudioManager: NSObject, ObservableObject {
updated.append(TranscriptChunk( updated.append(TranscriptChunk(
timestamp: captureStartedAt.addingTimeInterval(max(0, segment.start)), timestamp: captureStartedAt.addingTimeInterval(max(0, segment.start)),
source: source, source: source,
speaker: source == .system ? segment.speaker : nil,
text: text, text: text,
isFinal: true isFinal: true
)) ))
@@ -208,15 +221,18 @@ final class AudioManager: NSObject, ObservableObject {
private func prepareAudioFiles() throws { private func prepareAudioFiles() throws {
let settings: [String: Any] = [ let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC, AVFormatIDKey: kAudioFormatLinearPCM,
AVSampleRateKey: 16_000, AVSampleRateKey: 16_000,
AVNumberOfChannelsKey: 1, AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 48_000 AVLinearPCMBitDepthKey: 16,
AVLinearPCMIsFloatKey: false,
AVLinearPCMIsBigEndianKey: false,
AVLinearPCMIsNonInterleaved: false
] ]
let base = FileManager.default.temporaryDirectory let base = FileManager.default.temporaryDirectory
let id = sessionID.uuidString let id = sessionID.uuidString
let micURL = base.appendingPathComponent("meetingnotes-\(id)-mic.m4a") let micURL = base.appendingPathComponent("meetingnotes-\(id)-mic.wav")
let systemURL = base.appendingPathComponent("meetingnotes-\(id)-system.m4a") let systemURL = base.appendingPathComponent("meetingnotes-\(id)-system.wav")
let newMicAudioFile = try AVAudioFile( let newMicAudioFile = try AVAudioFile(
forWriting: micURL, forWriting: micURL,
settings: settings, settings: settings,
@@ -304,8 +320,7 @@ final class AudioManager: NSObject, ObservableObject {
return return
} }
let processIDs = audioProcessController.processes.map(\.objectID) let newTap = ProcessTap(target: .systemAudio)
let newTap = ProcessTap(target: .systemAudio(processObjectIDs: processIDs))
newTap.activate() newTap.activate()
if let tapError = newTap.errorMessage { if let tapError = newTap.errorMessage {
errorMessage = "Failed to activate system audio capture: \(tapError)" errorMessage = "Failed to activate system audio capture: \(tapError)"
@@ -329,21 +344,8 @@ final class AudioManager: NSObject, ObservableObject {
} }
} }
private func restartSystemAudioTapIfNeeded() async {
let next = Set(audioProcessController.processes.map(\.objectID))
let current: Set<AudioObjectID>
if case .systemAudio(let processIDs) = processTap?.target {
current = Set(processIDs)
} else {
current = []
}
if next != current { await restartSystemAudioTap() }
}
private func restartSystemAudioTap() async { private func restartSystemAudioTap() async {
guard isRecording else { return } guard isRecording else { return }
isRestartingSystemTap = true
defer { isRestartingSystemTap = false }
if isTapActive { if isTapActive {
processTap?.invalidate() processTap?.invalidate()
processTap = nil processTap = nil
@@ -366,13 +368,27 @@ 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 advertisedInputFormat = AVAudioFormat(streamDescription: &description),
let targetFormat = systemAudioFile?.processingFormat else { let targetFormat = systemAudioFile?.processingFormat,
advertisedInputFormat.sampleRate > 0 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"])
} }
var inputFormat: AVAudioFormat?
var converter: AVAudioConverter?
try tap.run(on: tapQueue) { [weak self] _, inputData, _, _, _ in try tap.run(on: tapQueue) { [weak self] _, inputData, _, _, _ in
guard let self, guard let self else { return }
let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else { return } if inputFormat == nil {
inputFormat = self.inputFormat(
for: inputData,
advertisedFormat: advertisedInputFormat
)
if let inputFormat {
converter = AVAudioConverter(from: inputFormat, to: targetFormat)
}
}
guard let inputFormat, let converter else { return }
// The tap queue is serial. Reusing the converter preserves its
// resampler state instead of discarding audio at every callback.
self.processAudioBuffer( self.processAudioBuffer(
{ self.copyAudioBuffer(from: inputData, format: inputFormat) }, { self.copyAudioBuffer(from: inputData, format: inputFormat) },
converter: converter, converter: converter,
@@ -380,11 +396,35 @@ final class AudioManager: NSObject, ObservableObject {
source: .system source: .system
) )
} invalidationHandler: { [weak self] _ in } invalidationHandler: { [weak self] _ in
guard let self, !self.isRestartingSystemTap, self.isRecording else { return } guard let self, self.isRecording else { return }
Task { await self.restartSystemAudioTap() } Task { await self.restartSystemAudioTap() }
} }
} }
private func inputFormat(
for inputData: UnsafePointer<AudioBufferList>,
advertisedFormat: AVAudioFormat
) -> AVAudioFormat? {
let buffers = UnsafeMutableAudioBufferListPointer(
UnsafeMutablePointer(mutating: inputData)
)
let channelCount = buffers.reduce(UInt32(0)) { $0 + $1.mNumberChannels }
guard channelCount > 0 else { return nil }
// HAL tap metadata can advertise interleaved stereo while the callback
// supplies one mono buffer per channel (or the reverse). Constructing a
// PCM buffer with that mismatched layout halves its frame count and
// produces 2x-speed system audio. The callback's AudioBufferList is the
// authoritative layout for the memory we are copying.
let isInterleaved = buffers.count == 1 && channelCount > 1
return AVAudioFormat(
commonFormat: advertisedFormat.commonFormat,
sampleRate: advertisedFormat.sampleRate,
channels: AVAudioChannelCount(channelCount),
interleaved: isInterleaved
)
}
private func copyAudioBuffer( private func copyAudioBuffer(
from inputData: UnsafePointer<AudioBufferList>, from inputData: UnsafePointer<AudioBufferList>,
format: AVAudioFormat format: AVAudioFormat
@@ -528,29 +568,92 @@ final class AudioManager: NSObject, ObservableObject {
for url in urls { try? FileManager.default.removeItem(at: url) } for url in urls { try? FileManager.default.removeItem(at: url) }
} }
private func preserveAudioFiles(_ urls: [URL], sessionID: UUID) -> URL? { private func preserveAudioFiles(_ urls: [URL], meetingID: UUID) -> URL? {
guard !urls.isEmpty, LocalStorageManager.shared.preserveAudioFiles(urls, for: meetingID)
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
return nil
} }
let folder = documents
.appendingPathComponent("Meetingnotes-Recovery", isDirectory: true) private func captureDurationMismatch(in files: [URL?]) -> String? {
.appendingPathComponent(sessionID.uuidString, isDirectory: true) guard files.count >= 2,
do { let micDuration = audioDuration(at: files[0]),
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) let systemDuration = audioDuration(at: files[1]),
} catch { micDuration >= 60 else { return nil }
return nil let ratio = systemDuration / micDuration
guard (0.45...0.55).contains(ratio) || (1.8...2.2).contains(ratio) else { return nil }
return String(format: "mic %.1fs, system %.1fs", micDuration, systemDuration)
} }
var preservedCount = 0
for url in urls { private func repairHalfDurationSystemWAVIfNeeded(in files: [URL?]) {
do { guard files.count >= 2,
try FileManager.default.moveItem(at: url, to: folder.appendingPathComponent(url.lastPathComponent)) let micDuration = audioDuration(at: files[0]),
preservedCount += 1 let systemURL = files[1],
} catch { let systemDuration = audioDuration(at: systemURL),
continue micDuration >= 60,
systemURL.pathExtension.caseInsensitiveCompare("wav") == .orderedSame,
(0.48...0.52).contains(systemDuration / micDuration) else { return }
try? halveWAVSampleRate(at: systemURL)
} }
private func halveWAVSampleRate(at url: URL) throws {
let handle = try FileHandle(forUpdating: url)
defer { try? handle.close() }
try handle.seek(toOffset: 0)
guard let riffHeader = try handle.read(upToCount: 12),
riffHeader.count == 12,
String(data: riffHeader[0..<4], encoding: .ascii) == "RIFF",
String(data: riffHeader[8..<12], encoding: .ascii) == "WAVE" else {
throw NSError(domain: "AudioManager", code: -2, userInfo: [NSLocalizedDescriptionKey: "Invalid WAV header"])
} }
return preservedCount > 0 ? folder : nil
var offset: UInt64 = 12
while true {
try handle.seek(toOffset: offset)
guard let chunkHeader = try handle.read(upToCount: 8), chunkHeader.count == 8 else { break }
let chunkID = String(data: chunkHeader[0..<4], encoding: .ascii)
let chunkSize = UInt32(chunkHeader[4])
| (UInt32(chunkHeader[5]) << 8)
| (UInt32(chunkHeader[6]) << 16)
| (UInt32(chunkHeader[7]) << 24)
let chunkDataOffset = offset + 8
if chunkID == "fmt ", chunkSize >= 16 {
try handle.seek(toOffset: chunkDataOffset)
guard let format = try handle.read(upToCount: 16), format.count == 16 else { break }
let audioFormat = UInt16(format[0]) | (UInt16(format[1]) << 8)
let blockAlign = UInt16(format[12]) | (UInt16(format[13]) << 8)
let sampleRate = UInt32(format[4])
| (UInt32(format[5]) << 8)
| (UInt32(format[6]) << 16)
| (UInt32(format[7]) << 24)
guard audioFormat == 1, sampleRate >= 16_000, sampleRate.isMultiple(of: 2) else {
throw NSError(domain: "AudioManager", code: -3, userInfo: [NSLocalizedDescriptionKey: "Unsupported WAV format"])
}
let correctedSampleRate = sampleRate / 2
let correctedByteRate = correctedSampleRate * UInt32(blockAlign)
try handle.seek(toOffset: chunkDataOffset + 4)
try handle.write(contentsOf: littleEndianData(correctedSampleRate))
try handle.write(contentsOf: littleEndianData(correctedByteRate))
try handle.synchronize()
return
}
offset = chunkDataOffset + UInt64(chunkSize) + UInt64(chunkSize % 2)
}
throw NSError(domain: "AudioManager", code: -4, userInfo: [NSLocalizedDescriptionKey: "WAV format chunk was not found"])
}
private func littleEndianData(_ value: UInt32) -> Data {
var littleEndianValue = value.littleEndian
return withUnsafeBytes(of: &littleEndianValue) { Data($0) }
}
private func audioDuration(at url: URL?) -> TimeInterval? {
guard let url,
let file = try? AVAudioFile(forReading: url),
file.processingFormat.sampleRate > 0 else { return nil }
return Double(file.length) / file.processingFormat.sampleRate
} }
private func resetAudioLevels() { private func resetAudioLevels() {
@@ -2,6 +2,7 @@
// Handles local storage of meetings and app data // Handles local storage of meetings and app data
import Foundation import Foundation
import AppKit
/// Manages local file storage for meetings and app data /// Manages local file storage for meetings and app data
class LocalStorageManager { class LocalStorageManager {
@@ -28,7 +29,7 @@ class LocalStorageManager {
// Create templates subdirectory // Create templates subdirectory
templatesDirectory = documentsDirectory.appendingPathComponent("Templates") templatesDirectory = documentsDirectory.appendingPathComponent("Templates")
recoveryDirectory = documentsDirectory.appendingPathComponent("Meetingnotes-Recovery") recoveryDirectory = documentsDirectory.appendingPathComponent("Meetingnotes Audio")
// Ensure directories exist // Ensure directories exist
try? FileManager.default.createDirectory(at: meetingsDirectory, try? FileManager.default.createDirectory(at: meetingsDirectory,
@@ -37,6 +38,8 @@ class LocalStorageManager {
withIntermediateDirectories: true) withIntermediateDirectories: true)
try? FileManager.default.createDirectory(at: recoveryDirectory, try? FileManager.default.createDirectory(at: recoveryDirectory,
withIntermediateDirectories: true) withIntermediateDirectories: true)
migrateLegacyRecoveryAudio()
purgeExpiredAudioFolders()
} }
// MARK: - Meeting Management // MARK: - Meeting Management
@@ -151,6 +154,67 @@ class LocalStorageManager {
// MARK: - Recovery Audio // MARK: - Recovery Audio
func preserveAudioFiles(_ urls: [URL], for meetingID: UUID) -> URL? {
guard !urls.isEmpty else { return nil }
purgeExpiredAudioFolders()
let folder = recoveryDirectory.appendingPathComponent(meetingID.uuidString, isDirectory: true)
do {
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
} catch {
return nil
}
var preservedCount = 0
for url in urls {
let destination = folder.appendingPathComponent(url.lastPathComponent)
do {
if FileManager.default.fileExists(atPath: destination.path) {
try FileManager.default.removeItem(at: destination)
}
try FileManager.default.moveItem(at: url, to: destination)
preservedCount += 1
} catch {
continue
}
}
return preservedCount > 0 ? folder : nil
}
func purgeExpiredAudioFolders(now: Date = Date()) {
guard let folders = try? FileManager.default.contentsOfDirectory(
at: recoveryDirectory,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
) else { return }
let retentionInterval = TimeInterval(UserDefaultsManager.shared.audioRetentionDays) * 24 * 60 * 60
let expirationDate = now.addingTimeInterval(-retentionInterval)
for folder in folders {
guard (try? folder.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { continue }
let audioFiles = recoveryAudioFiles(in: folder)
let newestDate = audioFiles.compactMap { file -> Date? in
let values = try? file.url.resourceValues(forKeys: [.contentModificationDateKey, .creationDateKey])
return values?.contentModificationDate ?? values?.creationDate
}.max()
guard let newestDate else {
try? FileManager.default.removeItem(at: folder)
continue
}
if newestDate < expirationDate {
try? FileManager.default.removeItem(at: folder)
}
}
}
@MainActor
func showAudioFolderInFinder(_ folder: URL? = nil) {
let target = folder ?? recoveryDirectory
try? FileManager.default.createDirectory(at: target, withIntermediateDirectories: true)
NSWorkspace.shared.open(target)
}
func recoveryAudioFolder(named name: String) -> URL? { func recoveryAudioFolder(named name: String) -> URL? {
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty, guard !trimmedName.isEmpty,
@@ -245,6 +309,22 @@ class LocalStorageManager {
try? FileManager.default.removeItem(at: folder) try? FileManager.default.removeItem(at: folder)
} }
private func migrateLegacyRecoveryAudio() {
let legacyDirectory = documentsDirectory.appendingPathComponent("Meetingnotes-Recovery", isDirectory: true)
guard let folders = try? FileManager.default.contentsOfDirectory(
at: legacyDirectory,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
) else { return }
for folder in folders {
let destination = recoveryDirectory.appendingPathComponent(folder.lastPathComponent, isDirectory: true)
guard !FileManager.default.fileExists(atPath: destination.path) else { continue }
try? FileManager.default.moveItem(at: folder, to: destination)
}
try? FileManager.default.removeItem(at: legacyDirectory)
}
/// Imports meeting JSON files from a folder selected by the user. /// Imports meeting JSON files from a folder selected by the user.
func importMeetings(from directory: URL) throws -> MeetingImportResult { func importMeetings(from directory: URL) throws -> MeetingImportResult {
let didStartAccess = directory.startAccessingSecurityScopedResource() let didStartAccess = directory.startAccessingSecurityScopedResource()
@@ -81,7 +81,7 @@ class RecordingSessionManager: ObservableObject {
activeMeetingId = meetingId activeMeetingId = meetingId
recordingStartedAt = Date() recordingStartedAt = Date()
audioManager.startRecording() audioManager.startRecording(for: meetingId)
} }
func stopRecording() async -> [TranscriptChunk] { func stopRecording() async -> [TranscriptChunk] {
@@ -23,6 +23,7 @@ class UserDefaultsManager {
static let transcriptionModel = "transcriptionModel" static let transcriptionModel = "transcriptionModel"
static let muteDeckAPIEnabled = "muteDeckAPIEnabled" static let muteDeckAPIEnabled = "muteDeckAPIEnabled"
static let muteDeckAPIPort = "muteDeckAPIPort" static let muteDeckAPIPort = "muteDeckAPIPort"
static let audioRetentionDays = "audioRetentionDays"
} }
// MARK: - User Blurb // MARK: - User Blurb
@@ -78,7 +79,7 @@ class UserDefaultsManager {
} }
var transcriptionModel: String { var transcriptionModel: String {
get { userDefaults.string(forKey: Keys.transcriptionModel) ?? "groq/whisper-large-v3-turbo" } get { userDefaults.string(forKey: Keys.transcriptionModel) ?? "local-parakeet/parakeet-tdt-0.6b-v3" }
set { userDefaults.set(newValue, forKey: Keys.transcriptionModel) } set { userDefaults.set(newValue, forKey: Keys.transcriptionModel) }
} }
@@ -94,4 +95,12 @@ class UserDefaultsManager {
} }
set { userDefaults.set(newValue, forKey: Keys.muteDeckAPIPort) } set { userDefaults.set(newValue, forKey: Keys.muteDeckAPIPort) }
} }
var audioRetentionDays: Int {
get {
guard userDefaults.object(forKey: Keys.audioRetentionDays) != nil else { return 3 }
return min(max(userDefaults.integer(forKey: Keys.audioRetentionDays), 1), 365)
}
set { userDefaults.set(min(max(newValue, 1), 365), forKey: Keys.audioRetentionDays) }
}
} }
+10
View File
@@ -46,6 +46,10 @@ struct MeetingnotesApp: App {
.commands { .commands {
CommandGroup(after: .appInfo) { CommandGroup(after: .appInfo) {
CheckForUpdatesView(updater: updaterController.updater) CheckForUpdatesView(updater: updaterController.updater)
Divider()
Button("Show Audio Folder") {
LocalStorageManager.shared.showAudioFolderInFinder()
}
} }
} }
@@ -79,6 +83,12 @@ private struct MeetingnotesMenu: View {
Label("Open Meetingnotes", systemImage: "macwindow") Label("Open Meetingnotes", systemImage: "macwindow")
} }
Button {
LocalStorageManager.shared.showAudioFolderInFinder()
} label: {
Label("Show Audio Folder", systemImage: "folder")
}
Divider() Divider()
Button { Button {
+21 -4
View File
@@ -48,30 +48,46 @@ struct TranscriptChunk: Codable, Identifiable, Hashable {
let id: UUID let id: UUID
let timestamp: Date let timestamp: Date
let source: AudioSource let source: AudioSource
let speaker: Int?
let text: String let text: String
let isFinal: Bool let isFinal: Bool
init(id: UUID = UUID(), timestamp: Date = Date(), source: AudioSource, text: String, isFinal: Bool = false) { init(id: UUID = UUID(), timestamp: Date = Date(), source: AudioSource, speaker: Int? = nil, text: String, isFinal: Bool = false) {
self.id = id self.id = id
self.timestamp = timestamp self.timestamp = timestamp
self.source = source self.source = source
self.speaker = speaker
self.text = text self.text = text
self.isFinal = isFinal self.isFinal = isFinal
} }
var displayName: String {
if source == .mic { return "Me" }
if let speaker { return "Speaker \(speaker)" }
return source.displayName
}
} }
struct CollapsedTranscriptChunk: Identifiable { struct CollapsedTranscriptChunk: Identifiable {
let id: UUID let id: UUID
let timestamp: Date let timestamp: Date
let source: AudioSource let source: AudioSource
let speaker: Int?
let combinedText: String let combinedText: String
init(id: UUID = UUID(), timestamp: Date, source: AudioSource, combinedText: String) { init(id: UUID = UUID(), timestamp: Date, source: AudioSource, speaker: Int? = nil, combinedText: String) {
self.id = id self.id = id
self.timestamp = timestamp self.timestamp = timestamp
self.source = source self.source = source
self.speaker = speaker
self.combinedText = combinedText self.combinedText = combinedText
} }
var displayName: String {
if source == .mic { return "Me" }
if let speaker { return "Speaker \(speaker)" }
return source.displayName
}
} }
struct Meeting: Codable, Identifiable, Hashable { struct Meeting: Codable, Identifiable, Hashable {
@@ -115,7 +131,7 @@ struct Meeting: Codable, Identifiable, Hashable {
var transcript: String { var transcript: String {
return transcriptChunks return transcriptChunks
.filter { $0.isFinal } .filter { $0.isFinal }
.map { "[\($0.source.rawValue)] \($0.text)" } .map { "[\($0.displayName)] \($0.text)" }
.joined(separator: " ") .joined(separator: " ")
} }
@@ -124,7 +140,7 @@ struct Meeting: Codable, Identifiable, Hashable {
let finalChunks = transcriptChunks.filter { $0.isFinal } let finalChunks = transcriptChunks.filter { $0.isFinal }
return finalChunks.map { chunk in return finalChunks.map { chunk in
"[\(TranscriptTimestampFormatter.string(from: chunk.timestamp))] \(chunk.source.copyPrefix): \(chunk.text)" "[\(TranscriptTimestampFormatter.string(from: chunk.timestamp))] \(chunk.displayName): \(chunk.text)"
}.joined(separator: "\n") }.joined(separator: "\n")
} }
@@ -135,6 +151,7 @@ struct Meeting: Codable, Identifiable, Hashable {
id: chunk.id, id: chunk.id,
timestamp: chunk.timestamp, timestamp: chunk.timestamp,
source: chunk.source, source: chunk.source,
speaker: chunk.speaker,
combinedText: chunk.text combinedText: chunk.text
) )
} }
+5
View File
@@ -54,6 +54,11 @@ struct Settings: Codable {
set { UserDefaultsManager.shared.muteDeckAPIPort = newValue } set { UserDefaultsManager.shared.muteDeckAPIPort = newValue }
} }
var audioRetentionDays: Int {
get { UserDefaultsManager.shared.audioRetentionDays }
set { UserDefaultsManager.shared.audioRetentionDays = newValue }
}
// System prompt default loading // System prompt default loading
static func defaultSystemPrompt() -> String { static func defaultSystemPrompt() -> String {
guard let path = Bundle.main.path(forResource: "DefaultSystemPrompt", ofType: "txt"), guard let path = Bundle.main.path(forResource: "DefaultSystemPrompt", ofType: "txt"),
+7 -7
View File
@@ -5,7 +5,7 @@ import AVFoundation
enum TapTarget { enum TapTarget {
case singleProcess(AudioProcess) case singleProcess(AudioProcess)
case systemAudio(processObjectIDs: [AudioObjectID]) case systemAudio
var displayName: String { var displayName: String {
switch self { switch self {
@@ -137,12 +137,12 @@ final class ProcessTap {
case .singleProcess(let process): case .singleProcess(let process):
tapDescription = CATapDescription(stereoMixdownOfProcesses: [process.objectID]) tapDescription = CATapDescription(stereoMixdownOfProcesses: [process.objectID])
logger.debug("Configuring tap for single process objectID: \(process.objectID)") logger.debug("Configuring tap for single process objectID: \(process.objectID)")
case .systemAudio(let processObjectIDs): case .systemAudio:
if processObjectIDs.isEmpty { // Keep the HAL tap's buffer layout consistent with the default
logger.warning("System audio tap configured with an empty list of processObjectIDs. This might not capture any audio or behave unexpectedly.") // output stream. AudioManager performs the stereo-to-mono mix when
} // it converts the captured audio to the 16 kHz transcription file.
tapDescription = CATapDescription(monoMixdownOfProcesses: processObjectIDs) tapDescription = CATapDescription(stereoGlobalTapButExcludeProcesses: [])
logger.debug("Configuring tap for system audio output using \(processObjectIDs.count) explicit processes.") logger.debug("Configuring a stereo global system audio tap.")
} }
tapDescription.uuid = UUID() tapDescription.uuid = UUID()
+231 -7
View File
@@ -1,3 +1,4 @@
import AVFoundation
import Foundation import Foundation
struct CoderModel: Codable, Identifiable, Hashable { struct CoderModel: Codable, Identifiable, Hashable {
@@ -20,6 +21,7 @@ struct CoderModel: Codable, Identifiable, Hashable {
var supportsChat: Bool { capabilities.isEmpty || capabilities.contains("chat") } var supportsChat: Bool { capabilities.isEmpty || capabilities.contains("chat") }
var supportsTranscription: Bool { capabilities.contains("audio_transcription") } var supportsTranscription: Bool { capabilities.contains("audio_transcription") }
var supportsSpeakerDiarization: Bool { capabilities.contains("speaker_diarization") }
} }
enum CoderAPIError: LocalizedError { enum CoderAPIError: LocalizedError {
@@ -53,6 +55,7 @@ final class CoderAPIClient {
let start: TimeInterval let start: TimeInterval
let end: TimeInterval let end: TimeInterval
let text: String let text: String
let speaker: Int?
} }
let text: String let text: String
@@ -69,10 +72,25 @@ final class CoderAPIClient {
} }
private struct TranscriptionResponse: Decodable { private struct TranscriptionResponse: Decodable {
let text: String struct Word: Decodable {
let segments: [Transcription.Segment]? let word: String
let start: TimeInterval
let end: TimeInterval
let speaker: Int?
} }
let text: String
let segments: [Transcription.Segment]?
let words: [Word]?
}
private struct AudioChunk {
let url: URL
let offset: TimeInterval
let isTemporary: Bool
}
private let transcriptionChunkDuration: TimeInterval = 3 * 60
private let transcriptionSession: URLSession private let transcriptionSession: URLSession
private init() { private init() {
@@ -151,15 +169,85 @@ final class CoderAPIClient {
} }
} }
func transcribe(fileURL: URL, model: String, language: String = "en") async throws -> Transcription { func transcribe(
fileURL: URL,
model: String,
language: String = "en",
diarization: Bool = false,
maxSpeakerCount: Int = 4
) async throws -> Transcription {
let selectedModel = model.trimmingCharacters(in: .whitespacesAndNewlines) let selectedModel = model.trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedModel.isEmpty else { throw CoderAPIError.missingModel("transcription") } guard !selectedModel.isEmpty else { throw CoderAPIError.missingModel("transcription") }
let apiKey = try requiredAPIKey(KeychainHelper.shared.getCoderAPIKey() ?? "") let apiKey = try requiredAPIKey(KeychainHelper.shared.getCoderAPIKey() ?? "")
let chunks = try makeAudioChunks(from: fileURL, preserveSpeakerIdentity: diarization)
defer {
for chunk in chunks where chunk.isTemporary {
try? FileManager.default.removeItem(at: chunk.url)
}
}
var textParts: [String] = []
var segments: [Transcription.Segment] = []
var lastNormalizedText = ""
var consecutiveDuplicateCount = 0
for chunk in chunks {
let transcription = try await transcribeChunk(
chunk.url,
model: selectedModel,
language: language,
apiKey: apiKey,
diarization: diarization,
maxSpeakerCount: maxSpeakerCount
)
if transcription.segments.isEmpty {
let text = transcription.text.trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty {
textParts.append(text)
segments.append(.init(start: chunk.offset, end: chunk.offset, text: text, speaker: nil))
}
continue
}
for segment in transcription.segments {
let text = segment.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { continue }
let normalized = text.lowercased()
if normalized == lastNormalizedText {
consecutiveDuplicateCount += 1
} else {
lastNormalizedText = normalized
consecutiveDuplicateCount = 1
}
guard consecutiveDuplicateCount <= 2 else { continue }
textParts.append(text)
segments.append(.init(
start: segment.start + chunk.offset,
end: segment.end + chunk.offset,
text: text,
speaker: segment.speaker
))
}
}
return Transcription(text: textParts.joined(separator: "\n"), segments: segments)
}
private func transcribeChunk(
_ fileURL: URL,
model: String,
language: String,
apiKey: String,
diarization: Bool,
maxSpeakerCount: Int
) async throws -> Transcription {
let boundary = "Meetingnotes-\(UUID().uuidString)" let boundary = "Meetingnotes-\(UUID().uuidString)"
let bodyURL = try makeMultipartBody( let bodyURL = try makeMultipartBody(
audioURL: fileURL, audioURL: fileURL,
model: selectedModel, model: model,
language: language, language: language,
diarization: diarization,
maxSpeakerCount: maxSpeakerCount,
boundary: boundary boundary: boundary
) )
defer { try? FileManager.default.removeItem(at: bodyURL) } defer { try? FileManager.default.removeItem(at: bodyURL) }
@@ -175,7 +263,132 @@ final class CoderAPIClient {
let (data, response) = try await transcriptionSession.upload(for: request, fromFile: bodyURL) let (data, response) = try await transcriptionSession.upload(for: request, fromFile: bodyURL)
try validate(response: response, data: data) try validate(response: response, data: data)
let decoded = try JSONDecoder().decode(TranscriptionResponse.self, from: data) let decoded = try JSONDecoder().decode(TranscriptionResponse.self, from: data)
return Transcription(text: decoded.text, segments: decoded.segments ?? []) let segments = decoded.segments ?? segments(from: decoded.words ?? [])
return Transcription(text: decoded.text, segments: segments)
}
private func makeAudioChunks(from fileURL: URL, preserveSpeakerIdentity: Bool) throws -> [AudioChunk] {
let input = try AVAudioFile(forReading: fileURL)
let format = input.processingFormat
guard format.sampleRate > 0 else { throw CoderAPIError.invalidResponse }
let framesPerChunk = preserveSpeakerIdentity
? max(1, input.length)
: AVAudioFramePosition(format.sampleRate * transcriptionChunkDuration)
if fileURL.pathExtension.caseInsensitiveCompare("wav") == .orderedSame,
input.fileFormat.streamDescription.pointee.mFormatID == kAudioFormatLinearPCM,
input.length <= framesPerChunk {
return [AudioChunk(url: fileURL, offset: 0, isTemporary: false)]
}
var chunks: [AudioChunk] = []
var frameOffset: AVAudioFramePosition = 0
do {
while frameOffset < input.length {
let frameCount = min(framesPerChunk, input.length - frameOffset)
let chunkURL = FileManager.default.temporaryDirectory
.appendingPathComponent("meetingnotes-transcription-\(UUID().uuidString).wav")
try writeAudioChunk(
from: input,
frameCount: frameCount,
format: format,
to: chunkURL
)
chunks.append(AudioChunk(
url: chunkURL,
offset: Double(frameOffset) / format.sampleRate,
isTemporary: true
))
frameOffset += frameCount
}
return chunks
} catch {
for chunk in chunks {
try? FileManager.default.removeItem(at: chunk.url)
}
throw error
}
}
private func writeAudioChunk(
from input: AVAudioFile,
frameCount: AVAudioFramePosition,
format: AVAudioFormat,
to outputURL: URL
) throws {
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatLinearPCM,
AVSampleRateKey: format.sampleRate,
AVNumberOfChannelsKey: format.channelCount,
AVLinearPCMBitDepthKey: 16,
AVLinearPCMIsFloatKey: false,
AVLinearPCMIsBigEndianKey: false,
AVLinearPCMIsNonInterleaved: false
]
let output = try AVAudioFile(
forWriting: outputURL,
settings: settings,
commonFormat: format.commonFormat,
interleaved: format.isInterleaved
)
var remaining = frameCount
while remaining > 0 {
let requestedFrames = AVAudioFrameCount(min(remaining, 8_192))
guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: requestedFrames) else {
throw CoderAPIError.invalidResponse
}
try input.read(into: buffer, frameCount: requestedFrames)
guard buffer.frameLength > 0 else { break }
try output.write(from: buffer)
remaining -= AVAudioFramePosition(buffer.frameLength)
}
}
private func segments(from words: [TranscriptionResponse.Word]) -> [Transcription.Segment] {
var result: [Transcription.Segment] = []
var currentWords: [String] = []
var currentStart: TimeInterval?
var currentEnd: TimeInterval = 0
var currentSpeaker: Int?
func flush() {
guard let start = currentStart, !currentWords.isEmpty else { return }
result.append(.init(
start: start,
end: currentEnd,
text: currentWords.joined(separator: " "),
speaker: currentSpeaker
))
currentWords.removeAll(keepingCapacity: true)
currentStart = nil
currentEnd = 0
currentSpeaker = nil
}
for word in words {
let text = word.word.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { continue }
let speakerChanged = currentStart != nil && word.speaker != currentSpeaker
let longPause = currentStart != nil && word.start - currentEnd > 1.5
if speakerChanged || longPause { flush() }
if currentStart == nil {
currentStart = word.start
currentSpeaker = word.speaker
}
currentWords.append(text)
currentEnd = word.end
let sentenceEnded = text.last.map { ".!?".contains($0) } ?? false
let duration = currentEnd - (currentStart ?? currentEnd)
if currentWords.count >= 40 || (sentenceEnded && (currentWords.count >= 12 || duration >= 8)) {
flush()
}
}
flush()
return result
} }
private func endpoint(baseURL: String, path: String) throws -> URL { private func endpoint(baseURL: String, path: String) throws -> URL {
@@ -207,7 +420,14 @@ final class CoderAPIClient {
} }
} }
private func makeMultipartBody(audioURL: URL, model: String, language: String, boundary: String) throws -> URL { private func makeMultipartBody(
audioURL: URL,
model: String,
language: String,
diarization: Bool,
maxSpeakerCount: Int,
boundary: String
) throws -> URL {
let bodyURL = FileManager.default.temporaryDirectory.appendingPathComponent("meetingnotes-upload-\(UUID().uuidString).body") let bodyURL = FileManager.default.temporaryDirectory.appendingPathComponent("meetingnotes-upload-\(UUID().uuidString).body")
_ = FileManager.default.createFile(atPath: bodyURL.path, contents: nil) _ = FileManager.default.createFile(atPath: bodyURL.path, contents: nil)
let output = try FileHandle(forWritingTo: bodyURL) let output = try FileHandle(forWritingTo: bodyURL)
@@ -219,7 +439,11 @@ final class CoderAPIClient {
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"model\"\r\n\r\n\(model)\r\n") try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"model\"\r\n\r\n\(model)\r\n")
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"language\"\r\n\r\n\(language)\r\n") try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"language\"\r\n\r\n\(language)\r\n")
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"response_format\"\r\n\r\nverbose_json\r\n") try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"response_format\"\r\n\r\nverbose_json\r\n")
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(audioURL.lastPathComponent)\"\r\nContent-Type: audio/mp4\r\n\r\n") if diarization {
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"diarization\"\r\n\r\ntrue\r\n")
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"max_speaker_count\"\r\n\r\n\(maxSpeakerCount)\r\n")
}
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(audioURL.lastPathComponent)\"\r\nContent-Type: audio/wav\r\n\r\n")
let input = try FileHandle(forReadingFrom: audioURL) let input = try FileHandle(forReadingFrom: audioURL)
defer { try? input.close() } defer { try? input.close() }
while let chunk = try input.read(upToCount: 1 << 20), !chunk.isEmpty { while let chunk = try input.read(upToCount: 1 << 20), !chunk.isEmpty {
@@ -244,10 +244,6 @@ class MeetingViewModel: ObservableObject {
throw CocoaError(.fileWriteUnknown) throw CocoaError(.fileWriteUnknown)
} }
LocalStorageManager.shared.deleteRecoveryAudioFolder(recoveryAudioFolderURL)
meeting.recoveryAudioFolderName = nil
self.recoveryAudioFolderURL = nil
saveMeeting()
await generateNotes() await generateNotes()
} catch { } catch {
errorMessage = error.localizedDescription errorMessage = error.localizedDescription
@@ -263,6 +259,11 @@ class MeetingViewModel: ObservableObject {
} }
} }
func showAudioInFinder() {
guard let recoveryAudioFolderURL else { return }
LocalStorageManager.shared.showAudioFolderInFinder(recoveryAudioFolderURL)
}
func loadTemplates() { func loadTemplates() {
templates = LocalStorageManager.shared.loadTemplates() templates = LocalStorageManager.shared.loadTemplates()
@@ -71,6 +71,7 @@ class SettingsViewModel: ObservableObject {
// via computed properties when they're modified // via computed properties when they're modified
let coderSaved = KeychainHelper.shared.saveCoderAPIKey(settings.coderAPIKey) let coderSaved = KeychainHelper.shared.saveCoderAPIKey(settings.coderAPIKey)
LocalAPIServer.shared.applyConfiguration() LocalAPIServer.shared.applyConfiguration()
LocalStorageManager.shared.purgeExpiredAudioFolders()
if showMessage { if showMessage {
if coderSaved { if coderSaved {
+8 -2
View File
@@ -208,12 +208,12 @@ struct CollapsedTranscriptChunkView: View {
.font(.caption) .font(.caption)
.foregroundColor(chunk.source == .mic ? .blue : .orange) .foregroundColor(chunk.source == .mic ? .blue : .orange)
Text(chunk.source.displayName) Text(chunk.displayName)
.font(.caption) .font(.caption)
.fontWeight(.medium) .fontWeight(.medium)
.foregroundColor(chunk.source == .mic ? .blue : .orange) .foregroundColor(chunk.source == .mic ? .blue : .orange)
} }
.frame(width: 50, alignment: .leading) .frame(width: 78, alignment: .leading)
// Transcript text // Transcript text
Text(chunk.combinedText) Text(chunk.combinedText)
@@ -267,6 +267,12 @@ struct MeetingDetailContentView: View {
} }
.disabled(!viewModel.canRetryTranscription) .disabled(!viewModel.canRetryTranscription)
Button {
viewModel.showAudioInFinder()
} label: {
Label("Show Audio in Finder", systemImage: "folder")
}
Divider() Divider()
} }
+29
View File
@@ -58,6 +58,18 @@ struct SettingsView: View {
Text(model.displayName).tag(model.id) Text(model.displayName).tag(model.id)
} }
} }
if let selectedModel = viewModel.coderModels.first(where: { $0.id == viewModel.settings.transcriptionModel }) {
if selectedModel.supportsSpeakerDiarization {
Label("Remote participants are labeled Speaker 14; your microphone is labeled Me.", systemImage: "person.2.wave.2")
.font(.caption)
.foregroundColor(.secondary)
} else {
Label("Remote participants are labeled Them; your microphone is labeled Me.", systemImage: "person.2")
.font(.caption)
.foregroundColor(.secondary)
}
}
} }
Text("The token is stored locally in Keychain. Audio and note generation are sent only to this Coder service.") Text("The token is stored locally in Keychain. Audio and note generation are sent only to this Coder service.")
@@ -128,6 +140,23 @@ struct SettingsView: View {
Text("Meeting Storage") Text("Meeting Storage")
.font(.headline) .font(.headline)
LabeledContent("Audio retention") {
Stepper(
value: $viewModel.settings.audioRetentionDays,
in: 1...365
) {
Text("\(viewModel.settings.audioRetentionDays) \(viewModel.settings.audioRetentionDays == 1 ? "day" : "days")")
.monospacedDigit()
.frame(minWidth: 70, alignment: .trailing)
}
}
Button {
LocalStorageManager.shared.showAudioFolderInFinder()
} label: {
Label("Show Audio Folder", systemImage: "folder")
}
Button { Button {
showingMeetingImporter = true showingMeetingImporter = true
} label: { } label: {
+32 -2
View File
@@ -14,6 +14,9 @@ APP_PATH="$DERIVED_DATA/Build/Products/Release/$APP_NAME.app"
required_variables=( required_variables=(
VERSION VERSION
SIGNING_IDENTITY SIGNING_IDENTITY
APPLE_ID
APPLE_TEAM_ID
APPLE_APP_PASSWORD
SPARKLE_PRIVATE_KEY SPARKLE_PRIVATE_KEY
GITHUB_REPOSITORY GITHUB_REPOSITORY
) )
@@ -80,6 +83,33 @@ ARCHIVE_NAME="$APP_NAME-$VERSION.zip"
ARCHIVE_PATH="$RELEASE_DIR/$ARCHIVE_NAME" ARCHIVE_PATH="$RELEASE_DIR/$ARCHIVE_NAME"
ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ARCHIVE_PATH" ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ARCHIVE_PATH"
NOTARY_RESULT="$BUILD_ROOT/notary-result.json"
xcrun notarytool submit "$ARCHIVE_PATH" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_PASSWORD" \
--wait \
--timeout 20m \
--output-format json > "$NOTARY_RESULT"
NOTARY_STATUS=$(plutil -extract status raw -o - "$NOTARY_RESULT")
if [[ "$NOTARY_STATUS" != "Accepted" ]]; then
submission_id=$(plutil -extract id raw -o - "$NOTARY_RESULT")
xcrun notarytool log "$submission_id" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_PASSWORD" || true
echo "Apple notarization failed with status: $NOTARY_STATUS" >&2
exit 1
fi
xcrun stapler staple "$APP_PATH"
xcrun stapler validate "$APP_PATH"
spctl --assess --type execute --verbose=2 "$APP_PATH"
rm -f "$ARCHIVE_PATH"
ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ARCHIVE_PATH"
GENERATE_APPCAST=$(find "$DERIVED_DATA/SourcePackages/artifacts" -type f -name generate_appcast -print -quit) GENERATE_APPCAST=$(find "$DERIVED_DATA/SourcePackages/artifacts" -type f -name generate_appcast -print -quit)
if [[ -z "$GENERATE_APPCAST" ]]; then if [[ -z "$GENERATE_APPCAST" ]]; then
echo "Sparkle generate_appcast tool was not found" >&2 echo "Sparkle generate_appcast tool was not found" >&2
@@ -97,8 +127,8 @@ grep -q "$DOWNLOAD_URL$ARCHIVE_NAME" "$RELEASE_DIR/appcast.xml"
grep -q 'sparkle:edSignature=' "$RELEASE_DIR/appcast.xml" grep -q 'sparkle:edSignature=' "$RELEASE_DIR/appcast.xml"
if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
printf 'Built and Developer ID-signed Meetingnotes %s. The GitHub release is ready to publish.\n' \ printf 'Built, Developer ID-signed, notarized, and stapled Meetingnotes %s. The GitHub release is ready to publish.\n' \
"$VERSION" >> "$GITHUB_STEP_SUMMARY" "$VERSION" >> "$GITHUB_STEP_SUMMARY"
fi fi
echo "Signed release artifacts are ready in $RELEASE_DIR" echo "Signed and notarized release artifacts are ready in $RELEASE_DIR"