#5 transcript: cover scheduled/web meetings on iOS too (not just native calls)

In a scheduled meeting the WebView owns the mic (the plugin has no LiveKit room), so
the native-call AudioRenderer tap didn't apply. Now the WebView reads its OWN local
mic PCM via Web Audio (the same non-intrusive tap the app already uses for
active-speaker metering — NOT a 2nd getUserMedia, which would fight the call's mic on
iOS and yield silence), downsamples to 16 kHz mono Int16, and forwards it to the
plugin's SFSpeechRecognizer via feedAudio(). Native calls keep the LiveKit tap.
Muted -> the local track carries silence -> nothing transcribed; the feed re-inits
when the mic goes live on unmute.

- Plugin: startTranscription({external:true}) runs the recognizer without a track;
  feedAudio({pcm,rate}) decodes base64 LE Int16 -> Float32 buffer -> recognizer.
- Web: startSR now uses the native recognizer for ALL iOS meetings (native call =
  LiveKit tap, scheduled = PCM feed); desktop/browser unchanged (Web Speech API).

Web deploys now; the plugin's feedAudio/startExternal ride the next Codemagic build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 17:05:56 +05:30
parent 68ff3a878b
commit e46ac1e7cc
2 changed files with 96 additions and 17 deletions
@@ -39,6 +39,7 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
CAPPluginMethod(name: "syncVideoTiles", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "startTranscription", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "stopTranscription", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "feedAudio", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "endCall", returnType: CAPPluginReturnPromise)
]
@@ -398,12 +399,18 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
return room?.localParticipant.audioTracks.first?.track as? LocalAudioTrack
}
// Start transcribing this device's mic. The web calls this when the room's transcript is active and we're
// in a native call (WKWebView has no Web Speech API). Attaches a LiveKit AudioRenderer to the local mic
// track and runs SFSpeechRecognizer; finalized segments fire the "transcript" event. Safe to call when the
// mic track doesn't exist yet we re-attach on unmute (setMicrophone-enable) too.
// Start transcribing this device's mic (WKWebView has no Web Speech API). Two audio sources:
// * NATIVE CALL (default): the plugin owns the LiveKit room, so we tap the local mic track with a LiveKit
// AudioRenderer (reuses the call's open mic reliable, no 2nd capturer). Re-attaches on unmute.
// * SCHEDULED/WEB MEETING ({external:true}): the WebView owns the mic (the plugin has no room), so the web
// reads its own mic PCM via Web Audio and pushes it here with feedAudio() no second mic capture on iOS
// (two input units would fight and yield silence).
@objc func startTranscription(_ call: CAPPluginCall) {
transcriber.start(track: localAudioTrack())
if call.getBool("external") == true {
transcriber.startExternal()
} else {
transcriber.start(track: localAudioTrack())
}
call.resolve()
}
@@ -412,6 +419,15 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
call.resolve()
}
// Web-forwarded mic PCM for the {external:true} path (scheduled/web meetings). `pcm` = base64 little-endian
// Int16 mono at `rate` Hz (the web downsamples to 16 kHz). Fed straight into the recognizer.
@objc func feedAudio(_ call: CAPPluginCall) {
guard let b64 = call.getString("pcm"), let data = Data(base64Encoded: b64) else { call.resolve(); return }
let rate = call.getDouble("rate") ?? 16000
transcriber.appendPCM(int16: data, sampleRate: rate)
call.resolve()
}
// MARK: - BroadcastManagerDelegate
public func broadcastManager(didChangeState isBroadcasting: Bool) {
@@ -667,23 +683,45 @@ final class SpeechTranscriber: NSObject, AudioRenderer, @unchecked Sendable {
private var lastEmitted = ""
private var silenceTimer: DispatchWorkItem?
// Begin transcription (idempotent). Asks for Speech authorization once, then starts recognition and taps the
// current mic track. Safe to call before the mic exists `refresh` re-attaches when it publishes (unmute).
func start(track: LocalAudioTrack?) {
// Begin transcription tapping a LiveKit local mic track (native calls). Idempotent; asks for Speech
// authorization once. Safe to call before the mic exists `refresh` re-attaches when it publishes (unmute).
func start(track: LocalAudioTrack?) { begin { self.attach(track) } }
// Begin transcription in EXTERNAL mode (scheduled/web meetings): no LiveKit track to tap PCM arrives via
// appendPCM() from the web. Same recognition pipeline.
func startExternal() { begin { } }
private func begin(_ afterStart: @escaping () -> Void) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if self.running { self.attach(track); return }
if self.running { afterStart(); return }
SFSpeechRecognizer.requestAuthorization { status in
DispatchQueue.main.async {
guard status == .authorized, !self.running else { return }
self.running = true
self.beginRequest()
self.attach(track)
afterStart()
}
}
}
}
// Web-forwarded PCM (external mode): little-endian Int16 mono Float32 buffer recognizer.
func appendPCM(int16 data: Data, sampleRate: Double) {
let count = data.count / 2
guard count > 0,
let fmt = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: sampleRate, channels: 1, interleaved: false),
let buf = AVAudioPCMBuffer(pcmFormat: fmt, frameCapacity: AVAudioFrameCount(count)) else { return }
buf.frameLength = AVAudioFrameCount(count)
guard let dst = buf.floatChannelData?[0] else { return }
data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in
let src = raw.bindMemory(to: Int16.self)
for i in 0..<count { dst[i] = Float(src[i]) / 32768.0 }
}
lock.lock(); let r = request; lock.unlock()
r?.append(buf)
}
// (Re)attach to the current local mic track called on start and whenever the mic (re)publishes on unmute.
func refresh(track: LocalAudioTrack?) { DispatchQueue.main.async { [weak self] in self?.attach(track) } }