Compare commits

...
5 Commits
11 changed files with 326 additions and 31 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 = 26; CURRENT_PROJECT_VERSION = 31;
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.14; MARKETING_VERSION = 1.1.19;
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 = 26; CURRENT_PROJECT_VERSION = 31;
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.14; MARKETING_VERSION = 1.1.19;
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;
+25 -11
View File
@@ -87,8 +87,8 @@ final class AudioManager: NSObject, ObservableObject {
} }
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]
@@ -102,12 +102,14 @@ final class AudioManager: NSObject, ObservableObject {
let audioFolder = preserveAudioFiles(completedFiles, meetingID: completedMeetingID) let audioFolder = preserveAudioFiles(completedFiles, meetingID: completedMeetingID)
lastRecoveryAudioFolderName = audioFolder?.lastPathComponent lastRecoveryAudioFolderName = audioFolder?.lastPathComponent
if !failures.isEmpty { if !failures.isEmpty {
let retentionDays = UserDefaultsManager.shared.audioRetentionDays
let retentionUnit = retentionDays == 1 ? "day" : "days"
let recoveryMessage = audioFolder == nil let recoveryMessage = audioFolder == nil
? " The audio remains in the app's temporary folder." ? " The audio remains in the app's temporary folder."
: " Audio was kept for three days. Use Show Audio Folder in the Meetingnotes menu to find it." : " 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 { } else if audioFolder == nil, !completedFiles.isEmpty {
errorMessage = "The transcript completed, but Meetingnotes could not move the audio into its three-day storage folder." errorMessage = "The transcript completed, but Meetingnotes could not move the audio into its retention folder."
} }
return updated return updated
} }
@@ -123,8 +125,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(
@@ -147,10 +149,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)
} }
@@ -179,6 +190,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
)) ))
@@ -342,12 +354,14 @@ 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 else { let targetFormat = systemAudioFile?.processingFormat,
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 else { return }
let converter = AVAudioConverter(from: inputFormat, to: targetFormat) 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,
@@ -17,7 +17,6 @@ class LocalStorageManager {
private let meetingsDirectory: URL private let meetingsDirectory: URL
private let templatesDirectory: URL private let templatesDirectory: URL
private let recoveryDirectory: URL private let recoveryDirectory: URL
private let audioRetentionInterval: TimeInterval = 3 * 24 * 60 * 60
private init() { private init() {
// Get the app's documents directory // Get the app's documents directory
@@ -189,7 +188,8 @@ class LocalStorageManager {
options: [.skipsHiddenFiles] options: [.skipsHiddenFiles]
) else { return } ) else { return }
let expirationDate = now.addingTimeInterval(-audioRetentionInterval) let retentionInterval = TimeInterval(UserDefaultsManager.shared.audioRetentionDays) * 24 * 60 * 60
let expirationDate = now.addingTimeInterval(-retentionInterval)
for folder in folders { for folder in folders {
guard (try? folder.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { continue } guard (try? folder.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { continue }
let audioFiles = recoveryAudioFiles(in: folder) let audioFiles = recoveryAudioFiles(in: folder)
@@ -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,13 @@ class UserDefaultsManager {
} }
var transcriptionModel: String { var transcriptionModel: String {
get { userDefaults.string(forKey: Keys.transcriptionModel) ?? "groq/whisper-large-v3-turbo" } get {
let stored = userDefaults.string(forKey: Keys.transcriptionModel)
if stored == "local-whisper/whisper-large-v3-turbo" {
return "local-parakeet/parakeet-tdt-0.6b-v3"
}
return stored ?? "local-parakeet/parakeet-tdt-0.6b-v3"
}
set { userDefaults.set(newValue, forKey: Keys.transcriptionModel) } set { userDefaults.set(newValue, forKey: Keys.transcriptionModel) }
} }
@@ -94,4 +101,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) }
}
} }
+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"),
+5 -2
View File
@@ -138,8 +138,11 @@ final class ProcessTap {
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: case .systemAudio:
tapDescription = CATapDescription(monoGlobalTapButExcludeProcesses: []) // Keep the HAL tap's buffer layout consistent with the default
logger.debug("Configuring a global system audio tap.") // output stream. AudioManager performs the stereo-to-mono mix when
// it converts the captured audio to the 16 kHz transcription file.
tapDescription = CATapDescription(stereoGlobalTapButExcludeProcesses: [])
logger.debug("Configuring a stereo global system audio tap.")
} }
tapDescription.uuid = UUID() tapDescription.uuid = UUID()
+222 -5
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 {
struct Word: Decodable {
let word: String
let start: TimeInterval
let end: TimeInterval
let speaker: Int?
}
let text: String let text: String
let segments: [Transcription.Segment]? 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,125 @@ 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)
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 +413,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 +432,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 {
@@ -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 {
+2 -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)
+23
View File
@@ -58,6 +58,12 @@ struct SettingsView: View {
Text(model.displayName).tag(model.id) Text(model.displayName).tag(model.id)
} }
} }
if viewModel.coderModels.first(where: { $0.id == viewModel.settings.transcriptionModel })?.supportsSpeakerDiarization == true {
Label("Remote participants are labeled Speaker 14; your microphone is labeled Me.", systemImage: "person.2.wave.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 +134,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: {