Add push-to-talk voice notes functionality

- 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
This commit is contained in:
jack
2025-07-03 01:13:16 +02:00
parent d142fde11d
commit 94a9888ef5
9 changed files with 470 additions and 8 deletions
+2
View File
@@ -26,6 +26,8 @@
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
<key>NSMicrophoneUsageDescription</key>
<string>bitchat needs access to your microphone to record voice notes.</string>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
+51 -2
View File
@@ -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..<offset+8]
let durationBits = durationData.reduce(0) { result, byte in
(result << 8) | UInt64(byte)
}
voiceNoteDuration = TimeInterval(bitPattern: durationBits)
offset += 8
// Voice data length (4 bytes)
let lengthData = dataCopy[offset..<offset+4]
let voiceLength = lengthData.reduce(0) { result, byte in
(result << 8) | UInt32(byte)
}
offset += 4
// Voice data
if offset + Int(voiceLength) <= dataCopy.count {
voiceNoteData = dataCopy[offset..<offset+Int(voiceLength)]
}
}
let message = BitchatMessage(
sender: sender,
content: content,
@@ -389,7 +436,9 @@ extension BitchatMessage {
isPrivate: isPrivate,
recipientNickname: recipientNickname,
senderPeerID: senderPeerID,
mentions: mentions
mentions: mentions,
voiceNoteData: voiceNoteData,
voiceNoteDuration: voiceNoteDuration
)
return message
}
+6 -1
View File
@@ -10,6 +10,7 @@ enum MessageType: UInt8 {
case keyExchange = 0x06
case leave = 0x07
case privateMessage = 0x08
case voiceNote = 0x09
}
struct BitchatPacket: Codable {
@@ -69,8 +70,10 @@ struct BitchatMessage: Codable, Equatable {
let recipientNickname: String?
let senderPeerID: String?
let mentions: [String]? // Array of mentioned nicknames
let voiceNoteData: Data? // Audio data for voice notes
let voiceNoteDuration: TimeInterval? // Duration in seconds
init(sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil) {
init(sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, voiceNoteData: Data? = nil, voiceNoteDuration: TimeInterval? = nil) {
self.id = UUID().uuidString
self.sender = sender
self.content = content
@@ -81,6 +84,8 @@ struct BitchatMessage: Codable, Equatable {
self.recipientNickname = recipientNickname
self.senderPeerID = senderPeerID
self.mentions = mentions
self.voiceNoteData = voiceNoteData
self.voiceNoteDuration = voiceNoteDuration
}
}
+111
View File
@@ -0,0 +1,111 @@
import Foundation
import AVFoundation
class AudioPlaybackService: NSObject, ObservableObject {
@Published var isPlaying = false
@Published var currentPlayingMessageID: String?
@Published var playbackProgress: Double = 0
private var audioPlayer: AVAudioPlayer?
private var playbackTimer: Timer?
private var audioFiles: [String: URL] = [:] // messageID -> 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, Error>) -> 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, Error>) -> 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()
}
}
@@ -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<URL, Error>) -> 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<URL, Error>) -> 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")
}
}
}
@@ -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
}
+28
View File
@@ -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)")
}
}
}
}
+81 -5
View File
@@ -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)")
}
}
}
}
+1
View File
@@ -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