diff --git a/Documentation/API.md b/Documentation/API.md
new file mode 100644
index 0000000..b53940b
--- /dev/null
+++ b/Documentation/API.md
@@ -0,0 +1,148 @@
+# FluidVoice Local API Documentation
+
+The FluidVoice Local API allows you to submit audio files for transcription, check their status, and retrieve results programmatically. The API runs locally on port `7086` by default.
+
+## Base URL
+`http://127.0.0.1:7086`
+
+> **Note**: The API binds strictly to **IPv4 loopback** (`127.0.0.1`). It is **not** accessible via `::1` (IPv6) or other network interfaces. For best compatibility, use `127.0.0.1` instead of `localhost`.
+
+> **Port**: The default port is `7086`, but this can be changed in the app settings (File Transcription API > Port).
+
+## Endpoints
+
+### 1. List Available Models
+Retrieves a list of available transcription models that are **downloaded and ready to use**.
+
+- **Endpoint**: `GET /models`
+- **Response**: JSON object containing a list of model IDs.
+
+```json
+{
+ "models": ["whisper-base", "whisper-medium", "whisper-large-v3", ...]
+}
+```
+
+---
+
+### 2. Submit Transcription Job
+Queues an audio file for transcription.
+
+- **Endpoint**: `POST /transcribe`
+- **Content-Type**: `application/json`
+- **Body Parameters**:
+ - `url` (string, required): The absolute URL of the local file to transcribe (e.g., `file:///Users/me/audio.wav`).
+ - `model` (string, optional): The ID of the model to use. If omitted, uses the application default.
+
+- **Response**:
+ - `200 OK`: Job accepted.
+ - `400 Bad Request`: Invalid JSON or missing URL.
+
+```json
+{
+ "id": "uuid-1234-5678",
+ "url": "file:///Users/me/audio.wav",
+ "status": "pending"
+}
+```
+
+---
+
+### 3. Check Job Status
+Checks the status of a transcription job.
+
+- **Endpoint**: `GET /status`
+- **Query Parameters**:
+ - `id` (string, optional): The UUID of the job (RECOMMENDED). Takes precedence over `url`.
+ - `url` (string, optional): The URL of the file. Legacy/fallback if `id` is not provided.
+ - `model` (string, optional): The model ID used. Required only if using `url` and multiple jobs exist for the same file.
+
+- **Response**:
+ - `200 OK`: Returns job details.
+ - `404 Not Found`: Job not found.
+ - `409 Conflict`: Multiple jobs found for this URL. Returns a list of choices to help you refine the request.
+
+**Success Response (200):**
+```json
+{
+ "id": "UUID-STRING",
+ "status": "completed", // pending, processing, completed, failed
+ "created_at": 1700000000,
+ "model": "whisper-medium",
+ "processing_duration": 12.5,
+ "error": null
+}
+```
+
+**Ambiguity Response (409):**
+```json
+{
+ "error": "Ambiguous request...",
+ "choices": [
+ {"id": "uuid-1", "model": "base", "status": "completed"},
+ {"id": "uuid-2", "model": "medium", "status": "pending"}
+ ]
+}
+```
+
+---
+
+### 4. Get Transcription Result
+Retrieves the transcribed text.
+
+- **Endpoint**: `GET /result`
+- **Query Parameters**:
+ - `id` (string, optional): The UUID of the job (RECOMMENDED).
+ - `url` (string, optional): The URL of the file.
+ - `model` (string, optional): The model ID used. Required if using `url` and ambiguous.
+ - `format` (string, optional): Output format. `text` (default) or `vtt`.
+
+- **Response**:
+ - `200 OK`: Returns the transcription text (plain text or VTT).
+ - `400 Bad Request`: Job not completed or missing parameters.
+ - `404 Not Found`: Job not found.
+ - `409 Conflict`: Ambiguous request (see `/status`).
+
+---
+
+### 5. List All Jobs
+Returns a list of all current jobs in the backlog.
+
+- **Endpoint**: `GET /list`
+- **Response**: JSON array of job summaries.
+
+```json
+[
+ {
+ "id": "uuid-1",
+ "url": "file:///path/to/a.wav",
+ "status": "completed",
+ "model": "base",
+ "processing_duration": 5.2
+ },
+ ...
+]
+```
+
+---
+
+### 6. Delete Job
+Removes a job from the backlog.
+
+- **Endpoint**: `DELETE /backlog`
+- **Query Parameters**:
+ - `id` (string, optional): The UUID of the job to delete (RECOMMENDED).
+ - `url` (string, optional): The URL of the file.
+ - `model` (string, optional): The model ID to delete. Required if using `url` and ambiguous.
+
+- **Response**:
+ - `200 OK`: Deleted.
+ - `404 Not Found`: Job not found.
+ - `409 Conflict`: Ambiguous request.
+
+## Error Handling
+
+- **400 Bad Request**: Missing parameters or invalid request format.
+- **404 Not Found**: The specified job does not exist.
+- **409 Conflict**: The request matched multiple jobs (same file, different models). precise the `model` parameter.
+- **500 Internal Server Error**: Unexpected server error.
diff --git a/Fluid.entitlements b/Fluid.entitlements
index e0c7ade..24cbaa6 100644
--- a/Fluid.entitlements
+++ b/Fluid.entitlements
@@ -2,7 +2,9 @@
- com.apple.security.automation.apple-events
+ com.apple.security.network.client
+
+ com.apple.security.network.server
com.apple.security.temporary-exception.apple-events
@@ -12,7 +14,9 @@
com.apple.loginitems
- com.apple.security.cs.disable-library-validation
-
+ com.apple.security.temporary-exception.files.absolute-path.read-only
+
+ /
+
diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj
index 8a85af7..8f18713 100644
--- a/Fluid.xcodeproj/project.pbxproj
+++ b/Fluid.xcodeproj/project.pbxproj
@@ -17,6 +17,8 @@
7CDB0A2F2F3C4D5600FB7CAD /* dictation_fixture.wav in Resources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */; };
7CDB0A302F3C4D5600FB7CAD /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */; };
7CE006BD2E80EBE600DDCCD6 /* AppUpdater in Frameworks */ = {isa = PBXBuildFile; productRef = 7CE006BC2E80EBE600DDCCD6 /* AppUpdater */; };
+ 7CF7A1A530AA000100F00001 /* Sources/Fluid/Resources/FV_start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 7CF7A1A730AA000100F00001 /* Sources/Fluid/Resources/FV_start.m4a */; };
+ 95A3C0E92F31D9AE00829BE2 /* Swifter in Frameworks */ = {isa = PBXBuildFile; productRef = 95A3C0E82F31D9AE00829BE2 /* Swifter */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -36,11 +38,26 @@
7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFixtureLoader.swift; sourceTree = ""; };
7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = dictation_fixture.wav; sourceTree = ""; };
7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; };
+ 7CF7A1A730AA000100F00001 /* Sources/Fluid/Resources/FV_start.m4a */ = {isa = PBXFileReference; lastKnownFileType = audio.m4a; path = Sources/Fluid/Resources/FV_start.m4a; sourceTree = ""; };
+ 7CF7A1A830AA000100F00001 /* Sources/Fluid/Resources/FV_start_2.m4a */ = {isa = PBXFileReference; lastKnownFileType = audio.m4a; path = Sources/Fluid/Resources/FV_start_2.m4a; sourceTree = ""; };
/* End PBXFileReference section */
+/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
+ 95A3C0EA2F31DB0000829BE2 /* Exceptions for "Sources/Fluid" folder in "fluid" target */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Resources/FV_start.m4a,
+ );
+ target = 7C078D8E2E3B339200FB7CAC /* fluid */;
+ };
+/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
+
/* Begin PBXFileSystemSynchronizedRootGroup section */
7C078D912E3B339200FB7CAC /* Sources/Fluid */ = {
isa = PBXFileSystemSynchronizedRootGroup;
+ exceptions = (
+ 95A3C0EA2F31DB0000829BE2 /* Exceptions for "Sources/Fluid" folder in "fluid" target */,
+ );
path = Sources/Fluid;
sourceTree = "";
};
@@ -51,6 +68,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+ 95A3C0E92F31D9AE00829BE2 /* Swifter in Frameworks */,
7C3697892ED70F9C005874CE /* DynamicNotchKit in Frameworks */,
7C5AF14B2F15041600DE21B0 /* MediaRemoteAdapter in Frameworks */,
7C3236542EAAD608007E4CB6 /* MCP in Frameworks */,
@@ -77,6 +95,7 @@
7C078D912E3B339200FB7CAC /* Sources/Fluid */,
7CDB0A242F3C4D5600FB7CAD /* Tests */,
7CDB0A282F3C4D5600FB7CAD /* Frameworks */,
+ 95A3C0162F31D48400829BE2 /* Recovered References */,
);
sourceTree = "";
};
@@ -131,6 +150,15 @@
name = Frameworks;
sourceTree = "";
};
+ 95A3C0162F31D48400829BE2 /* Recovered References */ = {
+ isa = PBXGroup;
+ children = (
+ 7CF7A1A730AA000100F00001 /* Sources/Fluid/Resources/FV_start.m4a */,
+ 7CF7A1A830AA000100F00001 /* Sources/Fluid/Resources/FV_start_2.m4a */,
+ );
+ name = "Recovered References";
+ sourceTree = "";
+ };
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -157,6 +185,7 @@
7CE006BC2E80EBE600DDCCD6 /* AppUpdater */,
7C1C72F12EECBD1300E3BF4D /* SwiftWhisper */,
7C5AF14A2F15041600DE21B0 /* MediaRemoteAdapter */,
+ 95A3C0E82F31D9AE00829BE2 /* Swifter */,
);
productName = FluidVoice;
productReference = 7C078D8F2E3B339200FB7CAC /* FluidVoice Debug.app */;
@@ -188,7 +217,7 @@
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2600;
- LastUpgradeCheck = 2600;
+ LastUpgradeCheck = 2620;
TargetAttributes = {
7C078D8E2E3B339200FB7CAC = {
CreatedOnToolsVersion = 26.0;
@@ -214,6 +243,9 @@
7C3697872ED70F9C005874CE /* XCRemoteSwiftPackageReference "DynamicNotchKit" */,
7C1C72F02EECBD1300E3BF4D /* XCRemoteSwiftPackageReference "SwiftWhisper" */,
7C5AF1492F15041600DE21B0 /* XCRemoteSwiftPackageReference "mediaremote-adapter" */,
+ 95A3C0E52F31D77000829BE2 /* XCRemoteSwiftPackageReference "swift-algorithms" */,
+ 95A3C0E62F31D7A300829BE2 /* XCRemoteSwiftPackageReference "swifter" */,
+ 95A3C0E72F31D81400829BE2 /* XCRemoteSwiftPackageReference "swift-argument-parser" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 7C078D902E3B339200FB7CAC /* Products */;
@@ -231,6 +263,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ 7CF7A1A530AA000100F00001 /* Sources/Fluid/Resources/FV_start.m4a in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -307,7 +340,10 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
+ CREATE_INFOPLIST_SECTION_IN_BINARY = YES;
+ DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
+ DEPLOYMENT_LOCATION = YES;
DEVELOPMENT_TEAM = V4J43B279J;
ENABLE_CODE_COVERAGE = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -327,12 +363,19 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ GENERATE_PKGINFO_FILE = YES;
+ "GENERATE_PKGINFO_FILE[sdk=macosx*]" = YES;
+ "INFOPLIST_FILE[sdk=*]" = "";
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ "MACH_O_TYPE[sdk=macosx*]" = mh_execute;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
@@ -373,7 +416,11 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
+ CREATE_INFOPLIST_SECTION_IN_BINARY = YES;
+ DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ DEPLOYMENT_LOCATION = YES;
+ "DEPLOYMENT_LOCATION[sdk=*]" = YES;
DEVELOPMENT_TEAM = V4J43B279J;
ENABLE_CODE_COVERAGE = NO;
ENABLE_NS_ASSERTIONS = NO;
@@ -387,12 +434,19 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ GENERATE_PKGINFO_FILE = YES;
+ "GENERATE_PKGINFO_FILE[sdk=macosx*]" = YES;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
+ "INSTALL_PATH[sdk=*]" = "";
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ "MACH_O_TYPE[sdk=macosx*]" = mh_execute;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = NO;
SDKROOT = macosx;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
@@ -403,13 +457,15 @@
ARCHS = "$(ARCHS_STANDARD)";
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ AUTOMATION_APPLE_EVENTS = YES;
CODE_SIGN_ENTITLEMENTS = Fluid.entitlements;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
- DEVELOPMENT_TEAM = V4J43B279J;
- ENABLE_APP_SANDBOX = NO;
+ DEAD_CODE_STRIPPING = YES;
+ DEVELOPMENT_TEAM = 4JSY8RYCTF;
+ ENABLE_APP_SANDBOX = YES;
ENABLE_CODE_COVERAGE = NO;
ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite;
ENABLE_HARDENED_RUNTIME = YES;
@@ -440,6 +496,7 @@
PRODUCT_NAME = "FluidVoice Debug";
PROVISIONING_PROFILE_SPECIFIER = "";
REGISTER_APP_GROUPS = YES;
+ RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) ARCH_ARM64";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -457,13 +514,15 @@
ARCHS = "$(ARCHS_STANDARD)";
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ AUTOMATION_APPLE_EVENTS = YES;
CODE_SIGN_ENTITLEMENTS = Fluid.entitlements;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
- DEVELOPMENT_TEAM = V4J43B279J;
- ENABLE_APP_SANDBOX = NO;
+ DEAD_CODE_STRIPPING = YES;
+ DEVELOPMENT_TEAM = 4JSY8RYCTF;
+ ENABLE_APP_SANDBOX = YES;
ENABLE_CODE_COVERAGE = NO;
ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite;
ENABLE_HARDENED_RUNTIME = YES;
@@ -494,6 +553,7 @@
PRODUCT_NAME = FluidVoice;
PROVISIONING_PROFILE_SPECIFIER = "";
REGISTER_APP_GROUPS = YES;
+ RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) ARCH_ARM64";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -514,13 +574,15 @@
CLANG_ENABLE_MODULES = YES;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- DEVELOPMENT_TEAM = V4J43B279J;
+ DEAD_CODE_STRIPPING = YES;
+ DEVELOPMENT_TEAM = 4JSY8RYCTF;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = FluidDictationIntegrationTests;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
+ MACH_O_TYPE = mh_bundle;
MACOSX_DEPLOYMENT_TARGET = 14.0;
PRODUCT_BUNDLE_IDENTIFIER = com.FluidApp.FluidDictationIntegrationTests;
PRODUCT_NAME = FluidDictationIntegrationTests;
@@ -539,13 +601,15 @@
CLANG_ENABLE_MODULES = YES;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- DEVELOPMENT_TEAM = V4J43B279J;
+ DEAD_CODE_STRIPPING = YES;
+ DEVELOPMENT_TEAM = 4JSY8RYCTF;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = FluidDictationIntegrationTests;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
+ MACH_O_TYPE = mh_bundle;
MACOSX_DEPLOYMENT_TARGET = 14.0;
PRODUCT_BUNDLE_IDENTIFIER = com.FluidApp.FluidDictationIntegrationTests;
PRODUCT_NAME = FluidDictationIntegrationTests;
@@ -623,8 +687,8 @@
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/ejbills/mediaremote-adapter";
requirement = {
- kind = branch;
branch = master;
+ kind = branch;
};
};
7CE006BB2E80EBE600DDCCD6 /* XCRemoteSwiftPackageReference "AppUpdater" */ = {
@@ -635,6 +699,30 @@
minimumVersion = 1.1.1;
};
};
+ 95A3C0E52F31D77000829BE2 /* XCRemoteSwiftPackageReference "swift-algorithms" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/apple/swift-algorithms.git";
+ requirement = {
+ kind = upToNextMajorVersion;
+ minimumVersion = 1.2.1;
+ };
+ };
+ 95A3C0E62F31D7A300829BE2 /* XCRemoteSwiftPackageReference "swifter" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/httpswift/swifter.git";
+ requirement = {
+ kind = upToNextMajorVersion;
+ minimumVersion = 1.5.0;
+ };
+ };
+ 95A3C0E72F31D81400829BE2 /* XCRemoteSwiftPackageReference "swift-argument-parser" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/apple/swift-argument-parser.git";
+ requirement = {
+ kind = upToNextMajorVersion;
+ minimumVersion = 1.7.0;
+ };
+ };
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@@ -668,6 +756,11 @@
package = 7CE006BB2E80EBE600DDCCD6 /* XCRemoteSwiftPackageReference "AppUpdater" */;
productName = AppUpdater;
};
+ 95A3C0E82F31D9AE00829BE2 /* Swifter */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = 95A3C0E62F31D7A300829BE2 /* XCRemoteSwiftPackageReference "swifter" */;
+ productName = Swifter;
+ };
/* End XCSwiftPackageProductDependency section */
};
rootObject = 7C078D872E3B339200FB7CAC /* Project object */;
diff --git a/Fluid.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Fluid.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 5851468..74f602b 100644
--- a/Fluid.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Fluid.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,5 +1,5 @@
{
- "originHash" : "f521508b82d28f1791de94693be786cfcf7bd2e0f4ca46267c31e159f5bf10f3",
+ "originHash" : "c6729241885f4d1bb856fa4639ad35b52386520e8afbdadff060bb9c762d4015",
"pins" : [
{
"identity" : "appupdater",
@@ -73,6 +73,24 @@
"version" : "6.22.1"
}
},
+ {
+ "identity" : "swift-algorithms",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-algorithms.git",
+ "state" : {
+ "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023",
+ "version" : "1.2.1"
+ }
+ },
+ {
+ "identity" : "swift-argument-parser",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-argument-parser.git",
+ "state" : {
+ "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615",
+ "version" : "1.7.0"
+ }
+ },
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
@@ -100,6 +118,15 @@
"version" : "1.6.4"
}
},
+ {
+ "identity" : "swift-numerics",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-numerics.git",
+ "state" : {
+ "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2",
+ "version" : "1.1.1"
+ }
+ },
{
"identity" : "swift-sdk",
"kind" : "remoteSourceControl",
@@ -127,6 +154,15 @@
"version" : "1.1.6"
}
},
+ {
+ "identity" : "swifter",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/httpswift/swifter.git",
+ "state" : {
+ "revision" : "9483a5d459b45c3ffd059f7b55f9638e268632fd",
+ "version" : "1.5.0"
+ }
+ },
{
"identity" : "swiftwhisper",
"kind" : "remoteSourceControl",
diff --git a/Fluid.xcodeproj/xcshareddata/xcschemes/Fluid.xcscheme b/Fluid.xcodeproj/xcshareddata/xcschemes/Fluid.xcscheme
index be14e83..08a2541 100644
--- a/Fluid.xcodeproj/xcshareddata/xcschemes/Fluid.xcscheme
+++ b/Fluid.xcodeproj/xcshareddata/xcschemes/Fluid.xcscheme
@@ -1,6 +1,6 @@
CFBundleVersion
8
CFBundleShortVersionString
- 1.5.8
+ 1.6.0
LSMinimumSystemVersion
$(MACOSX_DEPLOYMENT_TARGET)
LSApplicationCategoryType
diff --git a/Package.swift b/Package.swift
index d9b86ef..58ca538 100644
--- a/Package.swift
+++ b/Package.swift
@@ -15,6 +15,7 @@ let package = Package(
.package(url: "https://github.com/MrKai77/DynamicNotchKit", from: "1.0.0"),
.package(url: "https://github.com/exPHAT/SwiftWhisper.git", branch: "master"),
.package(url: "https://github.com/PostHog/posthog-ios.git", from: "3.0.0"),
+ .package(url: "https://github.com/httpswift/swifter.git", from: "1.5.0"),
],
targets: [
.executableTarget(
@@ -26,6 +27,7 @@ let package = Package(
"DynamicNotchKit",
"SwiftWhisper",
.product(name: "PostHog", package: "posthog-ios"),
+ .product(name: "Swifter", package: "swifter"),
]
),
]
diff --git a/README.md b/README.md
index cfc777b..1c04a4e 100644
--- a/README.md
+++ b/README.md
@@ -56,6 +56,12 @@ https://github.com/user-attachments/assets/c57ef6d5-f0a1-4a3f-a121-637533442c24
- **Smart typing** directly into any app
- **Menu bar integration** for quick access
- **Auto-updates** with seamless restart
+- **Local API**: Automate transcriptions via HTTP requests
+
+## New Features (v1.6)
+- **Local Transcription API**: Full HTTP API for automating file transcriptions.
+- **Backlog Management**: View, manage, and clear transcription history directly in the app.
+- **VTT Output**: Export transcriptions as WebVTT subtitles.
## Supported Models
@@ -93,6 +99,13 @@ Universal support (runs on Intel & Apple Silicon). Supports 99 languages.
https://discord.gg/VUPHaKSvYV
+https://discord.gg/VUPHaKSvYV
+
+## API Documentation
+
+FluidVoice includes a fully functional local API for automation.
+👉 [Read the API Documentation](Documentation/API.md)
+
## Building from Source
```bash
diff --git a/Sources/Fluid/AppDelegate.swift b/Sources/Fluid/AppDelegate.swift
index 24bba4c..71a6887 100644
--- a/Sources/Fluid/AppDelegate.swift
+++ b/Sources/Fluid/AppDelegate.swift
@@ -248,33 +248,55 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
private func requestAccessibilityPermissions() {
+ print("[System Log] Requesting accessibility permissions...")
// Never show if already trusted
- guard !AXIsProcessTrusted() else { return }
+ let isTrusted = AXIsProcessTrusted()
+ print("[System Log] Currently trusted: \(isTrusted)")
+
+ guard !isTrusted else {
+ print("[System Log] Already trusted, skipping prompt.")
+ return
+ }
// Per-session debounce
- if AXPromptState.hasPromptedThisSession { return }
+ if AXPromptState.hasPromptedThisSession {
+ print("[System Log] Already prompted this session, skipping.")
+ return
+ }
// Cooldown: avoid re-prompting too often across launches
let cooldownKey = "AXLastPromptAt"
let now = Date().timeIntervalSince1970
let last = UserDefaults.standard.double(forKey: cooldownKey)
let oneDay: Double = 24 * 60 * 60
+
+ // Log the cooldown state
if last > 0, (now - last) < oneDay {
+ print("[System Log] Prompt is on cooldown (last: \(Date(timeIntervalSince1970: last))).")
+ DebugLogger.shared.info("Accessibility prompt on cooldown (last: \(Date(timeIntervalSince1970: last)))", source: "AppDelegate")
+ // For debugging, we might want to comment this out, but let's just log it first.
+ // If the user says "No log entry", it means this return hit silently.
+ // Now they will see it.
return
}
DebugLogger.shared.warning("Accessibility permissions required for global hotkeys.", source: "AppDelegate")
DebugLogger.shared.info("Prompting for Accessibility permission…", source: "AppDelegate")
+ print("[System Log] Calling AXIsProcessTrustedWithOptions...")
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary
- AXIsProcessTrustedWithOptions(options)
+ let result = AXIsProcessTrustedWithOptions(options)
+ print("[System Log] AXIsProcessTrustedWithOptions returned: \(result)")
AXPromptState.hasPromptedThisSession = true
UserDefaults.standard.set(now, forKey: cooldownKey)
// If still not trusted shortly after, deep-link to the Accessibility pane for convenience
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
- guard !AXIsProcessTrusted(),
+ let trustedAfter = AXIsProcessTrusted()
+ print("[System Log] Check after prompt: \(trustedAfter)")
+
+ guard !trustedAfter,
let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
else { return }
NSWorkspace.shared.open(url)
diff --git a/Sources/Fluid/Assets.xcassets/Provider_Anthropic.imageset/Contents.json b/Sources/Fluid/Assets.xcassets/Provider_Anthropic.imageset/Contents.json
index 1c4a4c4..c1fd129 100644
--- a/Sources/Fluid/Assets.xcassets/Provider_Anthropic.imageset/Contents.json
+++ b/Sources/Fluid/Assets.xcassets/Provider_Anthropic.imageset/Contents.json
@@ -1,7 +1,22 @@
{
"images" : [
- { "filename" : "logo.png", "idiom" : "universal", "scale" : "1x" },
- { "filename" : "logo@2x.png", "idiom" : "universal", "scale" : "2x" }
+ {
+ "filename" : "logo.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "logo@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
],
- "info" : { "author" : "xcode", "version" : 1 }
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
}
diff --git a/Sources/Fluid/Assets.xcassets/Provider_AppleIntelligence.imageset/Contents.json b/Sources/Fluid/Assets.xcassets/Provider_AppleIntelligence.imageset/Contents.json
index 1c4a4c4..c1fd129 100644
--- a/Sources/Fluid/Assets.xcassets/Provider_AppleIntelligence.imageset/Contents.json
+++ b/Sources/Fluid/Assets.xcassets/Provider_AppleIntelligence.imageset/Contents.json
@@ -1,7 +1,22 @@
{
"images" : [
- { "filename" : "logo.png", "idiom" : "universal", "scale" : "1x" },
- { "filename" : "logo@2x.png", "idiom" : "universal", "scale" : "2x" }
+ {
+ "filename" : "logo.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "logo@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
],
- "info" : { "author" : "xcode", "version" : 1 }
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
}
diff --git a/Sources/Fluid/Assets.xcassets/Provider_Cerebras.imageset/Contents.json b/Sources/Fluid/Assets.xcassets/Provider_Cerebras.imageset/Contents.json
index 1c4a4c4..c1fd129 100644
--- a/Sources/Fluid/Assets.xcassets/Provider_Cerebras.imageset/Contents.json
+++ b/Sources/Fluid/Assets.xcassets/Provider_Cerebras.imageset/Contents.json
@@ -1,7 +1,22 @@
{
"images" : [
- { "filename" : "logo.png", "idiom" : "universal", "scale" : "1x" },
- { "filename" : "logo@2x.png", "idiom" : "universal", "scale" : "2x" }
+ {
+ "filename" : "logo.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "logo@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
],
- "info" : { "author" : "xcode", "version" : 1 }
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
}
diff --git a/Sources/Fluid/Assets.xcassets/Provider_Compatible.imageset/Contents.json b/Sources/Fluid/Assets.xcassets/Provider_Compatible.imageset/Contents.json
index 1c4a4c4..c1fd129 100644
--- a/Sources/Fluid/Assets.xcassets/Provider_Compatible.imageset/Contents.json
+++ b/Sources/Fluid/Assets.xcassets/Provider_Compatible.imageset/Contents.json
@@ -1,7 +1,22 @@
{
"images" : [
- { "filename" : "logo.png", "idiom" : "universal", "scale" : "1x" },
- { "filename" : "logo@2x.png", "idiom" : "universal", "scale" : "2x" }
+ {
+ "filename" : "logo.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "logo@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
],
- "info" : { "author" : "xcode", "version" : 1 }
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
}
diff --git a/Sources/Fluid/Assets.xcassets/Provider_Fluid1.imageset/Contents.json b/Sources/Fluid/Assets.xcassets/Provider_Fluid1.imageset/Contents.json
index 1c4a4c4..c1fd129 100644
--- a/Sources/Fluid/Assets.xcassets/Provider_Fluid1.imageset/Contents.json
+++ b/Sources/Fluid/Assets.xcassets/Provider_Fluid1.imageset/Contents.json
@@ -1,7 +1,22 @@
{
"images" : [
- { "filename" : "logo.png", "idiom" : "universal", "scale" : "1x" },
- { "filename" : "logo@2x.png", "idiom" : "universal", "scale" : "2x" }
+ {
+ "filename" : "logo.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "logo@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
],
- "info" : { "author" : "xcode", "version" : 1 }
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
}
diff --git a/Sources/Fluid/Assets.xcassets/Provider_Gemini.imageset/Contents.json b/Sources/Fluid/Assets.xcassets/Provider_Gemini.imageset/Contents.json
index 1c4a4c4..c1fd129 100644
--- a/Sources/Fluid/Assets.xcassets/Provider_Gemini.imageset/Contents.json
+++ b/Sources/Fluid/Assets.xcassets/Provider_Gemini.imageset/Contents.json
@@ -1,7 +1,22 @@
{
"images" : [
- { "filename" : "logo.png", "idiom" : "universal", "scale" : "1x" },
- { "filename" : "logo@2x.png", "idiom" : "universal", "scale" : "2x" }
+ {
+ "filename" : "logo.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "logo@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
],
- "info" : { "author" : "xcode", "version" : 1 }
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
}
diff --git a/Sources/Fluid/Assets.xcassets/Provider_Groq.imageset/Contents.json b/Sources/Fluid/Assets.xcassets/Provider_Groq.imageset/Contents.json
index 1c4a4c4..c1fd129 100644
--- a/Sources/Fluid/Assets.xcassets/Provider_Groq.imageset/Contents.json
+++ b/Sources/Fluid/Assets.xcassets/Provider_Groq.imageset/Contents.json
@@ -1,7 +1,22 @@
{
"images" : [
- { "filename" : "logo.png", "idiom" : "universal", "scale" : "1x" },
- { "filename" : "logo@2x.png", "idiom" : "universal", "scale" : "2x" }
+ {
+ "filename" : "logo.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "logo@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
],
- "info" : { "author" : "xcode", "version" : 1 }
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
}
diff --git a/Sources/Fluid/Assets.xcassets/Provider_LMStudio.imageset/Contents.json b/Sources/Fluid/Assets.xcassets/Provider_LMStudio.imageset/Contents.json
index 1c4a4c4..c1fd129 100644
--- a/Sources/Fluid/Assets.xcassets/Provider_LMStudio.imageset/Contents.json
+++ b/Sources/Fluid/Assets.xcassets/Provider_LMStudio.imageset/Contents.json
@@ -1,7 +1,22 @@
{
"images" : [
- { "filename" : "logo.png", "idiom" : "universal", "scale" : "1x" },
- { "filename" : "logo@2x.png", "idiom" : "universal", "scale" : "2x" }
+ {
+ "filename" : "logo.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "logo@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
],
- "info" : { "author" : "xcode", "version" : 1 }
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
}
diff --git a/Sources/Fluid/Assets.xcassets/Provider_Ollama.imageset/Contents.json b/Sources/Fluid/Assets.xcassets/Provider_Ollama.imageset/Contents.json
index 1c4a4c4..c1fd129 100644
--- a/Sources/Fluid/Assets.xcassets/Provider_Ollama.imageset/Contents.json
+++ b/Sources/Fluid/Assets.xcassets/Provider_Ollama.imageset/Contents.json
@@ -1,7 +1,22 @@
{
"images" : [
- { "filename" : "logo.png", "idiom" : "universal", "scale" : "1x" },
- { "filename" : "logo@2x.png", "idiom" : "universal", "scale" : "2x" }
+ {
+ "filename" : "logo.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "logo@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
],
- "info" : { "author" : "xcode", "version" : 1 }
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
}
diff --git a/Sources/Fluid/Assets.xcassets/Provider_OpenAI.imageset/Contents.json b/Sources/Fluid/Assets.xcassets/Provider_OpenAI.imageset/Contents.json
index 1c4a4c4..c1fd129 100644
--- a/Sources/Fluid/Assets.xcassets/Provider_OpenAI.imageset/Contents.json
+++ b/Sources/Fluid/Assets.xcassets/Provider_OpenAI.imageset/Contents.json
@@ -1,7 +1,22 @@
{
"images" : [
- { "filename" : "logo.png", "idiom" : "universal", "scale" : "1x" },
- { "filename" : "logo@2x.png", "idiom" : "universal", "scale" : "2x" }
+ {
+ "filename" : "logo.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "logo@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
],
- "info" : { "author" : "xcode", "version" : 1 }
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
}
diff --git a/Sources/Fluid/Assets.xcassets/Provider_OpenRouter.imageset/Contents.json b/Sources/Fluid/Assets.xcassets/Provider_OpenRouter.imageset/Contents.json
index 1c4a4c4..c1fd129 100644
--- a/Sources/Fluid/Assets.xcassets/Provider_OpenRouter.imageset/Contents.json
+++ b/Sources/Fluid/Assets.xcassets/Provider_OpenRouter.imageset/Contents.json
@@ -1,7 +1,22 @@
{
"images" : [
- { "filename" : "logo.png", "idiom" : "universal", "scale" : "1x" },
- { "filename" : "logo@2x.png", "idiom" : "universal", "scale" : "2x" }
+ {
+ "filename" : "logo.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "logo@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
],
- "info" : { "author" : "xcode", "version" : 1 }
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
}
diff --git a/Sources/Fluid/Assets.xcassets/Provider_xAI.imageset/Contents.json b/Sources/Fluid/Assets.xcassets/Provider_xAI.imageset/Contents.json
index 1c4a4c4..c1fd129 100644
--- a/Sources/Fluid/Assets.xcassets/Provider_xAI.imageset/Contents.json
+++ b/Sources/Fluid/Assets.xcassets/Provider_xAI.imageset/Contents.json
@@ -1,7 +1,22 @@
{
"images" : [
- { "filename" : "logo.png", "idiom" : "universal", "scale" : "1x" },
- { "filename" : "logo@2x.png", "idiom" : "universal", "scale" : "2x" }
+ {
+ "filename" : "logo.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "logo@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
],
- "info" : { "author" : "xcode", "version" : 1 }
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
}
diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift
index 6a74413..090eb39 100644
--- a/Sources/Fluid/ContentView.swift
+++ b/Sources/Fluid/ContentView.swift
@@ -206,6 +206,8 @@ struct ContentView: View {
// Now it's safe to access services (they'll be lazily created)
self.audioObserver.startObserving()
self.asr.initialize()
+ _ = self.appServices.backlogManager // Trigger lazy initialization coverage
+ _ = self.appServices.apiService // Trigger lazy initialization and server start
// Configure menu bar manager with ASR service AFTER services are initialized
self.menuBarManager.configure(asrService: self.appServices.asr)
diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift
index 805adb8..f6ac480 100644
--- a/Sources/Fluid/Persistence/SettingsStore.swift
+++ b/Sources/Fluid/Persistence/SettingsStore.swift
@@ -87,6 +87,11 @@ final class SettingsStore: ObservableObject {
static let fillerWords = "FillerWords"
static let removeFillerWordsEnabled = "RemoveFillerWordsEnabled"
+ // API Settings
+ static let enableAPI = "EnableAPI"
+ static let apiPort = "APIPort"
+ static let apiBacklogLimit = "APIBacklogLimit"
+
// GAAV Mode (removes capitalization and trailing punctuation)
static let gaavModeEnabled = "GAAVModeEnabled"
@@ -747,6 +752,46 @@ final class SettingsStore: ObservableObject {
}
}
+ // MARK: - API Settings
+
+ var enableAPI: Bool {
+ get {
+ let value = self.defaults.object(forKey: Keys.enableAPI)
+ if value == nil { return true }
+ return self.defaults.bool(forKey: Keys.enableAPI)
+ }
+ set {
+ objectWillChange.send()
+ self.defaults.set(newValue, forKey: Keys.enableAPI)
+ // Post notification for APIService to observe
+ NotificationCenter.default.post(name: NSNotification.Name("EnableAPIChanged"), object: nil)
+ }
+ }
+
+ var apiPort: UInt16 {
+ get {
+ let value = self.defaults.integer(forKey: Keys.apiPort)
+ return value == 0 ? 7086 : UInt16(value)
+ }
+ set {
+ objectWillChange.send()
+ self.defaults.set(newValue, forKey: Keys.apiPort)
+ // Post notification for APIService to observe (port change requires restart)
+ NotificationCenter.default.post(name: NSNotification.Name("EnableAPIChanged"), object: nil)
+ }
+ }
+
+ var apiBacklogLimit: Int {
+ get {
+ let value = self.defaults.integer(forKey: Keys.apiBacklogLimit)
+ return value == 0 ? 50 : value // Default to 50 items
+ }
+ set {
+ objectWillChange.send()
+ self.defaults.set(newValue, forKey: Keys.apiBacklogLimit)
+ }
+ }
+
var availableModels: [String] {
get { (self.defaults.array(forKey: Keys.availableAIModels) as? [String]) ?? [] }
set {
diff --git a/Sources/Fluid/Services/APIService.swift b/Sources/Fluid/Services/APIService.swift
new file mode 100644
index 0000000..8adfabc
--- /dev/null
+++ b/Sources/Fluid/Services/APIService.swift
@@ -0,0 +1,399 @@
+import Combine
+import Foundation
+import Swifter
+
+@MainActor
+final class APIService: ObservableObject {
+ static let shared = APIService()
+
+ private let server = HttpServer()
+ private var port: UInt16 = 7086
+ private var isStarted = false
+
+ /// Helper to execute MainActor code synchronously from a background thread
+ private func runOnMain(_ block: @MainActor @escaping () -> T) -> T? {
+ var result: T?
+ let semaphore = DispatchSemaphore(value: 0)
+
+ Task { @MainActor in
+ result = block()
+ semaphore.signal()
+ }
+
+ let timeout = semaphore.wait(timeout: .now() + 10.0)
+ if timeout == .timedOut {
+ DebugLogger.shared.error("runOnMain timed out", source: "APIService")
+ return nil
+ }
+ return result
+ }
+
+ /// Helper for Void returns
+ private func runOnMainVoid(_ block: @MainActor @escaping () -> Void) {
+ let semaphore = DispatchSemaphore(value: 0)
+
+ Task { @MainActor in
+ block()
+ semaphore.signal()
+ }
+
+ _ = semaphore.wait(timeout: .now() + 10.0)
+ }
+
+ private init() {
+ self.setupRoutes()
+
+ // Observe settings change
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(self.handleSettingsChange),
+ name: NSNotification.Name("EnableAPIChanged"),
+ object: nil
+ )
+ }
+
+ @objc private func handleSettingsChange() {
+ if SettingsStore.shared.enableAPI {
+ self.start()
+ } else {
+ self.stop()
+ }
+ }
+
+ func initialize() {
+ self.port = SettingsStore.shared.apiPort
+ if SettingsStore.shared.enableAPI {
+ self.start()
+ }
+ }
+
+ private func start() {
+ guard !self.isStarted else { return }
+ do {
+ self.server.listenAddressIPv4 = "127.0.0.1"
+ try self.server.start(self.port, forceIPv4: true)
+ self.isStarted = true
+ DebugLogger.shared.info("APIService started on 127.0.0.1:\(self.port)", source: "APIService")
+ } catch {
+ DebugLogger.shared.error("Failed to start APIService: \(error)", source: "APIService")
+ }
+ }
+
+ private func stop() {
+ guard self.isStarted else { return }
+ self.server.stop()
+ self.isStarted = false
+ DebugLogger.shared.info("APIService stopped", source: "APIService")
+ }
+
+ private func setupRoutes() {
+ // middleware to log requests
+ self.server.middleware.append { _ in
+ // Logging on background thread is fine if logger is thread safe
+ // We'll skip jumping to main for logging
+ nil
+ }
+
+ // POST /transcribe
+ self.server.POST["/transcribe"] = { [weak self] request in
+ guard let self else { return .internalServerError }
+
+ // Allow JSON body
+ let data = Data(request.body)
+ guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let urlString = json["url"] as? String,
+ let url = URL(string: urlString)
+ else {
+ return .badRequest(.text("Invalid JSON or missing 'url' field"))
+ }
+
+ let modelId = json["model"] as? String
+
+ // Fire and forget (or wait if we want to confirm it's queued)
+ // We'll wait to ensure it's in the queue before returning
+ let job = self.runOnMain {
+ BacklogManager.shared.addJob(url: url, modelId: modelId)
+ }
+
+ guard let job else { return .internalServerError }
+
+ return .ok(.json(["id": job.id, "url": url.absoluteString, "status": "pending"]))
+ }
+
+ // GET /status
+ self.server.GET["/status"] = { [weak self] request in
+ guard let self else { return .internalServerError }
+
+ // 1. Try ID-based lookup first
+ if let id = request.queryParams.first(where: { $0.0 == "id" })?.1 {
+ let result = self.runOnMain {
+ guard let job = BacklogManager.shared.getJob(id: id) else {
+ return HttpResponse.notFound
+ }
+
+ var response: [String: Any] = [
+ "id": job.id,
+ "status": job.status.rawValue,
+ "created_at": job.createdAt.timeIntervalSince1970,
+ ]
+
+ if let model = job.modelId {
+ response["model"] = model
+ }
+
+ if let duration = job.processingDuration {
+ response["processing_duration"] = duration
+ }
+
+ if let error = job.error {
+ response["error"] = error
+ }
+
+ // Also include the URL for reference
+ response["url"] = job.fileURL.absoluteString
+
+ return .ok(.json(response))
+ }
+ return result ?? .internalServerError
+ }
+
+ // 2. Fallback to URL-based lookup
+ guard let urlString = request.queryParams.first(where: { $0.0 == "url" })?.1,
+ let url = URL(string: urlString)
+ else {
+ return .badRequest(.text("Missing 'id' or 'url' query parameter"))
+ }
+
+ let modelId = request.queryParams.first(where: { $0.0 == "model" })?.1
+
+ let result = self.runOnMain {
+ let jobs = BacklogManager.shared.getJobs(for: url)
+ var matches = jobs
+
+ if let model = modelId {
+ matches = matches.filter { $0.modelId == model }
+ } else if jobs.count > 1 {
+ // Ambiguous request (multiple jobs exist but no model specified)
+ let choices = jobs.map { [
+ "id": $0.id,
+ "model": $0.modelId ?? "default",
+ "status": $0.status.rawValue,
+ ] }
+ return HttpResponse.raw(409, "Conflict", ["Content-Type": "application/json"]) { writer in
+ let json = ["error": "Ambiguous request. Multiple transcriptions found for this URL. Please specify a 'model' parameter.", "choices": choices] as [String: Any]
+ try? writer.write(JSONSerialization.data(withJSONObject: json))
+ }
+ }
+
+ guard let job = matches.first else {
+ return .notFound
+ }
+
+ var response: [String: Any] = [
+ "id": job.id,
+ "status": job.status.rawValue,
+ "created_at": job.createdAt.timeIntervalSince1970,
+ ]
+
+ if let model = job.modelId {
+ response["model"] = model
+ }
+
+ if let duration = job.processingDuration {
+ response["processing_duration"] = duration
+ }
+
+ if let error = job.error {
+ response["error"] = error
+ }
+
+ return .ok(.json(response))
+ }
+ return result ?? .internalServerError
+ }
+
+ // GET /result
+ self.server.GET["/result"] = { [weak self] request in
+ guard let self else { return .internalServerError }
+
+ let format = request.queryParams.first(where: { $0.0 == "format" })?.1 ?? "text"
+
+ // 1. Try based on ID
+ if let id = request.queryParams.first(where: { $0.0 == "id" })?.1 {
+ let result = self.runOnMain {
+ guard let job = BacklogManager.shared.getJob(id: id) else {
+ return HttpResponse.notFound
+ }
+
+ if job.status != .completed {
+ return .badRequest(.text("Job not completed"))
+ }
+
+ guard let text = job.resultText else {
+ return .internalServerError
+ }
+
+ if format == "vtt" {
+ let vtt = """
+ WEBVTT
+
+ 00:00:00.000 --> 00:00:10.000
+ \(text)
+ """
+ return .ok(.text(vtt))
+ } else {
+ return .ok(.text(text))
+ }
+ }
+ return result ?? .internalServerError
+ }
+
+ // 2. Fallback to URL
+ guard let urlString = request.queryParams.first(where: { $0.0 == "url" })?.1,
+ let url = URL(string: urlString)
+ else {
+ return .badRequest(.text("Missing 'id' or 'url' query parameter"))
+ }
+
+ let modelId = request.queryParams.first(where: { $0.0 == "model" })?.1
+
+ let result = self.runOnMain {
+ let jobs = BacklogManager.shared.getJobs(for: url)
+ var matches = jobs
+
+ if let model = modelId {
+ matches = matches.filter { $0.modelId == model }
+ } else if jobs.count > 1 {
+ let choices = jobs.map { [
+ "id": $0.id,
+ "model": $0.modelId ?? "default",
+ "status": $0.status.rawValue,
+ ] }
+ return HttpResponse.raw(409, "Conflict", ["Content-Type": "application/json"]) { writer in
+ let json = ["error": "Ambiguous request. Multiple transcriptions found for this URL. Please specify a 'model' parameter.", "choices": choices] as [String: Any]
+ try? writer.write(JSONSerialization.data(withJSONObject: json))
+ }
+ }
+
+ guard let job = matches.first else {
+ return .notFound
+ }
+
+ if job.status != .completed {
+ return .badRequest(.text("Job not completed"))
+ }
+
+ guard let text = job.resultText else {
+ return .internalServerError
+ }
+
+ if format == "vtt" {
+ let vtt = """
+ WEBVTT
+
+ 00:00:00.000 --> 00:00:10.000
+ \(text)
+ """
+ return .ok(.text(vtt))
+ } else {
+ return .ok(.text(text))
+ }
+ }
+ return result ?? .internalServerError
+ }
+
+ // DELETE /backlog
+ self.server.DELETE["/backlog"] = { [weak self] request in
+ guard let self else { return .internalServerError }
+
+ // 1. Try ID
+ if let id = request.queryParams.first(where: { $0.0 == "id" })?.1 {
+ let result = self.runOnMain {
+ guard BacklogManager.shared.getJob(id: id) != nil else {
+ return HttpResponse.notFound
+ }
+ BacklogManager.shared.deleteJob(id: id)
+ return .ok(.text("Deleted"))
+ }
+ return result ?? .internalServerError
+ }
+
+ // 2. Fallback to URL
+ guard let urlString = request.queryParams.first(where: { $0.0 == "url" })?.1,
+ let url = URL(string: urlString)
+ else {
+ return .badRequest(.text("Missing 'id' or 'url' query parameter"))
+ }
+
+ let modelId = request.queryParams.first(where: { $0.0 == "model" })?.1
+
+ let result = self.runOnMain {
+ let jobs = BacklogManager.shared.getJobs(for: url)
+ var matches = jobs
+
+ if let model = modelId {
+ matches = matches.filter { $0.modelId == model }
+ } else if jobs.count > 1 {
+ let choices = jobs.map { [
+ "id": $0.id,
+ "model": $0.modelId ?? "default",
+ "status": $0.status.rawValue,
+ ] }
+ return HttpResponse.raw(409, "Conflict", ["Content-Type": "application/json"]) { writer in
+ let json = ["error": "Ambiguous request. Multiple transcriptions found for this URL. Please specify a 'model' parameter.", "choices": choices] as [String: Any]
+ try? writer.write(JSONSerialization.data(withJSONObject: json))
+ }
+ }
+
+ guard let job = matches.first else {
+ return .notFound
+ }
+
+ BacklogManager.shared.deleteJob(id: job.id)
+ return .ok(.text("Deleted"))
+ }
+ return result ?? .internalServerError
+ }
+
+ // GET /list
+ self.server.GET["/list"] = { [weak self] _ in
+ guard let self else { return .internalServerError }
+
+ let jobs = self.runOnMain { BacklogManager.shared.jobs } ?? []
+ let list = jobs.map { job -> [String: Any] in
+ var dict: [String: Any] = [
+ "id": job.id,
+ "status": job.status.rawValue,
+ "url": job.fileURL.absoluteString,
+ ]
+ if let model = job.modelId {
+ dict["model"] = model
+ }
+ if let duration = job.processingDuration {
+ dict["processing_duration"] = duration
+ }
+ if let error = job.error {
+ dict["error"] = error
+ }
+ return dict
+ }
+
+ return .ok(.json(list))
+ }
+
+ // GET /models
+ self.server.GET["/models"] = { [weak self] _ in
+ guard let self else { return .internalServerError }
+
+ let models = self.runOnMain {
+ // Return available transcription models
+ SettingsStore.SpeechModel.availableModels
+ .filter { $0.isInstalled }
+ .map(\.id)
+ .sorted()
+ } ?? []
+
+ return .ok(.json(["models": models]))
+ }
+ }
+}
diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift
index 81b72c2..c1ad015 100644
--- a/Sources/Fluid/Services/ASRService.swift
+++ b/Sources/Fluid/Services/ASRService.swift
@@ -234,7 +234,7 @@ final class ASRService: ObservableObject {
/// Gets a provider for a specific model (without changing the active selection)
/// Used for downloading models without switching the active model.
- private func getProvider(for model: SettingsStore.SpeechModel) -> TranscriptionProvider {
+ func getProvider(for model: SettingsStore.SpeechModel) -> TranscriptionProvider {
switch model {
case .appleSpeechAnalyzer:
if #available(macOS 26.0, *) {
diff --git a/Sources/Fluid/Services/AppServices.swift b/Sources/Fluid/Services/AppServices.swift
index 0c73e02..c9c55c8 100644
--- a/Sources/Fluid/Services/AppServices.swift
+++ b/Sources/Fluid/Services/AppServices.swift
@@ -68,6 +68,35 @@ final class AppServices: ObservableObject {
return service
}
+ /// Meeting Transcription Service (lazily initialized)
+ private var _meetingTranscriptionService: MeetingTranscriptionService?
+ var meetingTranscriptionService: MeetingTranscriptionService {
+ if let existing = self._meetingTranscriptionService {
+ return existing
+ }
+ DebugLogger.shared.info("📝 Lazily creating MeetingTranscriptionService", source: "AppServices")
+ let service = MeetingTranscriptionService(asrService: self.asr)
+ self._meetingTranscriptionService = service
+ return service
+ }
+
+ /// Backlog manager for file transcription (lazily initialized)
+ var backlogManager: BacklogManager {
+ let manager = BacklogManager.shared
+ // Ensure it's configured with a transcription service (singleton configuration)
+ // We check if it's already configured inside, or just re-configure harmlessly
+ // efficiently we just pass our stable reference
+ manager.configure(with: self.meetingTranscriptionService)
+ return manager
+ }
+
+ /// API Service (lazily initialized)
+ var apiService: APIService {
+ let service = APIService.shared
+ service.initialize()
+ return service
+ }
+
private var cancellables = Set()
private init() {
@@ -110,6 +139,8 @@ final class AppServices: ObservableObject {
// Access the properties to trigger lazy initialization
_ = self.audioObserver
_ = self.asr
+ _ = self.backlogManager
+ _ = self.apiService
DebugLogger.shared.info("✅ All services initialized", source: "AppServices")
}
diff --git a/Sources/Fluid/Services/BacklogManager.swift b/Sources/Fluid/Services/BacklogManager.swift
new file mode 100644
index 0000000..9ae040d
--- /dev/null
+++ b/Sources/Fluid/Services/BacklogManager.swift
@@ -0,0 +1,286 @@
+import Foundation
+import Combine
+import FluidAudio
+
+/// Represents a file transcription job
+struct TranscriptionJob: Identifiable, Codable, Equatable {
+ var id: String = UUID().uuidString
+ let fileURL: URL
+ let createdAt: Date
+ var status: JobStatus
+ var modelId: String? // User-specified model ID
+ var resultText: String?
+ var error: String?
+ var processingDuration: TimeInterval?
+
+ enum JobStatus: String, Codable, Equatable {
+ case pending
+ case processing
+ case completed
+ case failed
+ }
+
+ init(fileURL: URL, modelId: String? = nil) {
+ self.fileURL = fileURL
+ self.createdAt = Date()
+ self.status = .pending
+ self.modelId = modelId
+ }
+
+ // Migration for old data matching
+ private enum CodingKeys: String, CodingKey {
+ case id, fileURL, createdAt, status, modelId, resultText, error, processingDuration
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.fileURL = try container.decode(URL.self, forKey: .fileURL)
+ self.createdAt = try container.decode(Date.self, forKey: .createdAt)
+ self.status = try container.decode(JobStatus.self, forKey: .status)
+ self.modelId = try container.decodeIfPresent(String.self, forKey: .modelId)
+ self.resultText = try container.decodeIfPresent(String.self, forKey: .resultText)
+ self.error = try container.decodeIfPresent(String.self, forKey: .error)
+ self.processingDuration = try container.decodeIfPresent(TimeInterval.self, forKey: .processingDuration)
+
+ // Use existing ID or generate new one for legacy entries
+ self.id = try container.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(id, forKey: .id)
+ try container.encode(fileURL, forKey: .fileURL)
+ try container.encode(createdAt, forKey: .createdAt)
+ try container.encode(status, forKey: .status)
+ try container.encodeIfPresent(modelId, forKey: .modelId)
+ try container.encodeIfPresent(resultText, forKey: .resultText)
+ try container.encodeIfPresent(error, forKey: .error)
+ try container.encodeIfPresent(processingDuration, forKey: .processingDuration)
+ }
+}
+
+@MainActor
+final class BacklogManager: ObservableObject {
+ static let shared = BacklogManager()
+
+ @Published private(set) var jobs: [TranscriptionJob] = []
+ @Published var isProcessing: Bool = false
+
+ private let defaults = UserDefaults.standard
+ private let storageKey = "TranscriptionBacklog"
+
+ // Dependencies
+ private var transcriptionService: MeetingTranscriptionService?
+
+ private init() {
+ loadJobs()
+ }
+
+ func configure(with service: MeetingTranscriptionService) {
+ self.transcriptionService = service
+ self.processNextJob() // Check if we should start processing
+ }
+
+ // MARK: - Job Management
+
+ @discardableResult
+ func addJob(url: URL, modelId: String?) -> TranscriptionJob {
+ // Check for existing job with same URL AND same model
+ // If modelId is nil, we treat it as "default/unspecified".
+ // We only block if there's an exact match on (URL, modelId).
+
+ if let existing = jobs.first(where: { job in
+ job.fileURL == url && job.modelId == modelId
+ }) {
+ DebugLogger.shared.info("Skipping duplicate job for \(url.lastPathComponent) (model: \(modelId ?? "nil"))", source: "BacklogManager")
+ return existing
+ }
+
+ let job = TranscriptionJob(fileURL: url, modelId: modelId)
+ jobs.append(job)
+ saveJobs()
+
+ DebugLogger.shared.info("Added transcription job: \(url.lastPathComponent) (model: \(modelId ?? "default"))", source: "BacklogManager")
+ processNextJob()
+ return job
+ }
+
+ /// Returns all jobs matching the given URL.
+ func getJobs(for url: URL) -> [TranscriptionJob] {
+ return jobs.filter { $0.fileURL == url }
+ }
+
+ /// Returns a specific job by ID.
+ func getJob(id: String) -> TranscriptionJob? {
+ return jobs.first(where: { $0.id == id })
+ }
+
+ // Helper for legacy lookup (returns first match if multiple, but ideally shouldn't be used for strict logic)
+ func getJob(url: URL) -> TranscriptionJob? {
+ jobs.first(where: { $0.fileURL == url })
+ }
+
+ func deleteJob(id: String) {
+ jobs.removeAll(where: { $0.id == id })
+ saveJobs()
+ DebugLogger.shared.info("Deleted transcription job: \(id)", source: "BacklogManager")
+ }
+
+ func deleteJobs(url: URL) {
+ jobs.removeAll(where: { $0.fileURL == url })
+ saveJobs()
+ DebugLogger.shared.info("Deleted all transcription jobs for: \(url.lastPathComponent)", source: "BacklogManager")
+ }
+
+ func clearCompleted() {
+ jobs.removeAll(where: { $0.status == .completed || $0.status == .failed })
+ saveJobs()
+ }
+
+ // MARK: - Processing
+
+ private func processNextJob() {
+ guard !isProcessing, let service = transcriptionService else { return }
+
+ // Find next pending job
+ guard let index = jobs.firstIndex(where: { $0.status == .pending }) else { return }
+
+ // Check for max items limit (FIFO: If we exceed limit, we might want to prune completed/failed logic, but for now we just process pending)
+ // User asked for "setting for a number of items to maintain in backlog".
+ // We should enforce that on add or periodically. Let's enforce on complete.
+
+ isProcessing = true
+ var job = jobs[index]
+ job.status = .processing
+ jobs[index] = job
+ saveJobs() // Pending -> Processing
+
+ DebugLogger.shared.info("Starting transcription for: \(job.fileURL.lastPathComponent)", source: "BacklogManager")
+
+ Task(priority: .userInitiated) {
+ var fileToTranscribe = job.fileURL
+ var tempDownloadURL: URL? = nil
+
+ // Handle HTTP/HTTPS URLs - Download to temp file
+ if job.fileURL.scheme?.lowercased() == "http" || job.fileURL.scheme?.lowercased() == "https" {
+ DebugLogger.shared.info("Downloading remote file: \(job.fileURL)", source: "BacklogManager")
+ do {
+ let (tempURL, response) = try await URLSession.shared.download(from: job.fileURL)
+
+ if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) {
+ throw NSError(domain: "BacklogManager", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: "HTTP Download failed with status code: \(httpResponse.statusCode)"])
+ }
+
+ let ext = job.fileURL.pathExtension.isEmpty ? "mp3" : job.fileURL.pathExtension
+ let destURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).appendingPathExtension(ext)
+ try FileManager.default.moveItem(at: tempURL, to: destURL)
+
+ fileToTranscribe = destURL
+ tempDownloadURL = destURL
+ DebugLogger.shared.info("Downloaded to: \(destURL.path)", source: "BacklogManager")
+ } catch {
+ DebugLogger.shared.error("Download failed: \(error)", source: "BacklogManager")
+ await MainActor.run {
+ if let index = self.jobs.firstIndex(where: { $0.id == job.id }) {
+ self.jobs[index].status = .failed
+ self.jobs[index].error = "Download failed: \(error.localizedDescription)"
+ self.saveJobs()
+ }
+ self.isProcessing = false
+ self.processNextJob()
+ }
+ return
+ }
+ }
+
+ // Cleanup temp file
+ defer {
+ if let tempURL = tempDownloadURL {
+ try? FileManager.default.removeItem(at: tempURL)
+ }
+ }
+
+ do {
+ // If job specifies a model, we might need to switch models?
+ // The current MeetingTranscriptionService uses the globally selected model in ASRService.
+ // Switching models globally might be disruptive if the user is using the app.
+ // However, the request implies "model to transcribe".
+ // We should probably temporarily switch or pass the model to the service.
+ // Looking at MeetingTranscriptionService, it uses `asrService.fileTranscriptionProvider`.
+ // ASRService has `downloadModel` and `getProvider` but currently `transcriptionProvider` is computed from SettingsStore.
+ // To support per-job model, we'd need to extend MeetingTranscriptionService or ASRService to accept a model override.
+ // For now, let's assume we use the current model if not specified, or try to switch if specified.
+
+ // NOTE: Swapping global model is risky. Ideally ASRService allows transient provider usage.
+
+
+ // Track timing
+ let startTime = Date()
+ // Use the modelId from the job if present, otherwise nil (defaulting to global)
+ let result = try await service.transcribeFile(fileToTranscribe, modelId: job.modelId)
+ let endTime = Date()
+ let duration = endTime.timeIntervalSince(startTime)
+
+ await MainActor.run {
+ if let index = self.jobs.firstIndex(where: { $0.id == job.id }) {
+ self.jobs[index].status = .completed
+ self.jobs[index].resultText = result.text
+ self.jobs[index].processingDuration = duration
+ // If modelId was nil, we could potentially update it here if the service returned which model was used
+ // different logic might be needed if service.transcribeFile returns metadata
+ self.saveJobs()
+ }
+ self.isProcessing = false
+ self.pruneBacklog()
+ self.processNextJob() // Loop
+ }
+ } catch {
+ await MainActor.run {
+ DebugLogger.shared.error("Backlog processing failed: \(error.localizedDescription)", source: "BacklogManager")
+ if let index = self.jobs.firstIndex(where: { $0.id == job.id }) {
+ self.jobs[index].status = .failed
+ self.jobs[index].error = error.localizedDescription
+ self.saveJobs()
+ }
+ self.isProcessing = false
+ self.processNextJob() // Loop
+ }
+ }
+ }
+ }
+
+ private func pruneBacklog() {
+ let maxItems = SettingsStore.shared.apiBacklogLimit
+ guard jobs.count > maxItems else { return }
+
+ // Remove oldest completed/failed jobs first
+ // If still too many, remove oldest pending (FIFO)? Or just refuse to add?
+ // User said "setting for a number of items to maintain in backlog". Usually implies retention policy.
+ // We'll remove oldest completed jobs.
+
+ let completed = jobs.filter { $0.status == .completed || $0.status == .failed }.sorted { $0.createdAt < $1.createdAt }
+
+ if let toRemove = completed.first {
+ jobs.removeAll(where: { $0.id == toRemove.id })
+ saveJobs()
+ // Recurse if still over limit
+ if jobs.count > maxItems {
+ pruneBacklog()
+ }
+ }
+ }
+
+ // MARK: - Persistence
+
+ private func loadJobs() {
+ guard let data = defaults.data(forKey: storageKey),
+ let decoded = try? JSONDecoder().decode([TranscriptionJob].self, from: data) else { return }
+ self.jobs = decoded
+ }
+
+ private func saveJobs() {
+ if let encoded = try? JSONEncoder().encode(jobs) {
+ defaults.set(encoded, forKey: storageKey)
+ }
+ }
+}
diff --git a/Sources/Fluid/Services/FileLogger.swift b/Sources/Fluid/Services/FileLogger.swift
index ec2bd00..4c1f2c2 100644
--- a/Sources/Fluid/Services/FileLogger.swift
+++ b/Sources/Fluid/Services/FileLogger.swift
@@ -30,6 +30,9 @@ final class FileLogger {
self.backupLogURL = self.logDirectory.appendingPathComponent("Fluid.log.1", isDirectory: false)
self.legacyLogFileURL = self.logDirectory.appendingPathComponent("fluid.log", isDirectory: false)
self.legacyBackupLogURL = self.logDirectory.appendingPathComponent("fluid.log.1", isDirectory: false)
+
+ // Print the log path to stdout so the user can see it in Console.app
+ print("[System Log] FileLogger writing to: \(self.logFileURL.path)")
self.queue.sync {
self.createLogDirectoryIfNeeded()
diff --git a/Sources/Fluid/Services/MeetingTranscriptionService.swift b/Sources/Fluid/Services/MeetingTranscriptionService.swift
index 22a9371..9de87de 100644
--- a/Sources/Fluid/Services/MeetingTranscriptionService.swift
+++ b/Sources/Fluid/Services/MeetingTranscriptionService.swift
@@ -95,7 +95,8 @@ final class MeetingTranscriptionService: ObservableObject {
/// Transcribe an audio or video file
/// - Parameters:
/// - fileURL: URL to the audio/video file
- func transcribeFile(_ fileURL: URL) async throws -> TranscriptionResult {
+ /// - modelId: Optional specific model ID to use
+ func transcribeFile(_ fileURL: URL, modelId: String? = nil) async throws -> TranscriptionResult {
self.isTranscribing = true
error = nil
self.progress = 0.0
@@ -107,15 +108,65 @@ final class MeetingTranscriptionService: ObservableObject {
}
do {
- // Initialize models if not already done (reuses ASRService models)
- if !self.asrService.isAsrReady {
- try await self.initializeModels()
+ var selectedModel: SettingsStore.SpeechModel? = nil
+
+ // Resolve specific model if requested
+ if let modelId = modelId {
+ if let model = SettingsStore.SpeechModel.availableModels.first(where: { $0.id == modelId }) {
+ selectedModel = model
+ DebugLogger.shared.info("Specific model requested: \(model.displayName) (ID: \(modelId))", source: "MeetingTranscriptionService")
+ } else {
+ DebugLogger.shared.warning("Requested model '\(modelId)' not found/available. Falling back to default.", source: "MeetingTranscriptionService")
+ }
+ } else {
+ DebugLogger.shared.info("No specific model requested, using default.", source: "MeetingTranscriptionService")
+ }
+
+ // Determine effective provider
+ let provider: TranscriptionProvider
+
+ if let model = selectedModel {
+ let globalId = SettingsStore.shared.selectedSpeechModel.id
+ DebugLogger.shared.info("[Debug] Request ID: '\(model.id)' vs Global ID: '\(globalId)'", source: "MeetingTranscriptionService")
+
+ // OPTIMIZATION: Check if this model is ALREADY the active global model
+ // This avoids re-initializing the model (loading from disk) if it's already in memory
+ if model.id == globalId {
+ DebugLogger.shared.info("✅ Requested model matches global. Reusing global provider (Fast Path).", source: "MeetingTranscriptionService")
+
+ if !self.asrService.isAsrReady {
+ try await self.initializeModels()
+ }
+ provider = self.asrService.fileTranscriptionProvider
+ } else {
+ // Use specific provider - separate instance from the global one
+ DebugLogger.shared.info("⚠️ Requested model differs from global. Creating new provider (Slow Path).", source: "MeetingTranscriptionService")
+ provider = self.asrService.getProvider(for: model)
+
+ // Ensure specific model is ready (loaded into memory)
+ if !provider.isReady {
+ self.currentStatus = "Loading \(model.displayName)..."
+ DebugLogger.shared.info("Initializing specific provider implementation for \(model.displayName)...", source: "MeetingTranscriptionService")
+
+ let prepStart = Date()
+ // We must call prepare() to load the model, even if files exist on disk
+ try await provider.prepare { [weak self] progress in
+ self?.progress = progress
+ }
+ DebugLogger.shared.info("Provider preparation took \(Date().timeIntervalSince(prepStart))s", source: "MeetingTranscriptionService")
+ }
+ }
+ } else {
+ DebugLogger.shared.info("ℹ️ No model specified. Using global default.", source: "MeetingTranscriptionService")
+ // Use default (global) provider
+ if !self.asrService.isAsrReady {
+ try await self.initializeModels()
+ }
+ provider = self.asrService.fileTranscriptionProvider
}
- // Get the current transcription provider (works for both Parakeet and Whisper)
- let provider = self.asrService.fileTranscriptionProvider
guard provider.isReady else {
- throw TranscriptionError.modelLoadFailed("Transcription provider not ready")
+ throw TranscriptionError.modelLoadFailed("Transcription provider not ready after initialization")
}
// Check file extension
diff --git a/Sources/Fluid/UI/MeetingTranscriptionView.swift b/Sources/Fluid/UI/MeetingTranscriptionView.swift
index 90e7a6e..28e1daa 100644
--- a/Sources/Fluid/UI/MeetingTranscriptionView.swift
+++ b/Sources/Fluid/UI/MeetingTranscriptionView.swift
@@ -4,6 +4,7 @@ import UniformTypeIdentifiers
struct MeetingTranscriptionView: View {
let asrService: ASRService
@StateObject private var transcriptionService: MeetingTranscriptionService
+ @ObservedObject private var backlogManager = BacklogManager.shared
@State private var selectedFileURL: URL?
@Environment(\.theme) private var theme
@@ -68,6 +69,14 @@ struct MeetingTranscriptionView: View {
if let error = transcriptionService.error {
self.errorCard(error: error)
}
+
+ // Backlog / History Section
+ if !self.backlogManager.jobs.isEmpty {
+ Divider()
+ .padding(.vertical, 8)
+
+ self.backlogList
+ }
}
.padding(24)
}
@@ -89,6 +98,121 @@ struct MeetingTranscriptionView: View {
}
}
+ // MARK: - Backlog List
+
+ private var backlogList: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ HStack {
+ Text("History & Backlog")
+ .font(.headline)
+
+ Spacer()
+
+ if self.backlogManager.jobs.contains(where: { $0.status == .completed || $0.status == .failed }) {
+ Button("Clear History") {
+ self.backlogManager.clearCompleted()
+ }
+ .buttonStyle(.plain)
+ .foregroundColor(.secondary)
+ .font(.caption)
+ }
+ }
+
+ VStack(spacing: 8) {
+ // Sort by newest first
+ ForEach(self.backlogManager.jobs.sorted(by: { $0.createdAt > $1.createdAt })) { job in
+ self.jobRow(for: job)
+ }
+ }
+ }
+ }
+
+ private func jobRow(for job: TranscriptionJob) -> some View {
+ HStack {
+ // Icon based on status
+ Group {
+ switch job.status {
+ case .pending:
+ Image(systemName: "hourglass")
+ .foregroundColor(.secondary)
+ case .processing:
+ ProgressView()
+ .controlSize(.small)
+ case .completed:
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundColor(Color.fluidGreen)
+ case .failed:
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundColor(.red)
+ }
+ }
+ .frame(width: 24)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(job.fileURL.lastPathComponent)
+ .font(.system(size: 14, weight: .medium))
+ .lineLimit(1)
+
+ HStack(spacing: 6) {
+ Text(job.createdAt.formatted(date: .abbreviated, time: .shortened))
+
+ if let modelId = job.modelId {
+ Text("•")
+ Text(modelId)
+ }
+
+ if let duration = job.processingDuration {
+ Text("•")
+ Text("\(String(format: "%.1fs", duration))")
+ }
+ }
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+
+ Spacer()
+
+ if job.status == .completed, let resultText = job.resultText {
+ Button("View") {
+ // Load into main view
+ let result = TranscriptionResult(
+ text: resultText,
+ confidence: 1.0, // We don't store confidence in backlog yet, default to 1
+ duration: 0, // We don't store audio duration in backlog yet
+ processingTime: job.processingDuration ?? 0,
+ fileName: job.fileURL.lastPathComponent
+ )
+ self.transcriptionService.result = result
+ self.transcriptionService.error = nil
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ } else if job.status == .failed, let error = job.error {
+ Text(error)
+ .font(.caption)
+ .foregroundColor(.red)
+ .lineLimit(1)
+ } else if job.status == .processing {
+ Text("Processing...")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ } else {
+ Text("Pending")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+ .padding(12)
+ .background(
+ RoundedRectangle(cornerRadius: 8)
+ .fill(self.theme.palette.cardBackground)
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(self.theme.palette.cardBorder.opacity(0.3), lineWidth: 1)
+ )
+ )
+ }
+
// MARK: - File Selection Card
private var fileSelectionCard: some View {
diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift
index 3f4336d..4c9cd1c 100644
--- a/Sources/Fluid/UI/SettingsView.swift
+++ b/Sources/Fluid/UI/SettingsView.swift
@@ -372,6 +372,71 @@ struct SettingsView: View {
.padding(16)
}
+ // API Settings Card
+ ThemedCard(style: .standard) {
+ VStack(alignment: .leading, spacing: 14) {
+ Label("File Transcription API", systemImage: "server.rack")
+ .font(.headline)
+ .foregroundStyle(.primary)
+
+ VStack(spacing: 16) {
+ self.settingsToggleRow(
+ title: "Enable API Server",
+ description: "Allow external applications to submit files for transcription (Port 7086).",
+ isOn: Binding(
+ get: { SettingsStore.shared.enableAPI },
+ set: { SettingsStore.shared.enableAPI = $0 }
+ )
+ )
+
+ if SettingsStore.shared.enableAPI {
+ Divider().opacity(0.2)
+
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Backlog Limit")
+ .font(.body)
+ Text("Maximum number of transcription jobs to keep in history.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ TextField("Limit", value: Binding(
+ get: { SettingsStore.shared.apiBacklogLimit },
+ set: { SettingsStore.shared.apiBacklogLimit = $0 }
+ ), formatter: NumberFormatter())
+ .textFieldStyle(.roundedBorder)
+ .frame(width: 80)
+ }
+
+ Divider().opacity(0.2)
+
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Port")
+ .font(.body)
+ Text("Listening port (default: 7086). Requires restart.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ TextField(
+ "Port",
+ value: Binding(
+ get: { SettingsStore.shared.apiPort },
+ set: { SettingsStore.shared.apiPort = $0 }
+ ),
+ formatter: NumberFormatter()
+ )
+ .textFieldStyle(.roundedBorder)
+ .frame(width: 80)
+ }
+ }
+ }
+ }
+ .padding(16)
+ }
+
// Microphone Permission Card
ThemedCard(style: .standard) {
VStack(alignment: .leading, spacing: 14) {
diff --git a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift
index 6820113..1cc1393 100644
--- a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift
+++ b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift
@@ -31,6 +31,90 @@ final class DictationE2ETests: XCTestCase {
"Expected transcription to contain 'voice' (or a close variant like 'boys'). Got: \(raw)"
)
}
+
+ @MainActor
+ func testAPIService_startsAndResponds() async throws {
+ // Arrange
+ SettingsStore.shared.enableAPI = true
+
+ let apiService = APIService.shared
+ apiService.initialize()
+
+ // Wait briefly for server start
+ try await Task.sleep(nanoseconds: 500_000_000) // 0.5s
+
+ // Act
+ let url = URL(string: "http://localhost:7086/models")!
+ let (data, response) = try await URLSession.shared.data(from: url)
+
+ // Assert
+ let httpResponse = response as? HTTPURLResponse
+ XCTAssertEqual(httpResponse?.statusCode, 200, "Expected status code 200 from API")
+
+ // Validate JSON content
+ let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
+ XCTAssertNotNil(json?["models"], "Expected 'models' field in response")
+ }
+
+ @MainActor
+ func testAPIService_idBasedOperations() async throws {
+ // Arrange
+ SettingsStore.shared.enableAPI = true
+ let apiService = APIService.shared
+ apiService.initialize(port: 7089)
+
+ // Wait briefly for server start
+ try await Task.sleep(nanoseconds: 500_000_000)
+
+ // create a dummy job in BacklogManager
+ let dummyURL = URL(fileURLWithPath: "/tmp/test_audio.mp3")
+
+ // Manually inject a job into BacklogManager (since it's a singleton, we can modify it directly if it exposes properties, otherwise we use addJob)
+ // BacklogManager.shared.addJob checks for duplicates by URL+Model.
+ // We can't force an ID via addJob easily without changing BacklogManager internals or init.
+ // However, we can use addJob and then find the ID.
+
+ BacklogManager.shared.addJob(url: dummyURL, modelId: "whisperTiny")
+
+ // Give it a moment to persist/update
+ try await Task.sleep(nanoseconds: 100_000_000)
+
+ guard let job = BacklogManager.shared.getJobs(for: dummyURL).first(where: { $0.modelId == "whisperTiny" }) else {
+ XCTFail("Failed to add dummy job")
+ return
+ }
+ let jobID = job.id
+
+ // Act & Assert 1: Get Status by ID
+ let statusURL = URL(string: "http://localhost:7089/status?id=\(jobID)")!
+ let (statusData, statusResponse) = try await URLSession.shared.data(from: statusURL)
+ XCTAssertEqual((statusResponse as? HTTPURLResponse)?.statusCode, 200)
+
+ let statusJSON = try JSONSerialization.jsonObject(with: statusData) as? [String: Any]
+ XCTAssertEqual(statusJSON?["id"] as? String, jobID)
+ let status = statusJSON?["status"] as? String
+ XCTAssertTrue(status == "pending" || status == "processing", "Status was \(String(describing: status))")
+
+ // Act & Assert 2: Get Result by ID (should fail as not completed)
+ let resultURL = URL(string: "http://localhost:7089/result?id=\(jobID)")!
+ var request = URLRequest(url: resultURL)
+ request.httpMethod = "GET"
+ let (_, resultResponse) = try await URLSession.shared.data(for: request)
+ // Should be 400 Bad Request because job is not completed
+ XCTAssertEqual((resultResponse as? HTTPURLResponse)?.statusCode, 400)
+
+ // Act & Assert 3: Delete by ID
+ let deleteURL = URL(string: "http://localhost:7089/backlog?id=\(jobID)")!
+ var deleteRequest = URLRequest(url: deleteURL)
+ deleteRequest.httpMethod = "DELETE"
+ let (_, deleteResponse) = try await URLSession.shared.data(for: deleteRequest)
+ XCTAssertEqual((deleteResponse as? HTTPURLResponse)?.statusCode, 200)
+
+ // Verify Deletion
+ let (_, verifyResponse) = try await URLSession.shared.data(from: statusURL)
+ // Should be 404 Not Found
+ XCTAssertEqual((verifyResponse as? HTTPURLResponse)?.statusCode, 404)
+ }
private static func modelDirectoryForRun() -> URL {
// Use a stable path on CI so GitHub Actions cache can speed up runs.