From 94a9888ef5e83f7407789b8354c6c9ff447f236a Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 3 Jul 2025 01:13:16 +0200 Subject: [PATCH] Add push-to-talk voice notes functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added voice note support to BitchatMessage with audio data and duration - Updated binary protocol to encode/decode voice notes (flag 0x40) - Created AudioRecordingService for recording up to 10s voice clips - Created AudioPlaybackService for playing voice notes - Added microphone permission to Info.plist and project.yml - Implemented push-to-talk UI with mic button in ContentView - Voice notes use AAC compression at 16kHz/32kbps for small file sizes - Added play/pause buttons for voice notes in message list - Voice notes display as '🎤 Voice note (X.Xs)' in chat - Platform-specific audio session handling for iOS/macOS --- bitchat/Info.plist | 2 + bitchat/Protocols/BinaryProtocol.swift | 53 ++++++- bitchat/Protocols/BitchatProtocol.swift | 7 +- bitchat/Services/AudioPlaybackService.swift | 111 +++++++++++++++ bitchat/Services/AudioRecordingService.swift | 137 +++++++++++++++++++ bitchat/Services/BluetoothMeshService.swift | 53 +++++++ bitchat/ViewModels/ChatViewModel.swift | 28 ++++ bitchat/Views/ContentView.swift | 86 +++++++++++- project.yml | 1 + 9 files changed, 470 insertions(+), 8 deletions(-) create mode 100644 bitchat/Services/AudioPlaybackService.swift create mode 100644 bitchat/Services/AudioRecordingService.swift diff --git a/bitchat/Info.plist b/bitchat/Info.plist index eb825de3..79b59dd7 100644 --- a/bitchat/Info.plist +++ b/bitchat/Info.plist @@ -26,6 +26,8 @@ bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users. NSBluetoothPeripheralUsageDescription bitchat uses Bluetooth to discover and connect with other bitchat users nearby. + NSMicrophoneUsageDescription + bitchat needs access to your microphone to record voice notes. UILaunchScreen UIColorName diff --git a/bitchat/Protocols/BinaryProtocol.swift b/bitchat/Protocols/BinaryProtocol.swift index 2971ed6d..53214c75 100644 --- a/bitchat/Protocols/BinaryProtocol.swift +++ b/bitchat/Protocols/BinaryProtocol.swift @@ -172,7 +172,7 @@ extension BitchatMessage { var data = Data() // Message format: - // - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID) + // - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions, bit 6: hasVoiceNote) // - Timestamp: 8 bytes (seconds since epoch) // - ID length: 1 byte // - ID: variable @@ -184,6 +184,8 @@ extension BitchatMessage { // - Original sender length + data // - Recipient nickname length + data // - Sender peer ID length + data + // - Mentions array + // - Voice note duration (8 bytes, double) + data length (4 bytes) + data var flags: UInt8 = 0 if isRelay { flags |= 0x01 } @@ -192,6 +194,7 @@ extension BitchatMessage { if recipientNickname != nil { flags |= 0x08 } if senderPeerID != nil { flags |= 0x10 } if mentions != nil && !mentions!.isEmpty { flags |= 0x20 } + if voiceNoteData != nil { flags |= 0x40 } data.append(flags) @@ -258,6 +261,24 @@ extension BitchatMessage { } } + // Voice note data + if let voiceData = voiceNoteData, let duration = voiceNoteDuration { + // Duration as 8 bytes (double) + let durationBits = duration.bitPattern + for i in (0..<8).reversed() { + data.append(UInt8((durationBits >> (i * 8)) & 0xFF)) + } + + // Voice data length as 4 bytes (UInt32) + let voiceLength = UInt32(min(voiceData.count, Int(UInt32.max))) + for i in (0..<4).reversed() { + data.append(UInt8((voiceLength >> (i * 8)) & 0xFF)) + } + + // Voice data + data.append(voiceData.prefix(Int(voiceLength))) + } + return data } @@ -283,6 +304,7 @@ extension BitchatMessage { let hasRecipientNickname = (flags & 0x08) != 0 let hasSenderPeerID = (flags & 0x10) != 0 let hasMentions = (flags & 0x20) != 0 + let hasVoiceNote = (flags & 0x40) != 0 // Timestamp guard offset + 8 <= dataCopy.count else { @@ -380,6 +402,31 @@ extension BitchatMessage { } } + // Voice note data + var voiceNoteData: Data? + var voiceNoteDuration: TimeInterval? + if hasVoiceNote && offset + 12 <= dataCopy.count { + // Duration (8 bytes) + let durationData = dataCopy[offset.. temporary file URL + + override init() { + super.init() + setupAudioSession() + } + + private func setupAudioSession() { + #if os(iOS) + let audioSession = AVAudioSession.sharedInstance() + do { + try audioSession.setCategory(.playback, mode: .default) + try audioSession.setActive(true) + } catch { + print("[AUDIO] Failed to set up audio session: \(error)") + } + #endif + } + + func playVoiceNote(messageID: String, audioData: Data, completion: @escaping (Result) -> Void) { + // Stop any current playback + stopPlayback() + + // Create temporary file for audio data + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("voice_note_\(messageID).m4a") + + do { + try audioData.write(to: tempURL) + audioFiles[messageID] = tempURL + + audioPlayer = try AVAudioPlayer(contentsOf: tempURL) + audioPlayer?.delegate = self + audioPlayer?.prepareToPlay() + + if audioPlayer?.play() == true { + isPlaying = true + currentPlayingMessageID = messageID + playbackProgress = 0 + + // Start progress timer + playbackTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in + guard let self = self, let player = self.audioPlayer else { return } + self.playbackProgress = player.currentTime / player.duration + } + + completion(.success(())) + } else { + completion(.failure(AudioError.playbackFailed)) + } + + } catch { + completion(.failure(error)) + } + } + + func stopPlayback() { + audioPlayer?.stop() + isPlaying = false + currentPlayingMessageID = nil + playbackProgress = 0 + playbackTimer?.invalidate() + playbackTimer = nil + } + + func togglePlayback(messageID: String, audioData: Data, completion: @escaping (Result) -> Void) { + if isPlaying && currentPlayingMessageID == messageID { + stopPlayback() + completion(.success(())) + } else { + playVoiceNote(messageID: messageID, audioData: audioData, completion: completion) + } + } + + func cleanupTemporaryFiles() { + for (_, url) in audioFiles { + try? FileManager.default.removeItem(at: url) + } + audioFiles.removeAll() + } + + deinit { + cleanupTemporaryFiles() + } + + enum AudioError: LocalizedError { + case playbackFailed + + var errorDescription: String? { + switch self { + case .playbackFailed: + return "Failed to start audio playback" + } + } + } +} + +extension AudioPlaybackService: AVAudioPlayerDelegate { + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + stopPlayback() + } +} \ No newline at end of file diff --git a/bitchat/Services/AudioRecordingService.swift b/bitchat/Services/AudioRecordingService.swift new file mode 100644 index 00000000..036731ce --- /dev/null +++ b/bitchat/Services/AudioRecordingService.swift @@ -0,0 +1,137 @@ +import Foundation +import AVFoundation + +class AudioRecordingService: NSObject, ObservableObject { + @Published var isRecording = false + @Published var recordingTime: TimeInterval = 0 + + private var audioRecorder: AVAudioRecorder? + private var recordingTimer: Timer? + private var recordingStartTime: Date? + private let maxDuration: TimeInterval = 10.0 + + override init() { + super.init() + setupAudioSession() + } + + private func setupAudioSession() { + #if os(iOS) + let audioSession = AVAudioSession.sharedInstance() + do { + try audioSession.setCategory(.playAndRecord, mode: .default) + try audioSession.setActive(true) + } catch { + print("[AUDIO] Failed to set up audio session: \(error)") + } + #endif + } + + func startRecording(completion: @escaping (Result) -> Void) { + guard !isRecording else { return } + + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let audioFilename = documentsPath.appendingPathComponent("voice_note_\(Date().timeIntervalSince1970).m4a") + + let settings = [ + AVFormatIDKey: Int(kAudioFormatMPEG4AAC), + AVSampleRateKey: 16000, // Lower sample rate for smaller files + AVNumberOfChannelsKey: 1, // Mono for voice + AVEncoderAudioQualityKey: AVAudioQuality.medium.rawValue, + AVEncoderBitRateKey: 32000 // 32 kbps for voice + ] + + do { + audioRecorder = try AVAudioRecorder(url: audioFilename, settings: settings) + audioRecorder?.delegate = self + audioRecorder?.record() + + isRecording = true + recordingStartTime = Date() + recordingTime = 0 + + // Start timer to update recording time and enforce max duration + recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in + guard let self = self, let startTime = self.recordingStartTime else { return } + self.recordingTime = Date().timeIntervalSince(startTime) + + if self.recordingTime >= self.maxDuration { + self.stopRecording { result in + completion(result) + } + } + } + + } catch { + completion(.failure(error)) + } + } + + func stopRecording(completion: @escaping (Result) -> Void) { + guard isRecording, let recorder = audioRecorder else { + completion(.failure(AudioError.notRecording)) + return + } + + recorder.stop() + isRecording = false + recordingTimer?.invalidate() + recordingTimer = nil + + let url = recorder.url + let _ = recordingTime + + // Reset for next recording + audioRecorder = nil + recordingTime = 0 + recordingStartTime = nil + + // Verify file exists and has content + do { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + if let fileSize = attributes[.size] as? Int, fileSize > 0 { + completion(.success(url)) + } else { + completion(.failure(AudioError.emptyRecording)) + } + } catch { + completion(.failure(error)) + } + } + + func cancelRecording() { + guard isRecording, let recorder = audioRecorder else { return } + + recorder.stop() + recorder.deleteRecording() + + isRecording = false + recordingTimer?.invalidate() + recordingTimer = nil + audioRecorder = nil + recordingTime = 0 + recordingStartTime = nil + } + + enum AudioError: LocalizedError { + case notRecording + case emptyRecording + + var errorDescription: String? { + switch self { + case .notRecording: + return "Not currently recording" + case .emptyRecording: + return "Recording produced no audio data" + } + } + } +} + +extension AudioRecordingService: AVAudioRecorderDelegate { + func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) { + if !flag { + print("[AUDIO] Recording finished unsuccessfully") + } + } +} \ No newline at end of file diff --git a/bitchat/Services/BluetoothMeshService.swift b/bitchat/Services/BluetoothMeshService.swift index 096fa19c..b2887d78 100644 --- a/bitchat/Services/BluetoothMeshService.swift +++ b/bitchat/Services/BluetoothMeshService.swift @@ -179,6 +179,36 @@ class BluetoothMeshService: NSObject { } } + func sendVoiceNote(_ audioData: Data, duration: TimeInterval) { + messageQueue.async { [weak self] in + guard let self = self else { return } + + let nickname = self.delegate as? ChatViewModel + let senderNick = nickname?.nickname ?? self.myPeerID + + let message = BitchatMessage( + sender: senderNick, + content: "🎤 Voice note (\(String(format: "%.1f", duration))s)", + timestamp: Date(), + isRelay: false, + originalSender: nil, + voiceNoteData: audioData, + voiceNoteDuration: duration + ) + + if let messageData = message.toBinaryPayload() { + let packet = BitchatPacket( + type: MessageType.voiceNote.rawValue, + ttl: self.maxTTL, + senderID: self.myPeerID, + payload: messageData + ) + + self.broadcastPacket(packet) + } + } + } + func sendPrivateMessage(_ content: String, to recipientPeerID: String, recipientNickname: String) { messageQueue.async { [weak self] in guard let self = self else { return } @@ -509,6 +539,29 @@ class BluetoothMeshService: NSObject { } } + case .voiceNote: + if let message = BitchatMessage.fromBinaryPayload(packet.payload) { + // Ignore our own messages + if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8), senderID == myPeerID { + return + } + + // Store nickname mapping + if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) { + peerNicknames[senderID] = message.sender + } + + DispatchQueue.main.async { + self.delegate?.didReceiveMessage(message) + } + + var relayPacket = packet + relayPacket.ttl -= 1 + if relayPacket.ttl > 0 { + self.broadcastPacket(relayPacket) + } + } + default: break } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 7bbe7bfb..3bc6ebbc 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -27,6 +27,8 @@ class ChatViewModel: ObservableObject { @Published var privateMessageNotification: (sender: String, message: String)? = nil let meshService = BluetoothMeshService() + let audioRecorder = AudioRecordingService() + let audioPlayer = AudioPlaybackService() private let userDefaults = UserDefaults.standard private let nicknameKey = "bitchat.nickname" private var nicknameSaveTimer: Timer? @@ -471,4 +473,30 @@ extension ChatViewModel: BitchatDelegate { return Array(Set(mentions)) // Remove duplicates } + + func sendVoiceNote(_ audioData: Data, duration: TimeInterval) { + let message = BitchatMessage( + sender: nickname, + content: "🎤 Voice note (\(String(format: "%.1f", duration))s)", + timestamp: Date(), + isRelay: false, + originalSender: nil, + voiceNoteData: audioData, + voiceNoteDuration: duration + ) + messages.append(message) + + // Send via mesh + meshService.sendVoiceNote(audioData, duration: duration) + } + + func playVoiceNote(message: BitchatMessage) { + guard let audioData = message.voiceNoteData else { return } + + audioPlayer.togglePlayback(messageID: message.id, audioData: audioData) { result in + if case .failure(let error) = result { + print("[AUDIO] Failed to play voice note: \(error)") + } + } + } } \ No newline at end of file diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 2c1ab0ef..14a67db4 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -7,6 +7,7 @@ struct ContentView: View { @FocusState private var isTextFieldFocused: Bool @Environment(\.colorScheme) var colorScheme @State private var showPeerList = false + @State private var isRecordingVoice = false private var backgroundColor: Color { colorScheme == .dark ? Color.black : Color.white @@ -228,11 +229,32 @@ struct ContentView: View { // Check if current user is mentioned let isMentioned = message.mentions?.contains(viewModel.nickname) ?? false - Text(viewModel.formatMessage(message, colorScheme: colorScheme)) - .font(.system(size: 14, design: .monospaced)) - .fontWeight(isMentioned ? .bold : .regular) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) + if message.voiceNoteData != nil { + // Voice note message + HStack(spacing: 8) { + Button(action: { + viewModel.playVoiceNote(message: message) + }) { + Image(systemName: viewModel.audioPlayer.isPlaying && viewModel.audioPlayer.currentPlayingMessageID == message.id ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: 20)) + .foregroundColor(textColor) + } + .buttonStyle(.plain) + + Text(viewModel.formatMessage(message, colorScheme: colorScheme)) + .font(.system(size: 14, design: .monospaced)) + .fontWeight(isMentioned ? .bold : .regular) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + } else { + // Regular text message + Text(viewModel.formatMessage(message, colorScheme: colorScheme)) + .font(.system(size: 14, design: .monospaced)) + .fontWeight(isMentioned ? .bold : .regular) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } } .padding(.horizontal, 12) @@ -334,6 +356,27 @@ struct ContentView: View { sendMessage() } + // Push to talk button + Button(action: {}) { + Image(systemName: "mic.circle.fill") + .font(.system(size: 20)) + .foregroundColor(isRecordingVoice ? Color.red : textColor) + } + .buttonStyle(.plain) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if !isRecordingVoice { + startVoiceRecording() + } + } + .onEnded { _ in + if isRecordingVoice { + stopVoiceRecording() + } + } + ) + Button(action: sendMessage) { Image(systemName: "arrow.right.circle.fill") .font(.system(size: 20)) @@ -354,4 +397,37 @@ struct ContentView: View { viewModel.sendMessage(messageText) messageText = "" } + + private func startVoiceRecording() { + isRecordingVoice = true + #if os(iOS) + // Light haptic feedback + let impactFeedback = UIImpactFeedbackGenerator(style: .light) + impactFeedback.impactOccurred() + #endif + + viewModel.audioRecorder.startRecording { result in + // Will handle result in stopVoiceRecording + } + } + + private func stopVoiceRecording() { + isRecordingVoice = false + + viewModel.audioRecorder.stopRecording { result in + switch result { + case .success(let audioURL): + // Read audio file and send as voice note + if let audioData = try? Data(contentsOf: audioURL) { + let duration = viewModel.audioRecorder.recordingTime + viewModel.sendVoiceNote(audioData, duration: duration) + + // Clean up temporary file + try? FileManager.default.removeItem(at: audioURL) + } + case .failure(let error): + print("[AUDIO] Recording failed: \(error)") + } + } + } } \ No newline at end of file diff --git a/project.yml b/project.yml index 1ac80ac5..10c61dcf 100644 --- a/project.yml +++ b/project.yml @@ -26,6 +26,7 @@ targets: LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET) NSBluetoothAlwaysUsageDescription: bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users. NSBluetoothPeripheralUsageDescription: bitchat uses Bluetooth to discover and connect with other bitchat users nearby. + NSMicrophoneUsageDescription: bitchat needs access to your microphone to record voice notes. UIBackgroundModes: - bluetooth-central - bluetooth-peripheral