mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
working
This commit is contained in:
parent
90aecb4d08
commit
291e72cdbf
4 changed files with 84 additions and 13 deletions
|
|
@ -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" : {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue