From 099921b088077f4d375f6f60cae2e145473bf417 Mon Sep 17 00:00:00 2001 From: Juan Martitegui Date: Sun, 16 Aug 2026 01:06:54 -0300 Subject: [PATCH] fix(transcription): accept .opus/.oga files in file transcription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WhatsApp voice notes are Ogg Opus files with a .opus extension. macOS maps .opus and .oga to the same UTType as .ogg (org.xiph.ogg-audio) and decodes them natively, but supportedFileExtensions was built from each UTType's single preferredFilenameExtension, dropping every alternate extension tag — so dropping a WhatsApp .opus file was rejected as unsupported even though the decoder fully supports it. Build the set from every filenameExtension tag of each decodable audio/video UTType instead, and mention OPUS in the user-facing copy. Co-Authored-By: Claude Fable 5 --- Fluid.xcodeproj/project.pbxproj | 4 ++ .../MeetingTranscriptionService.swift | 16 ++++---- .../SupportedFileExtensionsTests.swift | 38 +++++++++++++++++++ 3 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 Tests/FluidDictationIntegrationTests/SupportedFileExtensionsTests.swift diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index 37167fada..be356ab5e 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -26,6 +26,7 @@ DA7100020000000000000002 /* DirectAudioReliabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7100010000000000000001 /* DirectAudioReliabilityTests.swift */; }; 7CFA1D0B2F500000C0DEF001 /* TypingServiceTransientPasteboardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CFA1D0B2F500000C0DEF002 /* TypingServiceTransientPasteboardTests.swift */; }; 803000000000000000000002 /* MediaPlaybackServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803000000000000000000001 /* MediaPlaybackServiceTests.swift */; }; + 0B05F11E000000000000A002 /* SupportedFileExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B05F11E000000000000A001 /* SupportedFileExtensionsTests.swift */; }; 803000000000000000000003 /* MediaRemoteAdapter in Frameworks */ = {isa = PBXBuildFile; productRef = 803000000000000000000004 /* MediaRemoteAdapter */; }; 7CDB0A2F2F3C4D5600FB7CAD /* dictation_fixture.wav in Resources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */; }; 7CDB0A302F3C4D5600FB7CAD /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */; }; @@ -73,6 +74,7 @@ CD1C7A0000000000000000B1 /* CustomDictionaryManualEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDictionaryManualEntryTests.swift; sourceTree = ""; }; 7CFA1D0B2F500000C0DEF002 /* TypingServiceTransientPasteboardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingServiceTransientPasteboardTests.swift; sourceTree = ""; }; 803000000000000000000001 /* MediaPlaybackServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlaybackServiceTests.swift; sourceTree = ""; }; + 0B05F11E000000000000A001 /* SupportedFileExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedFileExtensionsTests.swift; sourceTree = ""; }; 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; }; @@ -155,6 +157,7 @@ DA7100010000000000000001 /* DirectAudioReliabilityTests.swift */, 7CFA1D0B2F500000C0DEF002 /* TypingServiceTransientPasteboardTests.swift */, 803000000000000000000001 /* MediaPlaybackServiceTests.swift */, + 0B05F11E000000000000A001 /* SupportedFileExtensionsTests.swift */, ); path = FluidDictationIntegrationTests; sourceTree = ""; @@ -324,6 +327,7 @@ DA7100020000000000000002 /* DirectAudioReliabilityTests.swift in Sources */, 7CFA1D0B2F500000C0DEF001 /* TypingServiceTransientPasteboardTests.swift in Sources */, 803000000000000000000002 /* MediaPlaybackServiceTests.swift in Sources */, + 0B05F11E000000000000A002 /* SupportedFileExtensionsTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/Fluid/Services/MeetingTranscriptionService.swift b/Sources/Fluid/Services/MeetingTranscriptionService.swift index 227ffa859..f511c9db0 100644 --- a/Sources/Fluid/Services/MeetingTranscriptionService.swift +++ b/Sources/Fluid/Services/MeetingTranscriptionService.swift @@ -112,24 +112,26 @@ final class MeetingTranscriptionService: ObservableObject { /// File extensions the OS can actually decode, queried dynamically from AVFoundation. /// Filtered to audio/video types only — excludes subtitles, playlists, etc. + /// Uses every extension tag of each type, not just the preferred one: `.opus`/`.oga` + /// (e.g. WhatsApp voice notes) map to the same UTType as `.ogg` and decode natively. static let supportedFileExtensions: Set = { let avTypes = AVURLAsset.audiovisualTypes() - let extensions = avTypes.compactMap { fileType -> String? in - guard let utType = UTType(fileType.rawValue) else { return nil } - guard utType.conforms(to: .audio) || utType.conforms(to: .movie) else { return nil } - return utType.preferredFilenameExtension + let extensions = avTypes.flatMap { fileType -> [String] in + guard let utType = UTType(fileType.rawValue) else { return [] } + guard utType.conforms(to: .audio) || utType.conforms(to: .movie) else { return [] } + return utType.tags[.filenameExtension] ?? [] } - return Set(extensions) + return Set(extensions.map { $0.lowercased() }) }() /// Content types accepted by the file picker — broad categories so the OS filters naturally. static let allowedContentTypes: [UTType] = [.audio, .movie] /// User-facing description of supported formats (curated for readability). - static let supportedFormatsDescription = "Supported: WAV, MP3, M4A, OGG, MP4, MOV, and more" + static let supportedFormatsDescription = "Supported: WAV, MP3, M4A, OGG, OPUS, MP4, MOV, and more" /// Error copy shown when a dropped file is not accepted. - static let dropErrorCopy = "Accepted file types: WAV, MP3, M4A, OGG, MP4, MOV, and more." + static let dropErrorCopy = "Accepted file types: WAV, MP3, M4A, OGG, OPUS, MP4, MOV, and more." /// Share the ASR service instance to avoid loading models twice private let asrService: ASRService diff --git a/Tests/FluidDictationIntegrationTests/SupportedFileExtensionsTests.swift b/Tests/FluidDictationIntegrationTests/SupportedFileExtensionsTests.swift new file mode 100644 index 000000000..beefd67f0 --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/SupportedFileExtensionsTests.swift @@ -0,0 +1,38 @@ +@testable import FluidVoice_Debug +import XCTest + +// Regression tests for file-type acceptance in meeting/file transcription. +// WhatsApp voice notes are Ogg Opus files with a `.opus` extension. macOS maps +// `.opus` (and `.oga`) to the same UTType as `.ogg` (`org.xiph.ogg-audio`) and can +// decode them natively, but the accepted-extension set was built from each UTType's +// single `preferredFilenameExtension`, which dropped every alternate extension — +// so drag-and-dropping a WhatsApp `.opus` file was rejected as unsupported. + +@MainActor +final class SupportedFileExtensionsTests: XCTestCase { + func testWhatsAppOpusExtensionsAreAccepted() { + let supported = MeetingTranscriptionService.supportedFileExtensions + + XCTAssertTrue(supported.contains("opus"), "WhatsApp voice notes use .opus — macOS decodes them as Ogg Opus") + XCTAssertTrue(supported.contains("oga"), ".oga is an alternate Ogg audio extension macOS decodes natively") + XCTAssertTrue(supported.contains("ogg"), ".ogg must remain accepted") + } + + func testCommonFormatsRemainAccepted() { + let supported = MeetingTranscriptionService.supportedFileExtensions + + for ext in ["wav", "mp3", "m4a", "mp4", "mov", "flac", "aac"] { + XCTAssertTrue(supported.contains(ext), ".\(ext) must remain accepted") + } + } + + func testOnlyDecodableExtensionsAreAccepted() { + let supported = MeetingTranscriptionService.supportedFileExtensions + + // Every accepted extension must map to a UTType AVFoundation reports as decodable + // audio/video — the set must not drift into subtitles, playlists, or arbitrary types. + XCTAssertFalse(supported.contains("srt"), "subtitle files are not transcribable audio") + XCTAssertFalse(supported.contains("m3u"), "playlists are not transcribable audio") + XCTAssertFalse(supported.contains("txt")) + } +}