Schema v3: transcription attempts accumulate on AudioInfo (text, locale, confidence, forced flag) — latest attempt is displayed, payload.text is purely user writing, v2 notes migrate at read time. Editor shows the transcript in its own live-updating section with insert-into-note; search matches transcripts weighted by confidence. Watch recordings are now deleted only on the phone's durable-ingest ack (transferUserInfo), closing every phone-side loss window; failed sidecar writes unstage instead of orphaning, and unreadable inbox recordings are surfaced in logs. Claude-Session: https://claude.ai/code/session_014esDWi42URLEC6Cj17hGQ3
296 lines
13 KiB
Swift
296 lines
13 KiB
Swift
import AVFoundation
|
|
import Foundation
|
|
import IndieSync
|
|
import Speech
|
|
import SwiftData
|
|
|
|
/// On-device, after-the-fact transcription of voice notes. `@Observable
|
|
/// @MainActor` with a FIFO one-at-a-time queue: a `SpeechAnalyzer` is memory- and
|
|
/// model-heavy, so notes are transcribed strictly one after another. The analyzer
|
|
/// work is awaited (the analyzer is an actor), so it doesn't block the main actor
|
|
/// despite this class being main-actor confined.
|
|
///
|
|
/// Only this device's own pending notes are ever enqueued — ownership is decided
|
|
/// by `AudioInfo.transcriberDeviceID` upstream (`SyncEngine`), which this service
|
|
/// trusts. A failed job is terminal until the user asks to retry.
|
|
@Observable
|
|
@MainActor
|
|
final class TranscriptionService {
|
|
private let syncEngine: SyncEngine
|
|
private let settings: TranscriptionSettings
|
|
private let modelContainer: ModelContainer
|
|
|
|
/// Seconds of audio the sampling pass listens to when choosing a language.
|
|
private static let sampleSeconds: Double = 15
|
|
|
|
private struct Job {
|
|
let noteID: ULID
|
|
/// A fresh recording's temp file, passed on enqueue for the fast path; nil
|
|
/// for re-transcription, which pulls the audio back down from the container.
|
|
let localAudioURL: URL?
|
|
/// A forced language (re-transcribe menu), else auto-select.
|
|
let forcedLocaleID: String?
|
|
}
|
|
|
|
private var queue: [Job] = []
|
|
/// Ids currently queued or in flight, so overlapping triggers (launch resume,
|
|
/// watch ingest, a re-transcribe) can't enqueue the same note twice.
|
|
private var inFlightIDs: Set<String> = []
|
|
private var pumpTask: Task<Void, Never>?
|
|
|
|
init(syncEngine: SyncEngine, settings: TranscriptionSettings, modelContainer: ModelContainer) {
|
|
self.syncEngine = syncEngine
|
|
self.settings = settings
|
|
self.modelContainer = modelContainer
|
|
}
|
|
|
|
// MARK: - Queue
|
|
|
|
/// Queue a note for transcription. `localAudioURL` is the fresh-recording fast
|
|
/// path (skips the container round-trip); `forcedLocaleID` skips language
|
|
/// auto-selection. Duplicate enqueues of an in-flight note are ignored.
|
|
func enqueue(noteID: ULID, localAudioURL: URL? = nil, forcedLocaleID: String? = nil) {
|
|
let key = noteID.stringValue
|
|
guard !inFlightIDs.contains(key) else { return }
|
|
inFlightIDs.insert(key)
|
|
queue.append(Job(noteID: noteID, localAudioURL: localAudioURL, forcedLocaleID: forcedLocaleID))
|
|
pump()
|
|
}
|
|
|
|
/// On launch, re-enqueue every note this device owns that is still pending —
|
|
/// recovering work that a crash or kill interrupted mid-transcription. Reads
|
|
/// the cache (already rebuilt by the sync engine's connect).
|
|
func resumePendingWork() {
|
|
let context = modelContainer.mainContext
|
|
let installID = TranscriptionSettings.installID
|
|
let pendingRaw = Note.TranscriptionStatus.pending.rawValue
|
|
let descriptor = FetchDescriptor<NoteEntity>(predicate: #Predicate {
|
|
$0.hasAudio
|
|
&& $0.transcriptionStatusRaw == pendingRaw
|
|
&& $0.transcriberDeviceID == installID
|
|
})
|
|
let rows = (try? context.fetch(descriptor)) ?? []
|
|
for row in rows {
|
|
if let id = ULID(string: row.id) { enqueue(noteID: id) }
|
|
}
|
|
}
|
|
|
|
private func pump() {
|
|
guard pumpTask == nil else { return }
|
|
pumpTask = Task { [weak self] in
|
|
while let self, let job = self.dequeue() {
|
|
await self.process(job)
|
|
self.inFlightIDs.remove(job.noteID.stringValue)
|
|
}
|
|
self?.pumpTask = nil
|
|
}
|
|
}
|
|
|
|
private func dequeue() -> Job? {
|
|
queue.isEmpty ? nil : queue.removeFirst()
|
|
}
|
|
|
|
// MARK: - Processing
|
|
|
|
/// Transcribe one job, retrying once before marking the note failed. A failed
|
|
/// note is left for the user to retry explicitly (re-transcribe / Retry).
|
|
private func process(_ job: Job) async {
|
|
do {
|
|
try await transcribe(job)
|
|
} catch {
|
|
do {
|
|
try await transcribe(job)
|
|
} catch {
|
|
print("[Transcription] \(job.noteID.stringValue) failed: \(error)")
|
|
try? await syncEngine.markTranscriptionFailed(id: job.noteID)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func transcribe(_ job: Job) async throws {
|
|
let audio = try await resolveAudioFile(for: job)
|
|
defer { cleanUp(audio: audio, job: job) }
|
|
|
|
let locale = try await selectLocale(fileURL: audio.url, forcedLocaleID: job.forcedLocaleID)
|
|
let transcript = try await runTranscription(fileURL: audio.url, locale: locale)
|
|
let text = String(transcript.characters).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
try await syncEngine.applyTranscription(
|
|
id: job.noteID,
|
|
text: text,
|
|
localeID: TranscriptionSettings.canonicalID(locale),
|
|
// Real mean confidence when the engine attributed any runs; nil for
|
|
// a confidence-less engine so it's never mistaken for a measured 0.
|
|
confidence: LanguagePicker.hasConfidence(of: transcript)
|
|
? LanguagePicker.meanConfidence(of: transcript) : nil,
|
|
languageWasForced: job.forcedLocaleID != nil
|
|
)
|
|
}
|
|
|
|
// MARK: - Audio acquisition
|
|
|
|
private struct AudioFile {
|
|
let url: URL
|
|
/// Whether we wrote a temp copy that must be cleaned up after the job.
|
|
let isTemporary: Bool
|
|
}
|
|
|
|
/// The fresh recording if it's still on disk, else a temp copy pulled back
|
|
/// from the container (bounded wait for an evicted file).
|
|
private func resolveAudioFile(for job: Job) async throws -> AudioFile {
|
|
if let local = job.localAudioURL, FileManager.default.fileExists(atPath: local.path) {
|
|
return AudioFile(url: local, isTemporary: false)
|
|
}
|
|
let data = try await syncEngine.loadAudioDataWaiting(for: job.noteID)
|
|
let url = FileManager.default.temporaryDirectory
|
|
.appending(path: "transcribe-\(job.noteID.stringValue).m4a")
|
|
try data.write(to: url)
|
|
return AudioFile(url: url, isTemporary: true)
|
|
}
|
|
|
|
private func cleanUp(audio: AudioFile, job: Job) {
|
|
let fm = FileManager.default
|
|
if audio.isTemporary { try? fm.removeItem(at: audio.url) }
|
|
// A fresh recording's temp file is no longer needed once the container
|
|
// holds the bytes; drop it to keep the temp dir tidy.
|
|
if let local = job.localAudioURL,
|
|
local.path.hasPrefix(fm.temporaryDirectory.path) {
|
|
try? fm.removeItem(at: local)
|
|
}
|
|
}
|
|
|
|
// MARK: - Language selection
|
|
|
|
private func selectLocale(fileURL: URL, forcedLocaleID: String?) async throws -> Locale {
|
|
if let forcedLocaleID {
|
|
return Locale(identifier: forcedLocaleID)
|
|
}
|
|
let candidates = await settings.enabledInstalledLocales()
|
|
guard !candidates.isEmpty else { throw TranscriptionError.noInstalledLanguage }
|
|
if candidates.count == 1 { return candidates[0] }
|
|
|
|
// Sampling pass: transcribe a short clip in each language and pick the
|
|
// most confident. Run sequentially — one analyzer at a time keeps the
|
|
// memory ceiling low.
|
|
let sample = try makeSampleClip(from: fileURL, seconds: Self.sampleSeconds)
|
|
defer { if sample.isTemporary { try? FileManager.default.removeItem(at: sample.url) } }
|
|
|
|
var scored: [LanguageCandidate] = []
|
|
for locale in candidates {
|
|
let transcript = (try? await runTranscription(fileURL: sample.url, locale: locale)) ?? AttributedString()
|
|
// `rankingConfidence`, not `meanConfidence`: a `DictationTranscriber`
|
|
// locale may leave runs unattributed, so an unscored-but-non-empty
|
|
// transcript gets a neutral score and competes on length rather than
|
|
// losing outright to a confidence-reporting `SpeechTranscriber` locale.
|
|
scored.append(LanguageCandidate(
|
|
localeID: TranscriptionSettings.canonicalID(locale),
|
|
meanConfidence: LanguagePicker.rankingConfidence(of: transcript),
|
|
transcriptLength: transcript.characters.count
|
|
))
|
|
}
|
|
guard let winner = LanguagePicker.pick(from: scored) else { return candidates[0] }
|
|
return Locale(identifier: winner.localeID)
|
|
}
|
|
|
|
/// The first `seconds` of the recording as its own file, so the sampling pass
|
|
/// isn't run over a whole long note. Returns the original file (no copy) when
|
|
/// the recording is already shorter than the sample window.
|
|
private func makeSampleClip(from fileURL: URL, seconds: Double) throws -> AudioFile {
|
|
let source = try AVAudioFile(forReading: fileURL)
|
|
let format = source.processingFormat
|
|
let wantFrames = format.sampleRate * seconds
|
|
guard Double(source.length) > wantFrames else {
|
|
return AudioFile(url: fileURL, isTemporary: false)
|
|
}
|
|
let frameCount = AVAudioFrameCount(wantFrames)
|
|
guard frameCount > 0, let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else {
|
|
throw TranscriptionError.audioUnreadable
|
|
}
|
|
try source.read(into: buffer, frameCount: frameCount)
|
|
let clipURL = FileManager.default.temporaryDirectory
|
|
.appending(path: "sample-\(UUID().uuidString).caf")
|
|
let clip = try AVAudioFile(forWriting: clipURL, settings: format.settings)
|
|
try clip.write(from: buffer)
|
|
return AudioFile(url: clipURL, isTemporary: true)
|
|
}
|
|
|
|
// MARK: - Analyzer
|
|
|
|
/// Transcribe a whole file in one locale and return the finalized attributed
|
|
/// transcript (carrying per-run confidence where the engine emits it). Routes
|
|
/// to whichever engine supports the locale — `SpeechTranscriber` or, for its
|
|
/// dictation-only locales (ru-RU, uk-UA, pl-PL …), `DictationTranscriber` —
|
|
/// via `TranscriptionSettings.engine(for:)`. Both request confidence; the two
|
|
/// `Result` types share `isFinal`/`text` but only concretely, so the engine
|
|
/// branch supplies the module and its result stream to a shared `analyze`.
|
|
private func runTranscription(fileURL: URL, locale: Locale) async throws -> AttributedString {
|
|
switch settings.engine(for: locale) {
|
|
case .speech:
|
|
let transcriber = SpeechTranscriber(
|
|
locale: locale,
|
|
transcriptionOptions: [],
|
|
reportingOptions: [],
|
|
attributeOptions: [.transcriptionConfidence]
|
|
)
|
|
return try await analyze(module: transcriber, results: transcriber.results, fileURL: fileURL) { $0.text }
|
|
case .dictation:
|
|
let transcriber = DictationTranscriber(
|
|
locale: locale,
|
|
contentHints: [],
|
|
transcriptionOptions: [],
|
|
reportingOptions: [],
|
|
attributeOptions: [.transcriptionConfidence]
|
|
)
|
|
return try await analyze(module: transcriber, results: transcriber.results, fileURL: fileURL) { $0.text }
|
|
}
|
|
}
|
|
|
|
/// Drive `SpeechAnalyzer` over the whole file with a single module and fold its
|
|
/// finalized results into one transcript. Generic over the module's result
|
|
/// stream so one pipeline serves both engines (their `Result`s share `isFinal`
|
|
/// via `SpeechModuleResult` but expose `text` only concretely — hence `textOf`).
|
|
/// Follows the documented pattern: start a collector over `results`, feed the
|
|
/// file, finalize.
|
|
private func analyze<Results>(
|
|
module: any SpeechModule,
|
|
results: Results,
|
|
fileURL: URL,
|
|
textOf: @escaping @Sendable (Results.Element) -> AttributedString
|
|
) async throws -> AttributedString
|
|
where Results: AsyncSequence & Sendable,
|
|
Results.Element: SpeechModuleResult & Sendable,
|
|
Results.Failure == any Error {
|
|
let analyzer = SpeechAnalyzer(
|
|
modules: [module],
|
|
options: SpeechAnalyzer.Options(priority: .utility, modelRetention: .whileInUse)
|
|
)
|
|
let file = try AVAudioFile(forReading: fileURL)
|
|
|
|
// Collect finalized results concurrently; `results` terminates when the
|
|
// analyzer finishes, ending the loop.
|
|
let collector = Task {
|
|
var full = AttributedString()
|
|
for try await result in results where result.isFinal {
|
|
full.append(textOf(result))
|
|
}
|
|
return full
|
|
}
|
|
|
|
do {
|
|
if let lastSample = try await analyzer.analyzeSequence(from: file) {
|
|
try await analyzer.finalizeAndFinish(through: lastSample)
|
|
} else {
|
|
await analyzer.cancelAndFinishNow()
|
|
}
|
|
} catch {
|
|
collector.cancel()
|
|
throw error
|
|
}
|
|
return try await collector.value
|
|
}
|
|
|
|
enum TranscriptionError: Error {
|
|
case audioUnreadable
|
|
case noInstalledLanguage
|
|
}
|
|
}
|