feat(compute): Redundanz-Routing per targetInstance (Stage 3)

Mehrere Instanzen pro Dienst nutzbar: Anfragen werden gezielt an eine freie
Instanz adressiert statt an alle gebroadcastet. Mehrere f5tts → naechstes
freies; mehrere LLM → parallele Turns (Multitasking); STT-Redundanz ueber
mehrere Apps via Lease.

- Worker (alle vier): filtern am Loop-Eingang — targetInstance gesetzt und
  != eigener INSTANCE_ID → Nachricht ignorieren. Feld fehlt → wie bisher.
- bridge (TTS/LLM, emittiert die Bridge selbst): _pick_worker() waehlt eine
  online+freie Instanz (Round-Robin), stempelt targetInstance auf
  xtts_request / llm_request. Keine Instanz bekannt → Broadcast.
- bridge (STT-Lease, emittiert die App): neuer stt_lease_request-Handler →
  _pick_stt_worker() (voxtral vor whisper) → stt_lease {instanceId}.
- app (audio.ts): requestSttLease() vor dem Stream, stempelt targetInstance
  auf stt_stream_start / stt_audio_chunk / stt_stream_end (+cancel). Kurzer
  Timeout → '' (Broadcast), Aufnahme haengt nie.

Voll rueckwaertskompatibel: Routing aktiviert sich erst, wenn Worker sich per
worker_hello (Stage 2) registriert haben — sonst bleibt alles Broadcast.
Braucht APK-Rebuild fuer die STT-Lease-Seite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 12:27:51 +02:00
co-authored by Claude Opus 4.8
parent f2ead1242f
commit ec78eb8efe
6 changed files with 118 additions and 14 deletions
+49 -2
View File
@@ -204,6 +204,39 @@ export async function transcribeBlob(pcmBase64: string, timeoutMs = 2500): Promi
});
}
/** Fragt die Bridge vor dem Aufnahme-Stream, welche STT-Instanz adressiert
* werden soll (Redundanz ueber mehrere STT-Nodes/Apps). Schickt
* stt_lease_request, wartet kurz auf stt_lease (matching requestId).
* Rueckgabe: instanceId (z.B. "voxtral@box-a") oder '' bei Timeout/keine
* Instanz — dann streamt die App wie bisher an ALLE (Broadcast, Single-Node
* unveraendert). Bewusst kurzer Timeout, damit die Aufnahme nie haengt. */
export async function requestSttLease(timeoutMs = 250): Promise<string> {
const requestId = `sttlease_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
return new Promise<string>((resolve) => {
let done = false;
let unsub: (() => void) | null = null;
const timer = setTimeout(() => finish(''), timeoutMs);
function finish(val: string) {
if (done) return;
done = true;
try { unsub && unsub(); } catch {}
clearTimeout(timer);
resolve(val);
}
try {
unsub = rvs.onMessage((msg: any) => {
if (msg?.type !== 'stt_lease') return;
const p = (msg as any).payload || {};
if (String(p.requestId || '') !== requestId) return;
finish(typeof p.instanceId === 'string' ? p.instanceId : '');
});
rvs.send('stt_lease_request' as any, { requestId });
} catch {
finish('');
}
});
}
export async function loadSttEndpointMs(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY);
@@ -383,6 +416,9 @@ class AudioService {
// lich Chunks einer alten Session in eine neue mischen.
private streamRequestId: string = '';
private streamAudioRequestId: string = '';
// Adressierte STT-Instanz fuer diesen Stream (Redundanz-Routing). '' =
// Broadcast an alle STT-Nodes (Single-Node / kein Lease = wie bisher).
private streamTargetInstance: string = '';
// Latch: ist endpointListeners fuer den aktuellen Session-Cycle schon gefeuert
// worden? Wird auf false gesetzt beim startStreamingRecording, auf true beim
// ersten Endpoint (egal ob via RVS oder Fallback). Verhindert Doppel-Fires.
@@ -1126,6 +1162,15 @@ class AudioService {
const requestId = `sttstr_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
this.streamRequestId = requestId;
this.streamAudioRequestId = opts.audioRequestId || '';
// Redundanz-Routing: freie STT-Instanz leasen BEVOR Chunks fliessen, damit
// start + alle Chunks + end dieselbe Instanz adressieren. Kurzer Timeout →
// '' (Broadcast) falls keine Instanz/keine Antwort. Nie blockierend genug
// um die Aufnahme spuerbar zu verzoegern.
try {
this.streamTargetInstance = await requestSttLease();
} catch {
this.streamTargetInstance = '';
}
this.streamGotPartial = false;
this.streamEndpointFired = false;
this.recordingStartTime = Date.now();
@@ -1146,6 +1191,7 @@ class AudioService {
requestId: sessionId,
pcm: String(e?.pcm || ''),
seq: Number(e?.seq || 0),
targetInstance: this.streamTargetInstance,
});
});
this.streamPcmErrorSub = emitter.addListener('PcmStreamError', (e: any) => {
@@ -1181,6 +1227,7 @@ class AudioService {
hardCapMs: typeof opts.hardCapMs === 'number' ? opts.hardCapMs : 60000,
sampleRate: 16000,
projectId: opts.projectId || '',
targetInstance: this.streamTargetInstance,
});
// No-Speech-Watchdog — ersetzt den alten VAD-noSpeechTimer.
@@ -1230,7 +1277,7 @@ class AudioService {
if (!reqId) return;
const audioReqId = this.streamAudioRequestId;
try {
rvs.send('stt_stream_end' as any, { requestId: reqId, reason });
rvs.send('stt_stream_end' as any, { requestId: reqId, reason, targetInstance: this.streamTargetInstance });
} catch (e) {
console.warn('[Audio] stt_stream_end senden fehlgeschlagen:', e);
}
@@ -1265,7 +1312,7 @@ class AudioService {
if (!reqId) return;
const audioReqId = this.streamAudioRequestId;
try {
rvs.send('stt_stream_end' as any, { requestId: reqId, reason: `cancel:${reason}` });
rvs.send('stt_stream_end' as any, { requestId: reqId, reason: `cancel:${reason}`, targetInstance: this.streamTargetInstance });
} catch {}
this._cleanupStreamLocal(`cancel:${reason}`);
// Listener feuern damit ChatScreen reagieren kann (endConversation etc.)