SEC.SI CARE

Silent Emergency Live-Audio

WebRTC · Stealth · Evidence

30s Rolling Buffer · Adaptive Bitrate · Kein UI-Indicator · Behördensichere Speicherung

30s Ringpuffer
Stealth Mode
Adaptive Bitrate
Evidence-Sicherung

End-to-End Pipeline · 8 Stufen

01Session Start

RollingBuffer + AudioCapture + VoiceListener initialisieren

02Kontinuierliches Recording

100ms Chunks → Noise Filter → Ringpuffer (30s RAM)

03Voice Trigger Event

85% Speaker-Match → Pre-Buffer exportieren + GPS holen

04Stealth Stream Start

WebRTC Signaling → TURN-Server → unsichtbarer Live-Stream

05Pre-Buffer Inject

30s vor Trigger als erste Pakete in Stream einspeisen

06Leitstelle Live

DNA-Mitarbeiterin hört Stream · Alarm-Entity auf rot

07Adaptive Bitrate

RTT/Loss Monitor alle 2s → HD/MID/LOW/MIN automatisch

08Evidence Sicherung

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.

pseudocode · rolling-buffer
// ─── 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

pseudocode · webrtc-stream
// ─── 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 = null

iOS

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.

pseudocode · stealth-mode
// ─── 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
HD
64 kbpsRTT < 150ms
MID
24 kbpsRTT < 400ms
LOW
12 kbpsRTT < 800ms
MIN
6 kbpsRTT > 800ms
pseudocode · adaptive-bitrate
// ─── 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
POST

/emergency/audio/signal

WebRTC SDP-Austausch + ICE

POST

/emergency/audio/buffer

Pre-Buffer Upload (Fallback)

GET

/emergency/audio/stream/:id/status

Stream-Status + Bitrate

WSS

/ws/leitstelle/audio/:id

Live-Monitor Leitstelle

api-spec · emergency-audio-endpoint
// ─── 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 }
}
pseudocode · background-service
// ─── 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

SEC.SI GERMANY · Silent Emergency Live-Audio v1.0 · Confidential

Sicherheit Macht SEC.SI