项目文件夹

文件
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

1388 行
51 KiB
Swift

import Foundation
import Capacitor
import AVFoundation
import Speech
// MARK: - TalkModePlugin
@objc(TalkModePlugin)
public class TalkModePlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "TalkModePlugin"
public let jsName = "TalkMode"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "start", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "stop", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "isEnabled", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getState", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "updateConfig", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "speak", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "stopSpeaking", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "isSpeaking", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "checkPermissions", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "requestPermissions", returnType: CAPPluginReturnPromise),
]
private static let defaultModelId = "eleven_flash_v2_5"
private static let localInferenceTtsURL = "eliza-local-agent://ipc/api/tts/local-inference"
// MARK: - State
private var enabled = false
private var state: String = "idle"
private var statusText: String = "Off"
// MARK: - Speech Recognition
private let audioEngine = AVAudioEngine()
private var speechRecognizer: SFSpeechRecognizer?
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
private var recognitionTask: SFSpeechRecognitionTask?
private var silenceTask: Task<Void, Never>?
private var lastTranscript = ""
private var lastHeard: Date?
private var silenceWindow: TimeInterval = 0.7
// MARK: - TTS
private let systemSynthesizer = AVSpeechSynthesizer()
private var systemSpeechDelegate: SystemSpeechDelegate?
private var isSpeakingValue = false
private var usedSystemTts = false
private var lastSpokenText: String?
private var lastInterruptedAtSeconds: Double?
// MARK: - PCM Streaming Playback
private var pcmEngine: AVAudioEngine?
private var pcmPlayerNode: AVAudioPlayerNode?
private var pcmStopRequested = false
private var pcmPlaybackStartTime: Date?
// MARK: - MP3 Playback
private var audioPlayer: AVAudioPlayer?
private var mp3PlaybackStartTime: Date?
// MARK: - Active Tasks
private var speakTask: Task<Void, Error>?
// MARK: - Config
private var apiKey: String?
private var defaultVoiceId: String?
private var currentVoiceId: String?
private var defaultModelId: String? = TalkModePlugin.defaultModelId
private var currentModelId: String? = TalkModePlugin.defaultModelId
private var defaultOutputFormat: String? = "pcm_24000"
private var voiceAliases: [String: String] = [:]
private var interruptOnSpeech = true
private var sessionKey = "main"
private var voiceOverrideActive = false
private var modelOverrideActive = false
// MARK: - Lifecycle
public override func load() {
speechRecognizer = SFSpeechRecognizer()
}
// MARK: - Plugin Methods
@objc func start(_ call: CAPPluginCall) {
// Parse config first so STT language is set before availability check
if let config = call.getObject("config") {
applyConfig(config)
}
guard let recognizer = speechRecognizer, recognizer.isAvailable else {
call.resolve(["started": false, "error": "Speech recognition not available"])
return
}
Task { @MainActor in
let micOk = await self.requestMicrophonePermission()
guard micOk else {
call.resolve(["started": false, "error": "Microphone permission denied"])
return
}
let speechOk = await self.requestSpeechPermission()
guard speechOk else {
call.resolve(["started": false, "error": "Speech recognition permission denied"])
return
}
do {
try self.configureAudioSession()
try self.startRecognition()
self.enabled = true
self.setState("listening", "Listening")
self.startSilenceMonitor()
call.resolve(["started": true])
} catch {
self.emitError(code: "start_failed", message: error.localizedDescription, recoverable: true)
call.resolve(["started": false, "error": error.localizedDescription])
}
}
}
@objc func stop(_ call: CAPPluginCall) {
enabled = false
stopRecognition()
stopSpeakingInternal()
silenceTask?.cancel()
silenceTask = nil
lastTranscript = ""
lastHeard = nil
lastInterruptedAtSeconds = nil
setState("idle", "Off")
do {
try AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
} catch {
// Ignore deactivation errors
}
call.resolve()
}
@objc func isEnabled(_ call: CAPPluginCall) {
call.resolve(["enabled": enabled])
}
@objc func getState(_ call: CAPPluginCall) {
call.resolve(["state": state, "statusText": statusText])
}
@objc func updateConfig(_ call: CAPPluginCall) {
guard let config = call.getObject("config") else {
call.resolve()
return
}
applyConfig(config)
call.resolve()
}
@objc func speak(_ call: CAPPluginCall) {
guard let text = call.getString("text")?.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty else {
call.resolve(["completed": true, "interrupted": false, "usedSystemTts": false])
return
}
let useSystemTts = call.getBool("useSystemTts") ?? false
let useLocalInferenceTts = call.getBool("useLocalInferenceTts") ?? false
let directive = call.getObject("directive")
speakTask?.cancel()
speakTask = Task { @MainActor in
await self.speakInternal(
text: text,
forceSystemTts: useSystemTts,
useLocalInferenceTts: useLocalInferenceTts,
directive: directive,
call: call
)
}
}
@objc func stopSpeaking(_ call: CAPPluginCall) {
let interruptedAt = stopSpeakingInternal()
var result: JSObject = [:]
if let interruptedAt {
result["interruptedAt"] = interruptedAt
}
call.resolve(result)
}
@objc func isSpeaking(_ call: CAPPluginCall) {
call.resolve(["speaking": isSpeakingValue])
}
@objc public override func checkPermissions(_ call: CAPPluginCall) {
call.resolve(buildPermissionResult())
}
@objc public override func requestPermissions(_ call: CAPPluginCall) {
Task { @MainActor in
_ = await self.requestMicrophonePermission()
_ = await self.requestSpeechPermission()
call.resolve(self.buildPermissionResult())
}
}
// MARK: - Config Application
private func applyConfig(_ config: JSObject) {
if let tts = config["tts"] as? [String: Any] {
if let key = tts["apiKey"] as? String {
apiKey = key.trimmingCharacters(in: .whitespacesAndNewlines)
}
if let voice = tts["voiceId"] as? String {
defaultVoiceId = voice.trimmingCharacters(in: .whitespacesAndNewlines)
if !voiceOverrideActive { currentVoiceId = defaultVoiceId }
}
if let model = tts["modelId"] as? String {
let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines)
defaultModelId = trimmed.isEmpty ? Self.defaultModelId : trimmed
if !modelOverrideActive { currentModelId = defaultModelId }
}
if let format = tts["outputFormat"] as? String {
defaultOutputFormat = format.trimmingCharacters(in: .whitespacesAndNewlines)
}
if let interrupt = tts["interruptOnSpeech"] as? Bool {
interruptOnSpeech = interrupt
}
if let aliases = tts["voiceAliases"] as? [String: String] {
var normalized: [String: String] = [:]
for (key, value) in aliases {
let k = key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let v = value.trimmingCharacters(in: .whitespacesAndNewlines)
if !k.isEmpty, !v.isEmpty { normalized[k] = v }
}
voiceAliases = normalized
}
}
if let stt = config["stt"] as? [String: Any] {
if let lang = stt["language"] as? String, !lang.isEmpty {
speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: lang))
}
}
if let silenceMs = config["silenceWindowMs"] as? Int, silenceMs > 0 {
silenceWindow = TimeInterval(silenceMs) / 1000.0
}
if let interrupt = config["interruptOnSpeech"] as? Bool {
interruptOnSpeech = interrupt
}
if let key = config["sessionKey"] as? String {
sessionKey = key
}
}
// MARK: - Speech Recognition
private func startRecognition() throws {
#if targetEnvironment(simulator)
throw NSError(domain: "TalkMode", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Speech recognition not supported on simulator"
])
#endif
stopRecognition()
guard let recognizer = speechRecognizer, recognizer.isAvailable else {
throw NSError(domain: "TalkMode", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Speech recognizer unavailable"
])
}
recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
recognitionRequest?.shouldReportPartialResults = true
guard let request = recognitionRequest else { return }
let input = audioEngine.inputNode
let format = input.outputFormat(forBus: 0)
guard format.sampleRate > 0, format.channelCount > 0 else {
throw NSError(domain: "TalkMode", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Invalid audio input format"
])
}
input.removeTap(onBus: 0)
input.installTap(onBus: 0, bufferSize: 2048, format: format) { buffer, _ in
request.append(buffer)
}
audioEngine.prepare()
try audioEngine.start()
recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in
guard let self else { return }
if let error {
if !self.isSpeakingValue {
print("[TalkMode] Recognition error: \(error.localizedDescription)")
}
return
}
guard let result else { return }
let transcript = result.bestTranscription.formattedString
DispatchQueue.main.async {
self.handleTranscript(transcript: transcript, isFinal: result.isFinal)
}
}
}
private func stopRecognition() {
recognitionTask?.cancel()
recognitionTask = nil
recognitionRequest?.endAudio()
recognitionRequest = nil
audioEngine.inputNode.removeTap(onBus: 0)
audioEngine.stop()
}
private func handleTranscript(transcript: String, isFinal: Bool) {
let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
// During TTS playback, only listen for interrupt triggers
if isSpeakingValue, interruptOnSpeech {
if shouldInterrupt(with: trimmed) {
stopSpeakingInternal()
}
return
}
guard enabled else { return }
if !trimmed.isEmpty {
lastTranscript = trimmed
lastHeard = Date()
}
if isFinal {
lastTranscript = trimmed
}
notifyListeners("transcript", data: TalkModeBridgeContract.transcriptPayload(
transcript: trimmed,
isFinal: isFinal
))
}
/// Determines whether detected speech should interrupt current TTS playback.
/// Filters out echo where the mic picks up our own TTS output.
private func shouldInterrupt(with transcript: String) -> Bool {
return TalkModeBridgeContract.shouldInterruptSpeech(
transcript: transcript,
lastSpokenText: lastSpokenText
)
}
// MARK: - Silence Detection
private func startSilenceMonitor() {
silenceTask?.cancel()
silenceTask = Task { [weak self] in
while self?.enabled == true {
try? await Task.sleep(nanoseconds: 200_000_000) // 200ms poll
// Re-capture `self` explicitly in the inner MainActor
// closure. Without this, Swift 6 strict concurrency
// rejects it with:
// error: reference to captured var 'self' in
// concurrently-executing code
// because the outer `[weak self]` list does not
// propagate into the nested `MainActor.run` closure.
await MainActor.run { [weak self] in self?.checkSilence() }
}
}
}
/// Check if the user stopped speaking and enough silence has elapsed.
/// When silence exceeds the configured window, finalize the transcript
/// so the JS layer can send it to the agent.
private func checkSilence() {
guard enabled, !isSpeakingValue, state == "listening" else { return }
let transcript = lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
guard !transcript.isEmpty else { return }
guard let lastHeard else { return }
if Date().timeIntervalSince(lastHeard) >= silenceWindow {
finalizeTranscript(transcript)
}
}
/// Emit the final transcript and transition to processing state.
/// The JS layer picks this up to send the transcript to the agent.
private func finalizeTranscript(_ transcript: String) {
lastTranscript = ""
lastHeard = nil
setState("processing", "Processing")
stopRecognition()
notifyListeners("transcript", data: TalkModeBridgeContract.transcriptPayload(
transcript: transcript,
isFinal: true
))
}
// MARK: - TTS Orchestration
private func speakInternal(
text: String,
forceSystemTts: Bool,
useLocalInferenceTts: Bool,
directive: [String: Any]?,
call: CAPPluginCall
) async {
isSpeakingValue = true
usedSystemTts = false
pcmStopRequested = false
lastSpokenText = text
setState("speaking", "Speaking")
// Resolve voice/model from directive, with override persistence
let requestedVoice = (directive?["voiceId"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedVoice = resolveVoiceAlias(requestedVoice)
let isOnce = directive?["once"] as? Bool ?? false
if let voice = resolvedVoice, !isOnce {
currentVoiceId = voice
voiceOverrideActive = true
}
if let model = directive?["modelId"] as? String, !model.isEmpty, !isOnce {
currentModelId = model
modelOverrideActive = true
}
let effectiveVoiceId = resolvedVoice ?? currentVoiceId ?? defaultVoiceId
let effectiveModelId = (directive?["modelId"] as? String)
?? currentModelId ?? defaultModelId ?? Self.defaultModelId
let rawFormat = (directive?["outputFormat"] as? String)
?? defaultOutputFormat ?? "pcm_24000"
let effectiveFormat = Self.validatedOutputFormat(rawFormat) ?? "pcm_24000"
let effectiveApiKey = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines)
let canUseLocalInference = useLocalInferenceTts && !forceSystemTts
let canUseElevenLabs = !canUseLocalInference
&& !forceSystemTts
&& !(effectiveApiKey ?? "").isEmpty
&& !(effectiveVoiceId ?? "").isEmpty
notifyListeners("speaking", data: [
"text": text,
"isSystemTts": !(canUseLocalInference || canUseElevenLabs)
])
// Enable STT during playback for interrupt detection
if interruptOnSpeech {
do { try startRecognition() } catch {
print("[TalkMode] Recognition for interrupt detection failed: \(error)")
}
} else {
stopRecognition()
}
var interrupted = false
let language = Self.validatedLanguage(directive?["language"] as? String)
do {
if canUseLocalInference {
try await streamLocalInferenceTts(text: text, directive: directive)
interrupted = pcmStopRequested
} else if canUseElevenLabs {
do {
try await streamElevenLabsTts(
text: text,
voiceId: effectiveVoiceId ?? "",
apiKey: effectiveApiKey ?? "",
modelId: effectiveModelId,
outputFormat: effectiveFormat,
directive: directive
)
interrupted = pcmStopRequested
} catch {
// Fail closed (#12253): ElevenLabs is the configured voice, so
// do NOT silently swap to the system AVSpeechSynthesizer. Emit
// the error and stop — no audio plays. AVSpeechSynthesizer is
// reachable only when the caller explicitly requests the system
// engine (the `else` branch below), never as error recovery.
print("[TalkMode] ElevenLabs failed; failing closed, no system-TTS swap: \(error)")
emitError(
code: "elevenlabs_failed",
message: error.localizedDescription,
recoverable: true
)
call.resolve([
"completed": false,
"interrupted": false,
"usedSystemTts": false,
"error": error.localizedDescription
])
finishSpeaking()
return
}
} else {
try await speakWithSystemTts(text: text, language: language)
}
} catch {
emitError(code: "tts_failed", message: error.localizedDescription, recoverable: true)
call.resolve([
"completed": false,
"interrupted": false,
"usedSystemTts": usedSystemTts,
"error": error.localizedDescription
])
finishSpeaking()
return
}
var result: JSObject = [
"completed": !interrupted,
"interrupted": interrupted,
"usedSystemTts": usedSystemTts
]
if interrupted, let at = lastInterruptedAtSeconds {
result["interruptedAt"] = at
}
call.resolve(result)
notifyListeners("speakComplete", data: [
"completed": !interrupted
])
finishSpeaking()
}
// MARK: - Local Inference TTS
private struct LocalInferenceAudioResponse {
let status: Int
let statusText: String
let headers: [String: String]
let audioBase64: String
let bodyText: String
}
private struct WavPcmFormat {
let sampleRate: Int
let channels: Int
}
private func streamLocalInferenceTts(text: String, directive: [String: Any]?) async throws {
let response = try await requestLocalInferenceTtsAudio(text: text, directive: directive)
guard (200..<300).contains(response.status) else {
let detail = response.bodyText.trimmingCharacters(in: .whitespacesAndNewlines)
let suffix = detail.isEmpty ? response.statusText : detail
throw NSError(domain: "TalkMode", code: response.status, userInfo: [
NSLocalizedDescriptionKey: "Local inference TTS error: \(response.status) \(suffix)"
])
}
guard let audioData = Data(base64Encoded: response.audioBase64), !audioData.isEmpty else {
throw NSError(domain: "TalkMode", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Local inference TTS returned empty audio"
])
}
let format = try Self.parseWavPcmFormat(audioData)
notifyListeners("playbackStart", data: [
"provider": "local-inference",
"sampleRate": format.sampleRate,
"channels": format.channels
])
try await playBufferedAudio(audioData)
}
private func requestLocalInferenceTtsAudio(
text: String,
directive: [String: Any]?
) async throws -> LocalInferenceAudioResponse {
guard let webView = bridge?.webView else {
throw NSError(domain: "TalkMode", code: 503, userInfo: [
NSLocalizedDescriptionKey: "iOS local-agent WebView bridge is unavailable"
])
}
guard #available(iOS 14.0, *) else {
throw NSError(domain: "TalkMode", code: 503, userInfo: [
NSLocalizedDescriptionKey: "Local inference TTS requires iOS 14 or later"
])
}
let payload = buildLocalInferenceTtsPayload(text: text, directive: directive)
let source = """
const response = await fetch(options.url, {
method: "POST",
headers: {
"Accept": "audio/wav",
"Content-Type": "application/json"
},
body: JSON.stringify(options.payload)
});
const headers = {};
response.headers.forEach((value, key) => { headers[key] = value; });
const buffer = await response.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = "";
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode.apply(null, Array.from(chunk));
}
let bodyText = "";
if (!response.ok && typeof TextDecoder !== "undefined") {
bodyText = new TextDecoder().decode(bytes.slice(0, 2048));
}
return {
status: response.status,
statusText: response.statusText,
headers,
audioBase64: btoa(binary),
bodyText
};
"""
return try await withCheckedThrowingContinuation { continuation in
DispatchQueue.main.async {
Task { @MainActor in
do {
let value = try await webView.callAsyncJavaScript(
source,
arguments: [
"options": [
"url": Self.localInferenceTtsURL,
"payload": payload
]
],
in: nil,
contentWorld: .page
)
continuation.resume(returning: try self.parseLocalInferenceAudioResponse(value))
} catch {
continuation.resume(throwing: error)
}
}
}
}
}
private func buildLocalInferenceTtsPayload(text: String, directive: [String: Any]?) -> JSObject {
var payload: JSObject = ["text": text]
for key in ["voiceId", "voice", "modelId", "model"] {
if let value = directive?[key] as? String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { payload[key] = trimmed }
}
}
if let speed = directive?["speed"] as? Double, speed.isFinite, speed > 0 {
payload["speed"] = speed
} else if let speed = directive?["speed"] as? NSNumber, speed.doubleValue.isFinite, speed.doubleValue > 0 {
payload["speed"] = speed.doubleValue
}
if let outputFormat = directive?["outputFormat"] as? String {
let trimmed = outputFormat.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { payload["format"] = trimmed }
}
return payload
}
private func parseLocalInferenceAudioResponse(_ value: Any?) throws -> LocalInferenceAudioResponse {
guard let payload = value as? JSObject else {
throw NSError(domain: "TalkMode", code: 502, userInfo: [
NSLocalizedDescriptionKey: "Local inference TTS returned an invalid bridge response"
])
}
let status = (payload["status"] as? Int)
?? (payload["status"] as? NSNumber)?.intValue
?? 0
let headers = payload["headers"] as? [String: String] ?? [:]
return LocalInferenceAudioResponse(
status: status,
statusText: payload["statusText"] as? String ?? "",
headers: headers,
audioBase64: payload["audioBase64"] as? String ?? "",
bodyText: payload["bodyText"] as? String ?? ""
)
}
private static func parseWavPcmFormat(_ data: Data) throws -> WavPcmFormat {
guard data.count >= 12,
asciiString(data, offset: 0, length: 4) == "RIFF",
asciiString(data, offset: 8, length: 4) == "WAVE" else {
throw NSError(domain: "TalkMode", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Local inference TTS returned non-WAV audio"
])
}
var offset = 12
var parsedFormat: WavPcmFormat?
while offset + 8 <= data.count {
let id = asciiString(data, offset: offset, length: 4)
let size = Int(littleEndianUInt32(data, offset: offset + 4))
let payloadOffset = offset + 8
guard size >= 0, payloadOffset + size <= data.count else {
throw NSError(domain: "TalkMode", code: 4, userInfo: [
NSLocalizedDescriptionKey: "Invalid WAV chunk size"
])
}
if id == "fmt " {
guard size >= 16 else {
throw NSError(domain: "TalkMode", code: 5, userInfo: [
NSLocalizedDescriptionKey: "Invalid WAV fmt chunk"
])
}
let audioFormat = littleEndianUInt16(data, offset: payloadOffset)
let channels = Int(littleEndianUInt16(data, offset: payloadOffset + 2))
let sampleRate = Int(littleEndianUInt32(data, offset: payloadOffset + 4))
let bitsPerSample = littleEndianUInt16(data, offset: payloadOffset + 14)
guard audioFormat == 1 else {
throw NSError(domain: "TalkMode", code: 6, userInfo: [
NSLocalizedDescriptionKey: "Only PCM WAV is supported"
])
}
guard bitsPerSample == 16 else {
throw NSError(domain: "TalkMode", code: 7, userInfo: [
NSLocalizedDescriptionKey: "Only 16-bit PCM WAV is supported"
])
}
guard channels >= 1, channels <= 2, sampleRate > 0 else {
throw NSError(domain: "TalkMode", code: 8, userInfo: [
NSLocalizedDescriptionKey: "Invalid WAV sample rate or channel count"
])
}
parsedFormat = WavPcmFormat(sampleRate: sampleRate, channels: channels)
} else if id == "data" {
guard let parsedFormat else {
throw NSError(domain: "TalkMode", code: 9, userInfo: [
NSLocalizedDescriptionKey: "WAV data arrived before fmt chunk"
])
}
return parsedFormat
}
offset = payloadOffset + size + (size % 2)
}
throw NSError(domain: "TalkMode", code: 10, userInfo: [
NSLocalizedDescriptionKey: "WAV data chunk was not found"
])
}
private static func asciiString(_ data: Data, offset: Int, length: Int) -> String {
guard offset >= 0, length >= 0, offset + length <= data.count else { return "" }
return String(data: data.subdata(in: offset..<(offset + length)), encoding: .ascii) ?? ""
}
private static func littleEndianUInt16(_ data: Data, offset: Int) -> UInt16 {
guard offset + 2 <= data.count else { return 0 }
return UInt16(data[offset]) | (UInt16(data[offset + 1]) << 8)
}
private static func littleEndianUInt32(_ data: Data, offset: Int) -> UInt32 {
guard offset + 4 <= data.count else { return 0 }
return UInt32(data[offset])
| (UInt32(data[offset + 1]) << 8)
| (UInt32(data[offset + 2]) << 16)
| (UInt32(data[offset + 3]) << 24)
}
/// Clean up after speech and restart recognition if talk mode is still enabled.
private func finishSpeaking() {
isSpeakingValue = false
pcmStopRequested = false
stopRecognition()
if enabled {
setState("listening", "Listening")
do {
try startRecognition()
startSilenceMonitor()
} catch {
print("[TalkMode] Failed to restart recognition: \(error)")
emitError(
code: "recognition_restart_failed",
message: error.localizedDescription,
recoverable: true
)
}
} else {
setState("idle", "Off")
}
}
// MARK: - ElevenLabs Streaming TTS
private func streamElevenLabsTts(
text: String,
voiceId: String,
apiKey: String,
modelId: String,
outputFormat: String,
directive: [String: Any]?
) async throws {
let urlString = "https://api.elevenlabs.io/v1/text-to-speech/\(voiceId)/stream"
guard let url = URL(string: urlString) else {
throw NSError(domain: "TalkMode", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Invalid ElevenLabs URL"
])
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(apiKey, forHTTPHeaderField: "xi-api-key")
// Build voice settings from directive values
let speed = Self.resolveSpeed(
speed: directive?["speed"] as? Double,
rateWpm: directive?["rateWpm"] as? Int
)
let stability = Self.validatedUnit(directive?["stability"] as? Double) ?? 0.5
let similarity = Self.validatedUnit(directive?["similarity"] as? Double) ?? 0.75
var voiceSettings: [String: Any] = [
"stability": stability,
"similarity_boost": similarity
]
if let speed { voiceSettings["speed"] = speed }
if let style = Self.validatedUnit(directive?["style"] as? Double) {
voiceSettings["style"] = style
}
if let boost = directive?["speakerBoost"] as? Bool {
voiceSettings["use_speaker_boost"] = boost
}
var body: [String: Any] = [
"text": text,
"model_id": modelId,
"output_format": outputFormat,
"voice_settings": voiceSettings
]
if let seed = Self.validatedSeed(directive?["seed"] as? Int) {
body["seed"] = seed
}
if let normalize = Self.validatedNormalize(directive?["normalize"] as? String) {
body["apply_text_normalization"] = normalize
}
if let language = Self.validatedLanguage(directive?["language"] as? String) {
body["language_code"] = language
}
if let tier = Self.validatedLatencyTier(directive?["latencyTier"] as? Int) {
body["optimize_streaming_latency"] = tier
}
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let isPCM = outputFormat.hasPrefix("pcm_")
let sampleRate = Self.pcmSampleRate(from: outputFormat)
if isPCM, let sampleRate {
do {
try await streamPCMPlayback(request: request, sampleRate: sampleRate)
} catch {
// PCM playback failed; retry as MP3 as a fallback
guard !pcmStopRequested else { return }
print("[TalkMode] PCM playback failed, retrying as MP3: \(error)")
let mp3Format = "mp3_44100_128"
var retryBody = body
retryBody["output_format"] = mp3Format
var retryRequest = request
retryRequest.httpBody = try JSONSerialization.data(withJSONObject: retryBody)
try await downloadAndPlayAudio(request: retryRequest)
}
} else {
try await downloadAndPlayAudio(request: request)
}
}
/// Stream PCM audio from the network directly into an AVAudioPlayerNode.
/// Chunks are scheduled onto the player as they arrive for low-latency playback.
private func streamPCMPlayback(request: URLRequest, sampleRate: Double) async throws {
let engine = AVAudioEngine()
let playerNode = AVAudioPlayerNode()
let format = AVAudioFormat(
commonFormat: .pcmFormatInt16,
sampleRate: sampleRate,
channels: 1,
interleaved: true
)!
engine.attach(playerNode)
engine.connect(playerNode, to: engine.mainMixerNode, format: format)
try engine.start()
pcmEngine = engine
pcmPlayerNode = playerNode
pcmPlaybackStartTime = Date()
playerNode.play()
defer {
engine.stop()
pcmEngine = nil
pcmPlayerNode = nil
}
let (bytes, response) = try await URLSession.shared.bytes(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NSError(domain: "TalkMode", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Invalid HTTP response from ElevenLabs"
])
}
guard httpResponse.statusCode == 200 else {
// Read a bit of the error body for diagnostics
var errorData = Data()
for try await byte in bytes {
errorData.append(byte)
if errorData.count > 2048 { break }
}
let errorMsg = String(data: errorData, encoding: .utf8) ?? "status \(httpResponse.statusCode)"
throw NSError(domain: "TalkMode", code: httpResponse.statusCode, userInfo: [
NSLocalizedDescriptionKey: "ElevenLabs API error: \(errorMsg)"
])
}
// Accumulate bytes into chunks; schedule each on the player node.
// Chunk size is ~0.5s of audio for smooth playback without excessive latency.
// 16-bit mono PCM: sampleRate * 2 bytes per second.
let chunkSize = Int(sampleRate) // ~0.5s of 16-bit mono audio
var buffer = Data()
var scheduledCount = 0
let completionGroup = DispatchGroup()
for try await byte in bytes {
if pcmStopRequested { break }
buffer.append(byte)
if buffer.count >= chunkSize {
try scheduleChunk(buffer, on: playerNode, format: format, group: completionGroup)
scheduledCount += 1
buffer = Data()
}
}
// Schedule any remaining data
if !buffer.isEmpty, !pcmStopRequested {
try scheduleChunk(buffer, on: playerNode, format: format, group: completionGroup)
scheduledCount += 1
}
// Wait for all scheduled buffers to finish playback
if scheduledCount > 0, !pcmStopRequested {
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
completionGroup.notify(queue: .main) {
continuation.resume()
}
}
}
}
/// Create a PCM buffer from raw bytes and schedule it on the player node.
private func scheduleChunk(
_ data: Data,
on playerNode: AVAudioPlayerNode,
format: AVAudioFormat,
group: DispatchGroup
) throws {
let frameCount = UInt32(data.count / 2) // 16-bit = 2 bytes per sample
guard frameCount > 0 else { return }
guard let pcmBuffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else {
throw NSError(domain: "TalkMode", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Failed to create PCM buffer"
])
}
pcmBuffer.frameLength = frameCount
data.withUnsafeBytes { bytes in
guard let baseAddress = bytes.baseAddress else { return }
memcpy(pcmBuffer.int16ChannelData![0], baseAddress, data.count)
}
group.enter()
playerNode.scheduleBuffer(pcmBuffer) {
group.leave()
}
}
private func playBufferedAudio(_ data: Data) async throws {
mp3PlaybackStartTime = Date()
let player = try AVAudioPlayer(data: data)
audioPlayer = player
player.prepareToPlay()
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
let delegate = AudioPlayerDelegate {
continuation.resume()
}
objc_setAssociatedObject(player, "delegate", delegate, .OBJC_ASSOCIATION_RETAIN)
player.delegate = delegate
player.play()
}
audioPlayer = nil
mp3PlaybackStartTime = nil
}
/// Download a full audio response (MP3 etc.) and play it with AVAudioPlayer.
private func downloadAndPlayAudio(request: URLRequest) async throws {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
let msg = String(data: data.prefix(2048), encoding: .utf8) ?? "Unknown error"
throw NSError(domain: "TalkMode", code: 2, userInfo: [
NSLocalizedDescriptionKey: "ElevenLabs API error: \(msg)"
])
}
try await playBufferedAudio(data)
}
// MARK: - System TTS
private func speakWithSystemTts(text: String, language: String? = nil) async throws {
usedSystemTts = true
setState("speaking", "Speaking (System)")
let utterance = AVSpeechUtterance(string: text)
if let language, let voice = AVSpeechSynthesisVoice(language: language) {
utterance.voice = voice
} else {
let lang = Locale.current.languageCode ?? "en"
utterance.voice = AVSpeechSynthesisVoice(language: lang)
}
// Watchdog timeout: estimate from text length (0.08s per character, bounded)
let estimatedSeconds = max(3.0, min(180.0, Double(text.count) * 0.08))
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
let delegate = SystemSpeechDelegate(continuation: cont)
self.systemSpeechDelegate = delegate // retain
self.systemSynthesizer.delegate = delegate
self.systemSynthesizer.speak(utterance)
// Watchdog: force-finish if TTS takes too long
delegate.watchdog = Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(estimatedSeconds * 1_000_000_000))
guard !delegate.isFinished else { return }
self.systemSynthesizer.stopSpeaking(at: .immediate)
delegate.finish(error: NSError(domain: "TalkMode", code: 408, userInfo: [
NSLocalizedDescriptionKey: "System TTS timed out after \(Int(estimatedSeconds))s"
]))
}
}
} onCancel: {
Task { @MainActor in
self.systemSynthesizer.stopSpeaking(at: .immediate)
self.systemSpeechDelegate?.finish(
error: NSError(domain: "TalkMode", code: -999, userInfo: [
NSLocalizedDescriptionKey: "System TTS cancelled"
])
)
}
}
}
// MARK: - Stop Speaking
/// Stop all TTS playback. Returns the interrupted-at time in seconds, if available.
@discardableResult
private func stopSpeakingInternal() -> Double? {
guard isSpeakingValue else { return nil }
pcmStopRequested = true
// Compute how far into playback we were
var interruptedAt: Double?
if let start = pcmPlaybackStartTime {
interruptedAt = Date().timeIntervalSince(start)
} else if let start = mp3PlaybackStartTime {
interruptedAt = Date().timeIntervalSince(start)
}
lastInterruptedAtSeconds = interruptedAt
// Stop PCM streaming engine
pcmPlayerNode?.stop()
pcmEngine?.stop()
pcmEngine = nil
pcmPlayerNode = nil
pcmPlaybackStartTime = nil
// Stop MP3 player
audioPlayer?.stop()
audioPlayer = nil
mp3PlaybackStartTime = nil
// Stop system TTS
systemSynthesizer.stopSpeaking(at: .immediate)
systemSpeechDelegate?.finish(
error: NSError(domain: "TalkMode", code: -1, userInfo: [
NSLocalizedDescriptionKey: "Speech interrupted by user"
])
)
// Cancel in-flight speak task
speakTask?.cancel()
isSpeakingValue = false
return interruptedAt
}
// MARK: - Permissions
private func requestMicrophonePermission() async -> Bool {
await withCheckedContinuation { continuation in
if #available(iOS 17.0, *) {
AVAudioApplication.requestRecordPermission { granted in
continuation.resume(returning: granted)
}
} else {
AVAudioSession.sharedInstance().requestRecordPermission { granted in
continuation.resume(returning: granted)
}
}
}
}
private func requestSpeechPermission() async -> Bool {
await withCheckedContinuation { continuation in
SFSpeechRecognizer.requestAuthorization { status in
continuation.resume(returning: status == .authorized)
}
}
}
private func buildPermissionResult() -> JSObject {
let micStatus: String
switch AVAudioSession.sharedInstance().recordPermission {
case .granted: micStatus = "granted"
case .denied: micStatus = "denied"
case .undetermined: micStatus = "prompt"
@unknown default: micStatus = "prompt"
}
let speechStatus: String
switch SFSpeechRecognizer.authorizationStatus() {
case .authorized: speechStatus = "granted"
case .denied: speechStatus = "denied"
case .notDetermined: speechStatus = "prompt"
case .restricted: speechStatus = "denied"
@unknown default: speechStatus = "prompt"
}
// TalkModeBridgeContract.permissionPayload defines the canonical shape
// ([String: Any] for parity with the other payloads + their tests). A
// Capacitor JSObject is [String: any JSValue], so bridge the contract's
// String fields explicitly rather than returning [String: Any] directly
// (Any does not conform to JSValue — the implicit conversion fails to
// type-check).
var result = JSObject()
for (key, value) in TalkModeBridgeContract.permissionPayload(
microphone: micStatus,
speechRecognition: speechStatus
) {
if let stringValue = value as? String {
result[key] = stringValue
}
}
return result
}
// MARK: - Audio Session
private func configureAudioSession() throws {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playAndRecord, mode: .voiceChat, options: [
.duckOthers,
.mixWithOthers,
.allowBluetoothA2DP,
.defaultToSpeaker
])
try session.setActive(true)
}
// MARK: - State & Events
private func setState(_ newState: String, _ newStatusText: String) {
let previousState = state
state = newState
statusText = newStatusText
notifyListeners("stateChange", data: TalkModeBridgeContract.statePayload(
state: newState,
previousState: previousState,
statusText: newStatusText,
usingSystemTts: usedSystemTts
))
}
private func emitError(code: String, message: String, recoverable: Bool) {
notifyListeners("error", data: [
"code": code,
"message": message,
"recoverable": recoverable
])
}
// MARK: - Voice Alias Resolution
private func resolveVoiceAlias(_ value: String?) -> String? {
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty else {
return nil
}
let normalized = trimmed.lowercased()
// Check alias map
if let mapped = voiceAliases[normalized] { return mapped }
// Check if the value is already a known voice ID in aliases values
if voiceAliases.values.contains(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame }) {
return trimmed
}
// If it looks like a raw ElevenLabs voice ID (alphanumeric, 10+ chars), pass through
if trimmed.count >= 10,
trimmed.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" }) {
return trimmed
}
return nil
}
// MARK: - TTS Parameter Validation
/// Resolve speed from either explicit speed or words-per-minute rate.
/// ElevenLabs accepts 0.5–2.0; WPM is normalized against 175 WPM baseline.
private static func resolveSpeed(speed: Double?, rateWpm: Int?) -> Double? {
if let rateWpm, rateWpm > 0 {
let resolved = Double(rateWpm) / 175.0
guard resolved >= 0.5, resolved <= 2.0 else { return nil }
return resolved
}
if let speed {
guard speed >= 0.5, speed <= 2.0 else { return nil }
return speed
}
return nil
}
/// Validate a 0–1 unit range parameter (stability, similarity, style).
private static func validatedUnit(_ value: Double?) -> Double? {
guard let value, value >= 0, value <= 1 else { return nil }
return value
}
/// Validate seed (unsigned 32-bit integer range).
private static func validatedSeed(_ value: Int?) -> Int? {
guard let value, value >= 0, value <= 4_294_967_295 else { return nil }
return value
}
/// Validate text normalization mode (auto/on/off).
private static func validatedNormalize(_ value: String?) -> String? {
guard let value else { return nil }
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return ["auto", "on", "off"].contains(normalized) ? normalized : nil
}
/// Validate language code (2-letter ISO only).
static func validatedLanguage(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard trimmed.count == 2, trimmed.allSatisfy({ $0.isLetter }) else { return nil }
return trimmed
}
/// Validate latency optimization tier (1–4).
private static func validatedLatencyTier(_ value: Int?) -> Int? {
guard let value, value >= 1, value <= 4 else { return nil }
return value
}
/// Validate ElevenLabs output format string.
static func validatedOutputFormat(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let validFormats: Set<String> = [
"mp3_22050_32", "mp3_44100_32", "mp3_44100_64",
"mp3_44100_96", "mp3_44100_128", "mp3_44100_192",
"pcm_16000", "pcm_22050", "pcm_24000", "pcm_44100",
"ulaw_8000"
]
return validFormats.contains(trimmed) ? trimmed : nil
}
/// Extract sample rate from a PCM output format string (e.g. "pcm_24000" → 24000).
static func pcmSampleRate(from format: String?) -> Double? {
guard let format, format.hasPrefix("pcm_") else { return nil }
if format.contains("44100") { return 44100 }
if format.contains("24000") { return 24000 }
if format.contains("22050") { return 22050 }
if format.contains("16000") { return 16000 }
return nil
}
}
// MARK: - SystemSpeechDelegate
/// Delegate for AVSpeechSynthesizer that bridges the callback-based API to async/await
/// via a CheckedContinuation, with a watchdog timeout for safety.
private class SystemSpeechDelegate: NSObject, AVSpeechSynthesizerDelegate {
private var continuation: CheckedContinuation<Void, Error>?
var isFinished = false
var watchdog: Task<Void, Never>?
init(continuation: CheckedContinuation<Void, Error>) {
self.continuation = continuation
super.init()
}
func finish(error: Error? = nil) {
guard !isFinished else { return }
isFinished = true
watchdog?.cancel()
watchdog = nil
let cont = continuation
continuation = nil
if let error {
cont?.resume(throwing: error)
} else {
cont?.resume(returning: ())
}
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
finish()
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
finish(error: NSError(domain: "TalkMode", code: -1, userInfo: [
NSLocalizedDescriptionKey: "System TTS cancelled"
]))
}
}
// MARK: - AudioPlayerDelegate
/// Delegate for AVAudioPlayer (MP3 playback) that signals completion via a closure.
private class AudioPlayerDelegate: NSObject, AVAudioPlayerDelegate {
private var onComplete: (() -> Void)?
init(onComplete: @escaping () -> Void) {
self.onComplete = onComplete
super.init()
}
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
onComplete?()
onComplete = nil
}
func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) {
onComplete?()
onComplete = nil
}
}