diff --git a/VoiceInk/CustomSoundManager.swift b/VoiceInk/CustomSoundManager.swift new file mode 100644 index 0000000..5d67e0d --- /dev/null +++ b/VoiceInk/CustomSoundManager.swift @@ -0,0 +1,199 @@ +import Foundation +import AVFoundation +import SwiftUI + +class CustomSoundManager: ObservableObject { + static let shared = CustomSoundManager() + + enum SoundType: String { + case start + case stop + + var isUsingKey: String { "isUsingCustom\(rawValue.capitalized)Sound" } + var filenameKey: String { "custom\(rawValue.capitalized)SoundFilename" } + var standardName: String { "Custom\(rawValue.capitalized)Sound" } + } + + @Published var isUsingCustomStartSound: Bool { + didSet { UserDefaults.standard.set(isUsingCustomStartSound, forKey: SoundType.start.isUsingKey) } + } + + @Published var isUsingCustomStopSound: Bool { + didSet { UserDefaults.standard.set(isUsingCustomStopSound, forKey: SoundType.stop.isUsingKey) } + } + + private let maxSoundDuration: TimeInterval = 3.0 + + private var customStartSoundFilename: String? { + didSet { updateFilenameInUserDefaults(filename: customStartSoundFilename, for: .start) } + } + + private var customStopSoundFilename: String? { + didSet { updateFilenameInUserDefaults(filename: customStopSoundFilename, for: .stop) } + } + + private func updateFilenameInUserDefaults(filename: String?, for type: SoundType) { + if let filename = filename { + UserDefaults.standard.set(filename, forKey: type.filenameKey) + } else { + UserDefaults.standard.removeObject(forKey: type.filenameKey) + } + } + + private init() { + self.isUsingCustomStartSound = UserDefaults.standard.bool(forKey: SoundType.start.isUsingKey) + self.isUsingCustomStopSound = UserDefaults.standard.bool(forKey: SoundType.stop.isUsingKey) + self.customStartSoundFilename = UserDefaults.standard.string(forKey: SoundType.start.filenameKey) + self.customStopSoundFilename = UserDefaults.standard.string(forKey: SoundType.stop.filenameKey) + + createCustomSoundsDirectoryIfNeeded() + } + + private func customSoundsDirectory() -> URL? { + guard let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return nil + } + return appSupport.appendingPathComponent("VoiceInk/CustomSounds") + } + + private func createCustomSoundsDirectoryIfNeeded() { + guard let directory = customSoundsDirectory() else { return } + + if !FileManager.default.fileExists(atPath: directory.path) { + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + } + + func getCustomSoundURL(for type: SoundType) -> URL? { + let isUsing = (type == .start) ? isUsingCustomStartSound : isUsingCustomStopSound + let filename = (type == .start) ? customStartSoundFilename : customStopSoundFilename + + guard isUsing, let filename = filename, let directory = customSoundsDirectory() else { + return nil + } + return directory.appendingPathComponent(filename) + } + + func setCustomSound(url: URL, for type: SoundType) -> Result { + let result = validateAudioFile(url: url) + switch result { + case .success: + let copyResult = copySoundFile(from: url, standardName: type.standardName) + switch copyResult { + case .success(let filename): + if type == .start { + customStartSoundFilename = filename + isUsingCustomStartSound = true + } else { + customStopSoundFilename = filename + isUsingCustomStopSound = true + } + notifyCustomSoundsChanged() + return .success(()) + case .failure(let error): + return .failure(error) + } + case .failure(let error): + return .failure(error) + } + } + + func resetSoundToDefault(for type: SoundType) { + let filename = (type == .start) ? customStartSoundFilename : customStopSoundFilename + + if let filename = filename, let directory = customSoundsDirectory() { + let fileURL = directory.appendingPathComponent(filename) + try? FileManager.default.removeItem(at: fileURL) + } + + if type == .start { + isUsingCustomStartSound = false + customStartSoundFilename = nil + } else { + isUsingCustomStopSound = false + customStopSoundFilename = nil + } + notifyCustomSoundsChanged() + } + + private func notifyCustomSoundsChanged() { + NotificationCenter.default.post(name: NSNotification.Name("CustomSoundsChanged"), object: nil) + } + + func getSoundDisplayName(for type: SoundType) -> String? { + return (type == .start) ? customStartSoundFilename : customStopSoundFilename + } + + private func copySoundFile(from sourceURL: URL, standardName: String) -> Result { + guard let directory = customSoundsDirectory() else { + return .failure(.directoryCreationFailed) + } + + let fileExtension = sourceURL.pathExtension + let newFilename = "\(standardName).\(fileExtension)" + let destinationURL = directory.appendingPathComponent(newFilename) + + if sourceURL.resolvingSymlinksInPath() == destinationURL.resolvingSymlinksInPath() { + return .success(newFilename) + } + + if FileManager.default.fileExists(atPath: destinationURL.path) { + try? FileManager.default.removeItem(at: destinationURL) + } + + do { + try FileManager.default.copyItem(at: sourceURL, to: destinationURL) + return .success(newFilename) + } catch { + return .failure(.fileCopyFailed) + } + } + + private func validateAudioFile(url: URL) -> Result { + guard FileManager.default.fileExists(atPath: url.path) else { + return .failure(.fileNotFound) + } + + let asset = AVAsset(url: url) + let duration = asset.duration.seconds + + guard duration.isFinite && duration > 0 else { + return .failure(.invalidAudioFile) + } + + if duration > maxSoundDuration { + return .failure(.durationTooLong(duration: duration, maxDuration: maxSoundDuration)) + } + + do { + _ = try AVAudioPlayer(contentsOf: url) + } catch { + return .failure(.invalidAudioFile) + } + + return .success(()) + } +} + +enum CustomSoundError: LocalizedError { + case fileNotFound + case invalidAudioFile + case durationTooLong(duration: TimeInterval, maxDuration: TimeInterval) + case directoryCreationFailed + case fileCopyFailed + + var errorDescription: String? { + switch self { + case .fileNotFound: + return "Audio file not found" + case .invalidAudioFile: + return "Invalid audio file format" + case .durationTooLong(let duration, let maxDuration): + return String(format: "Audio file is %.1f seconds long. Please use an audio file that is %.0f seconds or shorter for start and stop sounds.", duration, maxDuration) + case .directoryCreationFailed: + return "Failed to create custom sounds directory" + case .fileCopyFailed: + return "Failed to copy audio file" + } + } +} diff --git a/VoiceInk/SoundManager.swift b/VoiceInk/SoundManager.swift index d0e278a..e4d46b1 100644 --- a/VoiceInk/SoundManager.swift +++ b/VoiceInk/SoundManager.swift @@ -2,45 +2,79 @@ import Foundation import AVFoundation import SwiftUI -class SoundManager { +@MainActor +class SoundManager: ObservableObject { static let shared = SoundManager() - + private var startSound: AVAudioPlayer? private var stopSound: AVAudioPlayer? private var escSound: AVAudioPlayer? - + private var customStartSound: AVAudioPlayer? + private var customStopSound: AVAudioPlayer? + @AppStorage("isSoundFeedbackEnabled") private var isSoundFeedbackEnabled = true - + private init() { Task(priority: .background) { await setupSounds() } + + NotificationCenter.default.addObserver( + self, + selector: #selector(reloadCustomSounds), + name: NSNotification.Name("CustomSoundsChanged"), + object: nil + ) } - - private func setupSounds() async { - // Try loading directly from the main bundle + + func setupSounds() async { if let startSoundURL = Bundle.main.url(forResource: "recstart", withExtension: "mp3"), let stopSoundURL = Bundle.main.url(forResource: "recstop", withExtension: "mp3"), let escSoundURL = Bundle.main.url(forResource: "esc", withExtension: "wav") { try? await loadSounds(start: startSoundURL, stop: stopSoundURL, esc: escSoundURL) - return + } + + await reloadCustomSoundsAsync() + } + + @objc private func reloadCustomSounds() { + Task { + await reloadCustomSoundsAsync() } } - + + private func loadAndPreparePlayer(from url: URL?) -> AVAudioPlayer? { + guard let url = url else { return nil } + let player = try? AVAudioPlayer(contentsOf: url) + player?.volume = 0.4 + player?.prepareToPlay() + return player + } + + private func reloadCustomSoundsAsync() async { + if customStartSound?.isPlaying == true { + customStartSound?.stop() + } + if customStopSound?.isPlaying == true { + customStopSound?.stop() + } + + customStartSound = loadAndPreparePlayer(from: CustomSoundManager.shared.getCustomSoundURL(for: .start)) + customStopSound = loadAndPreparePlayer(from: CustomSoundManager.shared.getCustomSoundURL(for: .stop)) + } + private func loadSounds(start startURL: URL, stop stopURL: URL, esc escURL: URL) async throws { do { startSound = try AVAudioPlayer(contentsOf: startURL) stopSound = try AVAudioPlayer(contentsOf: stopURL) escSound = try AVAudioPlayer(contentsOf: escURL) - - // Prepare sounds for instant playback first + await MainActor.run { startSound?.prepareToPlay() stopSound?.prepareToPlay() escSound?.prepareToPlay() } - - // Set lower volume for all sounds after preparation + startSound?.volume = 0.4 stopSound?.volume = 0.4 escSound?.volume = 0.3 @@ -48,17 +82,27 @@ class SoundManager { throw error } } - + func playStartSound() { guard isSoundFeedbackEnabled else { return } - startSound?.volume = 0.4 - startSound?.play() + + if let custom = customStartSound { + custom.play() + } else { + startSound?.volume = 0.4 + startSound?.play() + } } - + func playStopSound() { guard isSoundFeedbackEnabled else { return } - stopSound?.volume = 0.4 - stopSound?.play() + + if let custom = customStopSound { + custom.play() + } else { + stopSound?.volume = 0.4 + stopSound?.play() + } } func playEscSound() { @@ -69,6 +113,9 @@ class SoundManager { var isEnabled: Bool { get { isSoundFeedbackEnabled } - set { isSoundFeedbackEnabled = newValue } + set { + objectWillChange.send() + isSoundFeedbackEnabled = newValue + } } } diff --git a/VoiceInk/Views/Settings/CustomSoundSettingsView.swift b/VoiceInk/Views/Settings/CustomSoundSettingsView.swift new file mode 100644 index 0000000..92b5146 --- /dev/null +++ b/VoiceInk/Views/Settings/CustomSoundSettingsView.swift @@ -0,0 +1,145 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct CustomSoundSettingsView: View { + @StateObject private var customSoundManager = CustomSoundManager.shared + @State private var showingAlert = false + @State private var alertTitle = "" + @State private var alertMessage = "" + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + soundRow(for: .start) + soundRow(for: .stop) + } + .alert(alertTitle, isPresented: $showingAlert) { + Button("OK", role: .cancel) {} + } message: { + Text(alertMessage) + } + } + + @ViewBuilder + private func soundRow(for type: CustomSoundManager.SoundType) -> some View { + horizontalSoundRow( + title: type.rawValue.capitalized, + fileName: customSoundManager.getSoundDisplayName(for: type), + isCustom: type == .start ? customSoundManager.isUsingCustomStartSound : customSoundManager.isUsingCustomStopSound, + onSelect: { selectSound(for: type) }, + onTest: { + if type == .start { + SoundManager.shared.playStartSound() + } else { + SoundManager.shared.playStopSound() + } + }, + onReset: { customSoundManager.resetSoundToDefault(for: type) } + ) + } + + @ViewBuilder + private func horizontalSoundRow( + title: String, + fileName: String?, + isCustom: Bool, + onSelect: @escaping () -> Void, + onTest: @escaping () -> Void, + onReset: @escaping () -> Void + ) -> some View { + HStack(spacing: 12) { + Text(title) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.secondary) + .frame(maxWidth: 40, alignment: .leading) + + if let fileName = fileName, isCustom { + Text(fileName) + .font(.system(size: 12)) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: 160, alignment: .leading) + + HStack(spacing: 8) { + Button(action: onTest) { + Image(systemName: "play.circle.fill") + .font(.system(size: 16)) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .help("Test sound") + + Button(action: onSelect) { + Image(systemName: "folder") + .font(.system(size: 15)) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .help("Change sound") + + Button(action: onReset) { + Image(systemName: "arrow.uturn.backward.circle") + .font(.system(size: 15)) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .help("Reset to default") + } + } else { + Text("Default") + .font(.system(size: 12)) + .foregroundColor(.secondary) + .frame(maxWidth: 160, alignment: .leading) + + HStack(spacing: 8) { + Button(action: onTest) { + Image(systemName: "play.circle.fill") + .font(.system(size: 16)) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .help("Test sound") + + Button(action: onSelect) { + Image(systemName: "folder.badge.plus") + .font(.system(size: 15)) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .help("Choose custom sound") + } + } + } + } + + private func selectSound(for type: CustomSoundManager.SoundType) { + let panel = NSOpenPanel() + panel.title = "Choose \(type.rawValue.capitalized) Sound" + panel.message = "Select an audio file" + panel.allowedContentTypes = [ + UTType.audio, + UTType.mp3, + UTType.wav, + UTType.aiff + ] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + + panel.begin { response in + guard response == .OK, let url = panel.url else { return } + + let result = customSoundManager.setCustomSound(url: url, for: type) + if case .failure(let error) = result { + alertTitle = "Invalid Audio File" + alertMessage = error.localizedDescription + showingAlert = true + } + } + } +} + +#Preview { + CustomSoundSettingsView() + .frame(width: 600) + .padding() +} diff --git a/VoiceInk/Views/Settings/SettingsView.swift b/VoiceInk/Views/Settings/SettingsView.swift index 481ae34..479e8e1 100644 --- a/VoiceInk/Views/Settings/SettingsView.swift +++ b/VoiceInk/Views/Settings/SettingsView.swift @@ -11,6 +11,7 @@ struct SettingsView: View { @EnvironmentObject private var whisperState: WhisperState @EnvironmentObject private var enhancementService: AIEnhancementService @StateObject private var deviceManager = AudioDeviceManager.shared + @ObservedObject private var soundManager = SoundManager.shared @ObservedObject private var mediaController = MediaController.shared @ObservedObject private var playbackController = PlaybackController.shared @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = true @@ -19,6 +20,7 @@ struct SettingsView: View { @State private var showResetOnboardingAlert = false @State private var currentShortcut = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) @State private var isCustomCancelEnabled = false + @State private var isCustomSoundsExpanded = false var body: some View { @@ -217,13 +219,38 @@ struct SettingsView: View { subtitle: "Customize app & system feedback" ) { VStack(alignment: .leading, spacing: 12) { - Toggle(isOn: .init( - get: { SoundManager.shared.isEnabled }, - set: { SoundManager.shared.isEnabled = $0 } - )) { - Text("Sound feedback") + HStack { + Toggle(isOn: $soundManager.isEnabled) { + Text("Sound feedback") + } + .toggleStyle(.switch) + + if soundManager.isEnabled { + Spacer() + + Button(action: { + withAnimation(.easeInOut(duration: 0.2)) { + isCustomSoundsExpanded.toggle() + } + }) { + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + .rotationEffect(.degrees(isCustomSoundsExpanded ? 90 : 0)) + .animation(.easeInOut(duration: 0.2), value: isCustomSoundsExpanded) + } + .buttonStyle(.plain) + .help("Customize recording sounds") + } } - .toggleStyle(.switch) + + if soundManager.isEnabled && isCustomSoundsExpanded { + CustomSoundSettingsView() + .transition(.opacity.combined(with: .move(edge: .top))) + .padding(.top, 4) + } + + Divider() Toggle(isOn: $mediaController.isSystemMuteEnabled) { Text("Mute system audio during recording")