diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 79962d41..f1950e75 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -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" : { diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift index 07a7a665..a55125fc 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift @@ -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.. 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() diff --git a/Meshtastic/Audio/AudioManager.swift b/Meshtastic/Audio/AudioManager.swift index 10a301c6..ff9e1c14 100644 --- a/Meshtastic/Audio/AudioManager.swift +++ b/Meshtastic/Audio/AudioManager.swift @@ -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() } diff --git a/Meshtastic/Views/Messages/AudioMessageView.swift b/Meshtastic/Views/Messages/AudioMessageView.swift index 73a2af39..ce57cb4d 100644 --- a/Meshtastic/Views/Messages/AudioMessageView.swift +++ b/Meshtastic/Views/Messages/AudioMessageView.swift @@ -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..