#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:
@@ -39,6 +39,7 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
|
|||||||
CAPPluginMethod(name: "syncVideoTiles", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "syncVideoTiles", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "startTranscription", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "startTranscription", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "stopTranscription", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "stopTranscription", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "feedAudio", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "endCall", 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
|
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
|
// Start transcribing this device's mic (WKWebView has no Web Speech API). Two audio sources:
|
||||||
// in a native call (WKWebView has no Web Speech API). Attaches a LiveKit AudioRenderer to the local mic
|
// * NATIVE CALL (default): the plugin owns the LiveKit room, so we tap the local mic track with a LiveKit
|
||||||
// track and runs SFSpeechRecognizer; finalized segments fire the "transcript" event. Safe to call when the
|
// AudioRenderer (reuses the call's open mic — reliable, no 2nd capturer). Re-attaches on unmute.
|
||||||
// mic track doesn't exist yet — we re-attach on unmute (setMicrophone-enable) too.
|
// * 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) {
|
@objc func startTranscription(_ call: CAPPluginCall) {
|
||||||
transcriber.start(track: localAudioTrack())
|
if call.getBool("external") == true {
|
||||||
|
transcriber.startExternal()
|
||||||
|
} else {
|
||||||
|
transcriber.start(track: localAudioTrack())
|
||||||
|
}
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,6 +419,15 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
|
|||||||
call.resolve()
|
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
|
// MARK: - BroadcastManagerDelegate
|
||||||
|
|
||||||
public func broadcastManager(didChangeState isBroadcasting: Bool) {
|
public func broadcastManager(didChangeState isBroadcasting: Bool) {
|
||||||
@@ -667,23 +683,45 @@ final class SpeechTranscriber: NSObject, AudioRenderer, @unchecked Sendable {
|
|||||||
private var lastEmitted = ""
|
private var lastEmitted = ""
|
||||||
private var silenceTimer: DispatchWorkItem?
|
private var silenceTimer: DispatchWorkItem?
|
||||||
|
|
||||||
// Begin transcription (idempotent). Asks for Speech authorization once, then starts recognition and taps the
|
// Begin transcription tapping a LiveKit local mic track (native calls). Idempotent; asks for Speech
|
||||||
// current mic track. Safe to call before the mic exists — `refresh` re-attaches when it publishes (unmute).
|
// authorization once. Safe to call before the mic exists — `refresh` re-attaches when it publishes (unmute).
|
||||||
func start(track: LocalAudioTrack?) {
|
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
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
if self.running { self.attach(track); return }
|
if self.running { afterStart(); return }
|
||||||
SFSpeechRecognizer.requestAuthorization { status in
|
SFSpeechRecognizer.requestAuthorization { status in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
guard status == .authorized, !self.running else { return }
|
guard status == .authorized, !self.running else { return }
|
||||||
self.running = true
|
self.running = true
|
||||||
self.beginRequest()
|
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.
|
// (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) } }
|
func refresh(track: LocalAudioTrack?) { DispatchQueue.main.async { [weak self] in self?.attach(track) } }
|
||||||
|
|
||||||
|
|||||||
+48
-7
@@ -5778,14 +5778,19 @@ async function uploadRecording(blob, durMs){
|
|||||||
function toggleTranscribe(){ meetTranscribe=!meetTranscribe; meetSend({type:'meeting-transcribe', on:meetTranscribe}); updateTransBtn(); toast(meetTranscribe?'Transcript on — your private copy is saved to Past meetings after the call':'You left the transcript — your copy is being saved'); }
|
function toggleTranscribe(){ meetTranscribe=!meetTranscribe; meetSend({type:'meeting-transcribe', on:meetTranscribe}); updateTransBtn(); toast(meetTranscribe?'Transcript on — your private copy is saved to Past meetings after the call':'You left the transcript — your copy is being saved'); }
|
||||||
function applyRoomTx(active){ if(active===meetRoomTx) return; meetRoomTx=active; if(active) startSR(); else stopSR(); transcribeNotice(active); }
|
function applyRoomTx(active){ if(active===meetRoomTx) return; meetRoomTx=active; if(active) startSR(); else stopSR(); transcribeNotice(active); }
|
||||||
function startSR(){ if(meetSR) return;
|
function startSR(){ if(meetSR) return;
|
||||||
// #5: on an iOS NATIVE call the WKWebView has no Web Speech API — transcribe via the native plugin
|
// #5: the iOS WKWebView has no Web Speech API, so transcribe via the native plugin (SFSpeechRecognizer).
|
||||||
// (SFSpeechRecognizer tapping the call's mic). Its 'transcript' events feed the same meeting-transcript path.
|
// Its 'transcript' events feed the same meeting-transcript path as the desktop Web Speech API. Two mic sources:
|
||||||
if(meetNative){ const NC=nativeCallPlugin(); if(NC && NC.startTranscription){
|
// * NATIVE call (meetNative): the plugin owns the LiveKit mic → it taps its own track.
|
||||||
|
// * SCHEDULED/web meeting: the WebView owns the mic → we read its PCM (Web Audio, no 2nd capture) and push it
|
||||||
|
// to the plugin via feedAudio(). On desktop/browser NC is null → falls through to the Web Speech API below.
|
||||||
|
const NC=nativeCallPlugin();
|
||||||
|
if(NC && NC.startTranscription){
|
||||||
meetSR='native';
|
meetSR='native';
|
||||||
try{ _meetTxListener=NC.addListener('transcript', (e)=>{ const text=((e&&e.text)||'').trim(); if(text) meetSend({type:'meeting-transcript', text}); }); }catch(_){}
|
try{ _meetTxListener=NC.addListener('transcript', (e)=>{ const text=((e&&e.text)||'').trim(); if(text) meetSend({type:'meeting-transcript', text}); }); }catch(_){}
|
||||||
try{ const p=NC.startTranscription(); if(p&&p.catch) p.catch(()=>{}); }catch(_){}
|
try{ const p=NC.startTranscription(meetNative?{}:{external:true}); if(p&&p.catch) p.catch(()=>{}); }catch(_){}
|
||||||
|
if(!meetNative) _startWebPcmFeed(); // scheduled meeting → stream the WebView's own mic to the recognizer
|
||||||
return;
|
return;
|
||||||
}}
|
}
|
||||||
const SR=window.SpeechRecognition||window.webkitSpeechRecognition; if(!SR){ if(meetTranscribe) toast('Live transcript needs Chrome or Edge'); return; }
|
const SR=window.SpeechRecognition||window.webkitSpeechRecognition; if(!SR){ if(meetTranscribe) toast('Live transcript needs Chrome or Edge'); return; }
|
||||||
try{ meetSR=new SR(); }catch(_){ return; }
|
try{ meetSR=new SR(); }catch(_){ return; }
|
||||||
meetSR.continuous=true; meetSR.interimResults=false; meetSR.lang='en-US';
|
meetSR.continuous=true; meetSR.interimResults=false; meetSR.lang='en-US';
|
||||||
@@ -5795,9 +5800,45 @@ function startSR(){ if(meetSR) return;
|
|||||||
}
|
}
|
||||||
function _removeTxListener(){ const h=_meetTxListener; _meetTxListener=null; if(!h) return; try{ if(h.remove) h.remove(); else if(h.then) h.then(x=>{ try{ x&&x.remove&&x.remove(); }catch(_){} }); }catch(_){} }
|
function _removeTxListener(){ const h=_meetTxListener; _meetTxListener=null; if(!h) return; try{ if(h.remove) h.remove(); else if(h.then) h.then(x=>{ try{ x&&x.remove&&x.remove(); }catch(_){} }); }catch(_){} }
|
||||||
function stopSR(){
|
function stopSR(){
|
||||||
if(meetSR==='native'){ meetSR=null; _removeTxListener(); const NC=nativeCallPlugin(); if(NC&&NC.stopTranscription){ try{ const p=NC.stopTranscription(); if(p&&p.catch) p.catch(()=>{}); }catch(_){} } return; }
|
if(meetSR==='native'){ meetSR=null; _removeTxListener(); _stopWebPcmFeed(); const NC=nativeCallPlugin(); if(NC&&NC.stopTranscription){ try{ const p=NC.stopTranscription(); if(p&&p.catch) p.catch(()=>{}); }catch(_){} } return; }
|
||||||
if(meetSR){ try{ meetSR.onend=null; meetSR.stop(); }catch(_){} meetSR=null; }
|
if(meetSR){ try{ meetSR.onend=null; meetSR.stop(); }catch(_){} meetSR=null; }
|
||||||
}
|
}
|
||||||
|
// #5 (scheduled/web meetings on iOS): the WebView owns the mic, so read its 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) — downsample to 16 kHz mono Int16, and push it to the plugin's recognizer (feedAudio). While
|
||||||
|
// muted the local track carries silence, so nothing is transcribed. Re-inited on unmute (the mic track appears).
|
||||||
|
let _txCtx=null,_txProc=null,_txSrc=null,_txGain=null;
|
||||||
|
function _txMicTrack(){ try{ const ts=meetLocalStream&&meetLocalStream.getAudioTracks&&meetLocalStream.getAudioTracks(); return (ts&&ts[0])||null; }catch(_){ return null; } }
|
||||||
|
function _startWebPcmFeed(){
|
||||||
|
_stopWebPcmFeed();
|
||||||
|
const NC=nativeCallPlugin(); if(!NC||!NC.feedAudio) return;
|
||||||
|
const track=_txMicTrack(); if(!track) return; // mic not live yet — retried on unmute
|
||||||
|
try{
|
||||||
|
const Ctx=window.AudioContext||window.webkitAudioContext; if(!Ctx) return;
|
||||||
|
_txCtx=new Ctx();
|
||||||
|
_txSrc=_txCtx.createMediaStreamSource(new MediaStream([track]));
|
||||||
|
_txProc=_txCtx.createScriptProcessor(4096,1,1);
|
||||||
|
const inRate=_txCtx.sampleRate||48000, outRate=16000, ratio=inRate/outRate;
|
||||||
|
_txProc.onaudioprocess=(ev)=>{
|
||||||
|
try{
|
||||||
|
const inp=ev.inputBuffer.getChannelData(0); const outLen=Math.max(0,Math.floor(inp.length/ratio));
|
||||||
|
if(!outLen) return; const i16=new Int16Array(outLen);
|
||||||
|
for(let i=0;i<outLen;i++){ let s=inp[Math.floor(i*ratio)]; s=s<-1?-1:(s>1?1:s); i16[i]=s<0?s*0x8000:s*0x7fff; }
|
||||||
|
const bytes=new Uint8Array(i16.buffer); let bin=''; for(let i=0;i<bytes.length;i++) bin+=String.fromCharCode(bytes[i]);
|
||||||
|
const NC2=nativeCallPlugin(); if(NC2&&NC2.feedAudio) NC2.feedAudio({ pcm:btoa(bin), rate:outRate });
|
||||||
|
}catch(_){}
|
||||||
|
};
|
||||||
|
_txGain=_txCtx.createGain(); _txGain.gain.value=0; // silent sink so the processor runs without echoing the mic
|
||||||
|
_txSrc.connect(_txProc); _txProc.connect(_txGain); _txGain.connect(_txCtx.destination);
|
||||||
|
}catch(_){ _stopWebPcmFeed(); }
|
||||||
|
}
|
||||||
|
function _stopWebPcmFeed(){
|
||||||
|
try{ if(_txProc){ _txProc.onaudioprocess=null; _txProc.disconnect(); } }catch(_){}
|
||||||
|
try{ if(_txSrc) _txSrc.disconnect(); }catch(_){}
|
||||||
|
try{ if(_txGain) _txGain.disconnect(); }catch(_){}
|
||||||
|
try{ if(_txCtx) _txCtx.close(); }catch(_){}
|
||||||
|
_txProc=null; _txSrc=null; _txGain=null; _txCtx=null;
|
||||||
|
}
|
||||||
function updateTransBtn(){ const b=document.getElementById('meetTransBtn'); if(!b) return; b.classList.toggle('on', meetTranscribe); b.title=meetTranscribe?'Stop my transcript':'Transcribe (your private copy)'; }
|
function updateTransBtn(){ const b=document.getElementById('meetTransBtn'); if(!b) return; b.classList.toggle('on', meetTranscribe); b.title=meetTranscribe?'Stop my transcript':'Transcribe (your private copy)'; }
|
||||||
function transcribeNotice(on){ let el=document.getElementById('txNotice'); if(on){ if(!el){ el=document.createElement('div'); el.id='txNotice'; el.className='tx-notice'; el.innerHTML=ic('fileText',12)+' Transcribing'; document.body.appendChild(el); } } else if(el){ el.remove(); } }
|
function transcribeNotice(on){ let el=document.getElementById('txNotice'); if(on){ if(!el){ el=document.createElement('div'); el.id='txNotice'; el.className='tx-notice'; el.innerHTML=ic('fileText',12)+' Transcribing'; document.body.appendChild(el); } } else if(el){ el.remove(); } }
|
||||||
// iOS blocks media/WebRTC audio playback until a user gesture, so remote call audio stays SILENT until you
|
// iOS blocks media/WebRTC audio playback until a user gesture, so remote call audio stays SILENT until you
|
||||||
@@ -6005,7 +6046,7 @@ function updateFlipBtn(){ const b=document.getElementById('meetFlipBtn'); if(b)
|
|||||||
async function toggleMic(){
|
async function toggleMic(){
|
||||||
if(!meetLocalStream) return;
|
if(!meetLocalStream) return;
|
||||||
if(meetNative){ const next=!meetMic; const NC=nativeCallPlugin(); if(NC){ try{ NC.setMuted({ muted:!next }); }catch(_){} } meetMic=next; updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; } // native: mute the plugin's mic
|
if(meetNative){ const next=!meetMic; const NC=nativeCallPlugin(); if(NC){ try{ NC.setMuted({ muted:!next }); }catch(_){} } meetMic=next; updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; } // native: mute the plugin's mic
|
||||||
if(SFU.on){ const next=!meetMic; try{ await sfuSetMic(next); meetMic=next; }catch(e){ toast(mediaErrMsg(e,'microphone')); return; } updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; }
|
if(SFU.on){ const next=!meetMic; try{ await sfuSetMic(next); meetMic=next; }catch(e){ toast(mediaErrMsg(e,'microphone')); return; } updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); if(meetSR==='native' && next) _startWebPcmFeed(); /* #5: mic just went live → (re)connect the PCM feed to it */ return; }
|
||||||
const hasTrack=meetLocalStream.getAudioTracks().length>0;
|
const hasTrack=meetLocalStream.getAudioTracks().length>0;
|
||||||
if(!hasTrack){
|
if(!hasTrack){
|
||||||
let astream; try{ astream=await navigator.mediaDevices.getUserMedia({ audio:true }); }
|
let astream; try{ astream=await navigator.mediaDevices.getUserMedia({ audio:true }); }
|
||||||
|
|||||||
Reference in New Issue
Block a user