first release 0.0.0.2
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Audio-Service fuer Sprach-Ein-/Ausgabe
|
||||
*
|
||||
* Verwaltet Mikrofon-Aufnahme und TTS-Audiowiedergabe.
|
||||
* Nutzt react-native-sound und die nativen Audio-APIs.
|
||||
*/
|
||||
|
||||
import { Platform, PermissionsAndroid } from 'react-native';
|
||||
import Sound from 'react-native-sound';
|
||||
|
||||
// --- Typen ---
|
||||
|
||||
export interface RecordingResult {
|
||||
/** Base64-kodierte Audiodaten */
|
||||
base64: string;
|
||||
/** Dauer in Millisekunden */
|
||||
durationMs: number;
|
||||
/** MIME-Type (z.B. audio/wav) */
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export type RecordingState = 'idle' | 'recording' | 'processing';
|
||||
|
||||
type RecordingStateCallback = (state: RecordingState) => void;
|
||||
|
||||
// --- Konstanten ---
|
||||
|
||||
const AUDIO_SAMPLE_RATE = 16000;
|
||||
const AUDIO_CHANNELS = 1;
|
||||
const AUDIO_ENCODING = 'audio/wav';
|
||||
|
||||
// --- Audio-Service ---
|
||||
|
||||
class AudioService {
|
||||
private recordingState: RecordingState = 'idle';
|
||||
private recordingStartTime: number = 0;
|
||||
private stateListeners: RecordingStateCallback[] = [];
|
||||
private currentSound: Sound | null = null;
|
||||
|
||||
// --- Berechtigungen ---
|
||||
|
||||
/** Mikrofon-Berechtigung anfordern */
|
||||
async requestMicrophonePermission(): Promise<boolean> {
|
||||
if (Platform.OS !== 'android') {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const granted = await PermissionsAndroid.request(
|
||||
PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,
|
||||
{
|
||||
title: 'ARIA Cockpit - Mikrofon',
|
||||
message: 'ARIA benoetigt Zugriff auf das Mikrofon fuer Spracheingabe.',
|
||||
buttonPositive: 'Erlauben',
|
||||
buttonNegative: 'Ablehnen',
|
||||
},
|
||||
);
|
||||
return granted === PermissionsAndroid.RESULTS.GRANTED;
|
||||
} catch (err) {
|
||||
console.error('[Audio] Fehler bei Berechtigungsanfrage:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Aufnahme ---
|
||||
|
||||
/** Mikrofon-Aufnahme starten */
|
||||
async startRecording(): Promise<boolean> {
|
||||
if (this.recordingState !== 'idle') {
|
||||
console.warn('[Audio] Aufnahme laeuft bereits');
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasPermission = await this.requestMicrophonePermission();
|
||||
if (!hasPermission) {
|
||||
console.warn('[Audio] Keine Mikrofon-Berechtigung');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Nativer Aufnahme-Start ueber AudioRecorder-Bridge
|
||||
// In Produktion: Native Module oder react-native-audio-recorder-player nutzen
|
||||
this.recordingStartTime = Date.now();
|
||||
this.setState('recording');
|
||||
console.log('[Audio] Aufnahme gestartet');
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[Audio] Fehler beim Starten der Aufnahme:', err);
|
||||
this.setState('idle');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Aufnahme stoppen und Ergebnis zurueckgeben */
|
||||
async stopRecording(): Promise<RecordingResult | null> {
|
||||
if (this.recordingState !== 'recording') {
|
||||
console.warn('[Audio] Keine aktive Aufnahme');
|
||||
return null;
|
||||
}
|
||||
|
||||
this.setState('processing');
|
||||
|
||||
try {
|
||||
const durationMs = Date.now() - this.recordingStartTime;
|
||||
|
||||
// In Produktion: Audiodaten vom nativen Recorder holen
|
||||
// const audioData = await NativeAudioRecorder.stop();
|
||||
const base64Placeholder = ''; // Platzhalter bis Native-Bridge implementiert
|
||||
|
||||
this.setState('idle');
|
||||
|
||||
console.log(`[Audio] Aufnahme beendet (${durationMs}ms)`);
|
||||
|
||||
return {
|
||||
base64: base64Placeholder,
|
||||
durationMs,
|
||||
mimeType: AUDIO_ENCODING,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[Audio] Fehler beim Stoppen der Aufnahme:', err);
|
||||
this.setState('idle');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Wiedergabe ---
|
||||
|
||||
/** Base64-kodiertes Audio abspielen (z.B. TTS-Antwort von ARIA) */
|
||||
async playAudio(base64Data: string): Promise<void> {
|
||||
// Laufende Wiedergabe stoppen
|
||||
this.stopPlayback();
|
||||
|
||||
try {
|
||||
// Base64-Daten in temporaere Datei schreiben und abspielen
|
||||
// In Produktion: react-native-fs + Sound kombinieren
|
||||
const tmpPath = `${Platform.OS === 'android' ? '/data/user/0/' : ''}aria_tts_temp.wav`;
|
||||
|
||||
// Platzhalter: Sound aus Datei laden
|
||||
this.currentSound = new Sound(tmpPath, '', (error) => {
|
||||
if (error) {
|
||||
console.error('[Audio] Fehler beim Laden:', error);
|
||||
return;
|
||||
}
|
||||
this.currentSound?.play((success) => {
|
||||
if (success) {
|
||||
console.log('[Audio] Wiedergabe abgeschlossen');
|
||||
} else {
|
||||
console.warn('[Audio] Wiedergabe fehlgeschlagen');
|
||||
}
|
||||
this.currentSound?.release();
|
||||
this.currentSound = null;
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Audio] Wiedergabefehler:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Laufende Wiedergabe stoppen */
|
||||
stopPlayback(): void {
|
||||
if (this.currentSound) {
|
||||
this.currentSound.stop();
|
||||
this.currentSound.release();
|
||||
this.currentSound = null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Status ---
|
||||
|
||||
getRecordingState(): RecordingState {
|
||||
return this.recordingState;
|
||||
}
|
||||
|
||||
/** Callback fuer Aufnahmestatus-Aenderungen */
|
||||
onStateChange(callback: RecordingStateCallback): () => void {
|
||||
this.stateListeners.push(callback);
|
||||
return () => {
|
||||
this.stateListeners = this.stateListeners.filter(cb => cb !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
private setState(state: RecordingState): void {
|
||||
if (this.recordingState !== state) {
|
||||
this.recordingState = state;
|
||||
this.stateListeners.forEach(cb => cb(state));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
const audioService = new AudioService();
|
||||
export default audioService;
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* RVS (Rendezvous Server) - WebSocket-Verbindungsmanager
|
||||
*
|
||||
* Verwaltet die persistente WebSocket-Verbindung zwischen der ARIA Cockpit App
|
||||
* und dem Rendezvous Server. Unterstützt Auto-Reconnect, Heartbeat und
|
||||
* typisierte Nachrichten.
|
||||
*/
|
||||
|
||||
// --- Typen ---
|
||||
|
||||
export type ConnectionState = 'connecting' | 'connected' | 'disconnected';
|
||||
|
||||
export type MessageType = 'chat' | 'audio' | 'file' | 'location' | 'mode' | 'log' | 'event';
|
||||
|
||||
export interface RVSMessage {
|
||||
type: MessageType;
|
||||
payload: Record<string, unknown>;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface ConnectionConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
token: string;
|
||||
useTLS: boolean;
|
||||
}
|
||||
|
||||
type MessageCallback = (message: RVSMessage) => void;
|
||||
type StateCallback = (state: ConnectionState) => void;
|
||||
|
||||
// --- Konstanten ---
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 25_000;
|
||||
const INITIAL_RECONNECT_DELAY_MS = 1_000;
|
||||
const MAX_RECONNECT_DELAY_MS = 30_000;
|
||||
const RECONNECT_BACKOFF_FACTOR = 2;
|
||||
|
||||
// --- RVS-Klasse ---
|
||||
|
||||
class RVSConnection {
|
||||
private ws: WebSocket | null = null;
|
||||
private config: ConnectionConfig | null = null;
|
||||
private state: ConnectionState = 'disconnected';
|
||||
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectDelay: number = INITIAL_RECONNECT_DELAY_MS;
|
||||
private shouldReconnect: boolean = false;
|
||||
|
||||
private messageListeners: MessageCallback[] = [];
|
||||
private stateListeners: StateCallback[] = [];
|
||||
|
||||
// --- Konfiguration ---
|
||||
|
||||
/** Verbindungsdaten setzen (z.B. nach QR-Scan) */
|
||||
setConfig(config: ConnectionConfig): void {
|
||||
this.config = config;
|
||||
this.saveConfig(config);
|
||||
}
|
||||
|
||||
getConfig(): ConnectionConfig | null {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
getState(): ConnectionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
// --- Verbindung ---
|
||||
|
||||
/** Verbindung zum RVS aufbauen */
|
||||
connect(): void {
|
||||
if (!this.config) {
|
||||
console.warn('[RVS] Keine Verbindungskonfiguration vorhanden');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
console.log('[RVS] Bereits verbunden');
|
||||
return;
|
||||
}
|
||||
|
||||
this.shouldReconnect = true;
|
||||
this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
|
||||
this.establishConnection();
|
||||
}
|
||||
|
||||
/** Verbindung trennen (kein Auto-Reconnect) */
|
||||
disconnect(): void {
|
||||
this.shouldReconnect = false;
|
||||
this.clearTimers();
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close(1000, 'Benutzer hat getrennt');
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.setState('disconnected');
|
||||
}
|
||||
|
||||
/** Nachricht an den RVS senden */
|
||||
send(type: MessageType, payload: Record<string, unknown>): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn('[RVS] Kann nicht senden - nicht verbunden');
|
||||
return;
|
||||
}
|
||||
|
||||
const message: RVSMessage = {
|
||||
type,
|
||||
payload,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.ws.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
// --- Event-Listener ---
|
||||
|
||||
/** Callback fuer eingehende Nachrichten registrieren */
|
||||
onMessage(callback: MessageCallback): () => void {
|
||||
this.messageListeners.push(callback);
|
||||
// Gibt Unsubscribe-Funktion zurueck
|
||||
return () => {
|
||||
this.messageListeners = this.messageListeners.filter(cb => cb !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
/** Callback fuer Verbindungsstatus-Aenderungen registrieren */
|
||||
onStateChange(callback: StateCallback): () => void {
|
||||
this.stateListeners.push(callback);
|
||||
return () => {
|
||||
this.stateListeners = this.stateListeners.filter(cb => cb !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
// --- Interne Methoden ---
|
||||
|
||||
private establishConnection(): void {
|
||||
if (!this.config) return;
|
||||
|
||||
this.setState('connecting');
|
||||
|
||||
const protocol = this.config.useTLS ? 'wss' : 'ws';
|
||||
const url = `${protocol}://${this.config.host}:${this.config.port}?token=${this.config.token}`;
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('[RVS] Verbunden');
|
||||
this.setState('connected');
|
||||
this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
|
||||
this.startHeartbeat();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event: WebSocketMessageEvent) => {
|
||||
try {
|
||||
const message: RVSMessage = JSON.parse(event.data as string);
|
||||
this.notifyMessageListeners(message);
|
||||
} catch (err) {
|
||||
console.error('[RVS] Fehler beim Parsen der Nachricht:', err);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
console.log(`[RVS] Verbindung geschlossen (Code: ${event.code})`);
|
||||
this.clearTimers();
|
||||
this.ws = null;
|
||||
this.setState('disconnected');
|
||||
|
||||
if (this.shouldReconnect) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('[RVS] WebSocket-Fehler:', error);
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[RVS] Verbindungsfehler:', err);
|
||||
this.setState('disconnected');
|
||||
|
||||
if (this.shouldReconnect) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconnect mit exponentiellem Backoff planen */
|
||||
private scheduleReconnect(): void {
|
||||
console.log(`[RVS] Reconnect in ${this.reconnectDelay / 1000}s...`);
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.establishConnection();
|
||||
}, this.reconnectDelay);
|
||||
|
||||
// Exponentieller Backoff: 1s -> 2s -> 4s -> 8s -> ... -> max 30s
|
||||
this.reconnectDelay = Math.min(
|
||||
this.reconnectDelay * RECONNECT_BACKOFF_FACTOR,
|
||||
MAX_RECONNECT_DELAY_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/** Heartbeat starten (alle 25 Sekunden) */
|
||||
private startHeartbeat(): void {
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ type: 'heartbeat', timestamp: Date.now() }));
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private setState(state: ConnectionState): void {
|
||||
if (this.state !== state) {
|
||||
this.state = state;
|
||||
this.stateListeners.forEach(cb => cb(state));
|
||||
}
|
||||
}
|
||||
|
||||
private notifyMessageListeners(message: RVSMessage): void {
|
||||
this.messageListeners.forEach(cb => cb(message));
|
||||
}
|
||||
|
||||
// --- Persistenz (AsyncStorage Wrapper) ---
|
||||
|
||||
private async saveConfig(config: ConnectionConfig): Promise<void> {
|
||||
try {
|
||||
// In Produktion: AsyncStorage verwenden
|
||||
// await AsyncStorage.setItem('rvs_config', JSON.stringify(config));
|
||||
console.log('[RVS] Konfiguration gespeichert');
|
||||
} catch (err) {
|
||||
console.error('[RVS] Fehler beim Speichern:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async loadConfig(): Promise<ConnectionConfig | null> {
|
||||
try {
|
||||
// In Produktion: AsyncStorage verwenden
|
||||
// const data = await AsyncStorage.getItem('rvs_config');
|
||||
// if (data) { this.config = JSON.parse(data); return this.config; }
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error('[RVS] Fehler beim Laden:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton-Instanz
|
||||
const rvs = new RVSConnection();
|
||||
export default rvs;
|
||||
Reference in New Issue
Block a user