Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions freewrite/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import SwiftUI
import AppKit
import AVFoundation

struct HumanEntry: Identifiable {
let id: UUID
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
99 changes: 99 additions & 0 deletions freewrite/SoundManager.swift
Original file line number Diff line number Diff line change
@@ -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")")
}
}
Binary file added freewrite/Sounds/typewriter-backspace-1.mp3
Binary file not shown.
Binary file added freewrite/Sounds/typewriter-key-1.mp3
Binary file not shown.
Binary file added freewrite/Sounds/typewriter-return-1.mp3
Binary file not shown.
Binary file added freewrite/Sounds/typewriter-space-bar-1.mp3
Binary file not shown.