Files
hapi/ios/Hapi/Features/Chat/Composer/AVAudioRecorderDictation.swift
T
weishu 05d063cc33 feat(ios): composer attachments + dictation (A-M3f)
Attachments (Android B-M3f semantics ported verbatim):
- HapiClient/Attachments/AttachmentPolicy — pure plan matrix (>4 MB
  recompressible image -> 2048 px JPEG q85 with .jpg rename, 50 MB hard
  reject, 192 MB image read cap, 512 px q80 previewUrl data-URL thumbs,
  data-URL parse/round-trip).
- HapiClient/Attachments/ComposerAttachments — upload-on-pick tray over an
  AttachmentUploading seam (APIClient conforms): uploading/ready/failed
  chips, retained payload for retry, remove -> best-effort delete,
  mid-upload removal deletes the orphan on completion, consume() ->
  AttachmentMetadata with JPEG data-URL previewUrl, discardAllDetached +
  deinit orphan cleanup (Android onCleared analogue).
- ChatInteractor: tray ownership, unsettled chips refuse the send with a
  notice, attachments-only sends post empty text, optimistic rows carry
  the metadata, appendDictatedText/postNotice/discardAttachments.
- App: AttachmentPreparer (capped security-scoped reads, ImageIO
  downscale/encode with EXIF transform, HEIC-undecodable fallback),
  PhotosPicker multi (videos via FileRepresentation temp files),
  UIImagePickerController camera capture, fileImporter; composer chip row
  (thumb/spinner/tap-to-retry/remove) with attachment-aware send gating;
  user bubbles upgrade chips to off-main-decoded previewUrl thumbnails
  (web-sent attachments included).

Dictation (Android B-M3ce port):
- HapiProtocol/Models/VoiceApi — TranscriptionResponse,
  TranscriptionProvidersResponse, TranscriptionProviderInfo.
- HapiClient/Endpoints/VoiceEndpoints — GET
  /api/voice/transcription/providers + multipart POST
  /api/voice/transcription (file/provider/mode/language, Android part
  order) over MultipartFormData; DictationTranscribing conformance.
- HapiClient/Voice/DictationController — idle/starting/recording/
  transcribing, transcribed/noProvider/error events, provider memoized
  (first standard-capable entry), appendTranscript port.
- App: AVAudioRecorderDictation (m4a/AAC mono 44.1 kHz 96 kbps, session
  activate/deactivate), mic button + recording chip (elapsed + cancel),
  record-permission request via AVAudioApplication.

Info.plist gains NSMicrophoneUsageDescription; the camera string now
covers attachment capture (modern PhotosPicker needs no photo-library
permission). Tests: policy matrix, tray over the real client with exact
base64 upload bodies + gated in-flight scenarios, dictation controller
suite with fake recorder/transport, voice endpoint request shapes, and
the interactor attachment-send matrix transcribed from the Android VM
tests (wire bodies byte-for-byte).
2026-08-18 11:27:28 +08:00

92 lines
3.1 KiB
Swift

import AVFoundation
import Foundation
import HapiClient
/// `DictationRecorder` over `AVAudioRecorder`: AAC in an MPEG-4 container
/// (`.m4a`), a format every hub transcription provider accepts (the contract
/// allows any `audio/` type or mp4; the web itself falls back to mp4 on
/// Safari). Mono 44.1 kHz @ 96 kbps keeps minutes of speech far below the
/// endpoint's 25 MB cap. Mirrors the Android `MediaRecorderDictation`.
///
/// Requires `NSMicrophoneUsageDescription` (Info.plist) and the record
/// permission — the composer requests it before `start()`.
@MainActor
final class AVAudioRecorderDictation: DictationRecorder {
let filename = "speech.m4a"
let mimeType = "audio/mp4"
private var recorder: AVAudioRecorder?
private var output: URL?
enum RecorderError: LocalizedError {
case alreadyRecording
case couldNotStart
var errorDescription: String? {
switch self {
case .alreadyRecording:
return "A recording is already in progress"
case .couldNotStart:
return "Could not start recording"
}
}
}
func start() throws {
guard recorder == nil else { throw RecorderError.alreadyRecording }
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker])
try session.setActive(true)
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("dictation-\(UUID().uuidString).m4a")
let settings: [String: Any] = [
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
AVSampleRateKey: 44_100,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 96_000,
]
do {
let audioRecorder = try AVAudioRecorder(url: url, settings: settings)
guard audioRecorder.record() else {
throw RecorderError.couldNotStart
}
recorder = audioRecorder
output = url
} catch {
try? FileManager.default.removeItem(at: url)
deactivateSession()
throw error
}
}
func stop() throws -> Data? {
guard let audioRecorder = recorder, let url = output else { return nil }
recorder = nil
output = nil
audioRecorder.stop()
deactivateSession()
defer { try? FileManager.default.removeItem(at: url) }
// A take stopped immediately after start may hold no usable audio —
// treat as an empty take (the controller reports it).
guard let bytes = try? Data(contentsOf: url), !bytes.isEmpty else { return nil }
return bytes
}
func cancel() {
guard let audioRecorder = recorder else { return }
recorder = nil
audioRecorder.stop()
audioRecorder.deleteRecording()
if let url = output {
try? FileManager.default.removeItem(at: url)
}
output = nil
deactivateSession()
}
private func deactivateSession() {
try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
}
}