diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 41d570d4604..ccd96432927 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -1741,6 +1741,7 @@ A1A018521805C5E800A052A6 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */; }; A1A018531805C60D00A052A6 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D221A091169C9E5E00537ABF /* CoreGraphics.framework */; }; A5E7C675248C5443007C949A /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = A5E7C673248C5442007C949A /* InfoPlist.strings */; }; + A68C407AAE86CE67FAC11F79 /* LiveAudioWaveformView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0484486DA7CD91AB4D8E771E /* LiveAudioWaveformView.swift */; }; B60EDE041A05A01700D73516 /* AudioToolbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B60EDE031A05A01700D73516 /* AudioToolbox.framework */; }; B66DBF4A19D5BBC8006EA940 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B66DBF4919D5BBC8006EA940 /* Images.xcassets */; }; B69CD25119773E79005CE69A /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B69CD25019773E79005CE69A /* XCTest.framework */; }; @@ -4230,6 +4231,7 @@ 0477BE312FA4FC38002F9B47 /* TSReleaseNotesThread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TSReleaseNotesThread.swift; sourceTree = ""; }; 047A6DCF2E00B5640048EDF4 /* BackupKeyReminderMegaphoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupKeyReminderMegaphoneTests.swift; sourceTree = ""; }; 0480EFFF2E57C513006CBB29 /* BackupsEnabledNotificationMegaphone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupsEnabledNotificationMegaphone.swift; sourceTree = ""; }; + 0484486DA7CD91AB4D8E771E /* LiveAudioWaveformView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveAudioWaveformView.swift; path = Signal/ConversationView/CellViews/LiveAudioWaveformView.swift; sourceTree = SOURCE_ROOT; }; 0484CECD2F44B7BB009AB2CB /* AdminDeleteRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminDeleteRecord.swift; sourceTree = ""; }; 0484CECF2F44BCFB009AB2CB /* AdminDeleteManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminDeleteManager.swift; sourceTree = ""; }; 0484CED12F44DCFF009AB2CB /* OutgoingAdminDeleteMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutgoingAdminDeleteMessage.swift; sourceTree = ""; }; @@ -11961,6 +11963,7 @@ D221A08C169C9E5E00537ABF /* Frameworks */, D221A08A169C9E5E00537ABF /* Products */, 07B6BFA4D17E1353B0696C14 /* Pods */, + 0484486DA7CD91AB4D8E771E /* LiveAudioWaveformView.swift */, ); indentWidth = 4; sourceTree = ""; @@ -18794,6 +18797,7 @@ 45069FC629D3A7C800D0DD14 /* WideMediaTileViewLayout.swift in Sources */, 45906C6B29D238560025906D /* WidePhotoCell.swift in Sources */, 76616C9D2A266A05005F7001 /* WindowManager.swift in Sources */, + A68C407AAE86CE67FAC11F79 /* LiveAudioWaveformView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Signal/ConversationView/CellViews/LiveAudioWaveformView.swift b/Signal/ConversationView/CellViews/LiveAudioWaveformView.swift new file mode 100644 index 00000000000..70d706a710d --- /dev/null +++ b/Signal/ConversationView/CellViews/LiveAudioWaveformView.swift @@ -0,0 +1,115 @@ +// +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import UIKit +import SignalUI + +class LiveAudioWaveformView: UIView { + var playedColor: UIColor = .Signal.label { + didSet { + shapeLayer.fillColor = playedColor.cgColor + } + } + + private let shapeLayer = CAShapeLayer() + private let fadeMaskLayer = CAGradientLayer() + private var samples: [Float] = [] + + // Config + private let sampleWidth: CGFloat = 2 + private let minSampleSpacing: CGFloat = 2 + private let minSampleHeight: CGFloat = 2 + + override init(frame: CGRect) { + super.init(frame: frame) + + layer.addSublayer(shapeLayer) + + // Add a subtle fade on the left edge so samples don't clip harshly + fadeMaskLayer.colors = [ + UIColor.clear.cgColor, + UIColor.white.cgColor + ] + fadeMaskLayer.locations = [0.0, 0.15] + fadeMaskLayer.startPoint = CGPoint(x: 0, y: 0.5) + fadeMaskLayer.endPoint = CGPoint(x: 1, y: 0.5) + layer.mask = fadeMaskLayer + + clipsToBounds = true + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + shapeLayer.fillColor = playedColor.cgColor + shapeLayer.frame = bounds + fadeMaskLayer.frame = bounds + redrawSamples() + } + + func appendSample(powerLevel: Float) { + // powerLevel is usually -160 to 0. + // We need to map it to a scale of 0 to 1. + let minDb: Float = -35.0 + let clampedLevel = max(minDb, powerLevel) + let normalized = max(0.0, (clampedLevel - minDb) / abs(minDb)) + + // Optionally apply a curve (e.g. sqrt) for better visual pop + let displaySample = sqrt(normalized) + + samples.append(displaySample) + redrawSamples() + + // Smoothly slide the new sample into view using Core Animation + let sampleTotalWidth = sampleWidth + minSampleSpacing + + let animation = CABasicAnimation(keyPath: "transform.translation.x") + animation.fromValue = sampleTotalWidth + animation.toValue = 0 + animation.duration = 0.2 + animation.timingFunction = CAMediaTimingFunction(name: .linear) + + shapeLayer.add(animation, forKey: "slideLeft") + shapeLayer.transform = CATransform3DIdentity + } + + private func redrawSamples() { + guard bounds.width > 0, bounds.height > 0 else { return } + + let path = UIBezierPath() + + // Calculate max samples that fit in the view + let sampleTotalWidth = sampleWidth + minSampleSpacing + let maxSamples = Int(bounds.width / sampleTotalWidth) + + // Determine the slice of samples to display (always show the most recent ones) + let startIndex = max(0, samples.count - maxSamples) + let visibleSamples = samples[startIndex...] + + let totalSamplesWidth = CGFloat(visibleSamples.count) * sampleTotalWidth + let startX = bounds.width - totalSamplesWidth + + for (i, sample) in visibleSamples.enumerated() { + let safeSample = sample.isNaN ? 0 : sample + let sampleHeight = max(minSampleHeight, bounds.height * CGFloat(safeSample)) + let yPos = bounds.midY - sampleHeight / 2 + + let xPos = startX + CGFloat(i) * sampleTotalWidth + + let rect = CGRect(x: xPos, y: yPos, width: sampleWidth, height: sampleHeight) + path.append(UIBezierPath(roundedRect: rect, cornerRadius: sampleWidth / 2)) + } + + shapeLayer.path = path.cgPath + } + + func reset() { + samples.removeAll() + redrawSamples() + } +} diff --git a/Signal/ConversationView/ConversationInputToolbar.swift b/Signal/ConversationView/ConversationInputToolbar.swift index 505f1f69ced..c7897cb50e6 100644 --- a/Signal/ConversationView/ConversationInputToolbar.swift +++ b/Signal/ConversationView/ConversationInputToolbar.swift @@ -41,6 +41,8 @@ protocol ConversationInputToolbarDelegate: AnyObject { func voiceMemoGestureWasInterrupted() + func voiceMemoAveragePower() -> Float? + func sendVoiceMemoDraft(_ draft: VoiceMessageInterruptedDraft) // MARK: Attachments @@ -2430,6 +2432,17 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate { var voiceMemoDraft: VoiceMessageInterruptedDraft? private var voiceMemoStartTime: Date? private var voiceMemoUpdateTimer: Timer? + private lazy var voiceMemoLiveWaveformView: LiveAudioWaveformView = { + let view = LiveAudioWaveformView() + view.translatesAutoresizingMaskIntoConstraints = false + + // Force the view to expand to fill available space + let expandConstraint = view.widthAnchor.constraint(equalToConstant: 9999) + expandConstraint.priority = .defaultLow + expandConstraint.isActive = true + + return view + }() private var voiceMemoTooltipView: UIView? private lazy var voiceMemoDurationLabel: UILabel = { let label = UILabel() @@ -2469,6 +2482,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate { label.textAlignment = .right label.attributedText = cancelString label.translatesAutoresizingMaskIntoConstraints = false + label.setContentHuggingHigh() label.sizeToFit() return label }() @@ -2550,6 +2564,10 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate { // Duration Label updateVoiceMemoDurationLabel() voiceMemoContentView.addSubview(voiceMemoDurationLabel) + + // Live Waveform + voiceMemoLiveWaveformView.reset() + voiceMemoContentView.addSubview(voiceMemoLiveWaveformView) // < Swipe to Cancel voiceMemoCancelLabel.alpha = 1 @@ -2563,6 +2581,12 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate { voiceMemoDurationLabel.leadingAnchor.constraint(equalTo: redMicIconImageView.trailingAnchor, constant: 12), voiceMemoDurationLabel.centerYAnchor.constraint(equalTo: voiceMemoContentView.centerYAnchor), + + voiceMemoLiveWaveformView.leadingAnchor.constraint(equalTo: voiceMemoDurationLabel.trailingAnchor, constant: 12), + voiceMemoLiveWaveformView.trailingAnchor.constraint(equalTo: voiceMemoCancelLabel.leadingAnchor, constant: -12), + voiceMemoLiveWaveformView.widthAnchor.constraint(greaterThanOrEqualToConstant: 20), + voiceMemoLiveWaveformView.centerYAnchor.constraint(equalTo: voiceMemoContentView.centerYAnchor), + voiceMemoLiveWaveformView.heightAnchor.constraint(equalToConstant: 24), // X-position is configured relative to big red circle - later in this method. voiceMemoCancelLabel.centerYAnchor.constraint(equalTo: voiceMemoContentView.centerYAnchor, constant: -2), @@ -2619,12 +2643,15 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate { // Start recording timer. voiceMemoUpdateTimer?.invalidate() - voiceMemoUpdateTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] timer in + voiceMemoUpdateTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] timer in guard let self else { timer.invalidate() return } self.updateVoiceMemoDurationLabel() + if let power = self.inputToolbarDelegate?.voiceMemoAveragePower() { + self.voiceMemoLiveWaveformView.appendSample(powerLevel: power) + } } } @@ -2733,6 +2760,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate { voiceMemoContentView.addConstraints([ cancelButton.centerYAnchor.constraint(equalTo: voiceMemoContentView.centerYAnchor), cancelButton.trailingAnchor.constraint(equalTo: voiceMemoContentView.trailingAnchor, constant: -16), + voiceMemoLiveWaveformView.trailingAnchor.constraint(equalTo: cancelButton.leadingAnchor, constant: -12), ]) voiceMemoCancelLabel.removeFromSuperview() diff --git a/Signal/ConversationView/ConversationViewController+ConversationInputToolbarDelegate.swift b/Signal/ConversationView/ConversationViewController+ConversationInputToolbarDelegate.swift index 54cc85a8c22..c5cc3bde1bf 100644 --- a/Signal/ConversationView/ConversationViewController+ConversationInputToolbarDelegate.swift +++ b/Signal/ConversationView/ConversationViewController+ConversationInputToolbarDelegate.swift @@ -260,6 +260,13 @@ extension ConversationViewController: ConversationInputToolbarDelegate { finishRecordingVoiceMessage(sendImmediately: false) } + public func voiceMemoAveragePower() -> Float? { + AssertIsOnMainThread() + guard let draft = viewState.inProgressVoiceMessage else { return nil } + draft.updateMeters() + return draft.averagePower() + } + func sendVoiceMemoDraft(_ voiceMemoDraft: VoiceMessageInterruptedDraft) { AssertIsOnMainThread() diff --git a/Signal/ConversationView/VoiceMessage/VoiceMessageInProgressDraft.swift b/Signal/ConversationView/VoiceMessage/VoiceMessageInProgressDraft.swift index 16cc54efe14..945db518186 100644 --- a/Signal/ConversationView/VoiceMessage/VoiceMessageInProgressDraft.swift +++ b/Signal/ConversationView/VoiceMessage/VoiceMessageInProgressDraft.swift @@ -131,6 +131,13 @@ final class VoiceMessageInProgressDraft: VoiceMessageSendableDraft { private(set) var duration: TimeInterval? + func updateMeters() { + audioRecorder?.updateMeters() + } + + func averagePower() -> Float { + return audioRecorder?.averagePower(forChannel: 0) ?? -120.0 + } func convertToDraft(transaction: DBWriteTransaction) -> VoiceMessageInterruptedDraft { let directoryUrl = VoiceMessageInterruptedDraftStore.saveDraft( audioFileUrl: audioFileUrl,