Feat: Add custom start/stop sounds and fix race condition
This commit is contained in:
parent
35a08dce7b
commit
7365493366
195
VoiceInk/CustomSoundManager.swift
Normal file
195
VoiceInk/CustomSoundManager.swift
Normal file
@ -0,0 +1,195 @@
|
||||
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<Void, CustomSoundError> {
|
||||
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<String, CustomSoundError> {
|
||||
guard let directory = customSoundsDirectory() else {
|
||||
return .failure(.directoryCreationFailed)
|
||||
}
|
||||
|
||||
let fileExtension = sourceURL.pathExtension
|
||||
let newFilename = "\(standardName).\(fileExtension)"
|
||||
let destinationURL = directory.appendingPathComponent(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<Void, CustomSoundError> {
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,45 +2,72 @@ 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 {
|
||||
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 +75,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 +106,9 @@ class SoundManager {
|
||||
|
||||
var isEnabled: Bool {
|
||||
get { isSoundFeedbackEnabled }
|
||||
set { isSoundFeedbackEnabled = newValue }
|
||||
set {
|
||||
objectWillChange.send()
|
||||
isSoundFeedbackEnabled = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
145
VoiceInk/Views/Settings/CustomSoundSettingsView.swift
Normal file
145
VoiceInk/Views/Settings/CustomSoundSettingsView.swift
Normal file
@ -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()
|
||||
}
|
||||
@ -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")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user