mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-23 22:09:56 +00:00
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).
92 lines
3.1 KiB
Swift
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])
|
|
}
|
|
}
|