live recording

This commit is contained in:
callebtc
2025-09-18 18:59:49 +02:00
parent 57c5d81981
commit ac7feb380e
2 changed files with 197 additions and 97 deletions
@@ -2,6 +2,7 @@ import Foundation
#if os(iOS) #if os(iOS)
import AVFoundation import AVFoundation
import CoreGraphics
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate { final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
private var recorder: AVAudioRecorder? private var recorder: AVAudioRecorder?
@@ -34,6 +35,27 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
completion() 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 { enum VoiceRecorderPaths {
+175 -97
View File
@@ -9,12 +9,9 @@
import SwiftUI import SwiftUI
#if os(iOS) #if os(iOS)
import UIKit import UIKit
#if os(iOS)
import PhotosUI import PhotosUI
#endif #endif
#endif
// MARK: - Supporting Types // MARK: - Supporting Types
// //
@@ -61,9 +58,19 @@ struct ContentView: View {
// Window sizes for rendering (infinite scroll up) // Window sizes for rendering (infinite scroll up)
@State private var windowCountPublic: Int = 300 @State private var windowCountPublic: Int = 300
@State private var windowCountPrivate: [String: Int] = [:] @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) #if os(iOS)
@State private var voiceRecorder = VoiceRecorder() @State private var voiceRecorder = VoiceRecorder()
@State private var isRecordingVoice = false @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 @State private var isShowingImagePicker = false
#endif #endif
@@ -851,93 +858,123 @@ struct ContentView: View {
} }
HStack(alignment: .center, spacing: 4) { HStack(alignment: .center, spacing: 4) {
TextField("type a message...", text: $messageText) ZStack(alignment: .leading) {
.textFieldStyle(.plain) // Always keep the TextField mounted so the keyboard state doesn't change
.font(.system(size: 14, design: .monospaced)) TextField("type a message...", text: $messageText)
.foregroundColor(textColor) .textFieldStyle(.plain)
.focused($isTextFieldFocused) .font(.system(size: 14, design: .monospaced))
.padding(.leading, 12) .foregroundColor(textColor)
// iOS keyboard autocomplete and capitalization enabled by default .focused($isTextFieldFocused)
.onChange(of: messageText) { newValue in .padding(.leading, 12)
// Cancel previous debounce timer .opacity(isRecordingVoice ? 0.0 : 1.0)
autocompleteDebounceTimer?.invalidate() // Measure the text field height so we can match recorder height
.background(GeometryReader { geo in
// Debounce autocomplete updates to reduce calls during rapid typing Color.clear.preference(key: InputHeightPreferenceKey.self, value: geo.size.height)
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in })
// Get cursor position (approximate - end of text for now)
let cursorPosition = newValue.count // Recording overlay: live waveform + elapsed/max time, same height as input field
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition) 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)
} }
.padding(.leading, 12)
// Check for command autocomplete (instant, no debounce needed) .frame(height: max(28, inputFieldMeasuredHeight))
if newValue.hasPrefix("/") && newValue.count >= 1 { }
// Build context-aware command list
let isGeoPublic: Bool = { }
if case .location = locationManager.selectedChannel { return true } .onChange(of: messageText) { newValue in
return false // Cancel previous debounce timer
}() autocompleteDebounceTimer?.invalidate()
let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
var commandDescriptions = [ // Debounce autocomplete updates to reduce calls during rapid typing
("/block", "block or list blocked peers"), autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
("/clear", "clear chat messages"), // Get cursor position (approximate - end of text for now)
("/hug", "send someone a warm hug"), let cursorPosition = newValue.count
("/m", "send private message"), viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
("/slap", "slap someone with a trout"), }
("/unblock", "unblock a peer"),
("/w", "see who's online") // Check for command autocomplete (instant, no debounce needed)
] if newValue.hasPrefix("/") && newValue.count >= 1 {
// Only show favorites commands when not in geohash context // Build context-aware command list
if !(isGeoPublic || isGeoDM) { let isGeoPublic: Bool = {
commandDescriptions.append(("/fav", "add to favorites")) if case .location = locationManager.selectedChannel { return true }
commandDescriptions.append(("/unfav", "remove from favorites")) return false
} }()
let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
let input = newValue.lowercased() var commandDescriptions = [
("/block", "block or list blocked peers"),
// Map of aliases to primary commands ("/clear", "clear chat messages"),
let aliases: [String: String] = [ ("/hug", "send someone a warm hug"),
"/join": "/j", ("/m", "send private message"),
"/msg": "/m" ("/slap", "slap someone with a trout"),
] ("/unblock", "unblock a peer"),
("/w", "see who's online")
// Filter commands, but convert aliases to primary ]
commandSuggestions = commandDescriptions // Only show favorites commands when not in geohash context
.filter { $0.0.starts(with: input) } if !(isGeoPublic || isGeoDM) {
.map { $0.0 } commandDescriptions.append(("/fav", "add to favorites"))
commandDescriptions.append(("/unfav", "remove from favorites"))
// Also check if input matches an alias }
for (alias, primary) in aliases {
if alias.starts(with: input) && !commandSuggestions.contains(primary) { let input = newValue.lowercased()
if commandDescriptions.contains(where: { $0.0 == primary }) {
commandSuggestions.append(primary) // 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 { Group {
if messageText.isEmpty { if messageText.isEmpty {
#if os(iOS) #if os(iOS)
HStack(spacing: 8) { HStack(spacing: 8) {
// Plus button for image picker (to the left of mic) // Plus button for image picker (hidden while recording)
Button(action: { showImagePicker() }) { if !isRecordingVoice {
Image(systemName: "plus.circle") Button(action: { showImagePicker() }) {
.font(.system(size: 22)) Image(systemName: "plus.circle")
.foregroundColor(viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor) .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") Image(systemName: isRecordingVoice ? "mic.circle.fill" : "mic.circle")
.font(.system(size: 22)) .font(.system(size: 22))
@@ -949,31 +986,17 @@ struct ContentView: View {
do { do {
let url = try VoiceRecorderPaths.outgoingURL() let url = try VoiceRecorderPaths.outgoingURL()
try voiceRecorder.startRecording(to: url) try voiceRecorder.startRecording(to: url)
currentRecordingURL = url
isRecordingVoice = true isRecordingVoice = true
startRecordingMeters()
hapticStart()
} catch { } catch {
isRecordingVoice = false isRecordingVoice = false
} }
} }
} }
.onEnded { _ in .onEnded { _ in
voiceRecorder.stopRecording { finishRecordingAndSend()
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
}
}
} }
) )
} }
@@ -1020,9 +1043,64 @@ struct ContentView: View {
} }
#if os(iOS) #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() { private func showImagePicker() {
isShowingImagePicker = true 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 #endif
// MARK: - Sidebar View // MARK: - Sidebar View