Files
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 lines
51 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.52.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 01 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 (14).
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
}
}