Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58b673ffcc | ||
|
|
6cb2ae649e | ||
|
|
8b96d5d446 | ||
|
|
19968160e0 |
@@ -20,6 +20,9 @@ jobs:
|
||||
runs-on: macos-15
|
||||
env:
|
||||
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 }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -31,7 +34,7 @@ jobs:
|
||||
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
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
|
||||
echo "Missing GitHub Actions secret: $variable" >&2
|
||||
exit 1
|
||||
@@ -53,7 +56,7 @@ jobs:
|
||||
fi
|
||||
echo "SIGNING_IDENTITY=$signing_identity" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build and sign release
|
||||
- name: Build, sign, and notarize release
|
||||
timeout-minutes: 30
|
||||
run: scripts/package_release.sh
|
||||
|
||||
@@ -70,13 +73,16 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
if git rev-parse "$tag" >/dev/null 2>&1; then
|
||||
echo "Tag already exists: $tag" >&2
|
||||
exit 1
|
||||
if gh release view "$tag" >/dev/null 2>&1; then
|
||||
gh release upload "$tag" \
|
||||
"$RUNNER_TEMP/meetingnotes-release/release/Meetingnotes-$VERSION.zip" \
|
||||
"$RUNNER_TEMP/meetingnotes-release/release/appcast.xml" \
|
||||
--clobber
|
||||
else
|
||||
gh release create "$tag" \
|
||||
"$RUNNER_TEMP/meetingnotes-release/release/Meetingnotes-$VERSION.zip" \
|
||||
"$RUNNER_TEMP/meetingnotes-release/release/appcast.xml" \
|
||||
--target "$GITHUB_SHA" \
|
||||
--title "Meetingnotes $VERSION" \
|
||||
--generate-notes
|
||||
fi
|
||||
gh release create "$tag" \
|
||||
"$RUNNER_TEMP/meetingnotes-release/release/Meetingnotes-$VERSION.zip" \
|
||||
"$RUNNER_TEMP/meetingnotes-release/release/appcast.xml" \
|
||||
--target "$GITHUB_SHA" \
|
||||
--title "Meetingnotes $VERSION" \
|
||||
--generate-notes
|
||||
|
||||
@@ -276,7 +276,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
CURRENT_PROJECT_VERSION = 34;
|
||||
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.19;
|
||||
MARKETING_VERSION = 1.1.22;
|
||||
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 = 31;
|
||||
CURRENT_PROJECT_VERSION = 34;
|
||||
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.19;
|
||||
MARKETING_VERSION = 1.1.22;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
|
||||
|
||||
@@ -86,6 +86,18 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
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
|
||||
async let micResult = transcribe(files[0], model: model, diarization: false)
|
||||
async let systemResult = transcribe(files[1], model: model, diarization: true)
|
||||
@@ -209,15 +221,18 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
|
||||
private func prepareAudioFiles() throws {
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVFormatIDKey: kAudioFormatLinearPCM,
|
||||
AVSampleRateKey: 16_000,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderBitRateKey: 48_000
|
||||
AVLinearPCMBitDepthKey: 16,
|
||||
AVLinearPCMIsFloatKey: false,
|
||||
AVLinearPCMIsBigEndianKey: false,
|
||||
AVLinearPCMIsNonInterleaved: false
|
||||
]
|
||||
let base = FileManager.default.temporaryDirectory
|
||||
let id = sessionID.uuidString
|
||||
let micURL = base.appendingPathComponent("meetingnotes-\(id)-mic.m4a")
|
||||
let systemURL = base.appendingPathComponent("meetingnotes-\(id)-system.m4a")
|
||||
let micURL = base.appendingPathComponent("meetingnotes-\(id)-mic.wav")
|
||||
let systemURL = base.appendingPathComponent("meetingnotes-\(id)-system.wav")
|
||||
let newMicAudioFile = try AVAudioFile(
|
||||
forWriting: micURL,
|
||||
settings: settings,
|
||||
@@ -353,13 +368,25 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
|
||||
private func startTapIO(_ tap: ProcessTap) throws {
|
||||
guard var description = tap.tapStreamDescription,
|
||||
let inputFormat = AVAudioFormat(streamDescription: &description),
|
||||
let advertisedInputFormat = AVAudioFormat(streamDescription: &description),
|
||||
let targetFormat = systemAudioFile?.processingFormat,
|
||||
let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else {
|
||||
advertisedInputFormat.sampleRate > 0 else {
|
||||
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
|
||||
guard let self 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(
|
||||
@@ -374,6 +401,30 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
from inputData: UnsafePointer<AudioBufferList>,
|
||||
format: AVAudioFormat
|
||||
@@ -520,6 +571,90 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
private func preserveAudioFiles(_ urls: [URL], meetingID: UUID) -> URL? {
|
||||
LocalStorageManager.shared.preserveAudioFiles(urls, for: meetingID)
|
||||
}
|
||||
|
||||
private func captureDurationMismatch(in files: [URL?]) -> String? {
|
||||
guard files.count >= 2,
|
||||
let micDuration = audioDuration(at: files[0]),
|
||||
let systemDuration = audioDuration(at: files[1]),
|
||||
micDuration >= 60 else { 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)
|
||||
}
|
||||
|
||||
private func repairHalfDurationSystemWAVIfNeeded(in files: [URL?]) {
|
||||
guard files.count >= 2,
|
||||
let micDuration = audioDuration(at: files[0]),
|
||||
let systemURL = files[1],
|
||||
let systemDuration = audioDuration(at: systemURL),
|
||||
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"])
|
||||
}
|
||||
|
||||
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() {
|
||||
micAudioLevel = 0
|
||||
|
||||
@@ -79,13 +79,7 @@ class UserDefaultsManager {
|
||||
}
|
||||
|
||||
var transcriptionModel: String {
|
||||
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"
|
||||
}
|
||||
get { userDefaults.string(forKey: Keys.transcriptionModel) ?? "local-parakeet/parakeet-tdt-0.6b-v3" }
|
||||
set { userDefaults.set(newValue, forKey: Keys.transcriptionModel) }
|
||||
}
|
||||
|
||||
|
||||
@@ -275,6 +275,13 @@ final class CoderAPIClient {
|
||||
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
|
||||
|
||||
|
||||
@@ -59,10 +59,16 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.coderModels.first(where: { $0.id == viewModel.settings.transcriptionModel })?.supportsSpeakerDiarization == true {
|
||||
Label("Remote participants are labeled Speaker 1–4; your microphone is labeled Me.", systemImage: "person.2.wave.2")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
if let selectedModel = viewModel.coderModels.first(where: { $0.id == viewModel.settings.transcriptionModel }) {
|
||||
if selectedModel.supportsSpeakerDiarization {
|
||||
Label("Remote participants are labeled Speaker 1–4; 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ APP_PATH="$DERIVED_DATA/Build/Products/Release/$APP_NAME.app"
|
||||
required_variables=(
|
||||
VERSION
|
||||
SIGNING_IDENTITY
|
||||
APPLE_ID
|
||||
APPLE_TEAM_ID
|
||||
APPLE_APP_PASSWORD
|
||||
SPARKLE_PRIVATE_KEY
|
||||
GITHUB_REPOSITORY
|
||||
)
|
||||
@@ -80,6 +83,33 @@ ARCHIVE_NAME="$APP_NAME-$VERSION.zip"
|
||||
ARCHIVE_PATH="$RELEASE_DIR/$ARCHIVE_NAME"
|
||||
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)
|
||||
if [[ -z "$GENERATE_APPCAST" ]]; then
|
||||
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"
|
||||
|
||||
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"
|
||||
fi
|
||||
|
||||
echo "Signed release artifacts are ready in $RELEASE_DIR"
|
||||
echo "Signed and notarized release artifacts are ready in $RELEASE_DIR"
|
||||
|
||||
Reference in New Issue
Block a user