mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
247 lines
8.8 KiB
Swift
247 lines
8.8 KiB
Swift
import Foundation
|
|
import HapiProtocol
|
|
import Observation
|
|
|
|
/// Append a finished transcript to the composer text with a single space
|
|
/// separator (web `appendTranscript`, `useDictation.ts`; Android twin in
|
|
/// `Dictation.kt`).
|
|
public func appendTranscript(_ text: String, transcript: String) -> String {
|
|
let addition = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if addition.isEmpty { return text }
|
|
if text.isEmpty { return addition }
|
|
let separator = text.last?.isWhitespace == true ? "" : " "
|
|
return "\(text)\(separator)\(addition)"
|
|
}
|
|
|
|
/// Transport seam over the hub's two dictation endpoints (Android
|
|
/// `DictationApi`); ``APIClient`` conforms in `Endpoints/VoiceEndpoints.swift`,
|
|
/// tests drive a fake.
|
|
public protocol DictationTranscribing: Sendable {
|
|
/// `GET /api/voice/transcription/providers`.
|
|
func transcriptionProviders() async throws -> TranscriptionProvidersResponse
|
|
|
|
/// `POST /api/voice/transcription` (multipart, `mode=standard`).
|
|
func transcribe(
|
|
audio: Data,
|
|
filename: String,
|
|
mimeType: String,
|
|
provider: String,
|
|
language: String?
|
|
) async throws -> TranscriptionResponse
|
|
}
|
|
|
|
/// Recorder seam (production: the app-side `AVAudioRecorder` m4a/AAC impl;
|
|
/// tests: scripted bytes). Main-actor-bound like its one caller.
|
|
@MainActor
|
|
public protocol DictationRecorder: AnyObject {
|
|
/// Container filename the produced audio should upload under (`speech.m4a`).
|
|
var filename: String { get }
|
|
|
|
/// MIME type of the produced audio (`audio/mp4`).
|
|
var mimeType: String { get }
|
|
|
|
/// Begin capturing; throws when the microphone cannot be opened.
|
|
func start() throws
|
|
|
|
/// Stop and finalize the take; the recorded bytes, or nil when nothing
|
|
/// usable was captured. Throws on hard recorder failures.
|
|
func stop() throws -> Data?
|
|
|
|
/// Abandon the take, discarding any captured data.
|
|
func cancel()
|
|
}
|
|
|
|
/// The mic button / recording chip state machine.
|
|
public enum DictationState: Equatable, Sendable {
|
|
/// No take in progress — mic button shows the idle glyph.
|
|
case idle
|
|
|
|
/// Provider discovery / recorder spin-up after the first press.
|
|
case starting
|
|
|
|
/// Capturing; the chip derives elapsed time from `startedAtMs`.
|
|
case recording(startedAtMs: Int)
|
|
|
|
/// Upload + transcription in flight — mic button shows a spinner.
|
|
case transcribing
|
|
}
|
|
|
|
/// One-shot dictation outcomes for the screen.
|
|
public enum DictationEvent: Equatable, Sendable {
|
|
/// Final transcript — append to the composer via ``appendTranscript(_:transcript:)``.
|
|
case transcribed(String)
|
|
|
|
/// The hub has no transcription provider configured.
|
|
case noProvider
|
|
|
|
/// Recording or transcription failed (toast).
|
|
case error(String)
|
|
}
|
|
|
|
/// Press-to-toggle dictation (A-M3f, transcribed from the Android B-M3ce
|
|
/// `DictationController`): ``refreshAvailability()`` discovers a provider on
|
|
/// chat entry (`GET /providers`, first entry supporting `standard`). The first
|
|
/// ``toggle()`` reuses it (or discovers one) and starts the recorder; the second
|
|
/// stops it and posts the audio to
|
|
/// `POST /api/voice/transcription`, emitting ``DictationEvent/transcribed(_:)``
|
|
/// with the hub's text. ``cancel()`` abandons the take without uploading.
|
|
///
|
|
/// Web reference: `useDictation.ts` (standard mode; the realtime path is out
|
|
/// of scope for v1). No language override yet — the hub auto-detects; a
|
|
/// settings-backed `language` field is the M5 hook.
|
|
///
|
|
/// Plain constructor over two seams — package tests drive it with fakes.
|
|
/// One-shot outcomes surface through ``onEvent`` (the same callback pattern
|
|
/// as `ChatInteractor.onEvent`).
|
|
@MainActor @Observable
|
|
public final class DictationController {
|
|
public private(set) var state: DictationState = .idle
|
|
|
|
/// Hidden until the hub confirms a standard-capable provider.
|
|
public var isAvailable: Bool { cachedProviderId != nil }
|
|
|
|
/// One-shot outcomes; set by the owning screen model.
|
|
@ObservationIgnored public var onEvent: (@MainActor (DictationEvent) -> Void)?
|
|
|
|
private let api: any DictationTranscribing
|
|
private let recorder: any DictationRecorder
|
|
private let now: () -> Int
|
|
|
|
/// Reused across takes; refreshed when the composer appears.
|
|
private var cachedProviderId: String?
|
|
@ObservationIgnored private var operationRunning = false
|
|
|
|
public init(
|
|
api: any DictationTranscribing,
|
|
recorder: any DictationRecorder,
|
|
now: @escaping () -> Int = { Int(Date().timeIntervalSince1970 * 1000) }
|
|
) {
|
|
self.api = api
|
|
self.recorder = recorder
|
|
self.now = now
|
|
}
|
|
|
|
/// Silent preflight; no microphone access or notices on chat entry.
|
|
public func refreshAvailability() async {
|
|
guard state == .idle, !operationRunning else { return }
|
|
operationRunning = true
|
|
defer { operationRunning = false }
|
|
cachedProviderId = nil
|
|
let provider = await resolveProvider(reportErrors: false)
|
|
guard !Task.isCancelled else { return }
|
|
cachedProviderId = provider
|
|
}
|
|
|
|
/// Mic button press: Idle → record, Recording → stop + transcribe.
|
|
public func toggle() {
|
|
switch state {
|
|
case .idle:
|
|
startRecording()
|
|
case .recording:
|
|
stopAndTranscribe()
|
|
case .starting, .transcribing:
|
|
// An operation is already in flight.
|
|
break
|
|
}
|
|
}
|
|
|
|
/// Recording chip ✕: discard the take (no upload).
|
|
public func cancel() {
|
|
guard case .recording = state else { return }
|
|
recorder.cancel()
|
|
state = .idle
|
|
}
|
|
|
|
private func startRecording() {
|
|
guard !operationRunning else { return }
|
|
operationRunning = true
|
|
state = .starting
|
|
Task { [weak self] in
|
|
guard let self else { return }
|
|
defer { self.operationRunning = false }
|
|
guard let provider = await self.resolveProvider() else {
|
|
self.state = .idle
|
|
return
|
|
}
|
|
self.cachedProviderId = provider
|
|
do {
|
|
try self.recorder.start()
|
|
self.state = .recording(startedAtMs: self.now())
|
|
} catch {
|
|
self.emit(.error(Self.errorMessage(error, fallback: "Could not start recording")))
|
|
self.state = .idle
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The memoized or freshly discovered provider id; preflight suppresses
|
|
/// failure events.
|
|
private func resolveProvider(reportErrors: Bool = true) async -> String? {
|
|
if let cachedProviderId {
|
|
return cachedProviderId
|
|
}
|
|
let providers: [TranscriptionProviderInfo]
|
|
do {
|
|
providers = try await api.transcriptionProviders().providers
|
|
} catch {
|
|
if reportErrors {
|
|
emit(.error(Self.errorMessage(error, fallback: "Could not reach the hub")))
|
|
}
|
|
return nil
|
|
}
|
|
// First provider supporting standard (uploaded-file) transcription —
|
|
// the hub lists them in its own preference order. `browser-local`
|
|
// (realtime-only) never qualifies.
|
|
guard let chosen = providers.first(where: { $0.modes.contains("standard") }) else {
|
|
if reportErrors { emit(.noProvider) }
|
|
return nil
|
|
}
|
|
return chosen.id
|
|
}
|
|
|
|
private func stopAndTranscribe() {
|
|
guard let provider = cachedProviderId else { return } // unreachable: set before Recording
|
|
guard !operationRunning else { return }
|
|
operationRunning = true
|
|
state = .transcribing
|
|
Task { [weak self] in
|
|
guard let self else { return }
|
|
defer { self.operationRunning = false }
|
|
let audio: Data?
|
|
do {
|
|
audio = try self.recorder.stop()
|
|
} catch {
|
|
self.emit(.error(Self.errorMessage(error, fallback: "Audio recording failed")))
|
|
self.state = .idle
|
|
return
|
|
}
|
|
guard let audio, !audio.isEmpty else {
|
|
self.emit(.error("No audio was recorded"))
|
|
self.state = .idle
|
|
return
|
|
}
|
|
defer { self.state = .idle }
|
|
do {
|
|
let result = try await self.api.transcribe(
|
|
audio: audio,
|
|
filename: self.recorder.filename,
|
|
mimeType: self.recorder.mimeType,
|
|
provider: provider,
|
|
language: nil
|
|
)
|
|
self.emit(.transcribed(result.text))
|
|
} catch {
|
|
self.emit(.error(Self.errorMessage(error, fallback: "Transcription failed")))
|
|
}
|
|
}
|
|
}
|
|
|
|
private func emit(_ event: DictationEvent) {
|
|
onEvent?(event)
|
|
}
|
|
|
|
private static func errorMessage(_ error: any Error, fallback: String) -> String {
|
|
(error as? LocalizedError)?.errorDescription ?? fallback
|
|
}
|
|
}
|