jsdw_ios/QuickLocation/Section/Group/GroupChat/VoiceRecordView.swift

917 lines
34 KiB
Swift

//
// VoiceRecordView.swift
// QuickLocation
//
// Created by on 2026/6/6.
//
import UIKit
import AVFoundation
import Speech
final class VoiceRecordView: UIView, UIGestureRecognizerDelegate {
enum Phase {
case hidden
case recording
case cancelHover
case convertHover
case preview
case failed
}
enum HoverTarget {
case none
case cancel
case convert
}
private(set) var phase: Phase = .hidden
var onPreviewCancel: (() -> Void)?
var onPreviewSend: ((String) -> Void)?
private let overlayView: UIView = {
let view = UIView()
view.backgroundColor = UIColor.black.withAlphaComponent(0.65)
return view
}()
private let hillView = VoiceRecordHillView()
private let contentView = UIView()
private let hintLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 16, weight: .medium)
label.textColor = .white
label.textAlignment = .center
label.text = "松开 发送"
return label
}()
private let bubbleView: UIView = {
let view = UIView()
view.backgroundColor = UIColor(hexStr: "#4DA3FF")
view.layer.cornerRadius = 28
view.clipsToBounds = true
return view
}()
private let tailView = VoiceBubbleTailView()
private let dotsView = VoiceRecordingDotsView()
private let textView: UITextView = {
let view = UITextView()
view.backgroundColor = .clear
view.textColor = .white
view.font = .systemFont(ofSize: 18, weight: .medium)
view.isScrollEnabled = false
view.isEditable = false
view.textContainerInset = .zero
view.textContainer.lineFragmentPadding = 0
view.tintColor = .white
view.textAlignment = .left
return view
}()
private let waveformView = VoiceRecordWaveformView()
private let durationLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 15, weight: .medium)
label.textColor = .white
label.text = "0\""
return label
}()
private let editButton: UIButton = {
let button = UIButton(type: .custom)
button.setImage(UIImage(named: "IM/edit")?.withRenderingMode(.alwaysOriginal), for: .normal)
button.adjustsImageWhenHighlighted = false
button.isHidden = true
return button
}()
let cancelBtn = VoiceRecordCircleButton()
let convertBtn = VoiceRecordCircleButton()
let sendBtn = VoiceRecordCircleButton()
private var bubbleWidthConstraint: NSLayoutConstraint?
private var bubbleHeightConstraint: NSLayoutConstraint?
private var bubbleCenterXConstraint: NSLayoutConstraint?
private var tailCenterXConstraint: NSLayoutConstraint?
private var bubbleContentConstraints: [NSLayoutConstraint] = []
private var recognizedText: String = ""
private var duration: Int = 0
private var keyboardObserver: NSObjectProtocol?
override init(frame: CGRect) {
super.init(frame: frame)
isHidden = true
isUserInteractionEnabled = false
backgroundColor = .clear
setupUI()
bindActions()
observeKeyboard()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
if let keyboardObserver {
NotificationCenter.default.removeObserver(keyboardObserver)
}
}
func beginRecording() {
recognizedText = ""
duration = 0
textView.text = ""
textView.isEditable = false
textView.resignFirstResponder()
waveformView.reset()
waveformView.barColor = .white
durationLabel.text = "0\""
contentView.transform = .identity
isHidden = false
isUserInteractionEnabled = false
apply(phase: .recording, animated: false)
}
func setHover(_ target: HoverTarget) {
guard phase == .recording || phase == .cancelHover || phase == .convertHover else { return }
let next: Phase
switch target {
case .none: next = .recording
case .cancel: next = .cancelHover
case .convert: next = .convertHover
}
guard phase != next else { return }
apply(phase: next)
}
func updateDuration(_ seconds: Int) {
duration = max(0, min(60, seconds))
durationLabel.text = "\(duration)\""
}
func pushLevel(_ level: CGFloat) {
waveformView.push(level: level)
}
func updateRecognizedText(_ text: String) {
recognizedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
if phase == .convertHover {
refreshConvertHoverContent()
}
}
func showPreview(text: String) {
recognizedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
textView.text = recognizedText
isHidden = false
isUserInteractionEnabled = true
apply(phase: recognizedText.isEmpty ? .failed : .preview, animated: false)
}
func reset() {
textView.resignFirstResponder()
textView.isEditable = false
recognizedText = ""
duration = 0
waveformView.reset()
contentView.layer.removeAllAnimations()
contentView.transform = .identity
isUserInteractionEnabled = false
isHidden = true
apply(phase: .hidden, animated: false)
}
func hoverTarget(at point: CGPoint) -> HoverTarget {
let cancelFrame = cancelBtn.convert(cancelBtn.bounds.insetBy(dx: -28, dy: -28), to: self)
let convertFrame = convertBtn.convert(convertBtn.bounds.insetBy(dx: -28, dy: -28), to: self)
if !cancelBtn.isHidden, cancelFrame.contains(point) { return .cancel }
if !convertBtn.isHidden, convertFrame.contains(point) { return .convert }
return .none
}
var previewText: String {
textView.text.trimmingCharacters(in: .whitespacesAndNewlines)
}
var isPreviewing: Bool {
phase == .preview || phase == .failed
}
private func bindActions() {
cancelBtn.addTarget(self, action: #selector(handlePreviewCancel), for: .touchUpInside)
sendBtn.addTarget(self, action: #selector(handlePreviewSend), for: .touchUpInside)
editButton.addTarget(self, action: #selector(handleEdit), for: .touchUpInside)
let tap = UITapGestureRecognizer(target: self, action: #selector(handleBackgroundTap))
tap.cancelsTouchesInView = false
tap.delegate = self
addGestureRecognizer(tap)
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
guard textView.isFirstResponder else { return false }
var view = touch.view
while let current = view {
if current is UIControl { return false }
if current === textView || current === bubbleView { return false }
view = current.superview
}
return true
}
@objc private func handleBackgroundTap() {
guard textView.isFirstResponder else { return }
endEditing(true)
}
@objc private func handlePreviewCancel() {
guard phase == .preview || phase == .failed else { return }
contentView.layer.removeAllAnimations()
contentView.transform = .identity
endEditing(true)
onPreviewCancel?()
}
@objc private func handlePreviewSend() {
guard phase == .preview else { return }
let text = previewText
guard !text.isEmpty else { return }
contentView.layer.removeAllAnimations()
contentView.transform = .identity
endEditing(true)
onPreviewSend?(text)
}
@objc private func handleEdit() {
guard phase == .preview else { return }
if textView.isFirstResponder {
textView.resignFirstResponder()
return
}
textView.isEditable = true
textView.isUserInteractionEnabled = true
textView.becomeFirstResponder()
}
private func setupUI() {
addSubview(overlayView)
addSubview(hillView)
addSubview(contentView)
contentView.addSubview(hintLabel)
contentView.addSubview(cancelBtn)
contentView.addSubview(convertBtn)
contentView.addSubview(sendBtn)
contentView.addSubview(tailView)
contentView.addSubview(bubbleView)
bubbleView.addSubview(dotsView)
bubbleView.addSubview(textView)
bubbleView.addSubview(waveformView)
bubbleView.addSubview(durationLabel)
bubbleView.addSubview(editButton)
[overlayView, hillView, contentView, hintLabel, cancelBtn, convertBtn, sendBtn, tailView, bubbleView,
dotsView, textView, waveformView, durationLabel, editButton].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
}
overlayView.layoutChain.edges()
contentView.layoutChain.edges()
let hillSize = kScreenWidth + 180
NSLayoutConstraint.activate([
hillView.centerXAnchor.constraint(equalTo: centerXAnchor),
hillView.widthAnchor.constraint(equalToConstant: hillSize),
hillView.heightAnchor.constraint(equalToConstant: hillSize),
hillView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: hillSize - 118)
])
hintLabel.layoutChain
.centerX()
.bottom(46 + kSafeBottomMargin)
.height(22)
cancelBtn.layoutChain
.left(48)
.bottomToTopOfView(hintLabel, offset: -80)
.width(80)
.height(80)
convertBtn.layoutChain
.right(48)
.bottomToView(cancelBtn)
.width(80)
.height(80)
sendBtn.layoutChain
.right(48)
.bottomToView(cancelBtn)
.width(80)
.height(80)
bubbleCenterXConstraint = bubbleView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor)
bubbleWidthConstraint = bubbleView.widthAnchor.constraint(equalToConstant: 228)
bubbleHeightConstraint = bubbleView.heightAnchor.constraint(equalToConstant: 58)
NSLayoutConstraint.activate([
bubbleView.bottomAnchor.constraint(equalTo: cancelBtn.topAnchor, constant: -26),
bubbleCenterXConstraint!,
bubbleWidthConstraint!,
bubbleHeightConstraint!
])
tailCenterXConstraint = tailView.centerXAnchor.constraint(equalTo: bubbleView.centerXAnchor)
NSLayoutConstraint.activate([
tailView.topAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -1),
tailView.widthAnchor.constraint(equalToConstant: 16),
tailView.heightAnchor.constraint(equalToConstant: 9),
tailCenterXConstraint!
])
cancelBtn.configure(title: "取消", normalColor: UIColor(hexStr: "#E4EDF2"), normalTitleColor: UIColor(hexStr: "#2C3A4B"))
convertBtn.configure(title: "转文字", normalColor: UIColor(hexStr: "#E4EDF2"), normalTitleColor: UIColor(hexStr: "#2C3A4B"))
sendBtn.configure(title: "发送", normalColor: UIColor(hexStr: "#4DA3FF"), normalTitleColor: .white)
sendBtn.isHidden = true
}
private func observeKeyboard() {
keyboardObserver = NotificationCenter.default.addObserver(
forName: UIResponder.keyboardWillChangeFrameNotification,
object: nil,
queue: .main
) { [weak self] note in
self?.handleKeyboard(note)
}
}
private func handleKeyboard(_ note: Notification) {
guard !isHidden, phase == .preview || phase == .failed,
let frame = note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
else { return }
let duration = (note.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double) ?? 0.25
let curveRaw = (note.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt) ?? 7
let endInView = convert(frame, from: nil)
let overlap = max(0, bounds.maxY - endInView.minY)
UIView.animate(withDuration: duration, delay: 0, options: UIView.AnimationOptions(rawValue: curveRaw << 16)) {
self.contentView.transform = overlap > 0 ? CGAffineTransform(translationX: 0, y: -overlap) : .identity
}
}
private func apply(phase: Phase, animated: Bool = true) {
let changed = self.phase != phase
self.phase = phase
switch phase {
case .hidden:
dotsView.stop()
case .recording:
hintLabel.text = "松开 发送"
hintLabel.isHidden = false
setBubbleColor(UIColor(hexStr: "#4DA3FF"))
cancelBtn.setEmphasized(false, color: UIColor(hexStr: "#FF3B30"))
convertBtn.setEmphasized(false, color: UIColor(hexStr: "#4DA3FF"))
convertBtn.isHidden = false
sendBtn.isHidden = true
cancelBtn.isUserInteractionEnabled = false
sendBtn.isUserInteractionEnabled = false
layoutRecordingBubble(showText: false)
case .cancelHover:
hintLabel.text = "松开 取消"
hintLabel.isHidden = false
setBubbleColor(UIColor(hexStr: "#FF3B30"))
cancelBtn.setEmphasized(true, color: UIColor(hexStr: "#FF3B30"))
convertBtn.setEmphasized(false, color: UIColor(hexStr: "#4DA3FF"))
convertBtn.isHidden = false
sendBtn.isHidden = true
layoutRecordingBubble(showText: false)
case .convertHover:
hintLabel.text = "松开 编辑文字"
hintLabel.isHidden = false
setBubbleColor(UIColor(hexStr: "#4DA3FF"))
cancelBtn.setEmphasized(false, color: UIColor(hexStr: "#FF3B30"))
convertBtn.setEmphasized(true, color: UIColor(hexStr: "#4DA3FF"))
convertBtn.isHidden = false
sendBtn.isHidden = true
refreshConvertHoverContent()
case .preview:
hintLabel.text = "松开 取消"
hintLabel.isHidden = false
setBubbleColor(UIColor(hexStr: "#4DA3FF"))
cancelBtn.setEmphasized(false, color: UIColor(hexStr: "#FF3B30"))
convertBtn.isHidden = true
sendBtn.isHidden = false
sendBtn.configure(title: "发送", normalColor: UIColor(hexStr: "#4DA3FF"), normalTitleColor: .white)
sendBtn.setEmphasized(false, color: UIColor(hexStr: "#4DA3FF"))
sendBtn.isEnabled = true
cancelBtn.isUserInteractionEnabled = true
sendBtn.isUserInteractionEnabled = true
layoutPreviewBubble(failed: false)
case .failed:
hintLabel.text = "松开 取消"
hintLabel.isHidden = false
setBubbleColor(UIColor(hexStr: "#FF3B30"))
cancelBtn.setEmphasized(false, color: UIColor(hexStr: "#FF3B30"))
convertBtn.isHidden = true
sendBtn.isHidden = false
sendBtn.configure(title: "发送", normalColor: UIColor(hexStr: "#C4473E"), normalTitleColor: .white)
sendBtn.setEmphasized(false, color: UIColor(hexStr: "#C4473E"))
sendBtn.isEnabled = false
cancelBtn.isUserInteractionEnabled = true
sendBtn.isUserInteractionEnabled = false
layoutPreviewBubble(failed: true)
}
let sendScale: CGAffineTransform = phase == .preview
? CGAffineTransform(scaleX: 1.22, y: 1.22)
: .identity
if animated, changed, phase != .hidden {
UIView.animate(withDuration: 0.18, delay: 0, options: [.allowUserInteraction, .curveEaseOut]) {
self.layoutIfNeeded()
self.sendBtn.transform = sendScale
}
} else {
layoutIfNeeded()
sendBtn.transform = sendScale
}
}
private func refreshConvertHoverContent() {
textView.text = recognizedText
layoutRecordingBubble(showText: true)
}
private func layoutRecordingBubble(showText: Bool) {
let showLiveText = showText && !recognizedText.isEmpty
dotsView.isHidden = !showText
if showText { dotsView.start() } else { dotsView.stop() }
textView.isHidden = !showLiveText
textView.isEditable = false
textView.isUserInteractionEnabled = false
textView.textAlignment = .left
editButton.isHidden = true
waveformView.isHidden = false
durationLabel.isHidden = false
durationLabel.textColor = .white
waveformView.barColor = .white
waveformView.style = showText ? .mini : .full
if showText {
bubbleWidthConstraint?.constant = min(kScreenWidth - 48, 318)
bubbleHeightConstraint?.constant = showLiveText ? 118 : 96
bubbleView.layer.cornerRadius = 24
bubbleCenterXConstraint?.constant = 8
tailCenterXConstraint?.constant = 52
} else if phase == .cancelHover {
bubbleWidthConstraint?.constant = 228
bubbleHeightConstraint?.constant = 58
bubbleView.layer.cornerRadius = 29
bubbleCenterXConstraint?.constant = -36
tailCenterXConstraint?.constant = -8
} else {
bubbleWidthConstraint?.constant = 228
bubbleHeightConstraint?.constant = 58
bubbleView.layer.cornerRadius = 29
bubbleCenterXConstraint?.constant = 0
tailCenterXConstraint?.constant = 0
}
installBubbleContentConstraints(showText: showText, preview: false, failed: false)
}
private func layoutPreviewBubble(failed: Bool) {
dotsView.stop()
dotsView.isHidden = true
textView.isHidden = false
waveformView.isHidden = true
durationLabel.isHidden = true
editButton.isHidden = failed
textView.isEditable = false
textView.isUserInteractionEnabled = !failed
textView.text = failed ? "未识别到文字" : recognizedText
textView.textAlignment = failed ? .center : .left
bubbleWidthConstraint?.constant = min(kScreenWidth - 48, 318)
bubbleHeightConstraint?.constant = failed ? 72 : max(108, previewTextHeight() + 44)
bubbleView.layer.cornerRadius = 24
bubbleCenterXConstraint?.constant = 8
tailCenterXConstraint?.constant = 52
installBubbleContentConstraints(showText: false, preview: true, failed: failed)
}
private func previewTextHeight() -> CGFloat {
let width = min(kScreenWidth - 48, 318) - 52
let text = recognizedText as NSString
let rect = text.boundingRect(
with: CGSize(width: width, height: 160),
options: [.usesLineFragmentOrigin, .usesFontLeading],
attributes: [.font: UIFont.systemFont(ofSize: 18, weight: .medium)],
context: nil
)
return ceil(rect.height)
}
private func installBubbleContentConstraints(showText: Bool, preview: Bool, failed: Bool) {
NSLayoutConstraint.deactivate(bubbleContentConstraints)
bubbleContentConstraints.removeAll()
if preview {
if failed {
bubbleContentConstraints.append(contentsOf: [
textView.leadingAnchor.constraint(equalTo: bubbleView.leadingAnchor, constant: 18),
textView.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -18),
textView.centerYAnchor.constraint(equalTo: bubbleView.centerYAnchor)
])
} else {
bubbleContentConstraints.append(contentsOf: [
textView.leadingAnchor.constraint(equalTo: bubbleView.leadingAnchor, constant: 18),
textView.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -44),
textView.topAnchor.constraint(equalTo: bubbleView.topAnchor, constant: 16),
editButton.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -18),
editButton.bottomAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -16),
editButton.widthAnchor.constraint(equalToConstant: 22),
editButton.heightAnchor.constraint(equalToConstant: 22)
])
}
} else if showText {
bubbleContentConstraints.append(contentsOf: [
dotsView.leadingAnchor.constraint(equalTo: bubbleView.leadingAnchor, constant: 18),
dotsView.topAnchor.constraint(equalTo: bubbleView.topAnchor, constant: 18),
dotsView.widthAnchor.constraint(equalToConstant: 28),
dotsView.heightAnchor.constraint(equalToConstant: 10),
textView.leadingAnchor.constraint(equalTo: dotsView.trailingAnchor, constant: 8),
textView.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -18),
textView.centerYAnchor.constraint(equalTo: dotsView.centerYAnchor),
durationLabel.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -16),
durationLabel.bottomAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -14),
waveformView.trailingAnchor.constraint(equalTo: durationLabel.leadingAnchor, constant: -8),
waveformView.centerYAnchor.constraint(equalTo: durationLabel.centerYAnchor),
waveformView.widthAnchor.constraint(equalToConstant: 92),
waveformView.heightAnchor.constraint(equalToConstant: 16)
])
} else {
bubbleContentConstraints.append(contentsOf: [
durationLabel.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -16),
durationLabel.centerYAnchor.constraint(equalTo: bubbleView.centerYAnchor),
waveformView.leadingAnchor.constraint(equalTo: bubbleView.leadingAnchor, constant: 18),
waveformView.trailingAnchor.constraint(equalTo: durationLabel.leadingAnchor, constant: -8),
waveformView.centerYAnchor.constraint(equalTo: bubbleView.centerYAnchor),
waveformView.heightAnchor.constraint(equalToConstant: 28)
])
}
NSLayoutConstraint.activate(bubbleContentConstraints)
}
private func setBubbleColor(_ color: UIColor) {
bubbleView.backgroundColor = color
tailView.fillColor = color
}
}
final class VoiceRecordHillView: UIView {
private let gradientLayer = CAGradientLayer()
private let rimLayer = CAShapeLayer()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isOpaque = false
clipsToBounds = true
gradientLayer.colors = [
UIColor(white: 0.455, alpha: 1).cgColor,
UIColor(white: 0.75, alpha: 1).cgColor
]
gradientLayer.startPoint = CGPoint(x: 0.5, y: 0)
gradientLayer.endPoint = CGPoint(x: 0.5, y: 0.26)
layer.addSublayer(gradientLayer)
rimLayer.fillColor = UIColor.clear.cgColor
rimLayer.strokeColor = UIColor.white.withAlphaComponent(0.92).cgColor
rimLayer.lineWidth = 1.5
layer.addSublayer(rimLayer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = bounds.width / 2
gradientLayer.frame = bounds
rimLayer.frame = bounds
rimLayer.path = UIBezierPath(ovalIn: bounds.insetBy(dx: 1, dy: 1)).cgPath
}
}
final class VoiceRecordCircleButton: UIButton {
private let ringView: UIView = {
let view = UIView()
view.backgroundColor = .clear
view.layer.borderWidth = 8
view.isHidden = true
view.isUserInteractionEnabled = false
return view
}()
private var normalColor: UIColor = UIColor(hexStr: "#F3F6F8")
private var normalTitleColor: UIColor = UIColor(hexStr: "#2C3A4B")
override init(frame: CGRect) {
super.init(frame: frame)
titleLabel?.font = .systemFont(ofSize: 17, weight: .medium)
layer.masksToBounds = false
addSubview(ringView)
ringView.translatesAutoresizingMaskIntoConstraints = false
ringView.layoutChain
.centerX()
.centerY()
.widthToView(self, offset: 22)
.heightToView(self, offset: 22)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = min(bounds.width, bounds.height) / 2
ringView.layer.cornerRadius = min(ringView.bounds.width, ringView.bounds.height) / 2
}
func configure(title: String, normalColor: UIColor, normalTitleColor: UIColor) {
self.normalColor = normalColor
self.normalTitleColor = normalTitleColor
setTitle(title, for: .normal)
setTitleColor(normalTitleColor, for: .normal)
backgroundColor = normalColor
}
func setEmphasized(_ emphasized: Bool, color: UIColor) {
if emphasized {
backgroundColor = color
setTitleColor(.white, for: .normal)
ringView.isHidden = false
ringView.layer.borderColor = color.withAlphaComponent(0.35).cgColor
transform = CGAffineTransform(scaleX: 1.16, y: 1.16)
} else {
backgroundColor = normalColor
setTitleColor(normalTitleColor, for: .normal)
ringView.isHidden = true
transform = .identity
}
}
}
final class VoiceBubbleTailView: UIView {
var fillColor: UIColor = UIColor(hexStr: "#4DA3FF") {
didSet { setNeedsDisplay() }
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isOpaque = false
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
let path = UIBezierPath()
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: rect.maxX, y: 0))
path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY))
path.close()
fillColor.setFill()
path.fill()
}
}
final class VoiceRecordWaveformView: UIView {
enum Style {
case full
case mini
}
var style: Style = .full {
didSet { setNeedsDisplay() }
}
private var levels: [CGFloat] = Array(repeating: 0.18, count: 21)
var barColor: UIColor = .white {
didSet { setNeedsDisplay() }
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isOpaque = false
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func push(level: CGFloat) {
levels.removeFirst()
levels.append(min(1, max(0.12, level)))
setNeedsDisplay()
}
func reset() {
levels = Array(repeating: 0.18, count: 21)
setNeedsDisplay()
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext(), !levels.isEmpty else { return }
let count = levels.count
let mid = CGFloat(count - 1) / 2
let spacing = rect.width / CGFloat(count)
for (index, level) in levels.enumerated() {
let dist = abs(CGFloat(index) - mid) / mid
let envelope = CGFloat(exp(-Double(dist * dist * 3.8)))
let height = max(style == .mini ? 3 : 4, rect.height * envelope * max(level, 0.16))
let x = CGFloat(index) * spacing + spacing / 2
let lineWidth: CGFloat
if dist < 0.12 {
lineWidth = style == .mini ? 3.2 : 5
} else if dist < 0.38 {
lineWidth = style == .mini ? 2.4 : 3.4
} else {
lineWidth = style == .mini ? 2 : 2.6
}
context.setStrokeColor(barColor.withAlphaComponent(0.45 + 0.55 * envelope).cgColor)
context.setLineWidth(lineWidth)
context.setLineCap(.round)
context.move(to: CGPoint(x: x, y: rect.midY - height / 2))
context.addLine(to: CGPoint(x: x, y: rect.midY + height / 2))
context.strokePath()
}
}
}
final class VoiceRecordingDotsView: UIView {
private let dots = [UIView(), UIView(), UIView()]
private var isAnimating = false
override init(frame: CGRect) {
super.init(frame: frame)
dots.enumerated().forEach { index, dot in
dot.backgroundColor = UIColor.white.withAlphaComponent(index == 0 ? 1 : 0.55)
dot.layer.cornerRadius = 3
addSubview(dot)
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
let size: CGFloat = 6
let gap: CGFloat = 5
dots.enumerated().forEach { index, dot in
dot.frame = CGRect(x: CGFloat(index) * (size + gap), y: (bounds.height - size) / 2, width: size, height: size)
}
}
func start() {
guard !isAnimating else { return }
isAnimating = true
animateDots()
}
func stop() {
isAnimating = false
layer.removeAllAnimations()
dots.forEach { $0.layer.removeAllAnimations() }
dots.enumerated().forEach { index, dot in
dot.alpha = index == 0 ? 1 : 0.55
}
}
private func animateDots() {
guard isAnimating else { return }
for (index, dot) in dots.enumerated() {
UIView.animate(withDuration: 0.35, delay: Double(index) * 0.12, options: [.repeat, .autoreverse]) {
dot.alpha = 0.25
}
}
}
}
final class ChatVoiceCapture: @unchecked Sendable {
private let engine = AVAudioEngine()
private var audioFile: AVAudioFile?
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
private var recognitionTask: SFSpeechRecognitionTask?
private let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "zh-CN"))
private var startDate: Date?
private var tapInstalled = false
private(set) var latestText = ""
var onLevel: ((CGFloat) -> Void)?
var onPartialText: ((String) -> Void)?
var duration: Int {
guard let startDate else { return 0 }
return max(0, Int(Date().timeIntervalSince(startDate)))
}
func start(fileURL: URL, enableSpeech: Bool) throws {
stop(finishRecognition: false)
latestText = ""
startDate = Date()
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetooth])
try session.setActive(true, options: .notifyOthersOnDeactivation)
let input = engine.inputNode
let format = input.outputFormat(forBus: 0)
guard format.sampleRate > 0, format.channelCount > 0 else {
throw NSError(domain: "ChatVoiceCapture", code: -1, userInfo: [NSLocalizedDescriptionKey: "invalid audio format"])
}
audioFile = try AVAudioFile(forWriting: fileURL, settings: format.settings)
if enableSpeech, let recognizer, recognizer.isAvailable {
let request = SFSpeechAudioBufferRecognitionRequest()
request.shouldReportPartialResults = true
request.taskHint = .dictation
recognitionRequest = request
recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, _ in
guard let text = result?.bestTranscription.formattedString, !text.isEmpty else { return }
DispatchQueue.main.async {
self?.latestText = text
self?.onPartialText?(text)
}
}
}
input.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in
guard let self else { return }
try? self.audioFile?.write(from: buffer)
self.recognitionRequest?.append(buffer)
let level = ChatVoiceCapture.rms(from: buffer)
DispatchQueue.main.async {
self.onLevel?(level)
}
}
tapInstalled = true
engine.prepare()
try engine.start()
}
func stop(finishRecognition: Bool) {
if tapInstalled {
engine.inputNode.removeTap(onBus: 0)
tapInstalled = false
}
if engine.isRunning {
engine.stop()
}
if finishRecognition {
recognitionRequest?.endAudio()
recognitionRequest = nil
} else {
recognitionTask?.cancel()
recognitionRequest = nil
recognitionTask = nil
latestText = ""
}
audioFile = nil
startDate = nil
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
static func rms(from buffer: AVAudioPCMBuffer) -> CGFloat {
let n = Int(buffer.frameLength)
guard n > 0 else { return 0.12 }
if let data = buffer.floatChannelData?[0] {
var sum: Float = 0
for i in 0..<n {
let sample = data[i]
sum += sample * sample
}
return CGFloat(min(1, max(0.12, sqrt(sum / Float(n)) * 5)))
}
if let data = buffer.int16ChannelData?[0] {
var sum: Float = 0
for i in 0..<n {
let sample = Float(data[i]) / 32768
sum += sample * sample
}
return CGFloat(min(1, max(0.12, sqrt(sum / Float(n)) * 5)))
}
return 0.12
}
}