diff --git a/freewrite/ContentView.swift b/freewrite/ContentView.swift index 319994b..4198c0b 100644 --- a/freewrite/ContentView.swift +++ b/freewrite/ContentView.swift @@ -8,6 +8,7 @@ import SwiftUI import AppKit +import AVFoundation struct HumanEntry: Identifiable { let id: UUID @@ -79,6 +80,12 @@ struct ContentView: View { @State private var isHoveringHistoryText = false @State private var isHoveringHistoryPath = false @State private var isHoveringHistoryArrow = false + @State private var isHoveringSound = false + @State private var soundEnabled = true + + // For sound detection + @State private var previousText: String = "" + @State private var lastKeyPress = Date() let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() let entryHeight: CGFloat = 40 @@ -375,12 +382,54 @@ struct ContentView: View { TextEditor(text: Binding( get: { text }, set: { newValue in + let currentTime = Date() + // Debounce to prevent multiple sound events + let debounceTime = 0.05 // 50ms + + // Copy current text for comparison + let oldText = text + // Ensure the text always starts with two newlines if !newValue.hasPrefix("\n\n") { text = "\n\n" + newValue.trimmingCharacters(in: .newlines) } else { text = newValue } + + // Skip sound if disabled + guard soundEnabled && currentTime.timeIntervalSince(lastKeyPress) > debounceTime else { + return + } + + // Update timestamp + lastKeyPress = currentTime + + // Determine what changed and play the appropriate sound + if newValue.count > oldText.count { + // Added character(s) + + // Count newlines to detect Return/Enter + let oldLineCount = oldText.filter { $0 == "\n" }.count + let newLineCount = newValue.filter { $0 == "\n" }.count + + if newLineCount > oldLineCount { + // Enter/Return key press + SoundManager.shared.playReturnSound() + } + // Check for added space + else if newValue.count > 0 && oldText.count > 0 && + (newValue.hasSuffix(" ") && !oldText.hasSuffix(" ")) { + SoundManager.shared.playSpaceBarSound() + } + // Regular keystroke + else { + SoundManager.shared.playTypewriterSound() + } + } + // Backspace/Delete + else if newValue.count < oldText.count { + SoundManager.shared.playBackspaceSound() + } } )) .background(Color.white) @@ -682,6 +731,28 @@ struct ContentView: View { } } + Text("•") + .foregroundColor(.gray) + + Button(action: { + soundEnabled.toggle() + SoundManager.shared.toggleSound() + }) { + Image(systemName: soundEnabled ? "speaker.wave.2.fill" : "speaker.slash.fill") + .font(.system(size: 13)) + } + .buttonStyle(.plain) + .foregroundColor(isHoveringSound ? .black : .gray) + .onHover { hovering in + isHoveringSound = hovering + isHoveringBottomNav = hovering + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + Text("•") .foregroundColor(.gray) @@ -874,6 +945,9 @@ struct ContentView: View { .onAppear { showingSidebar = false // Hide sidebar by default loadExistingEntries() + + // Sync sound state with sound manager + soundEnabled = SoundManager.shared.soundEnabled } .onChange(of: text) { _ in // Save current entry when text changes @@ -900,6 +974,9 @@ struct ContentView: View { .onReceive(NotificationCenter.default.publisher(for: NSWindow.willExitFullScreenNotification)) { _ in isFullscreen = false } + .onChange(of: soundEnabled) { newValue in + SoundManager.shared.soundEnabled = newValue + } } private func backgroundColor(for entry: HumanEntry) -> Color { diff --git a/freewrite/SoundManager.swift b/freewrite/SoundManager.swift new file mode 100644 index 0000000..4271672 --- /dev/null +++ b/freewrite/SoundManager.swift @@ -0,0 +1,99 @@ +import Foundation +import AVFoundation +import AudioToolbox +import AppKit + +class SoundManager { + static let shared = SoundManager() + + // Sound types + enum SoundType { + case keyPress + case spaceBar + case backspace + case carriageReturn + } + + private var players: [SoundType: AVAudioPlayer] = [:] + + // Control whether sounds are enabled + var soundEnabled = true + + private init() { + prepareAllSounds() + } + + private func prepareAllSounds() { + // Load all the different sound types + prepareSound(type: .keyPress, filename: "typewriter-key-1") + prepareSound(type: .spaceBar, filename: "typewriter-space-bar-1") + prepareSound(type: .backspace, filename: "typewriter-backspace-1") + prepareSound(type: .carriageReturn, filename: "typewriter-return-1") + } + + private func prepareSound(type: SoundType, filename: String) { + // First try bundle approach + if let soundURL = Bundle.main.url(forResource: filename, withExtension: "mp3", subdirectory: "Sounds") { + print("Found sound URL in bundle: \(soundURL)") + loadSound(from: soundURL, for: type) + } else if let soundURL = Bundle.main.url(forResource: filename, withExtension: "mp3") { + // Try without subdirectory + print("Found sound URL in main bundle: \(soundURL)") + loadSound(from: soundURL, for: type) + } else { + print("ERROR: Sound file \(filename) not found in bundle!") + } + } + + private func loadSound(from url: URL, for type: SoundType) { + do { + let player = try AVAudioPlayer(contentsOf: url) + player.prepareToPlay() + player.volume = 0.5 + players[type] = player + print("Successfully loaded audio player for \(type)") + } catch { + print("Failed to create audio player for \(type): \(error)") + } + } + + func playSound(type: SoundType) { + guard soundEnabled else { return } + + // If AVAudioPlayer is available for this type, use it + if let player = players[type] { + // Create a slight variation in pitch for natural typing sound (except for return sound) + if type != .carriageReturn { + player.rate = Float.random(in: 0.95...1.05) + } + player.currentTime = 0 + player.play() + } else { + // If still not available, use system sound as fallback + print("Using macOS system sound fallback") + NSSound.beep() + } + } + + // Convenience methods for each sound type + func playTypewriterSound() { + playSound(type: .keyPress) + } + + func playSpaceBarSound() { + playSound(type: .spaceBar) + } + + func playBackspaceSound() { + playSound(type: .backspace) + } + + func playReturnSound() { + playSound(type: .carriageReturn) + } + + func toggleSound() { + soundEnabled.toggle() + print("Sound is now \(soundEnabled ? "enabled" : "disabled")") + } +} diff --git a/freewrite/Sounds/typewriter-backspace-1.mp3 b/freewrite/Sounds/typewriter-backspace-1.mp3 new file mode 100644 index 0000000..83624d1 Binary files /dev/null and b/freewrite/Sounds/typewriter-backspace-1.mp3 differ diff --git a/freewrite/Sounds/typewriter-key-1.mp3 b/freewrite/Sounds/typewriter-key-1.mp3 new file mode 100644 index 0000000..d8c549a Binary files /dev/null and b/freewrite/Sounds/typewriter-key-1.mp3 differ diff --git a/freewrite/Sounds/typewriter-return-1.mp3 b/freewrite/Sounds/typewriter-return-1.mp3 new file mode 100644 index 0000000..26f73de Binary files /dev/null and b/freewrite/Sounds/typewriter-return-1.mp3 differ diff --git a/freewrite/Sounds/typewriter-space-bar-1.mp3 b/freewrite/Sounds/typewriter-space-bar-1.mp3 new file mode 100644 index 0000000..357f0ac Binary files /dev/null and b/freewrite/Sounds/typewriter-space-bar-1.mp3 differ