From ac7feb380e0cb76965ab72b084defb91f488f84f Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Thu, 18 Sep 2025 18:59:49 +0200 Subject: [PATCH] live recording --- bitchat/Features/voice/VoiceRecorder.swift | 22 ++ bitchat/Views/ContentView.swift | 272 +++++++++++++-------- 2 files changed, 197 insertions(+), 97 deletions(-) diff --git a/bitchat/Features/voice/VoiceRecorder.swift b/bitchat/Features/voice/VoiceRecorder.swift index c45ff990..f42d0095 100644 --- a/bitchat/Features/voice/VoiceRecorder.swift +++ b/bitchat/Features/voice/VoiceRecorder.swift @@ -2,6 +2,7 @@ import Foundation #if os(iOS) import AVFoundation +import CoreGraphics final class VoiceRecorder: NSObject, AVAudioRecorderDelegate { private var recorder: AVAudioRecorder? @@ -34,6 +35,27 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate { completion() } } + + // MARK: - Live metrics for visualizer + + /// Returns normalized amplitude [0,1] based on average power in dB. + /// Call periodically while recording to drive a live waveform. + func pollNormalizedAmplitude() -> CGFloat { + guard let r = recorder, isRecording else { return 0 } + r.updateMeters() + // Use peak power for responsiveness, map dB [-160,0] -> linear [0,1] + let db = r.peakPower(forChannel: 0) + let linear = pow(10.0, db / 20.0) // 0..1 + if linear.isNaN || linear.isInfinite { return 0 } + // Keep linear to avoid globally scaling up low amplitudes + return CGFloat(max(0, min(1, linear))) + } + + /// Current elapsed recording time in milliseconds. + func currentTimeMs() -> Int { + guard let r = recorder, isRecording else { return 0 } + return Int(r.currentTime * 1000) + } } enum VoiceRecorderPaths { diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 0228c61a..d25ea1c1 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -9,12 +9,9 @@ import SwiftUI #if os(iOS) import UIKit -#if os(iOS) import PhotosUI #endif -#endif - // MARK: - Supporting Types // @@ -61,9 +58,19 @@ struct ContentView: View { // Window sizes for rendering (infinite scroll up) @State private var windowCountPublic: Int = 300 @State private var windowCountPrivate: [String: Int] = [:] + // Measure input field height so recorder overlay matches it + @State private var inputFieldMeasuredHeight: CGFloat = 0 + private struct InputHeightPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } + } #if os(iOS) @State private var voiceRecorder = VoiceRecorder() @State private var isRecordingVoice = false + @State private var recordingTimer: Timer? = nil + @State private var recordingElapsedMs: Int = 0 + @State private var recordingAmplitudeNorm: CGFloat = 0 + @State private var currentRecordingURL: URL? = nil @State private var isShowingImagePicker = false #endif @@ -851,93 +858,123 @@ struct ContentView: View { } HStack(alignment: .center, spacing: 4) { - TextField("type a message...", text: $messageText) - .textFieldStyle(.plain) - .font(.system(size: 14, design: .monospaced)) - .foregroundColor(textColor) - .focused($isTextFieldFocused) - .padding(.leading, 12) - // iOS keyboard autocomplete and capitalization enabled by default - .onChange(of: messageText) { newValue in - // Cancel previous debounce timer - autocompleteDebounceTimer?.invalidate() - - // Debounce autocomplete updates to reduce calls during rapid typing - autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in - // Get cursor position (approximate - end of text for now) - let cursorPosition = newValue.count - viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition) + ZStack(alignment: .leading) { + // Always keep the TextField mounted so the keyboard state doesn't change + TextField("type a message...", text: $messageText) + .textFieldStyle(.plain) + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(textColor) + .focused($isTextFieldFocused) + .padding(.leading, 12) + .opacity(isRecordingVoice ? 0.0 : 1.0) + // Measure the text field height so we can match recorder height + .background(GeometryReader { geo in + Color.clear.preference(key: InputHeightPreferenceKey.self, value: geo.size.height) + }) + + // Recording overlay: live waveform + elapsed/max time, same height as input field + if isRecordingVoice { + HStack(alignment: .center, spacing: 12) { + RealtimeScrollingWaveform( + amplitudeNorm: recordingAmplitudeNorm, + bars: 240, + barColor: (colorScheme == .dark ? Color.green : Color(red: 0, green: 0.6, blue: 0.3)) + ) + .frame(height: max(28, inputFieldMeasuredHeight)) + let secs = max(0, recordingElapsedMs / 1000) + let mm = secs / 60 + let ss = secs % 60 + Text(String(format: "%02d:%02d / 00:10", mm, ss)) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(textColor) + .padding(.trailing, 4) } - - // Check for command autocomplete (instant, no debounce needed) - if newValue.hasPrefix("/") && newValue.count >= 1 { - // Build context-aware command list - let isGeoPublic: Bool = { - if case .location = locationManager.selectedChannel { return true } - return false - }() - let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true) - var commandDescriptions = [ - ("/block", "block or list blocked peers"), - ("/clear", "clear chat messages"), - ("/hug", "send someone a warm hug"), - ("/m", "send private message"), - ("/slap", "slap someone with a trout"), - ("/unblock", "unblock a peer"), - ("/w", "see who's online") - ] - // Only show favorites commands when not in geohash context - if !(isGeoPublic || isGeoDM) { - commandDescriptions.append(("/fav", "add to favorites")) - commandDescriptions.append(("/unfav", "remove from favorites")) - } - - let input = newValue.lowercased() - - // Map of aliases to primary commands - let aliases: [String: String] = [ - "/join": "/j", - "/msg": "/m" - ] - - // Filter commands, but convert aliases to primary - commandSuggestions = commandDescriptions - .filter { $0.0.starts(with: input) } - .map { $0.0 } - - // Also check if input matches an alias - for (alias, primary) in aliases { - if alias.starts(with: input) && !commandSuggestions.contains(primary) { - if commandDescriptions.contains(where: { $0.0 == primary }) { - commandSuggestions.append(primary) - } + .padding(.leading, 12) + .frame(height: max(28, inputFieldMeasuredHeight)) + } + + } + .onChange(of: messageText) { newValue in + // Cancel previous debounce timer + autocompleteDebounceTimer?.invalidate() + + // Debounce autocomplete updates to reduce calls during rapid typing + autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in + // Get cursor position (approximate - end of text for now) + let cursorPosition = newValue.count + viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition) + } + + // Check for command autocomplete (instant, no debounce needed) + if newValue.hasPrefix("/") && newValue.count >= 1 { + // Build context-aware command list + let isGeoPublic: Bool = { + if case .location = locationManager.selectedChannel { return true } + return false + }() + let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true) + var commandDescriptions = [ + ("/block", "block or list blocked peers"), + ("/clear", "clear chat messages"), + ("/hug", "send someone a warm hug"), + ("/m", "send private message"), + ("/slap", "slap someone with a trout"), + ("/unblock", "unblock a peer"), + ("/w", "see who's online") + ] + // Only show favorites commands when not in geohash context + if !(isGeoPublic || isGeoDM) { + commandDescriptions.append(("/fav", "add to favorites")) + commandDescriptions.append(("/unfav", "remove from favorites")) + } + + let input = newValue.lowercased() + + // Map of aliases to primary commands + let aliases: [String: String] = [ + "/join": "/j", + "/msg": "/m" + ] + + // Filter commands, but convert aliases to primary + commandSuggestions = commandDescriptions + .filter { $0.0.starts(with: input) } + .map { $0.0 } + + // Also check if input matches an alias + for (alias, primary) in aliases { + if alias.starts(with: input) && !commandSuggestions.contains(primary) { + if commandDescriptions.contains(where: { $0.0 == primary }) { + commandSuggestions.append(primary) } } - - // Remove duplicates and sort - commandSuggestions = Array(Set(commandSuggestions)).sorted() - showCommandSuggestions = !commandSuggestions.isEmpty - } else { - showCommandSuggestions = false - commandSuggestions = [] } + + // Remove duplicates and sort + commandSuggestions = Array(Set(commandSuggestions)).sorted() + showCommandSuggestions = !commandSuggestions.isEmpty + } else { + showCommandSuggestions = false + commandSuggestions = [] } - .onSubmit { - sendMessage() - } + } + .onSubmit { sendMessage() } + .onPreferenceChange(InputHeightPreferenceKey.self) { inputFieldMeasuredHeight = $0 } Group { if messageText.isEmpty { #if os(iOS) HStack(spacing: 8) { - // Plus button for image picker (to the left of mic) - Button(action: { showImagePicker() }) { - Image(systemName: "plus.circle") - .font(.system(size: 22)) - .foregroundColor(viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor) + // Plus button for image picker (hidden while recording) + if !isRecordingVoice { + Button(action: { showImagePicker() }) { + Image(systemName: "plus.circle") + .font(.system(size: 22)) + .foregroundColor(viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor) + } + .buttonStyle(.plain) + .accessibilityLabel("Add media") } - .buttonStyle(.plain) - .accessibilityLabel("Add media") Image(systemName: isRecordingVoice ? "mic.circle.fill" : "mic.circle") .font(.system(size: 22)) @@ -949,31 +986,17 @@ struct ContentView: View { do { let url = try VoiceRecorderPaths.outgoingURL() try voiceRecorder.startRecording(to: url) + currentRecordingURL = url isRecordingVoice = true + startRecordingMeters() + hapticStart() } catch { isRecordingVoice = false } } } .onEnded { _ in - voiceRecorder.stopRecording { - isRecordingVoice = false - // Send the most recent recorded file - do { - let fm = FileManager.default - let base = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) - let folder = base.appendingPathComponent("bitchat_voicenotes/outgoing", isDirectory: true) - let files = try fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.contentModificationDateKey], options: [.skipsHiddenFiles]) - let latest = files.sorted { (a, b) -> Bool in - let ad = (try? a.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast - let bd = (try? b.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast - return ad > bd - }.first - if let u = latest { viewModel.sendVoiceNote(fileURL: u) } - } catch { - // ignore - } - } + finishRecordingAndSend() } ) } @@ -1020,9 +1043,64 @@ struct ContentView: View { } #if os(iOS) + + private func startRecordingMeters() { + recordingTimer?.invalidate() + recordingElapsedMs = 0 + recordingAmplitudeNorm = 0 + // Poll meters ~12.5Hz for elapsed and ~50Hz for waveform via the view's ticker. + recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { _ in + recordingAmplitudeNorm = voiceRecorder.pollNormalizedAmplitude() + recordingElapsedMs = voiceRecorder.currentTimeMs() + if recordingElapsedMs >= 10_000 { + // Auto-stop at 10s + finishRecordingAndSend() + } + } + } + + private func finishRecordingAndSend() { + guard isRecordingVoice else { return } + voiceRecorder.stopRecording { + isRecordingVoice = false + recordingTimer?.invalidate(); recordingTimer = nil + hapticStop() + let url = currentRecordingURL + currentRecordingURL = nil + if let u = url { + viewModel.sendVoiceNote(fileURL: u) + } else { + // Fallback to latest file in case we lost the URL + do { + let fm = FileManager.default + let base = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let folder = base.appendingPathComponent("bitchat_voicenotes/outgoing", isDirectory: true) + let files = try fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.contentModificationDateKey], options: [.skipsHiddenFiles]) + let latest = files.sorted { (a, b) -> Bool in + let ad = (try? a.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + let bd = (try? b.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + return ad > bd + }.first + if let u = latest { viewModel.sendVoiceNote(fileURL: u) } + } catch { + // ignore + } + } + } + } + private func showImagePicker() { isShowingImagePicker = true } + + private func hapticStart() { + let gen = UIImpactFeedbackGenerator(style: .medium) + gen.prepare(); gen.impactOccurred() + } + private func hapticStop() { + let gen = UINotificationFeedbackGenerator() + gen.prepare(); gen.notificationOccurred(.success) + } #endif // MARK: - Sidebar View