Feat: Add custom start/stop sounds and fix race condition

This commit is contained in:
Beingpax 2025-11-16 22:42:28 +05:45
parent 35a08dce7b
commit 7365493366
4 changed files with 433 additions and 26 deletions

View 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"
}
}
}

View 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
}
}
}

View 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()
}

View File

@ -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")