Skip to content

Commit bf02f56

Browse files
committed
Add audio conversion and drag-and-drop functionality in ContentView
- Implemented audio conversion to standard format (m4a) with error handling and logging for better user feedback. - Added drag-and-drop support for importing audio files, enhancing user experience by allowing easy file uploads. - Updated Logger to include new categories for LLM API interactions, improving tracking of API requests and responses. These changes significantly enhance the audio handling capabilities of the VibeScribe app, making it more user-friendly and maintainable while keeping the codebase clean.
1 parent cc82e6b commit bf02f56

File tree

6 files changed

+639
-4
lines changed

6 files changed

+639
-4
lines changed
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
//
2+
// AudioFileImportManager.swift
3+
// VibeScribe
4+
//
5+
// Created by System on 13.04.2025.
6+
//
7+
8+
import Foundation
9+
import SwiftData
10+
import AVFoundation
11+
12+
// MARK: - Import Errors
13+
14+
enum AudioFileImportError: LocalizedError {
15+
case fileNotFound(String)
16+
case unsupportedFormat(String)
17+
case invalidAudioFile(String)
18+
case conversionFailed(String)
19+
case saveFailed(String)
20+
21+
var errorDescription: String? {
22+
switch self {
23+
case .fileNotFound(let message):
24+
return "File not found: \(message)"
25+
case .unsupportedFormat(let message):
26+
return "Unsupported format: \(message)"
27+
case .invalidAudioFile(let message):
28+
return "Invalid audio file: \(message)"
29+
case .conversionFailed(let message):
30+
return "Conversion failed: \(message)"
31+
case .saveFailed(let message):
32+
return "Save failed: \(message)"
33+
}
34+
}
35+
}
36+
37+
@MainActor
38+
class AudioFileImportManager: ObservableObject {
39+
@Published var isImporting = false
40+
@Published var importProgress: String = ""
41+
@Published var error: Error? = nil
42+
43+
// Private task for cancellation support
44+
private var importTask: Task<Void, Never>?
45+
46+
// Supported audio file types - made private and with computed public access
47+
private static let audioFileExtensions: Set<String> = [
48+
"mp3", "wav", "m4a", "aac", "ogg", "flac", "wma", "mp4", "mov"
49+
]
50+
51+
private static let audioUTTypes: Set<String> = [
52+
"public.mp3", "public.audio", "public.audiovisual-content",
53+
"com.microsoft.waveform-audio", "public.aiff-audio",
54+
"public.mpeg-4-audio", "public.ac3-audio"
55+
]
56+
57+
/// Supported audio file extensions
58+
static var supportedAudioTypes: [String] {
59+
Array(audioFileExtensions).sorted()
60+
}
61+
62+
/// Supported UTI types for audio files
63+
static var supportedUTTypes: [String] {
64+
Array(audioUTTypes).sorted()
65+
}
66+
67+
deinit {
68+
importTask?.cancel()
69+
}
70+
71+
/// Imports audio files from drag-and-drop
72+
/// - Parameters:
73+
/// - urls: Array of URLs to import
74+
/// - modelContext: SwiftData model context for saving records
75+
func importAudioFiles(urls: [URL], modelContext: ModelContext) {
76+
guard !isImporting else {
77+
Logger.warning("Import already in progress, ignoring new request", category: .audio)
78+
return
79+
}
80+
81+
// Cancel any existing import task
82+
cancelImport()
83+
84+
isImporting = true
85+
error = nil
86+
87+
Logger.info("Starting import of \(urls.count) audio files", category: .audio)
88+
89+
importTask = Task {
90+
var successCount = 0
91+
var failureCount = 0
92+
93+
for (index, url) in urls.enumerated() {
94+
// Check for cancellation
95+
guard !Task.isCancelled else {
96+
Logger.info("Import cancelled by user", category: .audio)
97+
break
98+
}
99+
100+
do {
101+
try await importSingleFile(url: url, index: index + 1, total: urls.count, modelContext: modelContext)
102+
successCount += 1
103+
} catch {
104+
failureCount += 1
105+
Logger.error("Failed to import file: \(url.lastPathComponent)", error: error, category: .audio)
106+
await MainActor.run {
107+
self.error = error
108+
}
109+
}
110+
}
111+
112+
await MainActor.run {
113+
self.isImporting = false
114+
self.importProgress = ""
115+
let totalProcessed = successCount + failureCount
116+
Logger.info("Import completed: \(successCount) successful, \(failureCount) failed out of \(totalProcessed) files", category: .audio)
117+
}
118+
}
119+
}
120+
121+
/// Cancels the current import operation
122+
func cancelImport() {
123+
importTask?.cancel()
124+
importTask = nil
125+
126+
if isImporting {
127+
isImporting = false
128+
importProgress = ""
129+
Logger.info("Import operation cancelled", category: .audio)
130+
}
131+
}
132+
133+
private func importSingleFile(url: URL, index: Int, total: Int, modelContext: ModelContext) async throws {
134+
await MainActor.run {
135+
importProgress = "Importing \(index)/\(total): \(url.lastPathComponent)"
136+
}
137+
138+
Logger.info("Importing file \(index)/\(total): \(url.lastPathComponent)", category: .audio)
139+
140+
// Validate file exists and is accessible
141+
guard FileManager.default.fileExists(atPath: url.path) else {
142+
throw AudioFileImportError.fileNotFound("File not found: \(url.lastPathComponent)")
143+
}
144+
145+
// Check if file is actually an audio file
146+
guard isAudioFile(url: url) else {
147+
throw AudioFileImportError.unsupportedFormat("Unsupported file format: \(url.pathExtension)")
148+
}
149+
150+
// Validate it's actually a valid audio file
151+
guard await AudioUtils.isValidAudioFile(url: url) else {
152+
throw AudioFileImportError.invalidAudioFile("File does not contain valid audio: \(url.lastPathComponent)")
153+
}
154+
155+
// Get original filename with extension
156+
let originalName = url.lastPathComponent
157+
158+
// Convert to standard format
159+
await MainActor.run {
160+
importProgress = "Converting \(index)/\(total): \(originalName)"
161+
}
162+
163+
let convertedURL = try await convertToStandardFormat(url: url)
164+
165+
// Get duration
166+
let duration = AudioUtils.getAudioDuration(url: convertedURL)
167+
168+
// Validate duration is reasonable
169+
guard duration > 0 else {
170+
throw AudioFileImportError.invalidAudioFile("Audio file has invalid duration: \(originalName)")
171+
}
172+
173+
// Create record
174+
let record = Record(
175+
name: originalName,
176+
fileURL: convertedURL,
177+
duration: duration,
178+
includesSystemAudio: false // Imported files don't include system audio
179+
)
180+
181+
try await MainActor.run {
182+
importProgress = "Saving \(index)/\(total): \(originalName)"
183+
184+
modelContext.insert(record)
185+
186+
do {
187+
try modelContext.save()
188+
Logger.info("Successfully imported and saved record: \(originalName)", category: .audio)
189+
190+
// Notify about new record for UI updates
191+
NotificationCenter.default.post(
192+
name: NSNotification.Name("NewRecordCreated"),
193+
object: nil,
194+
userInfo: ["recordId": record.id]
195+
)
196+
197+
// The processing pipeline will start automatically when the record is viewed
198+
// thanks to the logic in RecordDetailView's onAppear and onReceive handlers
199+
200+
} catch {
201+
Logger.error("Failed to save imported record: \(originalName)", error: error, category: .audio)
202+
throw AudioFileImportError.saveFailed("Failed to save record: \(error.localizedDescription)")
203+
}
204+
}
205+
}
206+
207+
private func convertToStandardFormat(url: URL) async throws -> URL {
208+
return try await withCheckedThrowingContinuation { continuation in
209+
AudioUtils.convertAudioToStandardFormat(inputURL: url) { result in
210+
switch result {
211+
case .success(let convertedURL):
212+
continuation.resume(returning: convertedURL)
213+
case .failure(let error):
214+
continuation.resume(throwing: error)
215+
}
216+
}
217+
}
218+
}
219+
220+
private func isAudioFile(url: URL) -> Bool {
221+
let fileExtension = url.pathExtension.lowercased()
222+
return Self.audioFileExtensions.contains(fileExtension)
223+
}
224+
225+
226+
}
227+
228+
// MARK: - Drag and Drop Support
229+
230+
extension AudioFileImportManager {
231+
/// Checks if any of the provided URLs are supported audio files
232+
static func containsSupportedAudioFiles(urls: [URL]) -> Bool {
233+
return urls.contains { url in
234+
let fileExtension = url.pathExtension.lowercased()
235+
return audioFileExtensions.contains(fileExtension)
236+
}
237+
}
238+
239+
/// Filters URLs to only include supported audio files
240+
static func filterSupportedAudioFiles(urls: [URL]) -> [URL] {
241+
return urls.filter { url in
242+
let fileExtension = url.pathExtension.lowercased()
243+
return audioFileExtensions.contains(fileExtension)
244+
}
245+
}
246+
247+
/// Validates multiple files concurrently
248+
static func validateAudioFiles(urls: [URL]) async -> [URL: Bool] {
249+
await withTaskGroup(of: (URL, Bool).self, returning: [URL: Bool].self) { group in
250+
for url in urls {
251+
group.addTask {
252+
let isValid = await AudioUtils.isValidAudioFile(url: url)
253+
return (url, isValid)
254+
}
255+
}
256+
257+
var results: [URL: Bool] = [:]
258+
for await (url, isValid) in group {
259+
results[url] = isValid
260+
}
261+
return results
262+
}
263+
}
264+
}

0 commit comments

Comments
 (0)