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 {
+104 -26
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,13 +858,42 @@ struct ContentView: View {
} }
HStack(alignment: .center, spacing: 4) { HStack(alignment: .center, spacing: 4) {
ZStack(alignment: .leading) {
// Always keep the TextField mounted so the keyboard state doesn't change
TextField("type a message...", text: $messageText) TextField("type a message...", text: $messageText)
.textFieldStyle(.plain) .textFieldStyle(.plain)
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
.focused($isTextFieldFocused) .focused($isTextFieldFocused)
.padding(.leading, 12) .padding(.leading, 12)
// iOS keyboard autocomplete and capitalization enabled by default .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)
}
.padding(.leading, 12)
.frame(height: max(28, inputFieldMeasuredHeight))
}
}
.onChange(of: messageText) { newValue in .onChange(of: messageText) { newValue in
// Cancel previous debounce timer // Cancel previous debounce timer
autocompleteDebounceTimer?.invalidate() autocompleteDebounceTimer?.invalidate()
@@ -922,15 +958,15 @@ struct ContentView: View {
commandSuggestions = [] commandSuggestions = []
} }
} }
.onSubmit { .onSubmit { sendMessage() }
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)
if !isRecordingVoice {
Button(action: { showImagePicker() }) { Button(action: { showImagePicker() }) {
Image(systemName: "plus.circle") Image(systemName: "plus.circle")
.font(.system(size: 22)) .font(.system(size: 22))
@@ -938,6 +974,7 @@ struct ContentView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("Add media") .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