Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83710bc1fe | ||
|
|
7584863392 | ||
|
|
31584792e6 | ||
|
|
58fbe56298 | ||
|
|
4d87288f2f | ||
|
|
cda28cc6be | ||
|
|
df23c84474 | ||
|
|
2508c77f17 | ||
|
|
607e1236f7 | ||
|
|
204857cdd9 | ||
|
|
3a9ad8fe0c | ||
|
|
299986ab00 | ||
|
|
66447b2510 | ||
|
|
9f3805733b | ||
|
|
4e8fdc7603 | ||
|
|
aed34f6d28 | ||
|
|
79b2f61dfe | ||
|
|
f6f518b0a9 | ||
|
|
58b673ffcc | ||
|
|
6cb2ae649e | ||
|
|
8b96d5d446 | ||
|
|
19968160e0 | ||
|
|
3d093f1e1d | ||
|
|
579a91d357 | ||
|
|
19b17fc9a5 | ||
|
|
1268149114 | ||
|
|
0f45f2d066 | ||
|
|
f1d57da227 | ||
|
|
94d8469eba | ||
|
|
628587ae92 | ||
|
|
f992457cfe | ||
|
|
ad995b59ad | ||
|
|
a13153b8fe | ||
|
|
0008bd3081 | ||
|
|
6bcdd51201 | ||
|
|
ea44104458 | ||
|
|
336fb95b07 | ||
|
|
c7e29828b4 | ||
|
|
97eb58d1dc | ||
|
|
bcc16cd53f | ||
|
|
25ae1ae710 | ||
|
|
47ca61a811 | ||
|
|
e3397e5e85 | ||
|
|
d69968101a | ||
|
|
11dfd97801 | ||
|
|
e7d5455ec4 | ||
|
|
70a077492e | ||
|
|
5f128fd0cb | ||
|
|
6f60933930 | ||
|
|
f77b9f4c98 | ||
|
|
37e21bf398 | ||
|
|
d8f5c33141 | ||
|
|
34947e2119 |
@@ -9,7 +9,7 @@ on:
|
||||
|
||||
jobs:
|
||||
macos:
|
||||
runs-on: macos-15
|
||||
runs-on: macos-arm64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build Meetingnotes
|
||||
@@ -22,6 +22,7 @@ jobs:
|
||||
-derivedDataPath "$RUNNER_TEMP/DerivedData"
|
||||
ARCHS="arm64 x86_64"
|
||||
ONLY_ACTIVE_ARCH=NO
|
||||
PRODUCT_BUNDLE_IDENTIFIER=net.jamesbone.meetingnotes.ci
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
build
|
||||
- name: Sign test build
|
||||
@@ -34,28 +35,15 @@ jobs:
|
||||
codesign -d --entitlements :- "$app_path" 2>&1 \
|
||||
| grep -q 'com.apple.security.network.server'
|
||||
- name: Smoke test local API
|
||||
run: |
|
||||
defaults write owen.meetingnotes muteDeckAPIEnabled -bool true
|
||||
defaults write owen.meetingnotes muteDeckAPIPort -int 19880
|
||||
"$RUNNER_TEMP/DerivedData/Build/Products/Release/Meetingnotes.app/Contents/MacOS/Meetingnotes" >"$RUNNER_TEMP/meetingnotes.log" 2>&1 &
|
||||
app_pid=$!
|
||||
trap 'kill "$app_pid" 2>/dev/null || true' EXIT
|
||||
|
||||
for _ in {1..20}; do
|
||||
if curl -fsS http://127.0.0.1:19880/api/info >"$RUNNER_TEMP/api-info.json"; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
grep -q '"name":"MeetingDebrief"' "$RUNNER_TEMP/api-info.json"
|
||||
test "$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:19880/api/recording/status)" = "401"
|
||||
run: python3 scripts/smoke_test_api.py "$RUNNER_TEMP/DerivedData/Build/Products/Release/Meetingnotes.app"
|
||||
- name: Package test build
|
||||
if: always()
|
||||
run: |
|
||||
app_path="$RUNNER_TEMP/DerivedData/Build/Products/Release/Meetingnotes.app"
|
||||
ditto -c -k --sequesterRsrc --keepParent \
|
||||
"$app_path" "$RUNNER_TEMP/Meetingnotes-macOS.zip"
|
||||
- name: Upload test build
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Meetingnotes-macOS-${{ github.sha }}
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Release
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Version from MARKETING_VERSION, without the v prefix
|
||||
required: true
|
||||
type: string
|
||||
permissions:
|
||||
contents: write
|
||||
concurrency:
|
||||
group: meetingnotes-release
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
release:
|
||||
runs-on: macos-arm64
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
RELEASE_BASE_URL: https://git.jamesbone.net/coder/meetingnotes
|
||||
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:
|
||||
- name: Require main
|
||||
run: test "$GITHUB_REF" = refs/heads/main
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build, sign, and notarize release
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
run: python3 scripts/with_signing_identity.py scripts/package_release.sh
|
||||
- name: Preserve signed release artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: meetingnotes-signed-release-${{ github.run_id }}
|
||||
path: ${{ runner.temp }}/meetingnotes-release/release
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
- name: Publish Gitea release
|
||||
env:
|
||||
GITEA_SERVER_URL: ${{ github.server_url }}
|
||||
GITEA_TOKEN: ${{ github.token }}
|
||||
run: python3 scripts/publish_gitea_release.py
|
||||
@@ -1,39 +1 @@
|
||||
# Contributing to Meetingnotes
|
||||
|
||||
I anticipate the main users of this app to be technical, given the need to bring your own API key.
|
||||
|
||||
As you use the app, you will come across bugs and have desires for new features. It is my hope that you will then be eager to contribute to improve the app.
|
||||
|
||||
Since you're here, maybe that's you! Please take a moment to read this guide before making a pull request.
|
||||
|
||||
Rules:
|
||||
|
||||
- Be respectful
|
||||
- Open an issue first for major changes
|
||||
- Use conventional commits
|
||||
|
||||
---
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. **Fork** the repo
|
||||
2. **Create a branch**:
|
||||
`git checkout -b my-feature`
|
||||
3. **Commit your changes**:
|
||||
`git commit -m "feat: cool feature"`
|
||||
4. **Push to your fork**:
|
||||
`git push origin my-feature`
|
||||
5. **Open a pull request**
|
||||
|
||||
---
|
||||
|
||||
## Contributor License Notice
|
||||
|
||||
By submitting a pull request or otherwise contributing to this project, you agree to the following:
|
||||
|
||||
- You license your contribution under the same license as this project (LGPL-3.0).
|
||||
- You grant the me the right to relicense your contribution, including under proprietary or commercial licenses in the future.
|
||||
|
||||
This helps keep the project open-source while also allowing for commercial options down the road, such as a hosted version where users could pay through the app instead of using their own API key.
|
||||
|
||||
If you’re not comfortable with this, please feel free to contact me or open an issue to discuss alternatives.
|
||||
|
||||
@@ -276,9 +276,9 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 13;
|
||||
CURRENT_PROJECT_VERSION = 45;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = ML6HYR5LUR;
|
||||
DEVELOPMENT_TEAM = G9LVHZAJNX;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -290,10 +290,10 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 1.1.1;
|
||||
MARKETING_VERSION = 1.1.33;
|
||||
ONLY_ACTIVE_ARCH = NO;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = owen.meetingnotes;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -312,9 +312,9 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 13;
|
||||
CURRENT_PROJECT_VERSION = 45;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = ML6HYR5LUR;
|
||||
DEVELOPMENT_TEAM = G9LVHZAJNX;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -326,10 +326,10 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 1.1.1;
|
||||
MARKETING_VERSION = 1.1.33;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = owen.meetingnotes;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -366,7 +366,7 @@
|
||||
repositoryURL = "https://github.com/sparkle-project/Sparkle.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 2.7.1;
|
||||
minimumVersion = 2.9.4;
|
||||
};
|
||||
};
|
||||
CCC33F7F2E236B6F00EDE382 /* XCRemoteSwiftPackageReference "posthog-ios" */ = {
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/sparkle-project/Sparkle.git",
|
||||
"state" : {
|
||||
"revision" : "df074165274afaa39539c05d57b0832620775b11",
|
||||
"version" : "2.7.1"
|
||||
"revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7",
|
||||
"version" : "2.9.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,95 +6,9 @@
|
||||
|
||||
<p align="center">
|
||||
The Free, Open-Source AI Notetaker for Busy Engineers
|
||||
<br />
|
||||
<a href="https://github.com/owengretzinger/meetingnotes/releases/latest/download/Meetingnotes.dmg">Download for MacOS 14+</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
## Recall.ai - Meeting Transcription API
|
||||
|
||||
Meetingnotes runs locally, capturing two streams: system & mic.
|
||||
|
||||
If you’re looking for a transcription API for meetings, consider checking out [Recall.ai](
|
||||
https://www.recall.ai?utm_source=github&utm_medium=sponsorship&utm_campaign=owengretzinger+meetingnotes), an API that works with Zoom, Google Meet, Microsoft Teams, and more. Recall.ai diarizes by pulling the speaker data and separate audio streams from the meeting platforms, which means 100% accurate speaker diarization with actual speaker names.
|
||||
|
||||
## Demo
|
||||
|
||||
https://github.com/user-attachments/assets/cadd4504-e9d9-4ccd-874d-41d8a84f4c9d
|
||||
|
||||
<!--
|
||||
## Table of Contents
|
||||
|
||||
<details>
|
||||
<summary>Table of Contents</summary>
|
||||
<ol>
|
||||
<li>
|
||||
<a href="#about-the-project">About The Project</a>
|
||||
<ul>
|
||||
<li><a href="#key-features">Key Features</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="#architecture">Architecture</a></li>
|
||||
<li>
|
||||
<a href="#getting-started">Getting Started</a>
|
||||
<ul>
|
||||
<li><a href="#prerequisites">Prerequisites</a></li>
|
||||
<li><a href="#installation">Installation</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="#acknowledgments">Acknowledgments</a></li>
|
||||
</ol>
|
||||
</details>
|
||||
|
||||
## About The Project
|
||||
|
||||
Brief description of the project.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Feature 1:** ...
|
||||
- **Feature 2:** ...
|
||||
- ...
|
||||
|
||||
## Architecture
|
||||
|
||||

|
||||
|
||||
(Insert the different technologies used in the project here — could split this into frontend, backend, etc)
|
||||
|
||||
(Don't explain what well-known technologies like React are)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Requirement 1
|
||||
- Requirement 2
|
||||
```sh
|
||||
installation command (if applicable)
|
||||
```
|
||||
|
||||
### Installation
|
||||
|
||||
Instructions for cloning the repo, installing packages, configuring environment variables, etc:
|
||||
|
||||
1. Step 1
|
||||
```sh
|
||||
command
|
||||
```
|
||||
2. Step 2
|
||||
```sh
|
||||
command
|
||||
```
|
||||
3. ...
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- This README was created using [gitreadme.dev](https://gitreadme.dev) — an AI tool that looks at your entire codebase to instantly generate high-quality README files.
|
||||
- (Only include unique things that you are sure should be specifically acknowledged. Don't include libraries or tools like React, Next.js, etc. Don't include services like Vercel, OpenAI, Google Cloud, JetBrains, etc. Stay on the safe side since more can be added later. Do not hallucinate.)
|
||||
|
||||
-->
|
||||
|
||||
## Features
|
||||
|
||||
Implemented:
|
||||
@@ -108,7 +22,8 @@ Implemented:
|
||||
- Meeting search functionality
|
||||
- Abilty to edit system prompt
|
||||
- Select any compatible Coder model for transcription and note generation
|
||||
- Automatic start and stop from MuteDeck through a compatible local API
|
||||
- Automatic start and stop from MuteDeck through a compatible local API, with a 10-second reconnect grace period
|
||||
- Merge an automatically split continuation back into its previous meeting
|
||||
- Auto updates
|
||||
- Text formatting
|
||||
- Different note templates
|
||||
@@ -128,18 +43,10 @@ Later:
|
||||
- AI chat for asking questions about a meeting
|
||||
- Integrations for email, Slack, Notion, etc.
|
||||
|
||||
## Local Development
|
||||
|
||||
Open the project in Xcode. Command+R to build it and run it.
|
||||
|
||||
## Releasing a New Version
|
||||
|
||||
Follow these steps to create a new release with auto-updates:
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Homebrew packages: `brew install create-dmg sparkle`
|
||||
- Make scripts executable: `chmod +x scripts/update_version.sh scripts/build_release.sh`
|
||||
Production releases are Developer ID signed, notarized by Apple, published to
|
||||
[Gitea Releases](https://git.jamesbone.net/coder/meetingnotes/releases), and signed for Sparkle auto-updates.
|
||||
|
||||
### Release Process
|
||||
|
||||
@@ -159,31 +66,36 @@ Follow these steps to create a new release with auto-updates:
|
||||
./scripts/update_version.sh custom 1.2.0
|
||||
```
|
||||
|
||||
2. **Build the release:**
|
||||
2. Commit and push the version change to `main`.
|
||||
|
||||
```bash
|
||||
./scripts/build_release.sh
|
||||
```
|
||||
3. Run the `Release` workflow from Gitea Actions on `main` and enter the version without
|
||||
the `v` prefix. The workflow signs and notarizes the app, generates the
|
||||
signed appcast, creates the version tag, and publishes both release assets.
|
||||
|
||||
This will:
|
||||
The app checks
|
||||
`https://git.jamesbone.net/coder/meetingnotes/releases/download/latest/appcast.xml`
|
||||
and installs later releases automatically through Sparkle.
|
||||
|
||||
- Clean build the app in Release mode
|
||||
- Create a signed DMG file
|
||||
- Generate the appcast.xml for auto-updates
|
||||
### Recovering Meetings
|
||||
|
||||
3. **Create GitHub Release:**
|
||||
The first Developer ID signed build may not automatically inherit data from an
|
||||
older ad-hoc signed build. In Settings, use **Import Meetings...** and select the
|
||||
old `Meetings` folder. After this one-time transition, the stable signing
|
||||
identity keeps the same sandbox container across updates.
|
||||
|
||||
- Go to [GitHub Releases](https://github.com/owengretzinger/meetingnotes/releases)
|
||||
- Click "Create a new release"
|
||||
- Tag: `v1.0.1` (match the version number)
|
||||
- Title: `Meetingnotes v1.0.1`
|
||||
- Upload the DMG and zip files from `releases/` folder
|
||||
- Generate release notes
|
||||
### Build runner and GitHub transition
|
||||
|
||||
4. **Update appcast:**
|
||||
`.gitea/workflows/build.yml` builds universal macOS artifacts for pushes and pull
|
||||
requests to `main`. The shared account-scoped `mac-mini` runner uses the
|
||||
`macos-arm64` label. Smoke tests use a separate CI bundle identifier and temporary
|
||||
launch preferences and an internal-volume staging directory. Keychain services
|
||||
follow the bundle identifier, so CI never reads production credentials; the smoke
|
||||
test removes its isolated API token before and after each launch. Release jobs import the original Developer ID certificate
|
||||
into a temporary keychain and keep the original Sparkle signing key in Gitea
|
||||
Actions secrets. They never publish from a development branch.
|
||||
|
||||
```bash
|
||||
git add appcast.xml
|
||||
git commit -m "Update appcast for v1.0.1"
|
||||
git push
|
||||
```
|
||||
Version 1.1.33 moves the embedded update feed to Gitea. After its notarized archive
|
||||
is verified, replace the `appcast.xml` asset on the last GitHub release with the
|
||||
new appcast. Existing installations discover the Gitea download through that old
|
||||
GitHub feed; after installing it, they check Gitea directly. Keep the old GitHub
|
||||
repository and its release appcast available for installations that update later.
|
||||
|
||||
+5
-77
@@ -1,78 +1,6 @@
|
||||
<?xml version="1.0" standalone="yes"?>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>Meetingnotes</title>
|
||||
<item>
|
||||
<title>1.1.1</title>
|
||||
<pubDate>Tue, 05 Aug 2025 12:01:01 -0400</pubDate>
|
||||
<sparkle:version>13</sparkle:version>
|
||||
<sparkle:shortVersionString>1.1.1</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes-1.1.1.zip" length="9742223" type="application/octet-stream" sparkle:edSignature="IdBhnJDPglM1H+LxjIDMYvXieGyXK8FLV3o1LSBKgR3SAkbwjieN/dW/kWMw4jOL7ousjZMssvL7AVs+4OoeAw==" />
|
||||
<sparkle:deltas>
|
||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes13-12.delta" sparkle:deltaFrom="12" length="1495358" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="JHUS+WLJjdvNP+GGj25pUTXbVL5uzc9IO1eLtkFhSvl1+yb7l7btuRTnCPDVmvX/c8rF+MQraNRamrKVcB2eAw==" />
|
||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes13-11.delta" sparkle:deltaFrom="11" length="2386250" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="sSZug40z/SSNzUROZFM36FyyaJ6LvTqVGZtpYnvBITkd2/fqrbGaKUXCgBKY/vzvdq+jNduKlArSsQUIkRemAA==" />
|
||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes13-10.delta" sparkle:deltaFrom="10" length="2381262" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="ODG+JexsG6BGjbOyPMTnt9Gfpd+iHHqSPSQBsCJTIMIJ0/RcKa39iZfZ7rkL996sQllFbwdAAph3bDQCYa0NCg==" />
|
||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes13-9.delta" sparkle:deltaFrom="9" length="2384498" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="66GFKhxIVI5jzrV40NKywvn3Lx8cdxsQEHYqiWWw1IeqSqH4lF00qE1giPJgKdZkEbL6neMjIqofcvqkIbPRAw==" />
|
||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes13-8.delta" sparkle:deltaFrom="8" length="2970002" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="C+KVv/usN2Vmg8tkJmYRBWrT7bBrvQY2GbTgXuVljvz/HbgnA1fyrsrhIrkXTbWJ8pdH76pn/Lfzw2oYtvkGDQ==" />
|
||||
</sparkle:deltas>
|
||||
</item>
|
||||
<item>
|
||||
<title>1.1.0</title>
|
||||
<pubDate>Fri, 25 Jul 2025 17:39:07 -0400</pubDate>
|
||||
<sparkle:version>12</sparkle:version>
|
||||
<sparkle:shortVersionString>1.1.0</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes-1.1.0.zip" length="9715895" type="application/octet-stream" sparkle:edSignature="Htx9FYb58yGYIJc+xJd/W2PHgbjsVXY+TL2WsGa8gx4J4f0Uyk+1Uizl8c2lDC4+hIra+b9lIrjG6l69NjU4Cg==" />
|
||||
<sparkle:deltas>
|
||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes12-11.delta" sparkle:deltaFrom="11" length="2319634" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="L2IQPoN19GQfVV8leQCCh4U7/1/Oe3SyclBWbT+58VFKI+FH3KGj2vZM1x+v3X3pmyPdSy/S0N9sg+B7jFSUBg==" />
|
||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes12-10.delta" sparkle:deltaFrom="10" length="2301554" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="+GKgyT7MBDTqot6SFnvQax1il9p3ZEDG5SWg0gCOwMkKPrp6v48GrJY/VAF8tow2RbeVY2Q2HP6DNVPTm1jeDQ==" />
|
||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes12-9.delta" sparkle:deltaFrom="9" length="2342546" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="lPAYUzZGPYYgb+UWGCv237QvKTH8l3jZVXxyXHsWOtYcDzuGRYiZ39eMYTAF9OMEKbvfm5vQiXXxu42CKnj3DA==" />
|
||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes12-8.delta" sparkle:deltaFrom="8" length="2940458" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="iL16jVnWBp5Ws/F1B8goazEcGCrrMK9XwH2imslbJiAWlwG5sXAaN2N8XHcHA7xuysQkway/R1vCONjmm/DaDg==" />
|
||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes12-7.delta" sparkle:deltaFrom="7" length="2923010" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="LQHXBknrE6nFFdpDW4ZWP9f/BBhU3PIUZ7DihMLh+Iu46/8cCv8vDaH7/doEJGoUfa1oEstYsWX0bCdHkKp9Cw==" />
|
||||
</sparkle:deltas>
|
||||
</item>
|
||||
<item>
|
||||
<title>1.0.6</title>
|
||||
<pubDate>Mon, 21 Jul 2025 16:21:51 -0400</pubDate>
|
||||
<sparkle:version>11</sparkle:version>
|
||||
<sparkle:shortVersionString>1.0.6</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
|
||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes-1.0.6.zip" length="9530065" type="application/octet-stream" sparkle:edSignature="IxQ4qvkMsUjHaIn4g4/wH1MR1HtpPXJ/N1XaBtCpyeEK45J1UjoIn1zZae8SrHj8zNDglzK+XvV9vZM3iw1nAA==" />
|
||||
<sparkle:deltas>
|
||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes11-9.delta" sparkle:deltaFrom="9" length="1081770" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="uvLxEKq4bGpYvCqPPcp3mDal9z7DI3FPwpBMsdqZFUjmnBWXvtha7Pk6Rv+Ua6OuxjsNqOGg5qiwkp9z3J41Aw==" />
|
||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes11-10.delta" sparkle:deltaFrom="10" length="1192342" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="+u8c+Qeen3ILEKiPtOpW3KINmsBBk6nqBsCr5aZ4i/WqHHVbG/kTe6oWPCWhiq5XFlzNaRpXguDl3PdEhvafCQ==" />
|
||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes11-8.delta" sparkle:deltaFrom="8" length="2083034" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="uQinSWneeR1R9ad5GaE3Vr2pZI0vO9KGVVf/y5xw31iKvwe6S1fpZTdZpujlE1NZzl2R07ZOPt9mDYu6uMiXAA==" />
|
||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes11-7.delta" sparkle:deltaFrom="7" length="2075294" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="9u3ldVNYRuIIVGN0zG4T76yQ7JviM39Rl2cnnfUCpDwJ/+KjXgsjwpbelZa/5AWFeKffDth04D6xVRJB5tWfAw==" />
|
||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes11-6.delta" sparkle:deltaFrom="6" length="2075930" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="AgHLB01YstVzOTxXfief9M2rfxCNcya/w7N6QevalSI3Uxdw8CH5MVF2UGFWy1kdIiuQC7YBsftGmL20L9KzBA==" />
|
||||
</sparkle:deltas>
|
||||
</item>
|
||||
<item>
|
||||
<title>1.0.5</title>
|
||||
<pubDate>Thu, 17 Jul 2025 08:36:59 -0400</pubDate>
|
||||
<sparkle:version>10</sparkle:version>
|
||||
<sparkle:shortVersionString>1.0.5</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
|
||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.5/Meetingnotes-1.0.5.zip" length="9519029" type="application/octet-stream" sparkle:edSignature="XBVJM2eh02aEQ6aNotS/3DNYXaOXA7956k/6GWEcfpcDVpJmUMDKcTIX0mtDWJl5YXin6YTA9qpIZDUJmkitBg==" />
|
||||
<sparkle:deltas>
|
||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.5/Meetingnotes10-9.delta" sparkle:deltaFrom="9" length="358186" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="WCLkjV3zZwBgy8QEpQVcvI8mLeZVVwHkYMCis3GROY+TlX3o9P12tJfB/Mb0bRI5MHaXgl+6PelbdBC2SLL9Dg==" />
|
||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.5/Meetingnotes10-8.delta" sparkle:deltaFrom="8" length="2026862" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="wjQTre8hcYt6MS3gIXnXUpv+GnQ7tZALOAajn1jEgY7nNxk5P30v9qESknlDzLNCcOpb9iy/4aFTtv+aDqb1DQ==" />
|
||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.5/Meetingnotes10-7.delta" sparkle:deltaFrom="7" length="2033094" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="hUqgwwoswcKVUOA4RUs8DVriGh4BXFXZ15gV2TSaCw8C4TX83qgrDVFa6ZjmpFPO23ck5+lY4chyfAzTFHq8Bw==" />
|
||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.5/Meetingnotes10-6.delta" sparkle:deltaFrom="6" length="2026918" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="L8Y1rn8/8Kh7mVf9WNbYWIrXJo8jNtQ0MK14qWQ+PKzImSUc8nqVZ1uWgI7cClBRaPUhpqcL8CnWGWfI4BgQBg==" />
|
||||
</sparkle:deltas>
|
||||
</item>
|
||||
<item>
|
||||
<title>1.0.4</title>
|
||||
<pubDate>Wed, 16 Jul 2025 09:02:52 -0400</pubDate>
|
||||
<sparkle:version>9</sparkle:version>
|
||||
<sparkle:shortVersionString>1.0.4</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
|
||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.4/Meetingnotes-1.0.4.zip" length="9518446" type="application/octet-stream" sparkle:edSignature="aWBAf2cnvhAf1WBEa9oUHzL+N5RBph8Xbsh/XXBhR7wo9Vkeh51CqBh+3pjWJqxaDGxb3aimgydjIKrCVVpyAw==" />
|
||||
<sparkle:deltas>
|
||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.4/Meetingnotes9-8.delta" sparkle:deltaFrom="8" length="2038454" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="AUNQbGbKk3a7Phzxfm1yEQQ6KuaqvX0NASF7u657DtdkcTbxvBqPBon/JKaZIEVtlnq+qDyjfcUpTY1S1Cj0Cw==" />
|
||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.4/Meetingnotes9-7.delta" sparkle:deltaFrom="7" length="2027690" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="+libxIpQFVA0lsctoJzmH8dNGCcHZLE8agqRKMzbCN1+fRA8Zq0hrvIAxvrpyCim1StmOtvh/zWKz+483SyJBw==" />
|
||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.4/Meetingnotes9-6.delta" sparkle:deltaFrom="6" length="2022146" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="LLFyZQK3TLSNvD8RCpQKNmKfefg8mp9Z7J1uw09zaYhahjSa+hV1jbzkSMIEG6P6ncBScBfmOrhhuimAG6OWBw==" />
|
||||
</sparkle:deltas>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
<channel>
|
||||
<title>Meetingnotes</title>
|
||||
</channel>
|
||||
</rss>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
@@ -12,9 +14,13 @@
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Meetingnotes needs access to your microphone for transcription.</string>
|
||||
<key>SUFeedURL</key>
|
||||
<string>https://raw.githubusercontent.com/superdooper86/meetingnotes/main/appcast.xml</string>
|
||||
<string>https://git.jamesbone.net/coder/meetingnotes/releases/download/latest/appcast.xml</string>
|
||||
<key>SUPublicEDKey</key>
|
||||
<string>BVXHOV8ZxPxKZ1swhFndymzew9nyd3si7849JA9cqsg=</string>
|
||||
<string>9ZuN9G9ERB3Qoyyd/4FsF+6LMUv5jzAGP26OXAHBiW0=</string>
|
||||
<key>SUEnableAutomaticChecks</key>
|
||||
<true/>
|
||||
<key>SUAutomaticallyUpdate</key>
|
||||
<true/>
|
||||
<key>SUEnableInstallerLauncherService</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
||||
@@ -3,6 +3,44 @@ import Combine
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
private enum RecoveryTranscriptionError: LocalizedError {
|
||||
case noAudioFiles
|
||||
case noSpeech
|
||||
case requestFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noAudioFiles:
|
||||
return "The saved recovery audio could not be found."
|
||||
case .noSpeech:
|
||||
return "No speech was detected in the saved recovery audio."
|
||||
case .requestFailed(let details):
|
||||
return "Retry transcription failed for \(details)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SystemCaptureDiagnostics {
|
||||
var tapAdvertisedFormat = "unavailable"
|
||||
var aggregateInputFormat = "unavailable"
|
||||
var selectedInputFormat = "unavailable"
|
||||
var targetFormat = "unavailable"
|
||||
var selectedInputSampleRate: Double?
|
||||
var targetSampleRate: Double?
|
||||
var inputRateInference = "unavailable"
|
||||
var firstBufferLayout: String?
|
||||
var callbackCount: UInt64 = 0
|
||||
var inputFrameCount: UInt64 = 0
|
||||
var outputFrameCount: UInt64 = 0
|
||||
var discardedCallbackCount: UInt64 = 0
|
||||
var firstSampleTime: Double?
|
||||
var lastSampleTime: Double?
|
||||
var firstHostTime: UInt64?
|
||||
var lastHostTime: UInt64?
|
||||
var firstCallbackAt: Date?
|
||||
var lastCallbackAt: Date?
|
||||
}
|
||||
|
||||
/// Captures microphone and system audio locally, then sends completed files to Coder.
|
||||
@MainActor
|
||||
final class AudioManager: NSObject, ObservableObject {
|
||||
@@ -14,45 +52,44 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
@Published var errorMessage: String?
|
||||
@Published var micAudioLevel: Float = 0
|
||||
@Published var systemAudioLevel: Float = 0
|
||||
private(set) var lastRecoveryAudioFolderName: String?
|
||||
|
||||
private var audioEngine = AVAudioEngine()
|
||||
private var sessionID = UUID()
|
||||
private var meetingID = UUID()
|
||||
private var processTap: ProcessTap?
|
||||
private let audioProcessController = AudioProcessController()
|
||||
private let permission = AudioRecordingPermission()
|
||||
private let tapQueue = DispatchQueue(label: "io.meetingnotes.audiotap", qos: .userInitiated)
|
||||
private let audioFileLock = NSLock()
|
||||
private var isTapActive = false
|
||||
private var isRestartingSystemTap = false
|
||||
private var isAcceptingAudio = false
|
||||
private var micRetryCount = 0
|
||||
private var pendingMicRestart: DispatchWorkItem?
|
||||
private let maxMicRetries = 3
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private var micAudioFile: AVAudioFile?
|
||||
private var systemAudioFile: AVAudioFile?
|
||||
private var micAudioURL: URL?
|
||||
private var systemAudioURL: URL?
|
||||
private var recordingStartedAt = Date()
|
||||
private var systemDiagnostics = SystemCaptureDiagnostics()
|
||||
|
||||
private override init() {
|
||||
override init() {
|
||||
super.init()
|
||||
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 {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
func startRecording() {
|
||||
sessionID = UUID()
|
||||
func startRecording(for meetingID: UUID) {
|
||||
errorMessage = nil
|
||||
lastRecoveryAudioFolderName = nil
|
||||
cancelCapture(removeFiles: true)
|
||||
sessionID = UUID()
|
||||
self.meetingID = meetingID
|
||||
recordingStartedAt = Date()
|
||||
systemDiagnostics = SystemCaptureDiagnostics()
|
||||
do {
|
||||
try prepareAudioFiles()
|
||||
startMicrophoneTap()
|
||||
@@ -64,78 +101,223 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
func stopRecordingAndTranscribe() async -> [TranscriptChunk] {
|
||||
let completedMeetingID = meetingID
|
||||
let completedSessionID = sessionID
|
||||
let captureStartedAt = recordingStartedAt
|
||||
let files = stopCaptureAndCloseFiles()
|
||||
isProcessing = true
|
||||
defer {
|
||||
isProcessing = false
|
||||
removeAudioFiles(files.compactMap { $0 })
|
||||
}
|
||||
|
||||
let preRepairFileSummaries = files.map(audioFileSummary)
|
||||
let repairApplied = repairHalfDurationSystemWAVIfNeeded(in: files)
|
||||
let completedFiles = files.compactMap { $0 }
|
||||
let audioFolder = preserveAudioFiles(completedFiles, meetingID: completedMeetingID)
|
||||
let transcriptionFiles = preservedAudioFiles(files, in: audioFolder)
|
||||
lastRecoveryAudioFolderName = audioFolder?.lastPathComponent
|
||||
writeCaptureDiagnostics(
|
||||
sessionID: completedSessionID,
|
||||
captureStartedAt: captureStartedAt,
|
||||
preRepairFileSummaries: preRepairFileSummaries,
|
||||
repairedFiles: transcriptionFiles,
|
||||
repairApplied: repairApplied,
|
||||
audioFolder: audioFolder
|
||||
)
|
||||
if let mismatch = captureDurationMismatch(in: transcriptionFiles) {
|
||||
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)
|
||||
async let systemResult = transcribe(files[1], model: model)
|
||||
async let micResult = transcribe(transcriptionFiles[0], model: model, diarization: false)
|
||||
async let systemResult = transcribe(transcriptionFiles[1], model: model, diarization: true)
|
||||
let (micTranscription, systemTranscription) = await (micResult, systemResult)
|
||||
let results = [micTranscription, systemTranscription]
|
||||
|
||||
var updated = transcriptChunks.filter(\.isFinal)
|
||||
let (updated, failures) = buildTranscriptChunks(
|
||||
from: results,
|
||||
captureStartedAt: captureStartedAt,
|
||||
existingChunks: transcriptChunks.filter(\.isFinal)
|
||||
)
|
||||
transcriptChunks = updated
|
||||
if !failures.isEmpty {
|
||||
let retentionDays = UserDefaultsManager.shared.audioRetentionDays
|
||||
let retentionUnit = retentionDays == 1 ? "day" : "days"
|
||||
let recoveryMessage = audioFolder == nil
|
||||
? " The audio remains in the app's temporary folder."
|
||||
: " 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
|
||||
} else if audioFolder == nil, !completedFiles.isEmpty {
|
||||
errorMessage = "The transcript completed, but Meetingnotes could not move the audio into its retention folder."
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
func transcribeRecoveryAudio(in folder: URL, captureStartedAt: Date) async throws -> [TranscriptChunk] {
|
||||
let recoveryFiles = LocalStorageManager.shared.recoveryAudioFiles(in: folder)
|
||||
guard !recoveryFiles.isEmpty else {
|
||||
throw RecoveryTranscriptionError.noAudioFiles
|
||||
}
|
||||
|
||||
isProcessing = true
|
||||
defer { isProcessing = false }
|
||||
let model = UserDefaultsManager.shared.transcriptionModel
|
||||
let sessions = Dictionary(grouping: recoveryFiles) { recoverySessionKey(for: $0.url) }
|
||||
.values
|
||||
.map { files in
|
||||
(files: files, startedAt: recoveryCaptureStartedAt(for: files, fallback: captureStartedAt))
|
||||
}
|
||||
.sorted { $0.startedAt < $1.startedAt }
|
||||
|
||||
var chunks: [TranscriptChunk] = []
|
||||
var failures: [String] = []
|
||||
for session in sessions {
|
||||
let micURL = session.files.first(where: { $0.source == .mic })?.url
|
||||
let systemURL = session.files.first(where: { $0.source == .system })?.url
|
||||
async let micResult = transcribe(micURL, model: model, diarization: false)
|
||||
async let systemResult = transcribe(systemURL, model: model, diarization: true)
|
||||
let (micTranscription, systemTranscription) = await (micResult, systemResult)
|
||||
let result = buildTranscriptChunks(
|
||||
from: [micTranscription, systemTranscription],
|
||||
captureStartedAt: session.startedAt,
|
||||
existingChunks: chunks
|
||||
)
|
||||
chunks = result.0
|
||||
failures.append(contentsOf: result.1)
|
||||
}
|
||||
|
||||
if !failures.isEmpty {
|
||||
throw RecoveryTranscriptionError.requestFailed(failures.joined(separator: "; "))
|
||||
}
|
||||
guard !chunks.isEmpty else {
|
||||
throw RecoveryTranscriptionError.noSpeech
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
private func recoverySessionKey(for url: URL) -> String {
|
||||
let name = url.deletingPathExtension().lastPathComponent
|
||||
if name.hasSuffix("-mic") { return String(name.dropLast(4)) }
|
||||
if name.hasSuffix("-system") { return String(name.dropLast(7)) }
|
||||
return name
|
||||
}
|
||||
|
||||
private func recoveryCaptureStartedAt(
|
||||
for files: [(url: URL, source: AudioSource)],
|
||||
fallback: Date
|
||||
) -> Date {
|
||||
let estimatedStarts = files.compactMap { file -> Date? in
|
||||
guard let duration = audioDuration(at: file.url),
|
||||
let values = try? file.url.resourceValues(forKeys: [.contentModificationDateKey, .creationDateKey]),
|
||||
let finishedAt = values.contentModificationDate ?? values.creationDate else {
|
||||
return nil
|
||||
}
|
||||
return finishedAt.addingTimeInterval(-duration)
|
||||
}
|
||||
return estimatedStarts.min() ?? fallback
|
||||
}
|
||||
|
||||
func cancelRecording() {
|
||||
cancelCapture(removeFiles: true)
|
||||
lastRecoveryAudioFolderName = nil
|
||||
}
|
||||
|
||||
private func transcribe(
|
||||
_ fileURL: URL?,
|
||||
model: String,
|
||||
diarization: Bool
|
||||
) async -> Result<CoderAPIClient.Transcription, Error>? {
|
||||
guard let fileURL else { return nil }
|
||||
do {
|
||||
return .success(try await CoderAPIClient.shared.transcribe(
|
||||
fileURL: fileURL,
|
||||
model: model,
|
||||
diarization: diarization,
|
||||
maxSpeakerCount: 4
|
||||
))
|
||||
} catch {
|
||||
return .failure(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func buildTranscriptChunks(
|
||||
from results: [Result<CoderAPIClient.Transcription, Error>?],
|
||||
captureStartedAt: Date,
|
||||
existingChunks: [TranscriptChunk]
|
||||
) -> ([TranscriptChunk], [String]) {
|
||||
var updated = existingChunks
|
||||
var failures: [String] = []
|
||||
for (source, result) in zip([AudioSource.mic, .system], results) {
|
||||
guard let result else { continue }
|
||||
switch result {
|
||||
case .success(let text):
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
updated.append(TranscriptChunk(source: source, text: trimmed, isFinal: true))
|
||||
case .success(let transcription):
|
||||
if transcription.segments.isEmpty {
|
||||
let text = transcription.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !text.isEmpty {
|
||||
updated.append(TranscriptChunk(timestamp: captureStartedAt, source: source, text: text, isFinal: true))
|
||||
}
|
||||
} else {
|
||||
for segment in transcription.segments {
|
||||
let text = segment.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { continue }
|
||||
updated.append(TranscriptChunk(
|
||||
timestamp: captureStartedAt.addingTimeInterval(max(0, segment.start)),
|
||||
source: source,
|
||||
speaker: source == .system ? segment.speaker : nil,
|
||||
text: text,
|
||||
isFinal: true
|
||||
))
|
||||
}
|
||||
}
|
||||
case .failure(let error):
|
||||
failures.append("\(source.displayName): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
transcriptChunks = updated
|
||||
if !failures.isEmpty {
|
||||
errorMessage = "Transcription failed for " + failures.joined(separator: "; ")
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
func cancelRecording() {
|
||||
cancelCapture(removeFiles: true)
|
||||
}
|
||||
|
||||
private func transcribe(_ fileURL: URL?, model: String) async -> Result<String, Error>? {
|
||||
guard let fileURL else { return nil }
|
||||
do {
|
||||
return .success(try await CoderAPIClient.shared.transcribe(fileURL: fileURL, model: model))
|
||||
} catch {
|
||||
return .failure(error)
|
||||
updated.sort {
|
||||
if $0.timestamp != $1.timestamp { return $0.timestamp < $1.timestamp }
|
||||
return $0.source.rawValue < $1.source.rawValue
|
||||
}
|
||||
return (updated, failures)
|
||||
}
|
||||
|
||||
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")
|
||||
micAudioFile = try AVAudioFile(
|
||||
let micURL = base.appendingPathComponent("meetingnotes-\(id)-mic.wav")
|
||||
let systemURL = base.appendingPathComponent("meetingnotes-\(id)-system.wav")
|
||||
let newMicAudioFile = try AVAudioFile(
|
||||
forWriting: micURL,
|
||||
settings: settings,
|
||||
commonFormat: .pcmFormatFloat32,
|
||||
interleaved: false
|
||||
)
|
||||
systemAudioFile = try AVAudioFile(
|
||||
let newSystemAudioFile = try AVAudioFile(
|
||||
forWriting: systemURL,
|
||||
settings: settings,
|
||||
commonFormat: .pcmFormatFloat32,
|
||||
interleaved: false
|
||||
)
|
||||
|
||||
audioFileLock.lock()
|
||||
micAudioFile = newMicAudioFile
|
||||
systemAudioFile = newSystemAudioFile
|
||||
micAudioURL = micURL
|
||||
systemAudioURL = systemURL
|
||||
isAcceptingAudio = true
|
||||
audioFileLock.unlock()
|
||||
}
|
||||
|
||||
private func startMicrophoneTap() {
|
||||
@@ -147,9 +329,13 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported microphone format"])
|
||||
}
|
||||
inputNode.installTap(onBus: 0, bufferSize: 1024, format: inputFormat) { [weak self] buffer, _ in
|
||||
guard let self, buffer.frameLength > 0 else { return }
|
||||
self.updateAudioLevel(buffer, source: .mic)
|
||||
self.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .mic)
|
||||
guard let self else { return }
|
||||
self.processAudioBuffer(
|
||||
{ buffer },
|
||||
converter: converter,
|
||||
targetFormat: targetFormat,
|
||||
source: .mic
|
||||
)
|
||||
}
|
||||
audioEngine.prepare()
|
||||
try audioEngine.start()
|
||||
@@ -161,12 +347,17 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
private func restartMicrophone() {
|
||||
guard (isRecording || micAudioFile != nil), micRetryCount < maxMicRetries else { return }
|
||||
guard hasActiveAudioFiles(), micRetryCount < maxMicRetries else { return }
|
||||
micRetryCount += 1
|
||||
pendingMicRestart?.cancel()
|
||||
cleanupAudioEngine()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
|
||||
self?.startMicrophoneTap()
|
||||
|
||||
let restart = DispatchWorkItem { [weak self] in
|
||||
guard let self, self.hasActiveAudioFiles() else { return }
|
||||
self.startMicrophoneTap()
|
||||
}
|
||||
pendingMicRestart = restart
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: restart)
|
||||
}
|
||||
|
||||
private func cleanupAudioEngine() {
|
||||
@@ -194,8 +385,7 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
let processIDs = audioProcessController.processes.map(\.objectID)
|
||||
let newTap = ProcessTap(target: .systemAudio(processObjectIDs: processIDs))
|
||||
let newTap = ProcessTap(target: .systemAudio)
|
||||
newTap.activate()
|
||||
if let tapError = newTap.errorMessage {
|
||||
errorMessage = "Failed to activate system audio capture: \(tapError)"
|
||||
@@ -219,21 +409,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 {
|
||||
guard isRecording else { return }
|
||||
isRestartingSystemTap = true
|
||||
defer { isRestartingSystemTap = false }
|
||||
if isTapActive {
|
||||
processTap?.invalidate()
|
||||
processTap = nil
|
||||
@@ -256,32 +433,269 @@ 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"])
|
||||
}
|
||||
try tap.run(on: tapQueue) { [weak self] _, inputData, _, _, _ in
|
||||
guard let self,
|
||||
let buffer = AVAudioPCMBuffer(pcmFormat: inputFormat, bufferListNoCopy: inputData, deallocator: nil),
|
||||
buffer.frameLength > 0 else { return }
|
||||
self.updateAudioLevel(buffer, source: .system)
|
||||
self.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .system)
|
||||
systemDiagnostics.tapAdvertisedFormat = streamDescriptionSummary(tap.tapAdvertisedStreamDescription)
|
||||
systemDiagnostics.aggregateInputFormat = streamDescriptionSummary(tap.aggregateInputStreamDescription)
|
||||
systemDiagnostics.targetFormat = audioFormatSummary(targetFormat)
|
||||
systemDiagnostics.targetSampleRate = targetFormat.sampleRate
|
||||
var inputFormat: AVAudioFormat?
|
||||
var converter: AVAudioConverter?
|
||||
var pendingBuffer: AVAudioPCMBuffer?
|
||||
var pendingInputTime: AudioTimeStamp?
|
||||
var pendingNow: AudioTimeStamp?
|
||||
try tap.run(on: tapQueue) { [weak self] inNow, inputData, inInputTime, _, _ in
|
||||
guard let self else { return }
|
||||
if inputFormat == nil {
|
||||
guard let callbackFormat = self.inputFormat(
|
||||
for: inputData,
|
||||
sampleRate: advertisedInputFormat.sampleRate
|
||||
),
|
||||
let currentBuffer = self.copyAudioBuffer(from: inputData, format: callbackFormat) else {
|
||||
self.systemDiagnostics.discardedCallbackCount += 1
|
||||
return
|
||||
}
|
||||
|
||||
guard let previousBuffer = pendingBuffer,
|
||||
let previousInputTime = pendingInputTime,
|
||||
let previousNow = pendingNow else {
|
||||
pendingBuffer = currentBuffer
|
||||
pendingInputTime = inInputTime.pointee
|
||||
pendingNow = inNow.pointee
|
||||
return
|
||||
}
|
||||
|
||||
let selectedSampleRate = self.effectiveInputSampleRate(
|
||||
advertisedSampleRate: advertisedInputFormat.sampleRate,
|
||||
previousFrameLength: previousBuffer.frameLength,
|
||||
previousTimestamp: previousInputTime,
|
||||
currentTimestamp: inInputTime.pointee,
|
||||
previousHostTimestamp: previousNow,
|
||||
currentHostTimestamp: inNow.pointee
|
||||
)
|
||||
inputFormat = self.inputFormat(
|
||||
for: inputData,
|
||||
sampleRate: selectedSampleRate
|
||||
)
|
||||
if let inputFormat {
|
||||
self.systemDiagnostics.selectedInputFormat = self.audioFormatSummary(inputFormat)
|
||||
self.systemDiagnostics.selectedInputSampleRate = inputFormat.sampleRate
|
||||
converter = AVAudioConverter(from: inputFormat, to: targetFormat)
|
||||
}
|
||||
|
||||
guard let inputFormat, let converter else { return }
|
||||
self.processAudioBuffer(
|
||||
{ self.copyAudioBuffer(from: previousBuffer.audioBufferList, format: inputFormat) },
|
||||
converter: converter,
|
||||
targetFormat: targetFormat,
|
||||
source: .system,
|
||||
callbackTimestamp: previousInputTime,
|
||||
ioTimestamp: pendingNow
|
||||
)
|
||||
pendingBuffer = nil
|
||||
pendingInputTime = nil
|
||||
pendingNow = nil
|
||||
|
||||
self.processAudioBuffer(
|
||||
{ self.copyAudioBuffer(from: currentBuffer.audioBufferList, format: inputFormat) },
|
||||
converter: converter,
|
||||
targetFormat: targetFormat,
|
||||
source: .system,
|
||||
callbackTimestamp: inInputTime.pointee,
|
||||
ioTimestamp: inNow.pointee
|
||||
)
|
||||
return
|
||||
}
|
||||
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.copyAudioBuffer(from: inputData, format: inputFormat) },
|
||||
converter: converter,
|
||||
targetFormat: targetFormat,
|
||||
source: .system,
|
||||
callbackTimestamp: inInputTime.pointee,
|
||||
ioTimestamp: inNow.pointee
|
||||
)
|
||||
} invalidationHandler: { [weak self] _ in
|
||||
guard let self, !self.isRestartingSystemTap, self.isRecording else { return }
|
||||
guard let self, self.isRecording else { return }
|
||||
Task { await self.restartSystemAudioTap() }
|
||||
}
|
||||
}
|
||||
|
||||
private func inputFormat(
|
||||
for inputData: UnsafePointer<AudioBufferList>,
|
||||
sampleRate: Double
|
||||
) -> AVAudioFormat? {
|
||||
let buffers = UnsafeMutableAudioBufferListPointer(
|
||||
UnsafeMutablePointer(mutating: inputData)
|
||||
)
|
||||
let channelCount = buffers.reduce(UInt32(0)) { $0 + $1.mNumberChannels }
|
||||
guard channelCount > 0 else { return nil }
|
||||
|
||||
// HAL I/O proc samples use the canonical Float32 representation. The
|
||||
// tap's stream description can advertise a different common format;
|
||||
// using that to interpret the callback bytes can halve the frame count
|
||||
// (for example, treating four-byte Float32 samples as eight-byte
|
||||
// Float64 samples). The callback's AudioBufferList is authoritative for
|
||||
// its channel layout, while its sample rate comes from the input stream.
|
||||
let isInterleaved = buffers.count == 1 && channelCount > 1
|
||||
return AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32,
|
||||
sampleRate: sampleRate,
|
||||
channels: AVAudioChannelCount(channelCount),
|
||||
interleaved: isInterleaved
|
||||
)
|
||||
}
|
||||
|
||||
private func effectiveInputSampleRate(
|
||||
advertisedSampleRate: Double,
|
||||
previousFrameLength: AVAudioFrameCount,
|
||||
previousTimestamp: AudioTimeStamp,
|
||||
currentTimestamp: AudioTimeStamp,
|
||||
previousHostTimestamp: AudioTimeStamp,
|
||||
currentHostTimestamp: AudioTimeStamp
|
||||
) -> Double {
|
||||
let sampleTimeDelta = currentTimestamp.mSampleTime - previousTimestamp.mSampleTime
|
||||
guard advertisedSampleRate > 0, previousFrameLength > 0 else {
|
||||
systemDiagnostics.inputRateInference = "advertised (invalid advertised rate or frame count)"
|
||||
return advertisedSampleRate
|
||||
}
|
||||
|
||||
guard currentHostTimestamp.mHostTime > previousHostTimestamp.mHostTime else {
|
||||
systemDiagnostics.inputRateInference = "advertised (host timestamps unavailable)"
|
||||
return advertisedSampleRate
|
||||
}
|
||||
let hostTimeDelta = currentHostTimestamp.mHostTime - previousHostTimestamp.mHostTime
|
||||
let hostNanoseconds = AudioConvertHostTimeToNanos(hostTimeDelta)
|
||||
guard hostNanoseconds > 0 else {
|
||||
systemDiagnostics.inputRateInference = "advertised (host-time conversion failed)"
|
||||
return advertisedSampleRate
|
||||
}
|
||||
|
||||
let inferredSampleRate = Double(previousFrameLength) * 1_000_000_000 / Double(hostNanoseconds)
|
||||
let plausibleRange = (advertisedSampleRate * 0.25)...(advertisedSampleRate * 1.25)
|
||||
guard inferredSampleRate.isFinite, plausibleRange.contains(inferredSampleRate) else {
|
||||
systemDiagnostics.inputRateInference = String(
|
||||
format: "advertised (invalid host inference %.3f from %u frames / %llu ns; sampleTimeDelta=%.3f)",
|
||||
inferredSampleRate,
|
||||
previousFrameLength,
|
||||
hostNanoseconds,
|
||||
sampleTimeDelta
|
||||
)
|
||||
return advertisedSampleRate
|
||||
}
|
||||
|
||||
let commonSampleRates: [Double] = [8_000, 11_025, 12_000, 16_000, 22_050, 24_000, 32_000, 44_100, 48_000, 88_200, 96_000]
|
||||
let selectedSampleRate = commonSampleRates
|
||||
.min(by: { abs($0 - inferredSampleRate) < abs($1 - inferredSampleRate) })
|
||||
.flatMap { abs($0 - inferredSampleRate) / $0 <= 0.01 ? $0 : nil }
|
||||
?? inferredSampleRate
|
||||
systemDiagnostics.inputRateInference = String(
|
||||
format: "source=hostTime,advertised=%.3f,inferred=%.3f,selected=%.3f,previousFrames=%u,hostNanoseconds=%llu,sampleTimeDelta=%.3f",
|
||||
advertisedSampleRate,
|
||||
inferredSampleRate,
|
||||
selectedSampleRate,
|
||||
previousFrameLength,
|
||||
hostNanoseconds,
|
||||
sampleTimeDelta
|
||||
)
|
||||
return selectedSampleRate
|
||||
}
|
||||
|
||||
private func copyAudioBuffer(
|
||||
from inputData: UnsafePointer<AudioBufferList>,
|
||||
format: AVAudioFormat
|
||||
) -> AVAudioPCMBuffer? {
|
||||
let sourceBuffers = UnsafeMutableAudioBufferListPointer(
|
||||
UnsafeMutablePointer(mutating: inputData)
|
||||
)
|
||||
if systemDiagnostics.firstBufferLayout == nil {
|
||||
systemDiagnostics.firstBufferLayout = sourceBuffers.enumerated().map { index, buffer in
|
||||
"buffer\(index):channels=\(buffer.mNumberChannels),bytes=\(buffer.mDataByteSize)"
|
||||
}.joined(separator: "; ")
|
||||
}
|
||||
let frameLengths = sourceBuffers.compactMap { source -> AVAudioFrameCount? in
|
||||
let bytesPerFrame = Int(source.mNumberChannels) * MemoryLayout<Float32>.size
|
||||
guard bytesPerFrame > 0,
|
||||
Int(source.mDataByteSize).isMultiple(of: bytesPerFrame) else { return nil }
|
||||
return AVAudioFrameCount(Int(source.mDataByteSize) / bytesPerFrame)
|
||||
}
|
||||
guard frameLengths.count == sourceBuffers.count,
|
||||
let frameLength = frameLengths.first,
|
||||
frameLength > 0,
|
||||
frameLengths.allSatisfy({ $0 == frameLength }),
|
||||
let ownedBuffer = AVAudioPCMBuffer(
|
||||
pcmFormat: format,
|
||||
frameCapacity: frameLength
|
||||
) else { return nil }
|
||||
|
||||
// Set the frame count explicitly instead of asking AVAudioPCMBuffer to
|
||||
// infer it from potentially inconsistent tap metadata.
|
||||
ownedBuffer.frameLength = frameLength
|
||||
let destinationBuffers = UnsafeMutableAudioBufferListPointer(
|
||||
ownedBuffer.mutableAudioBufferList
|
||||
)
|
||||
guard sourceBuffers.count == destinationBuffers.count else { return nil }
|
||||
|
||||
for index in 0..<sourceBuffers.count {
|
||||
let source = sourceBuffers[index]
|
||||
let destination = destinationBuffers[index]
|
||||
let byteCount = Int(source.mDataByteSize)
|
||||
guard byteCount <= Int(destination.mDataByteSize),
|
||||
let sourceData = source.mData,
|
||||
let destinationData = destination.mData else { return nil }
|
||||
memcpy(destinationData, sourceData, byteCount)
|
||||
destinationBuffers[index].mDataByteSize = source.mDataByteSize
|
||||
}
|
||||
return ownedBuffer
|
||||
}
|
||||
|
||||
private func processAudioBuffer(
|
||||
_ inputBuffer: AVAudioPCMBuffer,
|
||||
_ inputBufferProvider: () -> AVAudioPCMBuffer?,
|
||||
converter: AVAudioConverter,
|
||||
targetFormat: AVAudioFormat,
|
||||
source: AudioSource
|
||||
source: AudioSource,
|
||||
callbackTimestamp: AudioTimeStamp? = nil,
|
||||
ioTimestamp: AudioTimeStamp? = nil
|
||||
) {
|
||||
// The system callback copies its borrowed Core Audio memory while this
|
||||
// lock prevents teardown, then conversion operates on the owned copy.
|
||||
audioFileLock.lock()
|
||||
defer { audioFileLock.unlock() }
|
||||
guard isAcceptingAudio else { return }
|
||||
if source == .system {
|
||||
systemDiagnostics.callbackCount += 1
|
||||
let callbackAt = Date()
|
||||
if systemDiagnostics.firstCallbackAt == nil { systemDiagnostics.firstCallbackAt = callbackAt }
|
||||
systemDiagnostics.lastCallbackAt = callbackAt
|
||||
if let callbackTimestamp {
|
||||
if systemDiagnostics.firstSampleTime == nil { systemDiagnostics.firstSampleTime = callbackTimestamp.mSampleTime }
|
||||
systemDiagnostics.lastSampleTime = callbackTimestamp.mSampleTime
|
||||
}
|
||||
if let ioTimestamp {
|
||||
if systemDiagnostics.firstHostTime == nil { systemDiagnostics.firstHostTime = ioTimestamp.mHostTime }
|
||||
systemDiagnostics.lastHostTime = ioTimestamp.mHostTime
|
||||
}
|
||||
}
|
||||
guard let inputBuffer = inputBufferProvider(), inputBuffer.frameLength > 0 else {
|
||||
if source == .system { systemDiagnostics.discardedCallbackCount += 1 }
|
||||
return
|
||||
}
|
||||
if source == .system {
|
||||
systemDiagnostics.inputFrameCount += UInt64(inputBuffer.frameLength)
|
||||
}
|
||||
|
||||
updateAudioLevel(inputBuffer, source: source)
|
||||
let ratio = targetFormat.sampleRate / inputBuffer.format.sampleRate
|
||||
let capacity = max(1, AVAudioFrameCount(ceil(Double(inputBuffer.frameLength) * ratio)))
|
||||
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else { return }
|
||||
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else {
|
||||
if source == .system { systemDiagnostics.discardedCallbackCount += 1 }
|
||||
return
|
||||
}
|
||||
var suppliedInput = false
|
||||
var conversionError: NSError?
|
||||
let status = converter.convert(to: outputBuffer, error: &conversionError) { _, outputStatus in
|
||||
@@ -293,13 +707,18 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
outputStatus.pointee = .haveData
|
||||
return inputBuffer
|
||||
}
|
||||
guard status != .error, conversionError == nil, outputBuffer.frameLength > 0 else { return }
|
||||
guard status != .error, conversionError == nil, outputBuffer.frameLength > 0 else {
|
||||
if source == .system { systemDiagnostics.discardedCallbackCount += 1 }
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
switch source {
|
||||
case .mic:
|
||||
try micAudioFile?.write(from: outputBuffer)
|
||||
case .system:
|
||||
try systemAudioFile?.write(from: outputBuffer)
|
||||
systemDiagnostics.outputFrameCount += UInt64(outputBuffer.frameLength)
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
@@ -327,7 +746,16 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
|
||||
private func stopCaptureAndCloseFiles() -> [URL?] {
|
||||
isRecording = false
|
||||
pendingMicRestart?.cancel()
|
||||
pendingMicRestart = nil
|
||||
AudioLevelManager.shared.updateRecordingState(false)
|
||||
|
||||
// Stop new callbacks and wait for any active conversion/write before
|
||||
// invalidating callback-owned buffers or finalizing AVAudioFile.
|
||||
audioFileLock.lock()
|
||||
isAcceptingAudio = false
|
||||
audioFileLock.unlock()
|
||||
|
||||
if isTapActive {
|
||||
processTap?.invalidate()
|
||||
processTap = nil
|
||||
@@ -337,11 +765,13 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
micRetryCount = 0
|
||||
resetAudioLevels()
|
||||
|
||||
audioFileLock.lock()
|
||||
let micHasAudio = (micAudioFile?.length ?? 0) > 0
|
||||
let systemHasAudio = (systemAudioFile?.length ?? 0) > 0
|
||||
micAudioFile = nil
|
||||
systemAudioFile = nil
|
||||
let files: [URL?] = [micHasAudio ? micAudioURL : nil, systemHasAudio ? systemAudioURL : nil]
|
||||
audioFileLock.unlock()
|
||||
if !micHasAudio, let micAudioURL { try? FileManager.default.removeItem(at: micAudioURL) }
|
||||
if !systemHasAudio, let systemAudioURL { try? FileManager.default.removeItem(at: systemAudioURL) }
|
||||
micAudioURL = nil
|
||||
@@ -358,6 +788,215 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
private func removeAudioFiles(_ urls: [URL]) {
|
||||
for url in urls { try? FileManager.default.removeItem(at: url) }
|
||||
}
|
||||
|
||||
private func preserveAudioFiles(_ urls: [URL], meetingID: UUID) -> URL? {
|
||||
LocalStorageManager.shared.preserveAudioFiles(urls, for: meetingID)
|
||||
}
|
||||
|
||||
private func preservedAudioFiles(_ urls: [URL?], in folder: URL?) -> [URL?] {
|
||||
urls.map { sourceURL in
|
||||
guard let sourceURL else { return nil }
|
||||
guard let folder else { return sourceURL }
|
||||
let preservedURL = folder.appendingPathComponent(sourceURL.lastPathComponent)
|
||||
return FileManager.default.fileExists(atPath: preservedURL.path) ? preservedURL : sourceURL
|
||||
}
|
||||
}
|
||||
|
||||
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?]) -> Bool {
|
||||
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 false }
|
||||
do {
|
||||
try halveWAVSampleRate(at: systemURL)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
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 streamDescriptionSummary(_ description: AudioStreamBasicDescription?) -> String {
|
||||
guard let description else { return "unavailable" }
|
||||
return String(
|
||||
format: "sampleRate=%.3f,formatID=%u,flags=%u,bytesPerPacket=%u,framesPerPacket=%u,bytesPerFrame=%u,channels=%u,bitsPerChannel=%u",
|
||||
description.mSampleRate,
|
||||
description.mFormatID,
|
||||
description.mFormatFlags,
|
||||
description.mBytesPerPacket,
|
||||
description.mFramesPerPacket,
|
||||
description.mBytesPerFrame,
|
||||
description.mChannelsPerFrame,
|
||||
description.mBitsPerChannel
|
||||
)
|
||||
}
|
||||
|
||||
private func audioFormatSummary(_ format: AVAudioFormat) -> String {
|
||||
"sampleRate=\(format.sampleRate),channels=\(format.channelCount),commonFormat=\(format.commonFormat.rawValue),interleaved=\(format.isInterleaved)"
|
||||
}
|
||||
|
||||
private func audioFileSummary(_ url: URL?) -> String {
|
||||
guard let url else { return "missing" }
|
||||
guard let file = try? AVAudioFile(forReading: url), file.processingFormat.sampleRate > 0 else {
|
||||
return "\(url.lastPathComponent):unreadable"
|
||||
}
|
||||
let duration = Double(file.length) / file.processingFormat.sampleRate
|
||||
return String(
|
||||
format: "%@:sampleRate=%.3f,channels=%u,frames=%lld,duration=%.6f",
|
||||
url.lastPathComponent,
|
||||
file.processingFormat.sampleRate,
|
||||
file.processingFormat.channelCount,
|
||||
file.length,
|
||||
duration
|
||||
)
|
||||
}
|
||||
|
||||
private func writeCaptureDiagnostics(
|
||||
sessionID: UUID,
|
||||
captureStartedAt: Date,
|
||||
preRepairFileSummaries: [String],
|
||||
repairedFiles: [URL?],
|
||||
repairApplied: Bool,
|
||||
audioFolder: URL?
|
||||
) {
|
||||
guard let audioFolder else { return }
|
||||
let callbackDuration = systemDiagnostics.firstCallbackAt.flatMap { first in
|
||||
systemDiagnostics.lastCallbackAt.map { $0.timeIntervalSince(first) }
|
||||
}
|
||||
let sampleTimeDelta = systemDiagnostics.firstSampleTime.flatMap { first in
|
||||
systemDiagnostics.lastSampleTime.map { $0 - first }
|
||||
}
|
||||
let hostTimeDelta = systemDiagnostics.firstHostTime.flatMap { first in
|
||||
systemDiagnostics.lastHostTime.map { $0 >= first ? $0 - first : 0 }
|
||||
}
|
||||
let inputFrameDuration = systemDiagnostics.selectedInputSampleRate.flatMap { sampleRate in
|
||||
sampleRate > 0 ? Double(systemDiagnostics.inputFrameCount) / sampleRate : nil
|
||||
}
|
||||
let outputFrameDuration = systemDiagnostics.targetSampleRate.flatMap { sampleRate in
|
||||
sampleRate > 0 ? Double(systemDiagnostics.outputFrameCount) / sampleRate : nil
|
||||
}
|
||||
let observedInputRate = callbackDuration.flatMap { duration in
|
||||
duration > 0 ? Double(systemDiagnostics.inputFrameCount) / duration : nil
|
||||
}
|
||||
let preRepairMic = preRepairFileSummaries.indices.contains(0) ? preRepairFileSummaries[0] : "missing"
|
||||
let preRepairSystem = preRepairFileSummaries.indices.contains(1) ? preRepairFileSummaries[1] : "missing"
|
||||
let postRepairMic = repairedFiles.indices.contains(0) ? audioFileSummary(repairedFiles[0]) : "missing"
|
||||
let postRepairSystem = repairedFiles.indices.contains(1) ? audioFileSummary(repairedFiles[1]) : "missing"
|
||||
let callbackDurationLine = callbackDuration.map { String(format: "callbackWallDuration=%.6f", $0) } ?? "callbackWallDuration=unavailable"
|
||||
let inputFrameDurationLine = inputFrameDuration.map { String(format: "inputFrameDurationAtSelectedRate=%.6f", $0) } ?? "inputFrameDurationAtSelectedRate=unavailable"
|
||||
let outputFrameDurationLine = outputFrameDuration.map { String(format: "outputFrameDurationAtTargetRate=%.6f", $0) } ?? "outputFrameDurationAtTargetRate=unavailable"
|
||||
let observedInputRateLine = observedInputRate.map { String(format: "observedInputFramesPerSecond=%.3f", $0) } ?? "observedInputFramesPerSecond=unavailable"
|
||||
let sampleTimeDeltaLine = sampleTimeDelta.map { String(format: "sampleTimeDelta=%.6f", $0) } ?? "sampleTimeDelta=unavailable"
|
||||
let hostTimeDeltaLine = hostTimeDelta.map { "hostTimeDelta=\($0)" } ?? "hostTimeDelta=unavailable"
|
||||
let lines: [String] = [
|
||||
"Meetingnotes system capture diagnostics",
|
||||
"sessionID=\(sessionID.uuidString)",
|
||||
"createdAt=\(ISO8601DateFormatter().string(from: Date()))",
|
||||
String(format: "captureWallDuration=%.6f", Date().timeIntervalSince(captureStartedAt)),
|
||||
"tapAdvertisedFormat=\(systemDiagnostics.tapAdvertisedFormat)",
|
||||
"aggregateInputFormat=\(systemDiagnostics.aggregateInputFormat)",
|
||||
"selectedInputFormat=\(systemDiagnostics.selectedInputFormat)",
|
||||
"inputRateInference=\(systemDiagnostics.inputRateInference)",
|
||||
"targetFormat=\(systemDiagnostics.targetFormat)",
|
||||
"firstBufferLayout=\(systemDiagnostics.firstBufferLayout ?? "unavailable")",
|
||||
"callbackCount=\(systemDiagnostics.callbackCount)",
|
||||
"inputFrameCount=\(systemDiagnostics.inputFrameCount)",
|
||||
"outputFrameCount=\(systemDiagnostics.outputFrameCount)",
|
||||
"discardedCallbackCount=\(systemDiagnostics.discardedCallbackCount)",
|
||||
callbackDurationLine,
|
||||
inputFrameDurationLine,
|
||||
outputFrameDurationLine,
|
||||
observedInputRateLine,
|
||||
sampleTimeDeltaLine,
|
||||
hostTimeDeltaLine,
|
||||
"repairApplied=\(repairApplied)",
|
||||
"preRepairMic=\(preRepairMic)",
|
||||
"preRepairSystem=\(preRepairSystem)",
|
||||
"postRepairMic=\(postRepairMic)",
|
||||
"postRepairSystem=\(postRepairSystem)"
|
||||
]
|
||||
let diagnosticsURL = audioFolder.appendingPathComponent("audio-diagnostics-\(sessionID.uuidString).txt")
|
||||
try? lines.joined(separator: "\n").appending("\n").write(
|
||||
to: diagnosticsURL,
|
||||
atomically: true,
|
||||
encoding: .utf8
|
||||
)
|
||||
}
|
||||
|
||||
private func resetAudioLevels() {
|
||||
micAudioLevel = 0
|
||||
@@ -365,6 +1004,12 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
AudioLevelManager.shared.updateMicLevel(0)
|
||||
AudioLevelManager.shared.updateSystemLevel(0)
|
||||
}
|
||||
|
||||
private func hasActiveAudioFiles() -> Bool {
|
||||
audioFileLock.lock()
|
||||
defer { audioFileLock.unlock() }
|
||||
return isAcceptingAudio
|
||||
}
|
||||
|
||||
private func handleAudioEngineConfigurationChange() {
|
||||
restartMicrophone()
|
||||
|
||||
@@ -8,7 +8,7 @@ import Security
|
||||
class KeychainHelper {
|
||||
static let shared = KeychainHelper()
|
||||
|
||||
private let serviceName = "owen.meetingnotes"
|
||||
private let serviceName = Bundle.main.bundleIdentifier ?? "net.jamesbone.meetingnotes"
|
||||
|
||||
private init() {}
|
||||
|
||||
|
||||
@@ -2,14 +2,21 @@
|
||||
// Handles local storage of meetings and app data
|
||||
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
/// Manages local file storage for meetings and app data
|
||||
class LocalStorageManager {
|
||||
static let shared = LocalStorageManager()
|
||||
|
||||
struct MeetingImportResult {
|
||||
let importedCount: Int
|
||||
let skippedCount: Int
|
||||
}
|
||||
|
||||
private let documentsDirectory: URL
|
||||
private let meetingsDirectory: URL
|
||||
private let templatesDirectory: URL
|
||||
private let recoveryDirectory: URL
|
||||
|
||||
private init() {
|
||||
// Get the app's documents directory
|
||||
@@ -21,12 +28,18 @@ class LocalStorageManager {
|
||||
|
||||
// Create templates subdirectory
|
||||
templatesDirectory = documentsDirectory.appendingPathComponent("Templates")
|
||||
|
||||
recoveryDirectory = documentsDirectory.appendingPathComponent("Meetingnotes Audio")
|
||||
|
||||
// Ensure directories exist
|
||||
try? FileManager.default.createDirectory(at: meetingsDirectory,
|
||||
withIntermediateDirectories: true)
|
||||
try? FileManager.default.createDirectory(at: templatesDirectory,
|
||||
withIntermediateDirectories: true)
|
||||
try? FileManager.default.createDirectory(at: recoveryDirectory,
|
||||
withIntermediateDirectories: true)
|
||||
migrateLegacyRecoveryAudio()
|
||||
purgeExpiredAudioFolders()
|
||||
}
|
||||
|
||||
// MARK: - Meeting Management
|
||||
@@ -47,7 +60,17 @@ class LocalStorageManager {
|
||||
// Write atomically using a temp file then replace
|
||||
let tmpURL = fileURL.appendingPathExtension("tmp")
|
||||
try data.write(to: tmpURL, options: .atomic)
|
||||
try FileManager.default.replaceItem(at: fileURL, withItemAt: tmpURL, backupItemName: nil, options: [], resultingItemURL: nil)
|
||||
if FileManager.default.fileExists(atPath: fileURL.path) {
|
||||
try FileManager.default.replaceItem(
|
||||
at: fileURL,
|
||||
withItemAt: tmpURL,
|
||||
backupItemName: nil,
|
||||
options: [],
|
||||
resultingItemURL: nil
|
||||
)
|
||||
} else {
|
||||
try FileManager.default.moveItem(at: tmpURL, to: fileURL)
|
||||
}
|
||||
|
||||
print("✅ Saved meeting: \(meeting.id)")
|
||||
return true
|
||||
@@ -128,6 +151,298 @@ class LocalStorageManager {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func mergeMeeting(_ continuation: Meeting, into previous: Meeting) -> Meeting? {
|
||||
guard continuation.id != previous.id, continuation.date >= previous.date else { return nil }
|
||||
|
||||
let previousFolder = recoveryDirectory.appendingPathComponent(previous.id.uuidString, isDirectory: true)
|
||||
let continuationFolder = recoveryAudioFolder(named: continuation.id.uuidString)
|
||||
var copiedAudioURLs: [URL] = []
|
||||
var createdPreviousFolder = false
|
||||
|
||||
if let continuationFolder {
|
||||
if !FileManager.default.fileExists(atPath: previousFolder.path) {
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: previousFolder, withIntermediateDirectories: true)
|
||||
createdPreviousFolder = true
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
let recoveryFiles = recoveryAudioFiles(in: continuationFolder).map(\.url)
|
||||
+ recoveryDiagnosticFiles(in: continuationFolder)
|
||||
for recoveryFile in recoveryFiles {
|
||||
let destination = previousFolder.appendingPathComponent(recoveryFile.lastPathComponent)
|
||||
if FileManager.default.fileExists(atPath: destination.path) {
|
||||
guard FileManager.default.contentsEqual(
|
||||
atPath: recoveryFile.path,
|
||||
andPath: destination.path
|
||||
) else {
|
||||
rollbackMergedAudio(copiedAudioURLs, removeFolder: createdPreviousFolder ? previousFolder : nil)
|
||||
return nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager.default.copyItem(at: recoveryFile, to: destination)
|
||||
copiedAudioURLs.append(destination)
|
||||
} catch {
|
||||
rollbackMergedAudio(copiedAudioURLs, removeFolder: createdPreviousFolder ? previousFolder : nil)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var merged = previous
|
||||
var seenChunkIDs = Set<UUID>()
|
||||
merged.transcriptChunks = (previous.transcriptChunks + continuation.transcriptChunks)
|
||||
.filter { seenChunkIDs.insert($0.id).inserted }
|
||||
.sorted {
|
||||
if $0.timestamp == $1.timestamp {
|
||||
return $0.id.uuidString < $1.id.uuidString
|
||||
}
|
||||
return $0.timestamp < $1.timestamp
|
||||
}
|
||||
merged.userNotes = mergedText(previous.userNotes, continuation.userNotes)
|
||||
merged.generatedNotes = mergedText(previous.generatedNotes, continuation.generatedNotes)
|
||||
merged.templateId = previous.templateId ?? continuation.templateId
|
||||
merged.transcriptionError = previous.transcriptionError ?? continuation.transcriptionError
|
||||
if !recoveryAudioFiles(in: previousFolder).isEmpty {
|
||||
merged.recoveryAudioFolderName = previous.id.uuidString
|
||||
}
|
||||
|
||||
guard saveMeeting(merged) else {
|
||||
rollbackMergedAudio(copiedAudioURLs, removeFolder: createdPreviousFolder ? previousFolder : nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
guard deleteMeeting(continuation) else {
|
||||
_ = saveMeeting(previous)
|
||||
rollbackMergedAudio(copiedAudioURLs, removeFolder: createdPreviousFolder ? previousFolder : nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
if let continuationFolder {
|
||||
try? FileManager.default.removeItem(at: continuationFolder)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
private func mergedText(_ first: String, _ second: String) -> String {
|
||||
let first = first.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let second = second.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !first.isEmpty else { return second }
|
||||
guard !second.isEmpty, second != first else { return first }
|
||||
return first + "\n\n---\n\n" + second
|
||||
}
|
||||
|
||||
private func rollbackMergedAudio(_ copiedURLs: [URL], removeFolder folder: URL?) {
|
||||
for url in copiedURLs {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
if let folder, recoveryAudioFiles(in: folder).isEmpty {
|
||||
try? FileManager.default.removeItem(at: folder)
|
||||
}
|
||||
}
|
||||
|
||||
// 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? {
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedName.isEmpty,
|
||||
URL(fileURLWithPath: trimmedName).lastPathComponent == trimmedName else {
|
||||
return nil
|
||||
}
|
||||
let folder = recoveryDirectory.appendingPathComponent(trimmedName, isDirectory: true)
|
||||
var isDirectory: ObjCBool = false
|
||||
guard FileManager.default.fileExists(atPath: folder.path, isDirectory: &isDirectory),
|
||||
isDirectory.boolValue,
|
||||
!recoveryAudioFiles(in: folder).isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return folder
|
||||
}
|
||||
|
||||
func recoveryAudioFiles(in folder: URL) -> [(url: URL, source: AudioSource)] {
|
||||
guard let files = try? FileManager.default.contentsOfDirectory(
|
||||
at: folder,
|
||||
includingPropertiesForKeys: [.creationDateKey, .contentModificationDateKey, .isRegularFileKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
return []
|
||||
}
|
||||
|
||||
return files.compactMap { url in
|
||||
let name = url.lastPathComponent.lowercased()
|
||||
guard ["m4a", "mp3", "wav", "flac", "webm", "mp4"].contains(url.pathExtension.lowercased()) else {
|
||||
return nil
|
||||
}
|
||||
if name.contains("-mic.") {
|
||||
return (url, .mic)
|
||||
}
|
||||
if name.contains("-system.") {
|
||||
return (url, .system)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func recoveryDiagnosticFiles(in folder: URL) -> [URL] {
|
||||
guard let files = try? FileManager.default.contentsOfDirectory(
|
||||
at: folder,
|
||||
includingPropertiesForKeys: [.isRegularFileKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
return []
|
||||
}
|
||||
|
||||
return files.filter { url in
|
||||
let values = try? url.resourceValues(forKeys: [.isRegularFileKey])
|
||||
return values?.isRegularFile == true
|
||||
&& url.pathExtension.caseInsensitiveCompare("txt") == .orderedSame
|
||||
&& url.lastPathComponent.lowercased().hasPrefix("audio-diagnostics-")
|
||||
}
|
||||
}
|
||||
|
||||
func findRecoveryAudioFolder(for meeting: Meeting) -> URL? {
|
||||
let canonicalName = meeting.id.uuidString
|
||||
guard meeting.recoveryAudioFolderName == nil
|
||||
|| meeting.recoveryAudioFolderName?.caseInsensitiveCompare(canonicalName) == .orderedSame else {
|
||||
return nil
|
||||
}
|
||||
return recoveryAudioFolder(named: canonicalName)
|
||||
}
|
||||
|
||||
func deleteRecoveryAudioFolder(_ folder: URL) {
|
||||
guard folder.deletingLastPathComponent().standardizedFileURL == recoveryDirectory.standardizedFileURL else {
|
||||
return
|
||||
}
|
||||
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.
|
||||
func importMeetings(from directory: URL) throws -> MeetingImportResult {
|
||||
let didStartAccess = directory.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if didStartAccess {
|
||||
directory.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
let existingIDs = Set(loadMeetings().map(\.id))
|
||||
var importedIDs = Set<UUID>()
|
||||
var skippedCount = 0
|
||||
|
||||
guard let enumerator = FileManager.default.enumerator(
|
||||
at: directory,
|
||||
includingPropertiesForKeys: [.isRegularFileKey],
|
||||
options: [.skipsHiddenFiles, .skipsPackageDescendants]
|
||||
) else {
|
||||
throw CocoaError(.fileReadUnknown)
|
||||
}
|
||||
|
||||
for case let fileURL as URL in enumerator where fileURL.pathExtension.lowercased() == "json" {
|
||||
do {
|
||||
let values = try fileURL.resourceValues(forKeys: [.isRegularFileKey])
|
||||
guard values.isRegularFile == true else { continue }
|
||||
let data = try Data(contentsOf: fileURL)
|
||||
let meeting = try decoder.decode(Meeting.self, from: data)
|
||||
guard meeting.dataVersion <= Meeting.currentDataVersion,
|
||||
!existingIDs.contains(meeting.id),
|
||||
!importedIDs.contains(meeting.id),
|
||||
saveMeeting(meeting) else {
|
||||
skippedCount += 1
|
||||
continue
|
||||
}
|
||||
importedIDs.insert(meeting.id)
|
||||
} catch {
|
||||
skippedCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
return MeetingImportResult(importedCount: importedIDs.count, skippedCount: skippedCount)
|
||||
}
|
||||
|
||||
// MARK: - Template Management
|
||||
|
||||
@@ -146,7 +461,17 @@ class LocalStorageManager {
|
||||
// Write atomically using a temp file then replace
|
||||
let tmpURL = fileURL.appendingPathExtension("tmp")
|
||||
try data.write(to: tmpURL, options: .atomic)
|
||||
try FileManager.default.replaceItem(at: fileURL, withItemAt: tmpURL, backupItemName: nil, options: [], resultingItemURL: nil)
|
||||
if FileManager.default.fileExists(atPath: fileURL.path) {
|
||||
try FileManager.default.replaceItem(
|
||||
at: fileURL,
|
||||
withItemAt: tmpURL,
|
||||
backupItemName: nil,
|
||||
options: [],
|
||||
resultingItemURL: nil
|
||||
)
|
||||
} else {
|
||||
try FileManager.default.moveItem(at: tmpURL, to: fileURL)
|
||||
}
|
||||
|
||||
print("✅ Saved template: \(template.id)")
|
||||
return true
|
||||
@@ -199,6 +524,19 @@ class LocalStorageManager {
|
||||
|
||||
return templates.sorted { $0.title < $1.title }
|
||||
}
|
||||
|
||||
func preferredTemplateID(in templates: [NoteTemplate]? = nil) -> UUID? {
|
||||
let availableTemplates = templates ?? loadTemplates()
|
||||
if let selectedTemplateID = UserDefaultsManager.shared.selectedTemplateId,
|
||||
availableTemplates.contains(where: { $0.id == selectedTemplateID }) {
|
||||
return selectedTemplateID
|
||||
}
|
||||
|
||||
let fallbackTemplateID = availableTemplates.first(where: { $0.title == "Standard Meeting" })?.id
|
||||
?? availableTemplates.first?.id
|
||||
UserDefaultsManager.shared.selectedTemplateId = fallbackTemplateID
|
||||
return fallbackTemplateID
|
||||
}
|
||||
|
||||
/// Deletes a template from local storage
|
||||
/// - Parameter template: The template to delete
|
||||
@@ -241,4 +579,4 @@ class LocalStorageManager {
|
||||
var meetingsDirectoryURL: URL {
|
||||
meetingsDirectory
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@ import Foundation
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
struct RecordingCompletion {
|
||||
let chunks: [TranscriptChunk]
|
||||
let recoveryAudioFolderName: String?
|
||||
let transcriptionError: String?
|
||||
}
|
||||
|
||||
/// Manages recording sessions at the app level to persist across navigation
|
||||
@MainActor
|
||||
class RecordingSessionManager: ObservableObject {
|
||||
@@ -14,9 +20,11 @@ class RecordingSessionManager: ObservableObject {
|
||||
@Published var errorMessage: String?
|
||||
@Published var activeRecordingTranscriptChunksUpdated: [TranscriptChunk] = []
|
||||
|
||||
private let audioManager = AudioManager.shared
|
||||
private var audioManager = AudioManager.shared
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var audioManagerCancellables = Set<AnyCancellable>()
|
||||
private let transcriptUpdateSubject = PassthroughSubject<[TranscriptChunk], Never>()
|
||||
private var processingMeetingIds = Set<UUID>()
|
||||
|
||||
// Store transcript chunks for the active recording session
|
||||
private var activeRecordingTranscriptChunks: [TranscriptChunk] = []
|
||||
@@ -27,24 +35,19 @@ class RecordingSessionManager: ObservableObject {
|
||||
}
|
||||
|
||||
private func setupAudioManagerBindings() {
|
||||
audioManagerCancellables.removeAll()
|
||||
// Bind to audio manager state
|
||||
audioManager.$isRecording
|
||||
.sink { [weak self] isRecording in
|
||||
self?.isRecording = isRecording
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
audioManager.$isProcessing
|
||||
.sink { [weak self] isProcessing in
|
||||
self?.isProcessing = isProcessing
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
.store(in: &audioManagerCancellables)
|
||||
|
||||
audioManager.$errorMessage
|
||||
.sink { [weak self] errorMessage in
|
||||
self?.errorMessage = errorMessage
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
.store(in: &audioManagerCancellables)
|
||||
|
||||
// When transcript chunks change, store them for the active recording and send to debouncer
|
||||
audioManager.$transcriptChunks
|
||||
@@ -55,7 +58,7 @@ class RecordingSessionManager: ObservableObject {
|
||||
|
||||
self.transcriptUpdateSubject.send(newChunks)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
.store(in: &audioManagerCancellables)
|
||||
}
|
||||
|
||||
private func setupDebouncedSaving() {
|
||||
@@ -81,25 +84,43 @@ class RecordingSessionManager: ObservableObject {
|
||||
|
||||
activeMeetingId = meetingId
|
||||
recordingStartedAt = Date()
|
||||
audioManager.startRecording()
|
||||
audioManager.startRecording(for: meetingId)
|
||||
}
|
||||
|
||||
func stopRecording() async -> [TranscriptChunk] {
|
||||
func stopRecording() async -> RecordingCompletion {
|
||||
print("🛑 Stopping recording for meeting: \(activeMeetingId?.uuidString ?? "unknown")")
|
||||
|
||||
guard let meetingId = activeMeetingId else {
|
||||
audioManager.cancelRecording()
|
||||
recordingStartedAt = nil
|
||||
return []
|
||||
return RecordingCompletion(chunks: [], recoveryAudioFolderName: nil, transcriptionError: nil)
|
||||
}
|
||||
recordingStartedAt = nil
|
||||
let chunks = await audioManager.stopRecordingAndTranscribe()
|
||||
activeRecordingTranscriptChunks = chunks
|
||||
activeRecordingTranscriptChunksUpdated = chunks
|
||||
updateActiveMeetingTranscript(meetingId: meetingId, chunks: chunks)
|
||||
|
||||
let completedAudioManager = audioManager
|
||||
audioManager = AudioManager()
|
||||
setupAudioManagerBindings()
|
||||
activeMeetingId = nil
|
||||
activeRecordingTranscriptChunks = []
|
||||
return chunks
|
||||
recordingStartedAt = nil
|
||||
processingMeetingIds.insert(meetingId)
|
||||
isProcessing = true
|
||||
defer {
|
||||
processingMeetingIds.remove(meetingId)
|
||||
isProcessing = !processingMeetingIds.isEmpty
|
||||
}
|
||||
|
||||
let chunks = await completedAudioManager.stopRecordingAndTranscribe()
|
||||
let completion = RecordingCompletion(
|
||||
chunks: chunks,
|
||||
recoveryAudioFolderName: completedAudioManager.lastRecoveryAudioFolderName,
|
||||
transcriptionError: completedAudioManager.errorMessage
|
||||
)
|
||||
updateActiveMeetingTranscript(
|
||||
meetingId: meetingId,
|
||||
chunks: chunks,
|
||||
recoveryAudioFolderName: completion.recoveryAudioFolderName,
|
||||
transcriptionError: completion.transcriptionError
|
||||
)
|
||||
return completion
|
||||
}
|
||||
|
||||
func cancelRecording() {
|
||||
@@ -112,14 +133,27 @@ class RecordingSessionManager: ObservableObject {
|
||||
func isRecordingMeeting(_ meetingId: UUID) -> Bool {
|
||||
return isRecording && activeMeetingId == meetingId
|
||||
}
|
||||
|
||||
private func updateActiveMeetingTranscript(meetingId: UUID, chunks: [TranscriptChunk]) {
|
||||
|
||||
func isProcessingMeeting(_ meetingId: UUID) -> Bool {
|
||||
processingMeetingIds.contains(meetingId)
|
||||
}
|
||||
|
||||
private func updateActiveMeetingTranscript(
|
||||
meetingId: UUID,
|
||||
chunks: [TranscriptChunk],
|
||||
recoveryAudioFolderName: String? = nil,
|
||||
transcriptionError: String? = nil
|
||||
) {
|
||||
// Load all meetings
|
||||
var meetings = LocalStorageManager.shared.loadMeetings()
|
||||
|
||||
// Find and update the active meeting
|
||||
if let index = meetings.firstIndex(where: { $0.id == meetingId }) {
|
||||
meetings[index].transcriptChunks = chunks
|
||||
if let recoveryAudioFolderName {
|
||||
meetings[index].recoveryAudioFolderName = recoveryAudioFolderName
|
||||
}
|
||||
meetings[index].transcriptionError = transcriptionError
|
||||
|
||||
// Save the updated meeting
|
||||
let success = LocalStorageManager.shared.saveMeeting(meetings[index])
|
||||
|
||||
@@ -23,6 +23,7 @@ class UserDefaultsManager {
|
||||
static let transcriptionModel = "transcriptionModel"
|
||||
static let muteDeckAPIEnabled = "muteDeckAPIEnabled"
|
||||
static let muteDeckAPIPort = "muteDeckAPIPort"
|
||||
static let audioRetentionDays = "audioRetentionDays"
|
||||
}
|
||||
|
||||
// MARK: - User Blurb
|
||||
@@ -78,7 +79,7 @@ class UserDefaultsManager {
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
|
||||
@@ -94,4 +95,12 @@ class UserDefaultsManager {
|
||||
}
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,14 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import Sparkle
|
||||
import PostHog
|
||||
|
||||
@main
|
||||
struct MeetingnotesApp: App {
|
||||
private let updaterController: SPUStandardUpdaterController
|
||||
@StateObject private var recordingSessionManager = RecordingSessionManager.shared
|
||||
|
||||
init() {
|
||||
updaterController = SPUStandardUpdaterController(updaterDelegate: nil, userDriverDelegate: nil)
|
||||
@@ -34,17 +36,145 @@ struct MeetingnotesApp: App {
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
WindowGroup("Meetingnotes", id: "main") {
|
||||
ContentView()
|
||||
.frame(minWidth: 700, minHeight: 400)
|
||||
.background(MainWindowConfigurator())
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
.windowResizability(.automatic)
|
||||
.defaultSize(width: 1000, height: 600)
|
||||
.commands {
|
||||
CommandGroup(after: .appInfo) {
|
||||
CheckForUpdatesView(updater: updaterController.updater)
|
||||
Divider()
|
||||
Button("Show Audio Folder") {
|
||||
LocalStorageManager.shared.showAudioFolderInFinder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MenuBarExtra {
|
||||
MeetingnotesMenu(
|
||||
recordingSessionManager: recordingSessionManager,
|
||||
updater: updaterController.updater
|
||||
)
|
||||
} label: {
|
||||
Image(systemName: recordingSessionManager.isRecording ? "record.circle.fill" : "waveform")
|
||||
.accessibilityLabel(recordingSessionManager.isRecording ? "Meetingnotes recording" : "Meetingnotes")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MeetingnotesMenu: View {
|
||||
@ObservedObject var recordingSessionManager: RecordingSessionManager
|
||||
let updater: SPUUpdater
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
|
||||
var body: some View {
|
||||
if recordingSessionManager.isRecording {
|
||||
Label("Recording", systemImage: "record.circle.fill")
|
||||
} else if recordingSessionManager.isProcessing {
|
||||
Label("Processing audio", systemImage: "hourglass")
|
||||
}
|
||||
|
||||
Button {
|
||||
MainWindowController.shared.show(using: openWindow)
|
||||
} label: {
|
||||
Label("Open Meetingnotes", systemImage: "macwindow")
|
||||
}
|
||||
|
||||
Button {
|
||||
LocalStorageManager.shared.showAudioFolderInFinder()
|
||||
} label: {
|
||||
Label("Show Audio Folder", systemImage: "folder")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button {
|
||||
updater.checkForUpdates()
|
||||
} label: {
|
||||
Label("Check for Updates...", systemImage: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
.keyboardShortcut("u")
|
||||
|
||||
Button {
|
||||
NSApplication.shared.terminate(nil)
|
||||
} label: {
|
||||
Label("Quit Meetingnotes", systemImage: "power")
|
||||
}
|
||||
.keyboardShortcut("q")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MainWindowController: NSObject {
|
||||
static let shared = MainWindowController()
|
||||
|
||||
private weak var window: NSWindow?
|
||||
|
||||
func configure(_ window: NSWindow) {
|
||||
if self.window !== window {
|
||||
if let previousWindow = self.window {
|
||||
NotificationCenter.default.removeObserver(
|
||||
self,
|
||||
name: NSWindow.willMiniaturizeNotification,
|
||||
object: previousWindow
|
||||
)
|
||||
}
|
||||
self.window = window
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(windowWillMiniaturize(_:)),
|
||||
name: NSWindow.willMiniaturizeNotification,
|
||||
object: window
|
||||
)
|
||||
}
|
||||
|
||||
if let minimizeButton = window.standardWindowButton(.miniaturizeButton) {
|
||||
minimizeButton.target = self
|
||||
minimizeButton.action = #selector(hideWindow(_:))
|
||||
}
|
||||
}
|
||||
|
||||
func show(using openWindow: OpenWindowAction) {
|
||||
if let window {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
} else {
|
||||
openWindow(id: "main")
|
||||
}
|
||||
NSApplication.shared.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
@objc private func hideWindow(_ sender: NSButton) {
|
||||
sender.window?.orderOut(nil)
|
||||
}
|
||||
|
||||
@objc private func windowWillMiniaturize(_ notification: Notification) {
|
||||
guard let window = notification.object as? NSWindow else { return }
|
||||
DispatchQueue.main.async {
|
||||
window.deminiaturize(nil)
|
||||
window.orderOut(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MainWindowConfigurator: NSViewRepresentable {
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = NSView()
|
||||
configureWindow(for: view)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
configureWindow(for: nsView)
|
||||
}
|
||||
|
||||
private func configureWindow(for view: NSView) {
|
||||
DispatchQueue.main.async {
|
||||
guard let window = view.window else { return }
|
||||
MainWindowController.shared.configure(window)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
enum TranscriptTimestampFormatter {
|
||||
static let formatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
static func string(from date: Date) -> String {
|
||||
formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
enum AudioSource: String, Codable, CaseIterable {
|
||||
case mic = "MIC"
|
||||
case system = "SYS"
|
||||
@@ -36,30 +48,46 @@ struct TranscriptChunk: Codable, Identifiable, Hashable {
|
||||
let id: UUID
|
||||
let timestamp: Date
|
||||
let source: AudioSource
|
||||
let speaker: Int?
|
||||
let text: String
|
||||
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.timestamp = timestamp
|
||||
self.source = source
|
||||
self.speaker = speaker
|
||||
self.text = text
|
||||
self.isFinal = isFinal
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
if source == .mic { return "Me" }
|
||||
if let speaker { return "Speaker \(speaker)" }
|
||||
return source.displayName
|
||||
}
|
||||
}
|
||||
|
||||
struct CollapsedTranscriptChunk: Identifiable {
|
||||
let id: UUID
|
||||
let timestamp: Date
|
||||
let source: AudioSource
|
||||
let speaker: Int?
|
||||
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.timestamp = timestamp
|
||||
self.source = source
|
||||
self.speaker = speaker
|
||||
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 {
|
||||
@@ -70,6 +98,8 @@ struct Meeting: Codable, Identifiable, Hashable {
|
||||
var userNotes: String
|
||||
var generatedNotes: String
|
||||
var templateId: UUID? // Add property to track per-meeting template
|
||||
var recoveryAudioFolderName: String?
|
||||
var transcriptionError: String?
|
||||
// MARK: - Data versioning
|
||||
/// Version of this Meeting record on disk. Useful for migration.
|
||||
var dataVersion: Int
|
||||
@@ -83,6 +113,8 @@ struct Meeting: Codable, Identifiable, Hashable {
|
||||
userNotes: String = "",
|
||||
generatedNotes: String = "",
|
||||
templateId: UUID? = nil,
|
||||
recoveryAudioFolderName: String? = nil,
|
||||
transcriptionError: String? = nil,
|
||||
dataVersion: Int = Meeting.currentDataVersion) {
|
||||
self.id = id
|
||||
self.date = date
|
||||
@@ -91,6 +123,8 @@ struct Meeting: Codable, Identifiable, Hashable {
|
||||
self.userNotes = userNotes
|
||||
self.generatedNotes = generatedNotes
|
||||
self.templateId = templateId
|
||||
self.recoveryAudioFolderName = recoveryAudioFolderName
|
||||
self.transcriptionError = transcriptionError
|
||||
self.dataVersion = dataVersion
|
||||
}
|
||||
|
||||
@@ -100,88 +134,30 @@ struct Meeting: Codable, Identifiable, Hashable {
|
||||
var transcript: String {
|
||||
return transcriptChunks
|
||||
.filter { $0.isFinal }
|
||||
.map { "[\($0.source.rawValue)] \($0.text)" }
|
||||
.map { "[\($0.displayName)] \($0.text)" }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
|
||||
// Formatted transcript for copying with collapsed sequential chunks
|
||||
var formattedTranscript: String {
|
||||
let finalChunks = transcriptChunks.filter { $0.isFinal }
|
||||
|
||||
guard !finalChunks.isEmpty else { return "" }
|
||||
|
||||
var result: [String] = []
|
||||
var currentSource: AudioSource?
|
||||
var currentTexts: [String] = []
|
||||
|
||||
for chunk in finalChunks {
|
||||
if chunk.source != currentSource {
|
||||
// Finish previous section if exists
|
||||
if let source = currentSource, !currentTexts.isEmpty {
|
||||
let combinedText = currentTexts.joined(separator: " ")
|
||||
result.append("\(source.copyPrefix): \(combinedText)")
|
||||
}
|
||||
|
||||
// Start new section
|
||||
currentSource = chunk.source
|
||||
currentTexts = [chunk.text]
|
||||
} else {
|
||||
// Same source, add to current section
|
||||
currentTexts.append(chunk.text)
|
||||
}
|
||||
}
|
||||
|
||||
// Finish last section
|
||||
if let source = currentSource, !currentTexts.isEmpty {
|
||||
let combinedText = currentTexts.joined(separator: " ")
|
||||
result.append("\(source.copyPrefix): \(combinedText)")
|
||||
}
|
||||
|
||||
return result.joined(separator: " \n")
|
||||
|
||||
return finalChunks.map { chunk in
|
||||
"[\(TranscriptTimestampFormatter.string(from: chunk.timestamp))] \(chunk.displayName): \(chunk.text)"
|
||||
}.joined(separator: "\n")
|
||||
}
|
||||
|
||||
// Collapsed chunks for UI display
|
||||
var collapsedTranscriptChunks: [CollapsedTranscriptChunk] {
|
||||
guard !transcriptChunks.isEmpty else { return [] }
|
||||
|
||||
var result: [CollapsedTranscriptChunk] = []
|
||||
var currentSource: AudioSource?
|
||||
var currentTexts: [String] = []
|
||||
var currentTimestamp: Date?
|
||||
|
||||
for chunk in transcriptChunks {
|
||||
if chunk.source != currentSource {
|
||||
// Finish previous section if exists
|
||||
if let source = currentSource, !currentTexts.isEmpty, let timestamp = currentTimestamp {
|
||||
let combinedText = currentTexts.joined(separator: " ")
|
||||
result.append(CollapsedTranscriptChunk(
|
||||
timestamp: timestamp,
|
||||
source: source,
|
||||
combinedText: combinedText
|
||||
))
|
||||
}
|
||||
|
||||
// Start new section
|
||||
currentSource = chunk.source
|
||||
currentTexts = [chunk.text]
|
||||
currentTimestamp = chunk.timestamp
|
||||
} else {
|
||||
// Same source, add to current section
|
||||
currentTexts.append(chunk.text)
|
||||
}
|
||||
transcriptChunks.filter(\.isFinal).map { chunk in
|
||||
CollapsedTranscriptChunk(
|
||||
id: chunk.id,
|
||||
timestamp: chunk.timestamp,
|
||||
source: chunk.source,
|
||||
speaker: chunk.speaker,
|
||||
combinedText: chunk.text
|
||||
)
|
||||
}
|
||||
|
||||
// Finish last section
|
||||
if let source = currentSource, !currentTexts.isEmpty, let timestamp = currentTimestamp {
|
||||
let combinedText = currentTexts.joined(separator: " ")
|
||||
result.append(CollapsedTranscriptChunk(
|
||||
timestamp: timestamp,
|
||||
source: source,
|
||||
combinedText: combinedText
|
||||
))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Separate computed properties for mic and system transcripts
|
||||
@@ -198,4 +174,4 @@ struct Meeting: Codable, Identifiable, Hashable {
|
||||
.map { $0.text }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,11 @@ struct Settings: Codable {
|
||||
set { UserDefaultsManager.shared.muteDeckAPIPort = newValue }
|
||||
}
|
||||
|
||||
var audioRetentionDays: Int {
|
||||
get { UserDefaultsManager.shared.audioRetentionDays }
|
||||
set { UserDefaultsManager.shared.audioRetentionDays = newValue }
|
||||
}
|
||||
|
||||
// System prompt default loading
|
||||
static func defaultSystemPrompt() -> String {
|
||||
guard let path = Bundle.main.path(forResource: "DefaultSystemPrompt", ofType: "txt"),
|
||||
|
||||
@@ -49,7 +49,7 @@ extension String: @retroactive LocalizedError {
|
||||
@Observable
|
||||
final class AudioProcessController {
|
||||
|
||||
private let logger = Logger(subsystem: "owen.meetingnotes", category: String(describing: AudioProcessController.self))
|
||||
private let logger = Logger(subsystem: "net.jamesbone.meetingnotes", category: String(describing: AudioProcessController.self))
|
||||
|
||||
private(set) var processes = [AudioProcess]() {
|
||||
didSet {
|
||||
@@ -236,4 +236,4 @@ private extension URL {
|
||||
var isApp: Bool {
|
||||
(try? resourceValues(forKeys: [.contentTypeKey]))?.contentType?.conforms(to: .application) == true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import OSLog
|
||||
/// Uses TCC SPI in order to check/request system audio recording permission.
|
||||
@Observable
|
||||
final class AudioRecordingPermission {
|
||||
private let logger = Logger(subsystem: "owen.meetingnotes", category: String(describing: AudioRecordingPermission.self))
|
||||
private let logger = Logger(subsystem: "net.jamesbone.meetingnotes", category: String(describing: AudioRecordingPermission.self))
|
||||
|
||||
enum Status: String {
|
||||
case unknown
|
||||
@@ -122,4 +122,4 @@ final class AudioRecordingPermission {
|
||||
return fn
|
||||
}()
|
||||
#endif // ENABLE_TCC_SPI
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,47 @@ extension AudioObjectID {
|
||||
try read(kAudioTapPropertyFormat, defaultValue: AudioStreamBasicDescription())
|
||||
}
|
||||
|
||||
/// Reads the virtual format of the first input stream exposed by this device.
|
||||
///
|
||||
/// An aggregate device can adapt a tap to the active output hardware. Its
|
||||
/// input stream format is therefore the format delivered to the I/O proc,
|
||||
/// which can differ from the tap object's originally advertised format.
|
||||
func readInputStreamBasicDescription() throws -> AudioStreamBasicDescription {
|
||||
var streamsAddress = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioDevicePropertyStreams,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
var dataSize: UInt32 = 0
|
||||
var status = AudioObjectGetPropertyDataSize(self, &streamsAddress, 0, nil, &dataSize)
|
||||
guard status == noErr else {
|
||||
throw "Error reading device stream list size: \(status)"
|
||||
}
|
||||
|
||||
var streamIDs = [AudioObjectID](
|
||||
repeating: .unknown,
|
||||
count: Int(dataSize) / MemoryLayout<AudioObjectID>.size
|
||||
)
|
||||
status = AudioObjectGetPropertyData(self, &streamsAddress, 0, nil, &dataSize, &streamIDs)
|
||||
guard status == noErr else {
|
||||
throw "Error reading device stream list: \(status)"
|
||||
}
|
||||
|
||||
for streamID in streamIDs {
|
||||
let direction: UInt32 = try streamID.read(
|
||||
kAudioStreamPropertyDirection,
|
||||
defaultValue: 0
|
||||
)
|
||||
guard direction == 1 else { continue }
|
||||
return try streamID.read(
|
||||
kAudioStreamPropertyVirtualFormat,
|
||||
defaultValue: AudioStreamBasicDescription()
|
||||
)
|
||||
}
|
||||
|
||||
throw "Device has no input stream."
|
||||
}
|
||||
|
||||
private func requireSystemObject() throws {
|
||||
if self != .system { throw "Only supported for the system object." }
|
||||
}
|
||||
@@ -307,4 +348,4 @@ extension AudioObjectID {
|
||||
func getDeviceName() throws -> String {
|
||||
return try readString(kAudioDevicePropertyDeviceNameCFString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import AVFoundation
|
||||
|
||||
enum TapTarget {
|
||||
case singleProcess(AudioProcess)
|
||||
case systemAudio(processObjectIDs: [AudioObjectID])
|
||||
case systemAudio
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
@@ -51,7 +51,7 @@ final class ProcessTap {
|
||||
init(target: TapTarget, muteWhenRunning: Bool = false) {
|
||||
self.target = target
|
||||
self.muteWhenRunning = muteWhenRunning
|
||||
self.logger = Logger(subsystem: "owen.meetingnotes", category: "\(String(describing: ProcessTap.self))(\(target.loggingProcessName))")
|
||||
self.logger = Logger(subsystem: "net.jamesbone.meetingnotes", category: "\(String(describing: ProcessTap.self))(\(target.loggingProcessName))")
|
||||
}
|
||||
|
||||
@ObservationIgnored
|
||||
@@ -63,6 +63,10 @@ final class ProcessTap {
|
||||
@ObservationIgnored
|
||||
private(set) var tapStreamDescription: AudioStreamBasicDescription?
|
||||
@ObservationIgnored
|
||||
private(set) var tapAdvertisedStreamDescription: AudioStreamBasicDescription?
|
||||
@ObservationIgnored
|
||||
private(set) var aggregateInputStreamDescription: AudioStreamBasicDescription?
|
||||
@ObservationIgnored
|
||||
private var invalidationHandler: InvalidationHandler?
|
||||
|
||||
@ObservationIgnored
|
||||
@@ -137,12 +141,12 @@ final class ProcessTap {
|
||||
case .singleProcess(let process):
|
||||
tapDescription = CATapDescription(stereoMixdownOfProcesses: [process.objectID])
|
||||
logger.debug("Configuring tap for single process objectID: \(process.objectID)")
|
||||
case .systemAudio(let processObjectIDs):
|
||||
if processObjectIDs.isEmpty {
|
||||
logger.warning("System audio tap configured with an empty list of processObjectIDs. This might not capture any audio or behave unexpectedly.")
|
||||
}
|
||||
tapDescription = CATapDescription(stereoMixdownOfProcesses: processObjectIDs)
|
||||
logger.debug("Configuring tap for system audio output using \(processObjectIDs.count) explicit processes.")
|
||||
case .systemAudio:
|
||||
// The transcription file is mono, so ask Core Audio for a mono
|
||||
// mixdown at the source. This avoids interpreting a stereo HAL
|
||||
// buffer as half as many frames before the 16 kHz conversion.
|
||||
tapDescription = CATapDescription(monoGlobalTapButExcludeProcesses: [])
|
||||
logger.info("Configuring a mono global system audio tap.")
|
||||
}
|
||||
|
||||
tapDescription.uuid = UUID()
|
||||
@@ -248,8 +252,23 @@ final class ProcessTap {
|
||||
|
||||
do {
|
||||
logger.debug("Attempting to read audio tap stream basic description for tapID #\(tapID)...")
|
||||
self.tapStreamDescription = try tapID.readAudioTapStreamBasicDescription()
|
||||
logger.debug("Successfully read tap stream description: \(String(describing: self.tapStreamDescription))")
|
||||
let advertisedDescription = try tapID.readAudioTapStreamBasicDescription()
|
||||
self.tapAdvertisedStreamDescription = advertisedDescription
|
||||
|
||||
// The aggregate device may adapt the tap to the active hardware's
|
||||
// sample rate. Its input stream is the format actually delivered
|
||||
// to the I/O proc, so use that rather than the tap's pre-aggregate
|
||||
// advertisement. Using the latter can halve the written duration
|
||||
// when, for example, a 48 kHz tap is delivered at 24 kHz.
|
||||
do {
|
||||
let aggregateDescription = try aggregateDeviceID.readInputStreamBasicDescription()
|
||||
self.aggregateInputStreamDescription = aggregateDescription
|
||||
self.tapStreamDescription = aggregateDescription
|
||||
logger.info("Using aggregate input stream description: \(String(describing: self.tapStreamDescription), privacy: .public); tap advertised: \(String(describing: advertisedDescription), privacy: .public)")
|
||||
} catch {
|
||||
self.tapStreamDescription = advertisedDescription
|
||||
logger.warning("Could not read aggregate input stream format; using tap format: \(error, privacy: .public)")
|
||||
}
|
||||
} catch {
|
||||
logger.error("Failed to read audio tap stream basic description for tapID #\(tapID): \(error)")
|
||||
throw error // Propagate error
|
||||
@@ -327,7 +346,7 @@ final class ProcessTapRecorder {
|
||||
self.tapDisplayName = tap.displayName
|
||||
self.fileURL = fileURL
|
||||
self._tap = tap
|
||||
self.logger = Logger(subsystem: "owen.meetingnotes", category: "\(String(describing: ProcessTapRecorder.self))(\(fileURL.lastPathComponent))")
|
||||
self.logger = Logger(subsystem: "net.jamesbone.meetingnotes", category: "\(String(describing: ProcessTapRecorder.self))(\(fileURL.lastPathComponent))")
|
||||
|
||||
self.icon = tap.target.iconImage
|
||||
}
|
||||
@@ -496,4 +515,4 @@ final class ProcessTapRecorder {
|
||||
self.currentAudioLevel = 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
struct CoderModel: Codable, Identifiable, Hashable {
|
||||
@@ -20,6 +21,7 @@ struct CoderModel: Codable, Identifiable, Hashable {
|
||||
|
||||
var supportsChat: Bool { capabilities.isEmpty || capabilities.contains("chat") }
|
||||
var supportsTranscription: Bool { capabilities.contains("audio_transcription") }
|
||||
var supportsSpeakerDiarization: Bool { capabilities.contains("speaker_diarization") }
|
||||
}
|
||||
|
||||
enum CoderAPIError: LocalizedError {
|
||||
@@ -28,6 +30,7 @@ enum CoderAPIError: LocalizedError {
|
||||
case missingModel(String)
|
||||
case invalidResponse
|
||||
case serviceError(Int, String)
|
||||
case audioPreparationFailed(String, String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
@@ -41,6 +44,8 @@ enum CoderAPIError: LocalizedError {
|
||||
return "Coder returned an invalid response."
|
||||
case .serviceError(let status, let message):
|
||||
return "Coder request failed (\(status)): \(message)"
|
||||
case .audioPreparationFailed(let filename, let message):
|
||||
return "Could not prepare \(filename) for transcription: \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +53,18 @@ enum CoderAPIError: LocalizedError {
|
||||
final class CoderAPIClient {
|
||||
static let shared = CoderAPIClient()
|
||||
|
||||
struct Transcription {
|
||||
struct Segment: Decodable {
|
||||
let start: TimeInterval
|
||||
let end: TimeInterval
|
||||
let text: String
|
||||
let speaker: Int?
|
||||
}
|
||||
|
||||
let text: String
|
||||
let segments: [Segment]
|
||||
}
|
||||
|
||||
private struct ModelsResponse: Decodable {
|
||||
let data: [CoderModel]
|
||||
}
|
||||
@@ -58,10 +75,33 @@ final class CoderAPIClient {
|
||||
}
|
||||
|
||||
private struct TranscriptionResponse: Decodable {
|
||||
struct Word: Decodable {
|
||||
let word: String
|
||||
let start: TimeInterval
|
||||
let end: TimeInterval
|
||||
let speaker: Int?
|
||||
}
|
||||
|
||||
let text: String
|
||||
let segments: [Transcription.Segment]?
|
||||
let words: [Word]?
|
||||
}
|
||||
|
||||
private init() {}
|
||||
private struct AudioChunk {
|
||||
let url: URL
|
||||
let offset: TimeInterval
|
||||
let isTemporary: Bool
|
||||
}
|
||||
|
||||
private let transcriptionChunkDuration: TimeInterval = 3 * 60
|
||||
private let transcriptionSession: URLSession
|
||||
|
||||
private init() {
|
||||
let configuration = URLSessionConfiguration.default
|
||||
configuration.timeoutIntervalForRequest = 2 * 60 * 60
|
||||
configuration.timeoutIntervalForResource = 2 * 60 * 60
|
||||
transcriptionSession = URLSession(configuration: configuration)
|
||||
}
|
||||
|
||||
func models(baseURL: String, apiKey: String) async throws -> [CoderModel] {
|
||||
var request = URLRequest(url: try endpoint(baseURL: baseURL, path: "models"))
|
||||
@@ -78,7 +118,11 @@ final class CoderAPIClient {
|
||||
)
|
||||
}
|
||||
|
||||
func streamChat(systemPrompt: String, model: String) -> AsyncThrowingStream<String, Error> {
|
||||
func streamChat(
|
||||
systemPrompt: String,
|
||||
userPrompt: String = "Create the meeting notes now.",
|
||||
model: String
|
||||
) -> AsyncThrowingStream<String, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
Task {
|
||||
do {
|
||||
@@ -94,7 +138,7 @@ final class CoderAPIClient {
|
||||
"model": selectedModel,
|
||||
"messages": [
|
||||
["role": "system", "content": systemPrompt],
|
||||
["role": "user", "content": "Create the meeting notes now."]
|
||||
["role": "user", "content": userPrompt]
|
||||
],
|
||||
"stream": true
|
||||
])
|
||||
@@ -128,15 +172,90 @@ final class CoderAPIClient {
|
||||
}
|
||||
}
|
||||
|
||||
func transcribe(fileURL: URL, model: String, language: String = "en") async throws -> String {
|
||||
func transcribe(
|
||||
fileURL: URL,
|
||||
model: String,
|
||||
language: String = "en",
|
||||
diarization: Bool = false,
|
||||
maxSpeakerCount: Int = 4
|
||||
) async throws -> Transcription {
|
||||
let selectedModel = model.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !selectedModel.isEmpty else { throw CoderAPIError.missingModel("transcription") }
|
||||
let apiKey = try requiredAPIKey(KeychainHelper.shared.getCoderAPIKey() ?? "")
|
||||
let chunks: [AudioChunk]
|
||||
do {
|
||||
chunks = try makeAudioChunks(from: fileURL, preserveSpeakerIdentity: diarization)
|
||||
} catch {
|
||||
throw CoderAPIError.audioPreparationFailed(fileURL.lastPathComponent, error.localizedDescription)
|
||||
}
|
||||
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 bodyURL = try makeMultipartBody(
|
||||
audioURL: fileURL,
|
||||
model: selectedModel,
|
||||
model: model,
|
||||
language: language,
|
||||
diarization: diarization,
|
||||
maxSpeakerCount: maxSpeakerCount,
|
||||
boundary: boundary
|
||||
)
|
||||
defer { try? FileManager.default.removeItem(at: bodyURL) }
|
||||
@@ -149,9 +268,160 @@ final class CoderAPIClient {
|
||||
let size = attributes[.size] as? NSNumber {
|
||||
request.setValue(size.stringValue, forHTTPHeaderField: "Content-Length")
|
||||
}
|
||||
let (data, response) = try await URLSession.shared.upload(for: request, fromFile: bodyURL)
|
||||
let (data, response) = try await uploadTranscription(request: request, bodyURL: bodyURL)
|
||||
try validate(response: response, data: data)
|
||||
return try JSONDecoder().decode(TranscriptionResponse.self, from: data).text
|
||||
let decoded = try JSONDecoder().decode(TranscriptionResponse.self, from: data)
|
||||
let segments = decoded.segments ?? segments(from: decoded.words ?? [])
|
||||
return Transcription(text: decoded.text, segments: segments)
|
||||
}
|
||||
|
||||
private func uploadTranscription(request: URLRequest, bodyURL: URL) async throws -> (Data, URLResponse) {
|
||||
let retryDelays: [UInt64] = [3, 10]
|
||||
for attempt in 0...retryDelays.count {
|
||||
do {
|
||||
return try await transcriptionSession.upload(for: request, fromFile: bodyURL)
|
||||
} catch {
|
||||
guard attempt < retryDelays.count, isRetryableTranscriptionError(error) else {
|
||||
throw error
|
||||
}
|
||||
try await Task.sleep(nanoseconds: retryDelays[attempt] * 1_000_000_000)
|
||||
}
|
||||
}
|
||||
throw CoderAPIError.invalidResponse
|
||||
}
|
||||
|
||||
private func isRetryableTranscriptionError(_ error: Error) -> Bool {
|
||||
guard let urlError = error as? URLError else { return false }
|
||||
switch urlError.code {
|
||||
case .networkConnectionLost, .cannotConnectToHost, .timedOut:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -183,7 +453,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")
|
||||
_ = FileManager.default.createFile(atPath: bodyURL.path, contents: nil)
|
||||
let output = try FileHandle(forWritingTo: bodyURL)
|
||||
@@ -194,7 +471,12 @@ 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=\"language\"\r\n\r\n\(language)\r\n")
|
||||
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(audioURL.lastPathComponent)\"\r\nContent-Type: audio/mp4\r\n\r\n")
|
||||
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"response_format\"\r\n\r\nverbose_json\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)
|
||||
defer { try? input.close() }
|
||||
while let chunk = try input.read(upToCount: 1 << 20), !chunk.isEmpty {
|
||||
|
||||
@@ -182,13 +182,13 @@ private enum LocalAPIRouter {
|
||||
}
|
||||
|
||||
private enum LocalRecordingError: LocalizedError {
|
||||
case processing
|
||||
case stopping
|
||||
case saveFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .processing:
|
||||
return "The previous meeting is still processing."
|
||||
case .stopping:
|
||||
return "The current recording is still stopping."
|
||||
case .saveFailed:
|
||||
return "Could not create a meeting for this recording."
|
||||
}
|
||||
@@ -200,18 +200,20 @@ private final class LocalRecordingController {
|
||||
static let shared = LocalRecordingController()
|
||||
|
||||
private let recordingManager = RecordingSessionManager.shared
|
||||
private let stopGraceNanoseconds: UInt64 = 10_000_000_000
|
||||
private var isStopping = false
|
||||
private var pendingStopTask: Task<Void, Never>?
|
||||
|
||||
private init() {}
|
||||
|
||||
func statusPayload() -> [String: Any] {
|
||||
let state: String
|
||||
if isStopping || recordingManager.isProcessing {
|
||||
state = "processing"
|
||||
} else if recordingManager.isRecording {
|
||||
if recordingManager.isRecording {
|
||||
state = "recording"
|
||||
} else if recordingManager.activeMeetingId != nil {
|
||||
state = "starting"
|
||||
} else if isStopping || recordingManager.isProcessing {
|
||||
state = "processing"
|
||||
} else {
|
||||
state = "idle"
|
||||
}
|
||||
@@ -237,14 +239,20 @@ private final class LocalRecordingController {
|
||||
}
|
||||
|
||||
func startRecording() throws -> [String: Any] {
|
||||
if isStopping || recordingManager.isProcessing {
|
||||
throw LocalRecordingError.processing
|
||||
if let pendingStopTask {
|
||||
pendingStopTask.cancel()
|
||||
self.pendingStopTask = nil
|
||||
isStopping = false
|
||||
return statusPayload()
|
||||
}
|
||||
if isStopping {
|
||||
throw LocalRecordingError.stopping
|
||||
}
|
||||
if recordingManager.activeMeetingId != nil {
|
||||
return statusPayload()
|
||||
}
|
||||
|
||||
let meeting = Meeting()
|
||||
let meeting = Meeting(templateId: LocalStorageManager.shared.preferredTemplateID())
|
||||
guard LocalStorageManager.shared.saveMeeting(meeting) else {
|
||||
throw LocalRecordingError.saveFailed
|
||||
}
|
||||
@@ -258,14 +266,22 @@ private final class LocalRecordingController {
|
||||
return statusPayload()
|
||||
}
|
||||
isStopping = true
|
||||
Task { [weak self] in
|
||||
pendingStopTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: self?.stopGraceNanoseconds ?? 0)
|
||||
guard !Task.isCancelled else { return }
|
||||
await self?.finishRecording()
|
||||
}
|
||||
return statusPayload()
|
||||
}
|
||||
|
||||
func cancelRecording() -> [String: Any] {
|
||||
guard !isStopping else { return statusPayload() }
|
||||
if let pendingStopTask {
|
||||
pendingStopTask.cancel()
|
||||
self.pendingStopTask = nil
|
||||
isStopping = false
|
||||
} else if isStopping {
|
||||
return statusPayload()
|
||||
}
|
||||
let meetingID = recordingManager.activeMeetingId
|
||||
recordingManager.cancelRecording()
|
||||
if let meetingID,
|
||||
@@ -277,20 +293,21 @@ private final class LocalRecordingController {
|
||||
}
|
||||
|
||||
private func finishRecording() async {
|
||||
pendingStopTask = nil
|
||||
let meetingID = recordingManager.activeMeetingId
|
||||
let chunks = await recordingManager.stopRecording()
|
||||
isStopping = false
|
||||
let completion = await recordingManager.stopRecording()
|
||||
guard let meetingID,
|
||||
var meeting = LocalStorageManager.shared.loadMeetings().first(where: { $0.id == meetingID }) else {
|
||||
isStopping = false
|
||||
return
|
||||
}
|
||||
|
||||
meeting.transcriptChunks = chunks
|
||||
meeting.transcriptChunks = completion.chunks
|
||||
meeting.recoveryAudioFolderName = completion.recoveryAudioFolderName
|
||||
meeting.transcriptionError = completion.transcriptionError
|
||||
let templates = LocalStorageManager.shared.loadTemplates()
|
||||
if meeting.templateId == nil {
|
||||
meeting.templateId = UserDefaultsManager.shared.selectedTemplateId
|
||||
?? templates.first(where: { $0.title == "Standard Meeting" })?.id
|
||||
?? templates.first?.id
|
||||
meeting.templateId = LocalStorageManager.shared.preferredTemplateID(in: templates)
|
||||
}
|
||||
_ = LocalStorageManager.shared.saveMeeting(meeting)
|
||||
NotificationCenter.default.post(name: .meetingSaved, object: meeting)
|
||||
@@ -311,13 +328,31 @@ private final class LocalRecordingController {
|
||||
generatedNotes = ""
|
||||
}
|
||||
}
|
||||
var meetingChanged = false
|
||||
if !generatedNotes.isEmpty {
|
||||
meeting.generatedNotes = generatedNotes
|
||||
meetingChanged = true
|
||||
}
|
||||
if meeting.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
do {
|
||||
if let title = try await NotesGenerator.shared.generateMeetingTitle(
|
||||
meeting: meeting,
|
||||
generatedNotes: generatedNotes,
|
||||
templateId: meeting.templateId
|
||||
) {
|
||||
meeting.title = title
|
||||
meetingChanged = true
|
||||
}
|
||||
} catch {
|
||||
// The recording and generated notes remain valid without a generated title.
|
||||
print("Meeting title generation failed: \(error)")
|
||||
}
|
||||
}
|
||||
if meetingChanged {
|
||||
_ = LocalStorageManager.shared.saveMeeting(meeting)
|
||||
NotificationCenter.default.post(name: .meetingSaved, object: meeting)
|
||||
}
|
||||
}
|
||||
isStopping = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,88 @@ class NotesGenerator {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates a short title after meeting notes have been created.
|
||||
func generateMeetingTitle(
|
||||
meeting: Meeting,
|
||||
generatedNotes: String,
|
||||
templateId: UUID?
|
||||
) async throws -> String? {
|
||||
let templates = LocalStorageManager.shared.loadTemplates()
|
||||
let template = templateId.flatMap { id in
|
||||
templates.first(where: { $0.id == id })
|
||||
}
|
||||
let meetingContext: String
|
||||
if let template {
|
||||
meetingContext = "\(template.title): \(template.context)"
|
||||
} else {
|
||||
meetingContext = "General meeting"
|
||||
}
|
||||
|
||||
let trimmedNotes = generatedNotes.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let meetingContent = trimmedNotes.isEmpty ? meeting.formattedTranscript : trimmedNotes
|
||||
guard !meetingContent.isEmpty else { return nil }
|
||||
|
||||
let systemPrompt = """
|
||||
Create a concise, descriptive title for a recorded meeting from its context and content.
|
||||
Return only the title in 3 to 8 words. Do not use quotation marks, Markdown, or a trailing period.
|
||||
Avoid generic titles such as Meeting, Discussion, Meeting Notes, or Untitled Meeting.
|
||||
"""
|
||||
let userPrompt = """
|
||||
Meeting context:
|
||||
\(meetingContext)
|
||||
|
||||
Meeting content:
|
||||
\(meetingContent)
|
||||
"""
|
||||
|
||||
var generatedTitle = ""
|
||||
let stream = CoderAPIClient.shared.streamChat(
|
||||
systemPrompt: systemPrompt,
|
||||
userPrompt: userPrompt,
|
||||
model: UserDefaultsManager.shared.notesModel
|
||||
)
|
||||
for try await content in stream {
|
||||
generatedTitle += content
|
||||
}
|
||||
|
||||
return normalizedTitle(generatedTitle)
|
||||
}
|
||||
|
||||
private func normalizedTitle(_ value: String) -> String? {
|
||||
guard var title = value
|
||||
.split(whereSeparator: \Character.isNewline)
|
||||
.map(String.init)
|
||||
.first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
while title.hasPrefix("#") {
|
||||
title.removeFirst()
|
||||
title = title.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
if title.lowercased().hasPrefix("title:") {
|
||||
title = String(title.dropFirst("title:".count))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
title = title.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
|
||||
if title.hasSuffix(".") {
|
||||
title.removeLast()
|
||||
}
|
||||
|
||||
if title.count > 80 {
|
||||
let prefix = String(title.prefix(80))
|
||||
if let lastSpace = prefix.lastIndex(of: " ") {
|
||||
title = String(prefix[..<lastSpace])
|
||||
} else {
|
||||
title = prefix
|
||||
}
|
||||
}
|
||||
|
||||
title = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return title.isEmpty ? nil : title
|
||||
}
|
||||
|
||||
/// Validates if the Coder service token is configured
|
||||
/// - Returns: True if API key exists, false otherwise
|
||||
@@ -115,4 +197,4 @@ class NotesGenerator {
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,13 +66,35 @@ class MeetingListViewModel: ObservableObject {
|
||||
meetings.removeAll { $0.id == meeting.id }
|
||||
_ = LocalStorageManager.shared.deleteMeeting(meeting)
|
||||
}
|
||||
|
||||
func previousMeeting(for meeting: Meeting) -> Meeting? {
|
||||
meetings
|
||||
.filter { $0.id != meeting.id && $0.date < meeting.date }
|
||||
.max { $0.date < $1.date }
|
||||
}
|
||||
|
||||
func mergeIntoPrevious(_ meeting: Meeting) -> Meeting? {
|
||||
guard let previous = previousMeeting(for: meeting),
|
||||
let merged = LocalStorageManager.shared.mergeMeeting(meeting, into: previous) else {
|
||||
errorMessage = "The meetings could not be merged. Their original records and audio were kept."
|
||||
return nil
|
||||
}
|
||||
|
||||
meetings.removeAll { $0.id == meeting.id || $0.id == previous.id }
|
||||
meetings.append(merged)
|
||||
meetings.sort { $0.date > $1.date }
|
||||
NotificationCenter.default.post(name: .meetingSaved, object: merged)
|
||||
NotificationCenter.default.post(name: .meetingDeleted, object: meeting)
|
||||
PostHogSDK.shared.capture("meetings_merged")
|
||||
return merged
|
||||
}
|
||||
|
||||
func createNewMeeting() -> Meeting {
|
||||
let newMeeting = Meeting()
|
||||
let newMeeting = Meeting(templateId: LocalStorageManager.shared.preferredTemplateID())
|
||||
meetings.insert(newMeeting, at: 0)
|
||||
_ = LocalStorageManager.shared.saveMeeting(newMeeting)
|
||||
// Track meeting creation event
|
||||
PostHogSDK.shared.capture("meeting_created")
|
||||
return newMeeting
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ extension Notification.Name {
|
||||
enum MeetingViewTab: String, CaseIterable {
|
||||
case myNotes = "My Notes"
|
||||
case transcript = "Transcript"
|
||||
case enhancedNotes = "Enhanced Notes"
|
||||
case enhancedNotes = "Meeting Notes"
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ class MeetingViewModel: ObservableObject {
|
||||
@Published private var recordingStateChanged = false // Trigger SwiftUI updates
|
||||
@Published var isValidatingKey = false // Indicates API key validation in progress
|
||||
@Published var isStartingRecording = false // Indicates recording start in progress
|
||||
@Published var isRetryingTranscription = false
|
||||
@Published private(set) var recoveryAudioFolderURL: URL?
|
||||
|
||||
// Computed property to determine if Generate button should animate
|
||||
var shouldAnimateGenerateButton: Bool {
|
||||
@@ -44,7 +46,13 @@ class MeetingViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
var isProcessing: Bool {
|
||||
return recordingSessionManager.isProcessing && recordingSessionManager.activeMeetingId == meeting.id
|
||||
return isRetryingTranscription ||
|
||||
recordingSessionManager.isProcessingMeeting(meeting.id)
|
||||
}
|
||||
|
||||
var canRetryTranscription: Bool {
|
||||
recoveryAudioFolderURL != nil &&
|
||||
!isRecording && !isProcessing && !isStartingRecording && !isValidatingKey
|
||||
}
|
||||
@Published var selectedTab: MeetingViewTab = .transcript // Default to transcript tab
|
||||
|
||||
@@ -54,15 +62,6 @@ class MeetingViewModel: ObservableObject {
|
||||
|
||||
private let recordingSessionManager = RecordingSessionManager.shared
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var isNewMeeting = false
|
||||
|
||||
// Computed property to check if meeting is empty
|
||||
var isEmpty: Bool {
|
||||
return meeting.transcriptChunks.isEmpty &&
|
||||
meeting.userNotes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
||||
meeting.generatedNotes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
||||
meeting.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
init(meeting: Meeting = Meeting()) {
|
||||
// Load the latest version of the meeting from storage if it exists
|
||||
@@ -76,9 +75,6 @@ class MeetingViewModel: ObservableObject {
|
||||
|
||||
|
||||
|
||||
// Detect if this is a new meeting based on content, not storage existence
|
||||
isNewMeeting = isEmpty
|
||||
|
||||
// Set initial tab based on notes existence
|
||||
if !self.meeting.generatedNotes.isEmpty {
|
||||
selectedTab = .enhancedNotes
|
||||
@@ -88,6 +84,7 @@ class MeetingViewModel: ObservableObject {
|
||||
|
||||
// Load templates and selected template
|
||||
loadTemplates()
|
||||
refreshRecoveryAudioFolder()
|
||||
// Observe template selection: save to meeting and regenerate notes on changes (skip initial)
|
||||
$selectedTemplateId
|
||||
.dropFirst()
|
||||
@@ -140,17 +137,26 @@ class MeetingViewModel: ObservableObject {
|
||||
self.meeting.transcriptChunks = recordingSessionManager.getTranscriptChunks(for: meeting.id)
|
||||
}
|
||||
|
||||
// Listen for final transcript updates for this meeting.
|
||||
// Listen for transcript updates emitted while this meeting is recording.
|
||||
recordingSessionManager.$activeRecordingTranscriptChunksUpdated
|
||||
.dropFirst()
|
||||
.sink { [weak self] updatedChunks in
|
||||
guard let self = self else { return }
|
||||
// Only update if this meeting is the active recording
|
||||
if recordingSessionManager.isRecordingMeeting(self.meeting.id) {
|
||||
if recordingSessionManager.activeMeetingId == self.meeting.id {
|
||||
self.meeting.transcriptChunks = updatedChunks
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .meetingSaved)
|
||||
.compactMap { $0.object as? Meeting }
|
||||
.filter { [weak self] in $0.id == self?.meeting.id }
|
||||
.sink { [weak self] savedMeeting in
|
||||
guard let self, !self.isDeleted, self.meeting != savedMeeting else { return }
|
||||
self.meeting = savedMeeting
|
||||
self.refreshRecoveryAudioFolder()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
|
||||
|
||||
@@ -215,8 +221,12 @@ class MeetingViewModel: ObservableObject {
|
||||
func stopRecording() {
|
||||
isStartingRecording = true
|
||||
Task {
|
||||
let chunks = await recordingSessionManager.stopRecording()
|
||||
meeting.transcriptChunks = chunks
|
||||
let completion = await recordingSessionManager.stopRecording()
|
||||
meeting.transcriptChunks = completion.chunks
|
||||
meeting.recoveryAudioFolderName = completion.recoveryAudioFolderName
|
||||
meeting.transcriptionError = completion.transcriptionError
|
||||
errorMessage = completion.transcriptionError
|
||||
refreshRecoveryAudioFolder()
|
||||
saveMeeting()
|
||||
if !meeting.formattedTranscript.isEmpty {
|
||||
await generateNotes()
|
||||
@@ -224,15 +234,58 @@ class MeetingViewModel: ObservableObject {
|
||||
isStartingRecording = false
|
||||
}
|
||||
}
|
||||
|
||||
func retryTranscription() {
|
||||
guard let recoveryAudioFolderURL, canRetryTranscription else { return }
|
||||
|
||||
isRetryingTranscription = true
|
||||
errorMessage = nil
|
||||
Task {
|
||||
defer { isRetryingTranscription = false }
|
||||
do {
|
||||
let chunks = try await AudioManager.shared.transcribeRecoveryAudio(
|
||||
in: recoveryAudioFolderURL,
|
||||
captureStartedAt: meeting.date
|
||||
)
|
||||
meeting.transcriptChunks = chunks
|
||||
meeting.recoveryAudioFolderName = recoveryAudioFolderURL.lastPathComponent
|
||||
meeting.transcriptionError = nil
|
||||
selectedTab = .transcript
|
||||
|
||||
guard saveMeeting() else {
|
||||
throw CocoaError(.fileWriteUnknown)
|
||||
}
|
||||
|
||||
await generateNotes()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
meeting.transcriptionError = error.localizedDescription
|
||||
_ = saveMeeting()
|
||||
print("Retry transcription failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshRecoveryAudioFolder() {
|
||||
recoveryAudioFolderURL = LocalStorageManager.shared.findRecoveryAudioFolder(for: meeting)
|
||||
meeting.recoveryAudioFolderName = recoveryAudioFolderURL?.lastPathComponent
|
||||
}
|
||||
|
||||
func showAudioInFinder() {
|
||||
guard let recoveryAudioFolderURL else { return }
|
||||
LocalStorageManager.shared.showAudioFolderInFinder(recoveryAudioFolderURL)
|
||||
}
|
||||
|
||||
func loadTemplates() {
|
||||
templates = LocalStorageManager.shared.loadTemplates()
|
||||
|
||||
// Load per-meeting template or default to Standard Meeting
|
||||
if let meetingTemplateId = meeting.templateId {
|
||||
// Keep an existing meeting's template, otherwise use the configured default.
|
||||
if let meetingTemplateId = meeting.templateId,
|
||||
templates.contains(where: { $0.id == meetingTemplateId }) {
|
||||
selectedTemplateId = meetingTemplateId
|
||||
} else if let defaultTemplate = templates.first(where: { $0.title == "Standard Meeting" }) {
|
||||
selectedTemplateId = defaultTemplate.id
|
||||
} else {
|
||||
selectedTemplateId = LocalStorageManager.shared.preferredTemplateID(in: templates)
|
||||
meeting.templateId = selectedTemplateId
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,20 +323,40 @@ class MeetingViewModel: ObservableObject {
|
||||
|
||||
// Only save if there was no error
|
||||
if !hasError {
|
||||
await generateTitleIfNeeded()
|
||||
saveMeeting()
|
||||
}
|
||||
|
||||
isGeneratingNotes = false
|
||||
}
|
||||
|
||||
private func generateTitleIfNeeded() async {
|
||||
guard meeting.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
|
||||
do {
|
||||
if let title = try await NotesGenerator.shared.generateMeetingTitle(
|
||||
meeting: meeting,
|
||||
generatedNotes: meeting.generatedNotes,
|
||||
templateId: selectedTemplateId
|
||||
) {
|
||||
meeting.title = title
|
||||
}
|
||||
} catch {
|
||||
// A title is helpful but should never prevent completed notes from being saved.
|
||||
print("Meeting title generation failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func saveMeeting() {
|
||||
if isDeleted { return }
|
||||
@discardableResult
|
||||
func saveMeeting() -> Bool {
|
||||
if isDeleted { return false }
|
||||
print("💾 Saving meeting: \(meeting.id)")
|
||||
let success = LocalStorageManager.shared.saveMeeting(meeting)
|
||||
print("💾 Save result: \(success ? "SUCCESS" : "FAILED")")
|
||||
if success {
|
||||
NotificationCenter.default.post(name: .meetingSaved, object: meeting)
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
func copyCurrentTabContent() {
|
||||
@@ -331,12 +404,4 @@ class MeetingViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func deleteIfEmpty() {
|
||||
if isEmpty && !isRecording && !isProcessing {
|
||||
print("🗑️ Auto-deleting empty meeting")
|
||||
deleteMeeting()
|
||||
} else {
|
||||
saveMeeting()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,24 +52,7 @@ class SettingsViewModel: ObservableObject {
|
||||
|
||||
func loadTemplates() {
|
||||
templates = LocalStorageManager.shared.loadTemplates()
|
||||
|
||||
// Validate that the selected template still exists
|
||||
if let selectedId = settings.selectedTemplateId {
|
||||
if !templates.contains(where: { $0.id == selectedId }) {
|
||||
// Selected template was deleted, clear the selection
|
||||
settings.selectedTemplateId = nil
|
||||
}
|
||||
}
|
||||
|
||||
// If no template is selected, select the first default template
|
||||
if settings.selectedTemplateId == nil {
|
||||
if let defaultTemplate = templates.first(where: { $0.title == "Standard Meeting" }) {
|
||||
settings.selectedTemplateId = defaultTemplate.id
|
||||
} else if let firstTemplate = templates.first {
|
||||
// Fallback to first available template
|
||||
settings.selectedTemplateId = firstTemplate.id
|
||||
}
|
||||
}
|
||||
settings.selectedTemplateId = LocalStorageManager.shared.preferredTemplateID(in: templates)
|
||||
}
|
||||
|
||||
func saveSettings(showMessage: Bool = true) {
|
||||
@@ -88,6 +71,7 @@ class SettingsViewModel: ObservableObject {
|
||||
// via computed properties when they're modified
|
||||
let coderSaved = KeychainHelper.shared.saveCoderAPIKey(settings.coderAPIKey)
|
||||
LocalAPIServer.shared.applyConfiguration()
|
||||
LocalStorageManager.shared.purgeExpiredAudioFolders()
|
||||
|
||||
if showMessage {
|
||||
if coderSaved {
|
||||
|
||||
@@ -4,6 +4,7 @@ import SwiftUI
|
||||
@MainActor
|
||||
class TemplatesViewModel: ObservableObject {
|
||||
@Published var templates: [NoteTemplate] = []
|
||||
@Published private(set) var defaultTemplateID: UUID? = nil
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
@@ -14,8 +15,14 @@ class TemplatesViewModel: ObservableObject {
|
||||
func loadTemplates() {
|
||||
isLoading = true
|
||||
templates = LocalStorageManager.shared.loadTemplates()
|
||||
defaultTemplateID = LocalStorageManager.shared.preferredTemplateID(in: templates)
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func setDefaultTemplate(_ template: NoteTemplate) {
|
||||
UserDefaultsManager.shared.selectedTemplateId = template.id
|
||||
defaultTemplateID = template.id
|
||||
}
|
||||
|
||||
func saveTemplate(_ template: NoteTemplate) {
|
||||
if LocalStorageManager.shared.saveTemplate(template) {
|
||||
@@ -42,4 +49,4 @@ class TemplatesViewModel: ObservableObject {
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ struct MeetingListView: View {
|
||||
@StateObject private var recordingSessionManager = RecordingSessionManager.shared
|
||||
@State private var selectedMeeting: Meeting?
|
||||
@State private var navigationPath = NavigationPath()
|
||||
@State private var recordingFailureMessage: String?
|
||||
@State private var failedMeetingID: UUID?
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
@@ -23,6 +25,38 @@ struct MeetingListView: View {
|
||||
.background(Color.clear)
|
||||
}
|
||||
}
|
||||
.onReceive(recordingSessionManager.$activeMeetingId.compactMap { $0 }) { meetingID in
|
||||
if let meeting = LocalStorageManager.shared.loadMeetings().first(where: { $0.id == meetingID }) {
|
||||
selectedMeeting = meeting
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .meetingSaved)) { notification in
|
||||
guard let savedMeeting = notification.object as? Meeting,
|
||||
selectedMeeting?.id == savedMeeting.id else { return }
|
||||
selectedMeeting = savedMeeting
|
||||
}
|
||||
.onReceive(recordingSessionManager.$errorMessage.compactMap { $0 }) { message in
|
||||
failedMeetingID = recordingSessionManager.activeMeetingId
|
||||
recordingFailureMessage = message
|
||||
}
|
||||
.alert("Transcription Failed", isPresented: Binding(
|
||||
get: { recordingFailureMessage != nil },
|
||||
set: { if !$0 { recordingFailureMessage = nil } }
|
||||
)) {
|
||||
if failedMeetingID != nil {
|
||||
Button("View Meeting") {
|
||||
selectFailedMeeting()
|
||||
recordingFailureMessage = nil
|
||||
recordingSessionManager.errorMessage = nil
|
||||
}
|
||||
}
|
||||
Button("OK") {
|
||||
recordingFailureMessage = nil
|
||||
recordingSessionManager.errorMessage = nil
|
||||
}
|
||||
} message: {
|
||||
Text(recordingFailureMessage ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private var sidebarContent: some View {
|
||||
@@ -83,10 +117,19 @@ struct MeetingListView: View {
|
||||
NavigationStack(path: $navigationPath) {
|
||||
Group {
|
||||
if let selectedMeeting = selectedMeeting {
|
||||
MeetingDetailContentView(meeting: selectedMeeting, onDelete: {
|
||||
// When a meeting is deleted from the detail view, clear the selection
|
||||
self.selectedMeeting = nil
|
||||
})
|
||||
MeetingDetailContentView(
|
||||
meeting: selectedMeeting,
|
||||
mergeCandidate: viewModel.previousMeeting(for: selectedMeeting),
|
||||
onMerge: { meeting in
|
||||
guard let merged = viewModel.mergeIntoPrevious(meeting) else { return false }
|
||||
self.selectedMeeting = merged
|
||||
return true
|
||||
},
|
||||
onDelete: {
|
||||
// When a meeting is deleted from the detail view, clear the selection
|
||||
self.selectedMeeting = nil
|
||||
}
|
||||
)
|
||||
.id(selectedMeeting.id) // Force recreation when selection changes
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
@@ -152,6 +195,14 @@ struct MeetingListView: View {
|
||||
return DayGroup(day: dayString, date: date, meetings: meetings.sorted { $0.date > $1.date })
|
||||
}.sorted { $0.date > $1.date }
|
||||
}
|
||||
|
||||
private func selectFailedMeeting() {
|
||||
guard let failedMeetingID,
|
||||
let meeting = LocalStorageManager.shared.loadMeetings().first(where: { $0.id == failedMeetingID }) else {
|
||||
return
|
||||
}
|
||||
selectedMeeting = meeting
|
||||
}
|
||||
}
|
||||
|
||||
struct DayGroup {
|
||||
@@ -173,7 +224,14 @@ struct MeetingRowView: View {
|
||||
.foregroundColor(.red)
|
||||
.font(.headline)
|
||||
}
|
||||
Text(meeting.title.isEmpty ? "Untitled meeting" : meeting.title)
|
||||
if meeting.transcriptionError != nil {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.orange)
|
||||
.accessibilityLabel("Transcription failed")
|
||||
}
|
||||
Text(meeting.title.isEmpty
|
||||
? (meeting.transcriptionError == nil ? "Untitled meeting" : "Transcription failed")
|
||||
: meeting.title)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
}
|
||||
@@ -197,18 +255,23 @@ struct CollapsedTranscriptChunkView: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(TranscriptTimestampFormatter.string(from: chunk.timestamp))
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 58, alignment: .leading)
|
||||
|
||||
// Source indicator
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: chunk.source.icon)
|
||||
.font(.caption)
|
||||
.foregroundColor(chunk.source == .mic ? .blue : .orange)
|
||||
|
||||
Text(chunk.source.displayName)
|
||||
Text(chunk.displayName)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(chunk.source == .mic ? .blue : .orange)
|
||||
}
|
||||
.frame(width: 50, alignment: .leading)
|
||||
.frame(width: 78, alignment: .leading)
|
||||
|
||||
// Transcript text
|
||||
Text(chunk.combinedText)
|
||||
@@ -224,12 +287,22 @@ struct MeetingDetailContentView: View {
|
||||
@StateObject private var viewModel: MeetingViewModel
|
||||
@StateObject private var recordingSessionManager = RecordingSessionManager.shared
|
||||
@State private var showDeleteAlert = false
|
||||
@State private var showMergeAlert = false
|
||||
@State private var isEditing = false
|
||||
@State private var showCopyConfirmation = false
|
||||
let mergeCandidate: Meeting?
|
||||
let onMerge: (Meeting) -> Bool
|
||||
let onDelete: () -> Void
|
||||
|
||||
init(meeting: Meeting, onDelete: @escaping () -> Void) {
|
||||
init(
|
||||
meeting: Meeting,
|
||||
mergeCandidate: Meeting?,
|
||||
onMerge: @escaping (Meeting) -> Bool,
|
||||
onDelete: @escaping () -> Void
|
||||
) {
|
||||
self._viewModel = StateObject(wrappedValue: MeetingViewModel(meeting: meeting))
|
||||
self.mergeCandidate = mergeCandidate
|
||||
self.onMerge = onMerge
|
||||
self.onDelete = onDelete
|
||||
}
|
||||
|
||||
@@ -254,6 +327,34 @@ struct MeetingDetailContentView: View {
|
||||
|
||||
// Ellipsis menu
|
||||
Menu {
|
||||
if viewModel.recoveryAudioFolderURL != nil {
|
||||
Button {
|
||||
viewModel.retryTranscription()
|
||||
} label: {
|
||||
Label("Retry Transcription", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.disabled(!viewModel.canRetryTranscription)
|
||||
|
||||
Button {
|
||||
viewModel.showAudioInFinder()
|
||||
} label: {
|
||||
Label("Show Audio in Finder", systemImage: "folder")
|
||||
}
|
||||
|
||||
Divider()
|
||||
}
|
||||
|
||||
if mergeCandidate != nil {
|
||||
Button {
|
||||
showMergeAlert = true
|
||||
} label: {
|
||||
Label("Merge into Previous Meeting", systemImage: "arrow.triangle.merge")
|
||||
}
|
||||
.disabled(recordingSessionManager.isRecording || recordingSessionManager.isProcessing)
|
||||
|
||||
Divider()
|
||||
}
|
||||
|
||||
Button("Delete Meeting", role: .destructive) {
|
||||
showDeleteAlert = true
|
||||
}
|
||||
@@ -322,7 +423,7 @@ struct MeetingDetailContentView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(viewModel.meeting.transcript.isEmpty || viewModel.isGeneratingNotes || viewModel.isRecording || viewModel.isProcessing || viewModel.isStartingRecording)
|
||||
.help("Generate enhanced notes using a template")
|
||||
.help("Generate meeting notes using a template")
|
||||
|
||||
// Recording Button
|
||||
Button(action: {
|
||||
@@ -369,7 +470,7 @@ struct MeetingDetailContentView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
// Edit/Preview button (for My Notes and Enhanced Notes)
|
||||
// Edit/Preview button (for My Notes and Meeting Notes)
|
||||
if viewModel.selectedTab == .myNotes || viewModel.selectedTab == .enhancedNotes {
|
||||
Button(action: {
|
||||
isEditing.toggle()
|
||||
@@ -439,9 +540,27 @@ struct MeetingDetailContentView: View {
|
||||
} message: {
|
||||
Text("Are you sure you want to delete this meeting? This action cannot be undone.")
|
||||
}
|
||||
.alert("Merge into Previous Meeting?", isPresented: $showMergeAlert) {
|
||||
Button("Merge", role: .destructive) {
|
||||
// Prevent the disappearing detail view from auto-saving the
|
||||
// continuation after the storage layer removes it.
|
||||
viewModel.isDeleted = true
|
||||
if !onMerge(viewModel.meeting) {
|
||||
viewModel.isDeleted = false
|
||||
viewModel.errorMessage = "The meetings could not be merged. Their original records and audio were kept."
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) { }
|
||||
} message: {
|
||||
let previousTitle = mergeCandidate?.title.isEmpty == false
|
||||
? mergeCandidate?.title ?? "the previous meeting"
|
||||
: "the previous meeting"
|
||||
Text("This combines this meeting's transcript, notes, and saved audio into \"\(previousTitle)\", keeps the earlier meeting's title and time, then removes this continuation.")
|
||||
}
|
||||
.onDisappear {
|
||||
// Auto-delete empty meetings when leaving, otherwise save
|
||||
viewModel.deleteIfEmpty()
|
||||
// A failed recording may still be empty. Keep it until the user
|
||||
// explicitly deletes it so app updates cannot erase history.
|
||||
viewModel.saveMeeting()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,24 +591,52 @@ struct MeetingDetailContentView: View {
|
||||
}
|
||||
|
||||
private var transcriptView: some View {
|
||||
ScrollView {
|
||||
if viewModel.meeting.collapsedTranscriptChunks.isEmpty {
|
||||
Text("Transcript will appear here...")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
LazyVStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(viewModel.meeting.collapsedTranscriptChunks) { chunk in
|
||||
CollapsedTranscriptChunkView(chunk: chunk)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if let transcriptionError = viewModel.meeting.transcriptionError {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.orange)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Transcription failed")
|
||||
.font(.headline)
|
||||
Text(transcriptionError)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if viewModel.recoveryAudioFolderURL != nil {
|
||||
Button("Retry") {
|
||||
viewModel.retryTranscription()
|
||||
}
|
||||
.disabled(!viewModel.canRetryTranscription)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color.orange.opacity(0.08))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
if viewModel.meeting.collapsedTranscriptChunks.isEmpty {
|
||||
Text(viewModel.meeting.transcriptionError == nil
|
||||
? "Transcript will appear here..."
|
||||
: "No transcript was produced. The recording was kept and can be retried.")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
LazyVStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(viewModel.meeting.collapsedTranscriptChunks) { chunk in
|
||||
CollapsedTranscriptChunkView(chunk: chunk)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.background(Color.gray.opacity(0.05))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.background(Color.gray.opacity(0.05))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
private var enhancedNotesView: some View {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct SettingsView: View {
|
||||
@ObservedObject var viewModel: SettingsViewModel
|
||||
@StateObject private var localAPIServer = LocalAPIServer.shared
|
||||
@State private var showingTemplateManager = false
|
||||
@State private var confirmingTokenRegeneration = false
|
||||
@State private var showingMeetingImporter = false
|
||||
@State private var meetingImportMessage = ""
|
||||
@State private var showingMeetingImportResult = false
|
||||
@Binding var navigationPath: NavigationPath
|
||||
|
||||
init(viewModel: SettingsViewModel, navigationPath: Binding<NavigationPath> = .constant(NavigationPath())) {
|
||||
@@ -54,6 +58,18 @@ struct SettingsView: View {
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("The token is stored locally in Keychain. Audio and note generation are sent only to this Coder service.")
|
||||
@@ -119,6 +135,34 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Meeting Storage")
|
||||
.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 {
|
||||
showingMeetingImporter = true
|
||||
} label: {
|
||||
Label("Import Meetings...", systemImage: "square.and.arrow.down")
|
||||
}
|
||||
}
|
||||
|
||||
// Note Templates Section: only the Manage Templates button
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -129,6 +173,12 @@ struct SettingsView: View {
|
||||
Text("Create and manage note templates")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Picker("Default template", selection: $viewModel.settings.selectedTemplateId) {
|
||||
ForEach(viewModel.templates) { template in
|
||||
Text(template.title).tag(Optional(template.id))
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
navigationPath.append("templates")
|
||||
@@ -205,7 +255,7 @@ struct SettingsView: View {
|
||||
|
||||
// Link to GitHub repository
|
||||
Link("GitHub",
|
||||
destination: URL(string: "https://github.com/superdooper86/meetingnotes")!)
|
||||
destination: URL(string: "https://git.jamesbone.net/coder/meetingnotes")!)
|
||||
.foregroundColor(.blue)
|
||||
|
||||
// Link to landing page
|
||||
@@ -276,6 +326,29 @@ struct SettingsView: View {
|
||||
} message: {
|
||||
Text(viewModel.saveMessage)
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showingMeetingImporter,
|
||||
allowedContentTypes: [.folder],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
do {
|
||||
guard let directory = try result.get().first else { return }
|
||||
let importResult = try LocalStorageManager.shared.importMeetings(from: directory)
|
||||
meetingImportMessage = "Imported \(importResult.importedCount) meeting\(importResult.importedCount == 1 ? "" : "s")."
|
||||
if importResult.skippedCount > 0 {
|
||||
meetingImportMessage += " Skipped \(importResult.skippedCount) existing or invalid file\(importResult.skippedCount == 1 ? "" : "s")."
|
||||
}
|
||||
NotificationCenter.default.post(name: .meetingSaved, object: nil)
|
||||
} catch {
|
||||
meetingImportMessage = "Meeting import failed: \(error.localizedDescription)"
|
||||
}
|
||||
showingMeetingImportResult = true
|
||||
}
|
||||
.alert("Meeting Import", isPresented: $showingMeetingImportResult) {
|
||||
Button("OK") { }
|
||||
} message: {
|
||||
Text(meetingImportMessage)
|
||||
}
|
||||
.confirmationDialog("Regenerate API token?", isPresented: $confirmingTokenRegeneration, titleVisibility: .visible) {
|
||||
Button("Regenerate", role: .destructive) {
|
||||
viewModel.regenerateMuteDeckAPIToken()
|
||||
|
||||
@@ -36,13 +36,13 @@ struct TemplateEditView: View {
|
||||
TextEditor(text: $template.context)
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(8)
|
||||
.background(Color.gray.opacity(0.05))
|
||||
.cornerRadius(8)
|
||||
.frame(minHeight: 100)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
.frame(height: 140)
|
||||
.background(Color.secondary.opacity(0.06))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(Color.secondary.opacity(0.25), lineWidth: 1)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
// Sections
|
||||
@@ -55,14 +55,12 @@ struct TemplateEditView: View {
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
withAnimation {
|
||||
template.sections.append(
|
||||
TemplateSection(
|
||||
title: "New Section",
|
||||
description: "Description of this section"
|
||||
)
|
||||
template.sections.append(
|
||||
TemplateSection(
|
||||
title: "New Section",
|
||||
description: "Description of this section"
|
||||
)
|
||||
}
|
||||
)
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "plus")
|
||||
@@ -77,29 +75,27 @@ struct TemplateEditView: View {
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
ForEach(template.sections.indices, id: \.self) { index in
|
||||
ForEach($template.sections) { $section in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
TextField("Section Title", text: $template.sections[index].title)
|
||||
TextField("Section Title", text: $section.title)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
TextEditor(text: $template.sections[index].description)
|
||||
TextEditor(text: $section.description)
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(8)
|
||||
.background(Color.gray.opacity(0.05))
|
||||
.cornerRadius(8)
|
||||
.frame(minHeight: 60)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
.frame(height: 90)
|
||||
.background(Color.secondary.opacity(0.06))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(Color.secondary.opacity(0.25), lineWidth: 1)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
Button {
|
||||
withAnimation {
|
||||
let _ = template.sections.remove(at: index)
|
||||
}
|
||||
template.sections.removeAll { $0.id == section.id }
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.foregroundColor(.red)
|
||||
@@ -131,12 +127,19 @@ struct TemplateEditView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top)
|
||||
.disabled(template.title.isEmpty || template.sections.isEmpty)
|
||||
.disabled(!canSave)
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.navigationTitle("Edit Template")
|
||||
}
|
||||
|
||||
private var canSave: Bool {
|
||||
let hasTitle = !template.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
let hasInstructions = !template.context.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| !template.sections.isEmpty
|
||||
return hasTitle && hasInstructions
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
@@ -152,4 +155,4 @@ struct TemplateEditView: View {
|
||||
)
|
||||
) { _ in }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,21 +2,21 @@ import SwiftUI
|
||||
|
||||
struct TemplateListView: View {
|
||||
@StateObject private var viewModel = TemplatesViewModel()
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var editingTemplate: NoteTemplate?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(viewModel.templates) { template in
|
||||
HStack {
|
||||
NavigationLink(destination: TemplateEditView(template: template) { updatedTemplate in
|
||||
viewModel.saveTemplate(updatedTemplate)
|
||||
}) {
|
||||
Button {
|
||||
presentEditor(for: template)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(template.title)
|
||||
.font(.headline)
|
||||
if template.isDefault {
|
||||
Text("Default")
|
||||
Text("Built-in")
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
@@ -36,8 +36,18 @@ struct TemplateListView: View {
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
viewModel.setDefaultTemplate(template)
|
||||
} label: {
|
||||
Image(systemName: viewModel.defaultTemplateID == template.id ? "star.fill" : "star")
|
||||
.foregroundColor(viewModel.defaultTemplateID == template.id ? .accentColor : .secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(viewModel.defaultTemplateID == template.id ? "Default template" : "Use as default template")
|
||||
|
||||
if !template.isDefault {
|
||||
Button(role: .destructive) {
|
||||
@@ -50,6 +60,14 @@ struct TemplateListView: View {
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
if viewModel.defaultTemplateID != template.id {
|
||||
Button {
|
||||
viewModel.setDefaultTemplate(template)
|
||||
} label: {
|
||||
Label("Use as Default", systemImage: "star")
|
||||
}
|
||||
}
|
||||
|
||||
if !template.isDefault {
|
||||
Button(role: .destructive) {
|
||||
viewModel.deleteTemplate(template)
|
||||
@@ -70,9 +88,9 @@ struct TemplateListView: View {
|
||||
.navigationTitle("Note Templates")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
NavigationLink(destination: TemplateEditView(template: viewModel.createNewTemplate()) { updatedTemplate in
|
||||
viewModel.saveTemplate(updatedTemplate)
|
||||
}) {
|
||||
Button {
|
||||
presentEditor(for: viewModel.createNewTemplate())
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
@@ -89,9 +107,22 @@ struct TemplateListView: View {
|
||||
} message: {
|
||||
Text(viewModel.errorMessage ?? "")
|
||||
}
|
||||
.sheet(item: $editingTemplate) { template in
|
||||
NavigationStack {
|
||||
TemplateEditView(template: template) { updatedTemplate in
|
||||
viewModel.saveTemplate(updatedTemplate)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 680, minHeight: 620)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentEditor(for template: NoteTemplate) {
|
||||
guard editingTemplate == nil else { return }
|
||||
editingTemplate = template
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
TemplateListView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,6 @@
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.owen.meetingnotes</string>
|
||||
</array>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.screen-capture</key>
|
||||
@@ -20,8 +16,8 @@
|
||||
<true/>
|
||||
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
|
||||
<array>
|
||||
<string>owen.meetingnotes-spks</string>
|
||||
<string>owen.meetingnotes-spki</string>
|
||||
<string>net.jamesbone.meetingnotes-spks</string>
|
||||
<string>net.jamesbone.meetingnotes-spki</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -7,7 +7,7 @@ set -e # Exit on any error
|
||||
|
||||
# Configuration
|
||||
APP_NAME="Meetingnotes"
|
||||
BUNDLE_ID="owen.meetingnotes"
|
||||
BUNDLE_ID="net.jamesbone.meetingnotes"
|
||||
VERSION=$(grep -m1 "MARKETING_VERSION" Meetingnotes.xcodeproj/project.pbxproj | sed 's/.*= \(.*\);/\1/')
|
||||
|
||||
# Source environment variables if .env file exists
|
||||
@@ -379,4 +379,4 @@ echo " 1. Test the DMG on another Mac"
|
||||
echo " 2. Create a GitHub release with tag v${VERSION}"
|
||||
echo " 3. Upload the DMG to the GitHub release"
|
||||
echo " 4. Commit and push the appcast.xml file"
|
||||
echo " 5. Your users will get auto-update notifications!"
|
||||
echo " 5. Your users will get auto-update notifications!"
|
||||
|
||||
Executable
+136
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
APP_NAME="Meetingnotes"
|
||||
PROJECT="Meetingnotes.xcodeproj"
|
||||
SCHEME="meetingnotes"
|
||||
RUNNER_TEMP="${RUNNER_TEMP:-/tmp}"
|
||||
BUILD_ROOT="${BUILD_ROOT:-$RUNNER_TEMP/meetingnotes-release}"
|
||||
DERIVED_DATA="$BUILD_ROOT/DerivedData"
|
||||
RELEASE_DIR="$BUILD_ROOT/release"
|
||||
APP_PATH="$DERIVED_DATA/Build/Products/Release/$APP_NAME.app"
|
||||
|
||||
required_variables=(
|
||||
VERSION
|
||||
SIGNING_IDENTITY
|
||||
SIGNING_KEYCHAIN
|
||||
APPLE_ID
|
||||
APPLE_TEAM_ID
|
||||
APPLE_APP_PASSWORD
|
||||
SPARKLE_PRIVATE_KEY
|
||||
GITHUB_REPOSITORY
|
||||
RELEASE_BASE_URL
|
||||
)
|
||||
|
||||
for variable in "${required_variables[@]}"; do
|
||||
if [[ -z "${!variable:-}" ]]; then
|
||||
echo "Missing required environment variable: $variable" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
project_version=$(grep -m1 'MARKETING_VERSION' "$PROJECT/project.pbxproj" | sed 's/.*= \(.*\);/\1/')
|
||||
if [[ "$project_version" != "$VERSION" ]]; then
|
||||
echo "Release version $VERSION does not match project version $project_version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "$BUILD_ROOT"
|
||||
mkdir -p "$RELEASE_DIR"
|
||||
|
||||
xcodebuild \
|
||||
-project "$PROJECT" \
|
||||
-scheme "$SCHEME" \
|
||||
-configuration Release \
|
||||
-destination 'generic/platform=macOS' \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
ARCHS="arm64 x86_64" \
|
||||
ONLY_ACTIVE_ARCH=NO \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
clean build
|
||||
|
||||
if [[ ! -d "$APP_PATH" ]]; then
|
||||
echo "Built app not found at $APP_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SPARKLE_FRAMEWORK="$APP_PATH/Contents/Frameworks/Sparkle.framework"
|
||||
SPARKLE_CONTENTS="$SPARKLE_FRAMEWORK/Versions/B"
|
||||
|
||||
sign_component() {
|
||||
codesign --force --timestamp --options runtime --keychain "$SIGNING_KEYCHAIN" --sign "$SIGNING_IDENTITY" "$1"
|
||||
}
|
||||
|
||||
sign_component "$SPARKLE_CONTENTS/XPCServices/Installer.xpc"
|
||||
if [[ -d "$SPARKLE_CONTENTS/XPCServices/Downloader.xpc" ]]; then
|
||||
codesign --force --timestamp --options runtime --keychain "$SIGNING_KEYCHAIN" \
|
||||
--preserve-metadata=entitlements \
|
||||
--sign "$SIGNING_IDENTITY" \
|
||||
"$SPARKLE_CONTENTS/XPCServices/Downloader.xpc"
|
||||
fi
|
||||
sign_component "$SPARKLE_CONTENTS/Autoupdate"
|
||||
sign_component "$SPARKLE_CONTENTS/Updater.app"
|
||||
sign_component "$SPARKLE_FRAMEWORK"
|
||||
|
||||
codesign --force --timestamp --options runtime --keychain "$SIGNING_KEYCHAIN" \
|
||||
--entitlements meetingnotes/meetingnotes.entitlements \
|
||||
--sign "$SIGNING_IDENTITY" \
|
||||
"$APP_PATH"
|
||||
|
||||
codesign --verify --deep --strict --verbose=2 "$APP_PATH"
|
||||
codesign -d --entitlements :- "$APP_PATH" 2>&1 | grep -q 'com.apple.security.app-sandbox'
|
||||
|
||||
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
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DOWNLOAD_URL="$RELEASE_BASE_URL/releases/download/v$VERSION/"
|
||||
printf '%s' "$SPARKLE_PRIVATE_KEY" | "$GENERATE_APPCAST" "$RELEASE_DIR" \
|
||||
--ed-key-file - \
|
||||
--download-url-prefix "$DOWNLOAD_URL" \
|
||||
--maximum-deltas 0 \
|
||||
-o "$RELEASE_DIR/appcast.xml"
|
||||
|
||||
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, Developer ID-signed, notarized, and stapled Meetingnotes %s. The Gitea release is ready to publish.\n' \
|
||||
"$VERSION" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
echo "Signed and notarized release artifacts are ready in $RELEASE_DIR"
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publish both signed assets together, keeping incomplete uploads as a draft."""
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
base = os.environ["GITEA_SERVER_URL"].rstrip("/") + "/api/v1/repos/" + os.environ["GITHUB_REPOSITORY"]
|
||||
token = os.environ["GITEA_TOKEN"]
|
||||
version = os.environ["VERSION"]
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
def request(path, method="GET", data=None, binary=False):
|
||||
body = data if binary else None if data is None else json.dumps(data).encode()
|
||||
req = urllib.request.Request(base + path, method=method, data=body, headers={
|
||||
"Authorization": "token " + token,
|
||||
"Content-Type": "application/octet-stream" if binary else "application/json",
|
||||
"User-Agent": "Meetingnotes-release",
|
||||
})
|
||||
with urllib.request.build_opener(NoRedirect()).open(req, timeout=120) as response:
|
||||
raw = response.read()
|
||||
return json.loads(raw) if raw else None
|
||||
|
||||
|
||||
release_dir = Path(os.environ["RUNNER_TEMP"]) / "meetingnotes-release/release"
|
||||
assets = [release_dir / f"Meetingnotes-{version}.zip", release_dir / "appcast.xml"]
|
||||
for asset in assets:
|
||||
if not asset.is_file() or not asset.stat().st_size:
|
||||
raise RuntimeError(f"Missing release artifact: {asset.name}")
|
||||
release = request("/releases", "POST", {
|
||||
"tag_name": "v" + version, "target_commitish": os.environ["GITHUB_SHA"],
|
||||
"name": "Meetingnotes " + version, "draft": True, "prerelease": False,
|
||||
"body": "Developer ID signed, Apple notarized, and signed for Sparkle updates.\n\nBuilt from main at `" + os.environ["GITHUB_SHA"] + "`.",
|
||||
})
|
||||
for asset in assets:
|
||||
request(f"/releases/{release['id']}/assets?name=" + urllib.parse.quote(asset.name), "POST", asset.read_bytes(), binary=True)
|
||||
request(f"/releases/{release['id']}", "PATCH", {"draft": False})
|
||||
print("Published Meetingnotes " + version)
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check the CI app's unauthenticated API without changing saved preferences."""
|
||||
import json
|
||||
import plistlib
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
# Launch from the Mac's internal volume; the runner checkout is on removable storage.
|
||||
cache = Path.home() / "Library/Caches/meetingnotes-ci"
|
||||
cache.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="smoke-", dir=cache) as directory:
|
||||
staged = Path(directory) / "Meetingnotes.app"
|
||||
subprocess.run(["ditto", sys.argv[1], str(staged)], check=True)
|
||||
app = staged / "Contents/MacOS/Meetingnotes"
|
||||
with (staged / "Contents/Info.plist").open("rb") as info_file:
|
||||
bundle_id = plistlib.load(info_file)["CFBundleIdentifier"]
|
||||
assert bundle_id == "net.jamesbone.meetingnotes.ci", "Smoke tests require the isolated CI app"
|
||||
|
||||
def clear_test_token():
|
||||
result = subprocess.run(
|
||||
["security", "delete-generic-password", "-s", bundle_id, "-a", "muteDeckAPIToken"],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
if result.returncode not in (0, 44): # 44: item does not exist
|
||||
raise RuntimeError("Could not clear the CI Keychain token")
|
||||
|
||||
clear_test_token()
|
||||
with socket.socket() as listener:
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
port = listener.getsockname()[1]
|
||||
with tempfile.TemporaryFile() as log:
|
||||
process = subprocess.Popen(
|
||||
[str(app), "-muteDeckAPIEnabled", "YES", "-muteDeckAPIPort", str(port), "-hasCompletedOnboarding", "YES", "-hasAcceptedTerms", "YES", "-SUEnableAutomaticChecks", "NO"],
|
||||
stdout=log, stderr=subprocess.STDOUT,
|
||||
)
|
||||
try:
|
||||
for attempt in range(30):
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError("CI app exited before the API became ready")
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/info", timeout=2) as response:
|
||||
info = json.load(response)
|
||||
break
|
||||
except (urllib.error.URLError, TimeoutError):
|
||||
time.sleep(1)
|
||||
else:
|
||||
raise RuntimeError("CI API did not become ready")
|
||||
assert info["name"] == "MeetingDebrief", "Unexpected API identity"
|
||||
try:
|
||||
urllib.request.urlopen(f"http://127.0.0.1:{port}/api/recording/status", timeout=2)
|
||||
except urllib.error.HTTPError as error:
|
||||
assert error.code == 401, f"Unexpected status: {error.code}"
|
||||
else:
|
||||
raise RuntimeError("Recording status allowed an unauthenticated request")
|
||||
print("Local API readiness and authentication checks passed")
|
||||
except Exception:
|
||||
if process.poll() is None:
|
||||
sample = subprocess.run(["sample", str(process.pid), "1", "1"], capture_output=True, text=True, timeout=10)
|
||||
print(sample.stdout.split("Binary Images:")[0], file=sys.stderr)
|
||||
log.seek(0)
|
||||
print(log.read().decode(errors="replace")[-8000:], file=sys.stderr)
|
||||
raise
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
clear_test_token()
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Import the CI certificate into an isolated keychain for one release command."""
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def security(*args):
|
||||
result = subprocess.run(["security", *args], capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"security {args[0]} failed (output withheld to protect credentials)")
|
||||
return result.stdout
|
||||
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="meetingnotes-signing-", dir=os.environ["RUNNER_TEMP"]) as directory:
|
||||
keychain = str(Path(directory) / "release.keychain-db")
|
||||
certificate = Path(directory) / "certificate.p12"
|
||||
certificate.write_bytes(base64.b64decode(os.environ["APPLE_CERTIFICATE_P12"]))
|
||||
certificate.chmod(0o600)
|
||||
password = secrets.token_urlsafe(32)
|
||||
created = False
|
||||
try:
|
||||
security("create-keychain", "-p", password, keychain)
|
||||
created = True
|
||||
security("set-keychain-settings", "-lut", "21600", keychain)
|
||||
security("unlock-keychain", "-p", password, keychain)
|
||||
security("import", str(certificate), "-k", keychain, "-P", os.environ["APPLE_CERTIFICATE_PASSWORD"], "-T", "/usr/bin/codesign", "-T", "/usr/bin/security")
|
||||
security("set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychain)
|
||||
identities = security("find-identity", "-v", "-p", "codesigning", keychain)
|
||||
match = re.search(r'([0-9A-F]{40}) "Developer ID Application:', identities)
|
||||
if not match:
|
||||
raise RuntimeError("The certificate contains no valid Developer ID Application identity")
|
||||
environment = dict(os.environ, SIGNING_IDENTITY=match[1], SIGNING_KEYCHAIN=keychain)
|
||||
status = subprocess.run(sys.argv[1:], env=environment).returncode
|
||||
finally:
|
||||
if created:
|
||||
security("delete-keychain", keychain)
|
||||
sys.exit(status)
|
||||
Reference in New Issue
Block a user