This commit is contained in:
Benjamin Faershtein 2026-03-09 10:57:36 -07:00
parent 90aecb4d08
commit 291e72cdbf
4 changed files with 84 additions and 13 deletions

View file

@ -1718,10 +1718,6 @@
}
}
},
"%llds voice message" : {
"comment" : "A label displaying the duration of a voice message. The argument is the duration of the voice message in seconds.",
"isCommentAutoGenerated" : true
},
"• %@" : {
"shouldTranslate" : false
},
@ -1764,6 +1760,13 @@
}
}
}
},
"~%llds voice message" : {
"comment" : "A label showing the duration of a voice message. The argument is the duration of the voice message in seconds.",
"isCommentAutoGenerated" : true
},
"~1s voice message" : {
},
"⚠️ The configured value: (%@) is not one of the optimized options." : {
"comment" : "A warning label below the picker, indicating that the selected update interval is not one of the optimized options.",
@ -29939,6 +29942,10 @@
}
}
},
"Partial voice message" : {
"comment" : "A label describing a voice message that has only some of its audio data available.",
"isCommentAutoGenerated" : true
},
"Partial Voice Message" : {
"comment" : "A label describing a voice message that has not yet been fully received.",
"isCommentAutoGenerated" : true
@ -30083,6 +30090,10 @@
}
}
},
"Pause voice message" : {
"comment" : "A button label that pauses a currently playing voice message.",
"isCommentAutoGenerated" : true
},
"PAX Counter" : {
"localizations" : {
"he" : {
@ -30752,6 +30763,10 @@
}
}
},
"Play voice message" : {
"comment" : "A button label that says \"Play voice message\".",
"isCommentAutoGenerated" : true
},
"Please be advised that because the map report is not encrypted, your data may be stored and displayed permanently by third parties. Meshtastic does not assume responsibility for any such storage, display or disclosure of this data." : {
"localizations" : {
"ja" : {
@ -47824,6 +47839,10 @@
"comment" : "A title for a voice message that was not fully received.",
"isCommentAutoGenerated" : true
},
"Voice message missed" : {
"comment" : "An accessibility label for a voice message that was not fully received.",
"isCommentAutoGenerated" : true
},
"Voltage" : {
"localizations" : {
"de" : {

View file

@ -451,12 +451,23 @@ extension AccessoryManager {
let chan = channel
let repId = replyID
// Get connected node's LoRa config for delay calculation
var hopLmt: UInt32? = nil
let hopsAway = newMessage.toUser?.userNode?.hopsAway ?? 0
if hopsAway > Int32(truncatingIfNeeded: newMessage.fromUser?.userNode?.loRaConfig?.hopLimit ?? 0) {
hopLmt = UInt32(truncatingIfNeeded: hopsAway)
if let fromUserNode = newMessage.fromUser?.userNode {
let hopsAway = fromUserNode.hopsAway
if hopsAway > Int32(truncatingIfNeeded: fromUserNode.loRaConfig?.hopLimit ?? 0) {
hopLmt = UInt32(truncatingIfNeeded: hopsAway)
}
}
// Use the connected node's LoRa config for delay calculation (our device's tx speed)
let connectedNodeNum = self.activeConnection?.device.num ?? 0
let connectedNodeRequest = NodeInfoEntity.fetchRequest()
connectedNodeRequest.predicate = NSPredicate(format: "num == %lld", connectedNodeNum)
let connectedNode = try? context.fetch(connectedNodeRequest).first
let modemPreset = connectedNode?.loRaConfig?.modemPreset
let chunkDelayNs = calculateChunkDelayNs(modemPreset: modemPreset)
Task {
for chunkIndex in 0..<totalChunks {
let startIndex = chunkIndex * chunkSize
@ -505,7 +516,7 @@ extension AccessoryManager {
try? await send(toRadio, debugDescription: logString)
if chunkIndex < totalChunks - 1 {
try? await Task.sleep(nanoseconds: 1_500_000_000) // Sleep 1.5 seconds between chunks to allow radio queueing
try? await Task.sleep(nanoseconds: chunkDelayNs)
}
}
}
@ -522,6 +533,28 @@ extension AccessoryManager {
Logger.data.error("💥 Send audio message failure \(self.activeDeviceNum?.toHex() ?? "0", privacy: .public) to \(toUserNum.toHex(), privacy: .public)")
}
}
private func calculateChunkDelayNs(modemPreset: Int32?) -> UInt64 {
guard let preset = modemPreset else {
return 1_500_000_000
}
switch Config.LoRaConfig.ModemPreset(rawValue: Int(preset)) {
case .shortTurbo, .longTurbo:
return 200_000_000
case .shortFast:
return 400_000_000
case .mediumFast:
return 700_000_000
case .longModerate, .longFast:
return 1_000_000_000
case .mediumSlow, .shortSlow:
return 1_500_000_000
case .longSlow, .veryLongSlow:
return 2_500_000_000
default:
return 1_500_000_000
}
}
public func setFavoriteNode(node: NodeInfoEntity, connectedNodeNum: Int64) async throws {
var adminPacket = AdminMessage()

View file

@ -2,17 +2,22 @@ import Foundation
import AVFoundation
import OSLog
private let audioRecordingMaxDurationReachedNotification = NSNotification.Name("audioRecordingMaxDurationReached")
@MainActor
class AudioManager: NSObject, ObservableObject, AVAudioRecorderDelegate {
static let shared = AudioManager()
@Published var isRecording = false
@Published var isPlaying = false
@Published var currentlyPlayingMessageId: Int64? = nil
@Published var currentlyPlayingMessageId: Int64?
@Published var recordingDuration: TimeInterval = 0
// Config based on typical Codec2 needs (8kHz, 16-bit PCM, mono)
private let sampleRate: Double = 8000
// Max recording duration (10 seconds) - matches payload limit of ~5s encoded audio
private let maxRecordingDuration: TimeInterval = 10.0
private var audioRecorder: AVAudioRecorder?
private var audioEngine: AVAudioEngine?
@ -63,6 +68,11 @@ class AudioManager: NSObject, ObservableObject, AVAudioRecorderDelegate {
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
guard let self = self, let recorder = self.audioRecorder else { return }
self.recordingDuration = recorder.currentTime
if self.recordingDuration >= self.maxRecordingDuration {
Logger.audio.info("🎙️ Max recording duration reached, stopping...")
self.stopRecordingCleanup()
NotificationCenter.default.post(name: audioRecordingMaxDurationReachedNotification, object: nil)
}
}
} catch {
Logger.services.error("Failed to start recording: \(error)")
@ -207,7 +217,7 @@ class AudioManager: NSObject, ObservableObject, AVAudioRecorderDelegate {
currentlyPlayingMessageId = nil
}
func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) {
@MainActor func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) {
if !flag {
stopRecordingCleanup()
}

View file

@ -43,6 +43,7 @@ struct AudioMessageView: View {
.scaledToFit()
.frame(width: 28, height: 28)
.foregroundColor(isCurrentUser ? .white.opacity(0.8) : .secondary)
.accessibilityLabel("Voice message missed")
} else if message.partialAudioInfo != nil {
// Partial some chunks are missing
Image(systemName: "exclamationmark.triangle.fill")
@ -50,6 +51,7 @@ struct AudioMessageView: View {
.scaledToFit()
.frame(width: 28, height: 28)
.foregroundColor(isCurrentUser ? .white : .orange)
.accessibilityLabel("Partial voice message")
} else {
// Full audio show play/pause for THIS message only
Button {
@ -70,6 +72,8 @@ struct AudioMessageView: View {
.animation(.easeInOut(duration: 0.15), value: isThisMessagePlaying)
}
.buttonStyle(.plain)
.accessibilityLabel(isThisMessagePlaying ? "Pause voice message" : "Play voice message")
.accessibilityAddTraits(.allowsDirectInteraction)
}
}
@ -97,6 +101,11 @@ struct AudioMessageView: View {
requestButton(label: "Request Audio", startChunk: 0, audioId: nil)
} else if let partial = message.partialAudioInfo {
// Partial: show progress + request button
let progress = Double(partial.chunks.count) / Double(partial.total)
ProgressView(value: progress)
.progressViewStyle(LinearProgressViewStyle(tint: isCurrentUser ? .white : .orange))
.scaleEffect(x: 1, y: 2, anchor: .center)
Text("\(partial.chunks.count) of \(partial.total) parts received")
.font(.caption)
.foregroundColor(isCurrentUser ? .white.opacity(0.8) : .secondary)
@ -136,10 +145,10 @@ struct AudioMessageView: View {
// MARK: - Helpers
private func firstMissingChunk(_ partial: PartialVoiceInfo) -> Int {
for i in 0..<partial.total {
if partial.chunks[i] == nil { return i }
for i in 0..<partial.total where partial.chunks[i] == nil {
return i
}
return 0
return -1
}
private var bubbleBackground: some View {