Silent Emergency Live-Audio
WebRTC · Stealth · Evidence30s Rolling Buffer · Adaptive Bitrate · Kein UI-Indicator · Behördensichere Speicherung
End-to-End Pipeline · 8 Stufen
RollingBuffer + AudioCapture + VoiceListener initialisieren
100ms Chunks → Noise Filter → Ringpuffer (30s RAM)
85% Speaker-Match → Pre-Buffer exportieren + GPS holen
WebRTC Signaling → TURN-Server → unsichtbarer Live-Stream
30s vor Trigger als erste Pakete in Stream einspeisen
DNA-Mitarbeiterin hört Stream · Alarm-Entity auf rot
RTT/Loss Monitor alle 2s → HD/MID/LOW/MIN automatisch
Recording → evidence.sec-si.de, 90 Tage, behördensicher
30s
vor dem Trigger
~1.9
MB RAM Verbrauch
100
ms Chunk-Größe
Warum 30 Sekunden? Das Codewort ist nur der letzte Moment einer Bedrohung. Die 30s davor enthalten Tatbestandsmerkmale (Drohungen, Kommentare) — entscheidend für strafprozessuale Verwertbarkeit nach § 100a StPO.
// ─── ROLLING BUFFER · 30s Ringpuffer im RAM ─────────────────────
// Ziel: Audio VOR dem Trigger mitliefern (Beweissicherung)
// Format: PCM Float32, 16kHz Mono → ~1.92 MB für 30s
MODULE RollingBuffer:
CONST SAMPLE_RATE = 16000 // 16 kHz Mono
CONST BUFFER_SECS = 30 // 30 Sekunden Ringpuffer
CONST CHUNK_MS = 100 // 100ms Chunks
CONST CHUNK_SAMPLES = SAMPLE_RATE * CHUNK_MS / 1000 // = 1600 Samples
CONST TOTAL_CHUNKS = BUFFER_SECS * 1000 / CHUNK_MS // = 300 Chunks
STATE ring = CircularArray(size: TOTAL_CHUNKS) // RAM only, kein Disk
STATE cursor = 0
// Kontinuierlich befüllen (läuft ab Session-Start)
FUNCTION onAudioChunk(pcmChunk: Float32Array):
ring[cursor % TOTAL_CHUNKS] = pcmChunk
cursor++
// Bei Trigger: gesamten Puffer chronologisch exportieren
FUNCTION exportPreTriggerAudio() → Float32Array:
oldest = cursor % TOTAL_CHUNKS // Ältester Slot
ordered = ring.slice(oldest) + ring.slice(0, oldest) // Sortiert
flat = ordered.flatten() // Kontinuierlicher PCM-Stream
RETURN flat // ~1.92 MB, 30s
// Privacy: Puffer bei Session-Ende sofort nullen
FUNCTION destroy():
ring.fill(0)
cursor = 0
ring = null
GC.force()
// ─── INTEGRATION: Trigger-Moment ────────────────────────────────
ON VoiceTriggerEvent:
preTriggerPCM = RollingBuffer.exportPreTriggerAudio() // 30s davor
RollingBuffer.destroy() // Puffer löschen
// An WebRTC-Stream übergeben (Modul 1)
WebRTCStreamer.injectPreBuffer(preTriggerPCM)
// Parallel: Backup-Upload (Modul 3)
EmergencyAudioEndpoint.uploadBuffer(preTriggerPCM)Eigene STUN/TURN-Server: sec-si.de-Infrastruktur — keine Daten über Google/AWS
Opus Codec: 6–64 kbps, variable Bitrate, perfekt für Sprache bei schlechtem Netz
Pre-Buffer Injection: 30s History als erste Pakete vor Live-Stream eingespielt
// ─── MODUL 1: WebRTC VOICE-STREAM · Stealth-Mode ────────────────
// Latenz-Ziel: < 250ms End-to-End
// STUN/TURN: SEC.SI eigene Infrastruktur (keine Google-Server)
MODULE SilentWebRTCStream:
CONST ICE_SERVERS = [
{ urls: "stun:stun.sec-si.de:3478" },
{ urls: "turn:turn.sec-si.de:3478",
username: SESSION.turn_user,
credential: SESSION.turn_pass }
]
CONST AUDIO_CONSTRAINTS = {
sampleRate: 16000,
channelCount: 1, // Mono
echoCancellation: false, // Keine Echo-Änderung → originaler Ton
noiseSuppression: false, // Eigener Filter aus Modul 2
autoGainControl: false, // Konstante Lautstärke für Beweise
latency: 0.01 // 10ms Puffer-Anforderung
}
STATE pc = null // RTCPeerConnection
STATE mediaStream = null
STATE statsTimer = null
FUNCTION init(sessionToken):
// getUserMedia im Hintergrund — kein UI-Indicator
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: AUDIO_CONSTRAINTS,
video: false // Kein Video — nur Audio
})
pc = new RTCPeerConnection({ iceServers: ICE_SERVERS })
// STEALTH: MediaStream Track unsichtbar hinzufügen
track = mediaStream.getAudioTracks()[0]
track.contentHint = "speech" // Optimierungshinweis für Codec
pc.addTrack(track, mediaStream)
// Adaptive Bitrate vorbereiten (Modul 2)
sender = pc.getSenders()[0]
AdaptiveBitrate.attach(sender)
// SDP Offer → SEC.SI Signaling-Server
offer = await pc.createOffer({ offerToReceiveAudio: false })
await pc.setLocalDescription(offer)
answer = await SignalingServer.exchange(offer, sessionToken)
await pc.setRemoteDescription(answer)
// Stats-Monitoring starten
statsTimer = setInterval(() => AdaptiveBitrate.monitor(pc), 2000)
FUNCTION injectPreBuffer(pcmData: Float32Array):
// Pre-Trigger-Audio als erste Pakete in den Stream einspeisen
// via AudioContext → MediaStreamDestination → RTCPeerConnection
ctx = new AudioContext({ sampleRate: 16000 })
buffer = ctx.createBuffer(1, pcmData.length, 16000)
buffer.copyToChannel(pcmData, 0)
source = ctx.createBufferSource()
source.buffer = buffer
dest = ctx.createMediaStreamDestination()
source.connect(dest)
source.start(0)
// Track austauschen: Pre-Buffer → Live-Mic
newTrack = dest.stream.getAudioTracks()[0]
sender = pc.getSenders()[0]
await sender.replaceTrack(newTrack)
// Nach Pre-Buffer-Abspielen: zurück zu Live-Mic
source.onended = () => sender.replaceTrack(
mediaStream.getAudioTracks()[0]
)
FUNCTION stop():
clearInterval(statsTimer)
mediaStream?.getTracks().forEach(t => t.stop())
pc?.close()
pc = nulliOS
AVAudioSession.mixWithOthers
Andere Apps (Tinder/Instagram) laufen weiter sichtbar
Android
Foreground Service · PRIORITY_MIN
Notification minimiert, kein roter Aufnahme-Hinweis im Status
Web PWA
Wake Lock + Visibility API
Stream läuft wenn Tab verdeckt, kein Re-Render
Rechtlicher Hinweis: Stealth-Recording ist nur mit explizitem Nutzerkonsent (bei Session-Start) und in Notsituationen rechtlich zulässig. SEC.SI Einwilligung gilt nur für die Nutzerin selbst — nicht für Dritte.
// ─── MODUL 2: STEALTH-MODE · Kein UI-Indicator ──────────────────
// Problem: iOS/Android zeigen rote Aufnahme-Leiste
// Lösung: Service Worker + Background Task API (Android)
// iOS: AVAudioSession mit mixWithOthers-Flag
MODULE StealthCapture:
// ── ANDROID APPROACH ─────────────────────────────────────────────
// Foreground Service mit MICROPHONE permission
// Notification-Icon: neutrales SEC.SI-Icon (kein "Aufnahme"-Text)
ANDROID_SERVICE AndroidAudioService extends Service:
FUNCTION onStartCommand(intent):
notification = NotificationCompat.Builder()
.setSmallIcon(R.drawable.ic_secsi_minimal) // Kein Mic-Icon
.setContentTitle("") // Leerer Titel
.setPriority(PRIORITY_MIN) // Minimal-Sichtbarkeit
.setOngoing(true)
.build()
startForeground(NOTIF_ID, notification)
// AudioRecord direkt — kein MediaRecorder (weniger Systemhinweise)
recorder = AudioRecord(
MediaRecorder.AudioSource.VOICE_RECOGNITION,
16000,
CHANNEL_IN_MONO,
ENCODING_PCM_16BIT,
bufferSize
)
recorder.startRecording()
streamToWebRTC(recorder)
// ── iOS APPROACH ─────────────────────────────────────────────────
// AVAudioSession: mixWithOthers → andere Apps laufen weiter
// Background Mode: "audio" in Info.plist aktivieren
IOS_SETUP:
AVAudioSession.sharedInstance()
.setCategory(.playAndRecord,
options: [.mixWithOthers, // Tinder/Instagram weiter hörbar
.allowBluetooth,
.defaultToSpeaker])
.setActive(true)
// Screen bleibt dunkel / App im Vordergrund
// Kein "Recording Indicator" da mixWithOthers aktiv
// ── WEB FALLBACK (PWA) ───────────────────────────────────────────
// Screen Wake Lock API: Verhindert Ausschalten
wakeLock = await navigator.wakeLock.request("screen")
// Visibility API: Audio läuft auch wenn Tab verdeckt
document.addEventListener("visibilitychange", () => {
IF document.hidden:
// MediaStream.active bleibt true — keine Unterbrechung
LOG("tab_hidden_stream_continues")
})
// Keine UI-Änderung während Stream aktiv
// Vorherige App-Ansicht bleibt im Vordergrund
UI.freeze() // Kein Re-Render, kein Blinken// ─── MODUL 3: ADAPTIVE BITRATE · Edge/3G Resilience ────────────
// Ziel: Stream bricht NIE ab — lieber schlechte Qualität als Stille
MODULE AdaptiveBitrate:
CONST PROFILES = [
{ name: "HD", kbps: 64, sampleRate: 16000, codec: "opus" }, // WiFi/4G
{ name: "MID", kbps: 24, sampleRate: 12000, codec: "opus" }, // 3G
{ name: "LOW", kbps: 12, sampleRate: 8000, codec: "opus" }, // Edge
{ name: "MIN", kbps: 6, sampleRate: 8000, codec: "opus" }, // GPRS
]
STATE currentProfile = PROFILES[0]
STATE sender = null
STATE lastRtt_ms = 0
STATE lostPackets = 0
FUNCTION attach(rtcSender):
sender = rtcSender
applyProfile(PROFILES[0])
FUNCTION monitor(pc):
stats = await pc.getStats()
FOR EACH report IN stats:
IF report.type == "remote-inbound-rtp":
rtt = report.roundTripTime * 1000 // ms
packLoss = report.fractionLost // 0.0–1.0
lastRtt_ms = rtt
lostPackets = packLoss
selectProfile(rtt, packLoss)
FUNCTION selectProfile(rtt_ms, lossRate):
IF rtt_ms < 150 AND lossRate < 0.02:
target = PROFILES[0] // HD
ELSE IF rtt_ms < 400 AND lossRate < 0.05:
target = PROFILES[1] // MID
ELSE IF rtt_ms < 800 AND lossRate < 0.15:
target = PROFILES[2] // LOW
ELSE:
target = PROFILES[3] // MIN — letzter Ausweg
IF target.name != currentProfile.name:
LOG("abr_switch", from: currentProfile.name, to: target.name,
rtt: rtt_ms, loss: lossRate)
applyProfile(target)
FUNCTION applyProfile(profile):
params = sender.getParameters()
params.encodings[0].maxBitrate = profile.kbps * 1000
// Codec-Parameter via SDP Renegotiation
params.encodings[0].priority = "high"
params.encodings[0].networkPriority = "high"
await sender.setParameters(params)
currentProfile = profile/emergency/audio/signal
WebRTC SDP-Austausch + ICE
/emergency/audio/buffer
Pre-Buffer Upload (Fallback)
/emergency/audio/stream/:id/status
Stream-Status + Bitrate
/ws/leitstelle/audio/:id
Live-Monitor Leitstelle
// ─── API: EMERGENCY AUDIO ENDPOINT ──────────────────────────────
// Base URL: https://api.sec-si.de/v2
// Auth: Bearer JWT (Session-Token, 15min TTL)
// Protokoll: HTTPS + WSS (WebSocket für Signaling)
// ── 1. WEBRTC SIGNALING ──────────────────────────────────────────
POST /emergency/audio/signal
Headers:
Authorization: Bearer <session_jwt>
X-Session-ID: <safe_session_id>
X-Trigger-Type: voice_verified | manual | timer_expired
X-Speaker-Score: 0.0–1.0 (bei voice_verified)
Body (JSON):
{
"sdp_offer": "<RTCSessionDescription.sdp>",
"ice_candidates": [ "<RTCIceCandidate>" ],
"pre_buffer_ms": 30000, // Länge des Pre-Trigger-Buffers
"location": {
"lat": 48.1351, "lng": 11.5820,
"accuracy_m": 12.5,
"timestamp": "2026-03-11T14:22:00Z"
}
}
Response 200:
{
"sdp_answer": "<RTCSessionDescription.sdp>",
"turn_user": "<ephemeral>",
"turn_pass": "<ephemeral>",
"stream_id": "strm_<uuid>",
"recording_url": null // Wird nach Aufnahme gesetzt
}
// ── 2. PRE-BUFFER UPLOAD (Fallback wenn WebRTC fehlschlägt) ─────
POST /emergency/audio/buffer
Headers:
Authorization: Bearer <session_jwt>
Content-Type: audio/wav
X-Session-ID: <safe_session_id>
X-Buffer-Secs: 30
Body: <raw WAV binary, max 5MB>
Response 201:
{
"buffer_id": "buf_<uuid>",
"stored_at": "2026-03-11T14:22:01Z",
"duration_s": 30,
"size_bytes": 1920000,
"evidence_url": "https://evidence.sec-si.de/buf_<uuid>"
// Signierte URL, 90 Tage gültig, nur für Behörden
}
// ── 3. STREAM STATUS / CONTROL ───────────────────────────────────
GET /emergency/audio/stream/{stream_id}/status
→ { "active": true, "duration_s": 142, "bitrate_kbps": 24, "quality": "MID" }
DELETE /emergency/audio/stream/{stream_id}
→ { "ended_at": "...", "recording_url": "https://evidence.sec-si.de/..." }
// Recording bleibt für Behörden gesichert — nicht löschbar
// ── 4. WEBSOCKET: LEITSTELLEN-MONITOR ───────────────────────────
WSS /ws/leitstelle/audio/{stream_id}
// Leitstellen-Mitarbeiterin kann Stream live mithören
// Events:
{
"type": "audio_stream_start" | "abr_switch" | "stream_end",
"stream_id": "strm_<uuid>",
"payload": { "quality": "MID", "rtt_ms": 380 }
}// ─── HINTERGRUND-SERVICE ARCHITEKTUR ────────────────────────────
// Läuft parallel zur Haupt-App — kein UI-Einfluss
BACKGROUND_SERVICE EmergencyAudioService:
// ── LEBENSZYKLUS ─────────────────────────────────────────────
ON SessionStart(safeSessionId):
RollingBuffer.init() // Ringpuffer starten
AudioCapture.start() // Mikrofon aktivieren (unsichtbar)
VoiceListener.start() // Trigger-Erkennung
LOOP every 100ms:
chunk = AudioCapture.getChunk()
filtered = NoiseFilter.apply(chunk)
RollingBuffer.onAudioChunk(filtered)
VoiceListener.feed(filtered)
ON VoiceTriggerEvent(score):
triggerTs = now()
// PARALLEL ausführen:
PARALLEL:
[1] prePCM = RollingBuffer.exportPreTriggerAudio()
[2] gps = GPS.getHighAccuracy(timeout: 500)
AWAIT BOTH
// WebRTC Stream starten
webrtc = SilentWebRTCStream.init(SESSION.jwt)
webrtc.injectPreBuffer(prePCM)
// Fallback: Buffer direkt hochladen
EmergencyAudioEndpoint.uploadBuffer(prePCM)
// Alarm auslösen
AlarmEntity.create({
session_id: SESSION.id,
trigger_type: "voice_verified",
audio_score: score,
gps_lat: gps.lat,
gps_lng: gps.lng,
status: "rot"
})
// Haptic + Cancel-Window
HapticLoop.trigger()
RollingBuffer.destroy()
// Live-Stream weiter bis manueller Stop
LOOP:
IF Session.status == "resolved" OR User.cancelled:
SilentWebRTCStream.stop()
BREAK
ON SessionEnd:
RollingBuffer.destroy()
VoiceListener.stop()
AudioCapture.stop()
SilentWebRTCStream.stop()
// ── FEHLER-RESILIENZ ─────────────────────────────────────────
ON NetworkLoss:
// WebRTC ICE-Restart — automatisch
pc.restartIce()
WAIT for reconnect (max 30s)
IF still_disconnected:
// SMS-Fallback via Twilio
SMS.send(emergencyContacts, "SOS aktiv — kein Datennetz")
ON AppKilledByOS:
// Android: Foreground Service überlebt App-Kill
// iOS: Background Audio Mode hält 3 Minuten
LOG("app_killed_service_running", ts: now())Security & Evidence Summary
E2E Verschlüsselung
DTLS-SRTP im WebRTC-Stack — kein Klartext auf dem Weg zur Leitstelle
Evidence URL
90 Tage signierte URL auf evidence.sec-si.de — nicht löschbar, behördensicher
ICE Restart
Automatische Neuverbindung bei Netzausfall ohne Stream-Unterbrechung
SMS Fallback
Bei >30s Datenverlust: SMS mit GPS-Position an Notfallkontakte
Keine Disk-Writes
Rolling Buffer bleibt im RAM — kein Audio auf Gerät gespeichert
Session Token
15min JWT, TURN-Credentials ephemer pro Session — kein Replay möglich