Compare commits

...
2 Commits
Author SHA1 Message Date
coder 579a91d357 fix: preserve system audio duration 2026-07-29 16:06:44 +02:00
james 19b17fc9a5 fix: transcribe long recordings in chunks 2026-07-23 14:48:25 +02:00
3 changed files with 149 additions and 7 deletions
+4 -4
View File
@@ -276,7 +276,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 28;
CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = G9LVHZAJNX;
ENABLE_HARDENED_RUNTIME = YES;
@@ -290,7 +290,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.1.16;
MARKETING_VERSION = 1.1.18;
ONLY_ACTIVE_ARCH = NO;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
@@ -312,7 +312,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 28;
CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = G9LVHZAJNX;
ENABLE_HARDENED_RUNTIME = YES;
@@ -326,7 +326,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.1.16;
MARKETING_VERSION = 1.1.18;
ONLY_ACTIVE_ARCH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
+5 -2
View File
@@ -138,8 +138,11 @@ final class ProcessTap {
tapDescription = CATapDescription(stereoMixdownOfProcesses: [process.objectID])
logger.debug("Configuring tap for single process objectID: \(process.objectID)")
case .systemAudio:
tapDescription = CATapDescription(monoGlobalTapButExcludeProcesses: [])
logger.debug("Configuring a global system audio tap.")
// Keep the HAL tap's buffer layout consistent with the default
// 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()
+140 -1
View File
@@ -1,3 +1,4 @@
import AVFoundation
import Foundation
struct CoderModel: Codable, Identifiable, Hashable {
@@ -73,6 +74,13 @@ final class CoderAPIClient {
let segments: [Transcription.Segment]?
}
private struct AudioChunk {
let url: URL
let offset: TimeInterval
let isTemporary: Bool
}
private let transcriptionChunkDuration: TimeInterval = 3 * 60
private let transcriptionSession: URLSession
private init() {
@@ -155,10 +163,67 @@ final class CoderAPIClient {
let selectedModel = model.trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedModel.isEmpty else { throw CoderAPIError.missingModel("transcription") }
let apiKey = try requiredAPIKey(KeychainHelper.shared.getCoderAPIKey() ?? "")
let chunks = try makeAudioChunks(from: fileURL)
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
)
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))
}
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
))
}
}
return Transcription(text: textParts.joined(separator: "\n"), segments: segments)
}
private func transcribeChunk(
_ fileURL: URL,
model: String,
language: String,
apiKey: String
) async throws -> Transcription {
let boundary = "Meetingnotes-\(UUID().uuidString)"
let bodyURL = try makeMultipartBody(
audioURL: fileURL,
model: selectedModel,
model: model,
language: language,
boundary: boundary
)
@@ -178,6 +243,80 @@ final class CoderAPIClient {
return Transcription(text: decoded.text, segments: decoded.segments ?? [])
}
private func makeAudioChunks(from fileURL: URL) throws -> [AudioChunk] {
let input = try AVAudioFile(forReading: fileURL)
let format = input.processingFormat
guard format.sampleRate > 0 else {
return [AudioChunk(url: fileURL, offset: 0, isTemporary: false)]
}
let duration = Double(input.length) / format.sampleRate
guard duration > transcriptionChunkDuration else {
return [AudioChunk(url: fileURL, offset: 0, isTemporary: false)]
}
let framesPerChunk = 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).m4a")
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: kAudioFormatMPEG4AAC,
AVSampleRateKey: format.sampleRate,
AVNumberOfChannelsKey: format.channelCount,
AVEncoderBitRateKey: 48_000 * max(1, Int(format.channelCount))
]
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 endpoint(baseURL: String, path: String) throws -> URL {
guard var components = URLComponents(string: baseURL.trimmingCharacters(in: .whitespacesAndNewlines)),
let scheme = components.scheme?.lowercased(),