// // 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..