Compare commits

...

6 Commits

Author SHA1 Message Date
duffyduck a2c0196e05 release: bump version to 0.0.2.1 2026-03-29 18:49:37 +02:00
duffyduck 680f7a64e2 slpit setnteces 2026-03-29 18:42:24 +02:00
duffyduck 4893616a5a playback issue 2026-03-29 18:36:00 +02:00
duffyduck 04e8c0245d voiice settings permanent 2026-03-29 18:23:31 +02:00
duffyduck 10cefaf1cd changed connection model 2026-03-29 18:12:26 +02:00
duffyduck adbb1fe80a changed docker file 2026-03-29 17:46:27 +02:00
8 changed files with 191 additions and 71 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit" applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 200 versionCode 201
versionName "0.0.2.0" versionName "0.0.2.1"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.0.2.0", "version": "0.0.2.1",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+47 -1
View File
@@ -74,6 +74,7 @@ const SettingsScreen: React.FC = () => {
const [ttsEnabled, setTtsEnabled] = useState(true); const [ttsEnabled, setTtsEnabled] = useState(true);
const [defaultVoice, setDefaultVoice] = useState('ramona'); const [defaultVoice, setDefaultVoice] = useState('ramona');
const [highlightVoice, setHighlightVoice] = useState('thorsten'); const [highlightVoice, setHighlightVoice] = useState('thorsten');
const [speechSpeed, setSpeechSpeed] = useState(1.0);
const [editingPath, setEditingPath] = useState(false); const [editingPath, setEditingPath] = useState(false);
const [tempPath, setTempPath] = useState(''); const [tempPath, setTempPath] = useState('');
@@ -103,6 +104,9 @@ const SettingsScreen: React.FC = () => {
AsyncStorage.getItem('aria_highlight_voice').then(saved => { AsyncStorage.getItem('aria_highlight_voice').then(saved => {
if (saved) setHighlightVoice(saved); if (saved) setHighlightVoice(saved);
}); });
AsyncStorage.getItem('aria_speech_speed').then(saved => {
if (saved) setSpeechSpeed(parseFloat(saved));
});
}, []); }, []);
// Speichergroesse berechnen // Speichergroesse berechnen
@@ -521,6 +525,48 @@ const SettingsScreen: React.FC = () => {
</View> </View>
</View> </View>
{/* Sprechgeschwindigkeit */}
<View style={{marginTop: 16}}>
<Text style={styles.toggleLabel}>Sprechgeschwindigkeit: {speechSpeed.toFixed(1)}x</Text>
<View style={{flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 8}}>
<Text style={{color: '#555570', fontSize: 11}}>0.5x</Text>
<View style={{flex: 1}}>
<TouchableOpacity
style={{height: 30, justifyContent: 'center'}}
onPress={(e) => {
const layout = e.nativeEvent;
// Einfacher Tap-basierter Slider
}}
>
<View style={{height: 4, backgroundColor: '#2A2A3E', borderRadius: 2}}>
<View style={{height: 4, backgroundColor: '#0096FF', borderRadius: 2, width: `${((speechSpeed - 0.5) / 1.5) * 100}%`}} />
</View>
</TouchableOpacity>
</View>
<Text style={{color: '#555570', fontSize: 11}}>2.0x</Text>
</View>
<View style={{flexDirection: 'row', justifyContent: 'space-around', marginTop: 8}}>
{[0.5, 0.75, 1.0, 1.25, 1.5, 2.0].map(speed => (
<TouchableOpacity
key={speed}
onPress={() => {
setSpeechSpeed(speed);
AsyncStorage.setItem('aria_speech_speed', String(speed));
rvs.send('config' as any, { speechSpeed: speed });
}}
style={{
paddingHorizontal: 10, paddingVertical: 6, borderRadius: 6,
backgroundColor: speechSpeed === speed ? '#0096FF' : '#1E1E2E',
}}
>
<Text style={{color: speechSpeed === speed ? '#fff' : '#8888AA', fontSize: 12, fontWeight: '600'}}>
{speed}x
</Text>
</TouchableOpacity>
))}
</View>
</View>
{/* Highlight-Trigger Info */} {/* Highlight-Trigger Info */}
<View style={{marginTop: 16, padding: 10, backgroundColor: '#1E1E2E', borderRadius: 8}}> <View style={{marginTop: 16, padding: 10, backgroundColor: '#1E1E2E', borderRadius: 8}}>
<Text style={styles.toggleLabel}>{'\u26A1'} Highlight-Trigger</Text> <Text style={styles.toggleLabel}>{'\u26A1'} Highlight-Trigger</Text>
@@ -690,7 +736,7 @@ const SettingsScreen: React.FC = () => {
<Text style={styles.sectionTitle}>{'\u00DC'}ber</Text> <Text style={styles.sectionTitle}>{'\u00DC'}ber</Text>
<View style={styles.card}> <View style={styles.card}>
<Text style={styles.aboutTitle}>ARIA Cockpit</Text> <Text style={styles.aboutTitle}>ARIA Cockpit</Text>
<Text style={styles.aboutVersion}>Version 0.0.2.0 </Text> <Text style={styles.aboutVersion}>Version 0.0.2.1 </Text>
<Text style={styles.aboutInfo}> <Text style={styles.aboutInfo}>
Stefans Kommandozentrale f{'\u00FC'}r ARIA.{'\n'} Stefans Kommandozentrale f{'\u00FC'}r ARIA.{'\n'}
Gebaut mit React Native + TypeScript. Gebaut mit React Native + TypeScript.
+85 -12
View File
@@ -199,20 +199,48 @@ class VoiceEngine:
return None return None
try: try:
# Piper gibt PCM-Samples zurueck, wir schreiben sie als WAV # Langen Text in Saetze aufteilen (Piper hat Limits bei langen Texten)
import re
sentences = re.split(r'(?<=[.!?])\s+', text.strip())
# Markdown-Formatierung entfernen
sentences = [re.sub(r'\*\*([^*]+)\*\*', r'\1', s).strip() for s in sentences if s.strip()]
if not sentences:
return None
# Jeden Satz einzeln synthetisieren und WAVs zusammenfuegen
all_audio = b""
sample_rate = None
for sentence in sentences:
if not sentence:
continue
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
tmp_path = tmp.name
with wave.open(tmp_path, "wb") as wav_file:
voice.synthesize_wav(sentence, wav_file)
with wave.open(tmp_path, "rb") as wav_file:
if sample_rate is None:
sample_rate = wav_file.getframerate()
all_audio += wav_file.readframes(wav_file.getnframes())
Path(tmp_path).unlink(missing_ok=True)
# Zusammengefuegtes WAV erstellen
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
tmp_path = tmp.name final_path = tmp.name
with wave.open(final_path, "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(sample_rate or 22050)
wav_file.writeframes(all_audio)
with wave.open(tmp_path, "wb") as wav_file: audio_data = Path(final_path).read_bytes()
voice.synthesize_wav(text, wav_file) Path(final_path).unlink(missing_ok=True)
audio_data = Path(tmp_path).read_bytes()
Path(tmp_path).unlink(missing_ok=True)
logger.info( logger.info(
"TTS: %d bytes erzeugt mit %s'%s'", "TTS: %d bytes erzeugt mit %s (%d Saetze)'%s'",
len(audio_data), len(audio_data),
voice_name, voice_name,
len(sentences),
text[:60], text[:60],
) )
return audio_data return audio_data
@@ -457,6 +485,19 @@ class ARIABridge:
# Komponenten # Komponenten
self.voice_engine = VoiceEngine(VOICES_DIR) self.voice_engine = VoiceEngine(VOICES_DIR)
self.tts_enabled = True
# Gespeicherte Voice-Config laden
try:
vc_path = "/shared/config/voice_config.json"
if os.path.exists(vc_path):
with open(vc_path) as f:
vc = json.load(f)
self.voice_engine.default_voice = vc.get("defaultVoice", "ramona")
self.voice_engine.highlight_voice = vc.get("highlightVoice", "thorsten")
self.tts_enabled = vc.get("ttsEnabled", True)
logger.info("Voice-Config geladen: %s", vc)
except Exception as e:
logger.warning("Voice-Config laden fehlgeschlagen: %s", e)
self.stt_engine = STTEngine( self.stt_engine = STTEngine(
model_size=self.config.get("WHISPER_MODEL", WHISPER_MODEL), model_size=self.config.get("WHISPER_MODEL", WHISPER_MODEL),
language=self.config.get("WHISPER_LANGUAGE", WHISPER_LANGUAGE), language=self.config.get("WHISPER_LANGUAGE", WHISPER_LANGUAGE),
@@ -484,7 +525,9 @@ class ARIABridge:
# Audio-Hardware pruefen (fuer lokales Mikro/Lautsprecher) # Audio-Hardware pruefen (fuer lokales Mikro/Lautsprecher)
self.audio_available = False self.audio_available = False
try: try:
sd.query_devices() devices = sd.query_devices()
# Pruefen ob ein Output-Device existiert
sd.query_devices(kind='output')
self.audio_available = True self.audio_available = True
logger.info("Audio-Geraet gefunden — Wake-Word und lokale TTS aktiv") logger.info("Audio-Geraet gefunden — Wake-Word und lokale TTS aktiv")
self.stt_engine.initialize() self.stt_engine.initialize()
@@ -913,10 +956,22 @@ class ARIABridge:
retry_delay = min(retry_delay * 2, 30) retry_delay = min(retry_delay * 2, 30)
async def _rvs_heartbeat(self) -> None: async def _rvs_heartbeat(self) -> None:
"""Sendet Heartbeats an den RVS damit die Verbindung offen bleibt.""" """Sendet Heartbeats + WebSocket Pings an den RVS damit die Verbindung offen bleibt."""
while True: while True:
await asyncio.sleep(25) await asyncio.sleep(15)
if self.ws_rvs: if self.ws_rvs:
try:
# WebSocket Protocol-Level Ping (haelt TCP-Verbindung am Leben)
pong = await self.ws_rvs.ping()
await asyncio.wait_for(pong, timeout=10)
except Exception:
logger.warning("[rvs] Ping fehlgeschlagen — Verbindung tot, erzwinge Reconnect")
try:
await self.ws_rvs.close()
except Exception:
pass
self.ws_rvs = None
break
try: try:
await self.ws_rvs.send(json.dumps({ await self.ws_rvs.send(json.dumps({
"type": "heartbeat", "type": "heartbeat",
@@ -951,20 +1006,38 @@ class ARIABridge:
return return
elif msg_type == "config": elif msg_type == "config":
# Konfiguration von App/Diagnostic empfangen # Konfiguration von App/Diagnostic empfangen + persistent speichern
changed = False
if "defaultVoice" in payload: if "defaultVoice" in payload:
new_voice = payload["defaultVoice"] new_voice = payload["defaultVoice"]
if new_voice in self.voice_engine.voices: if new_voice in self.voice_engine.voices:
self.voice_engine.default_voice = new_voice self.voice_engine.default_voice = new_voice
logger.info("[rvs] Standard-Stimme gewechselt: %s", new_voice) logger.info("[rvs] Standard-Stimme gewechselt: %s", new_voice)
changed = True
if "highlightVoice" in payload: if "highlightVoice" in payload:
new_voice = payload["highlightVoice"] new_voice = payload["highlightVoice"]
if new_voice in self.voice_engine.voices: if new_voice in self.voice_engine.voices:
self.voice_engine.highlight_voice = new_voice self.voice_engine.highlight_voice = new_voice
logger.info("[rvs] Highlight-Stimme gewechselt: %s", new_voice) logger.info("[rvs] Highlight-Stimme gewechselt: %s", new_voice)
changed = True
if "ttsEnabled" in payload: if "ttsEnabled" in payload:
self.tts_enabled = bool(payload["ttsEnabled"]) self.tts_enabled = bool(payload["ttsEnabled"])
logger.info("[rvs] TTS %s", "aktiviert" if self.tts_enabled else "deaktiviert") logger.info("[rvs] TTS %s", "aktiviert" if self.tts_enabled else "deaktiviert")
changed = True
# Persistent speichern in Shared Volume
if changed:
try:
os.makedirs("/shared/config", exist_ok=True)
config_data = {
"defaultVoice": self.voice_engine.default_voice,
"highlightVoice": self.voice_engine.highlight_voice,
"ttsEnabled": getattr(self, "tts_enabled", True),
}
with open("/shared/config/voice_config.json", "w") as f:
json.dump(config_data, f, indent=2)
logger.info("[rvs] Voice-Config gespeichert: %s", config_data)
except Exception as e:
logger.warning("[rvs] Config speichern fehlgeschlagen: %s", e)
return return
text = payload.get("text", "") text = payload.get("text", "")
if text: if text:
+12 -2
View File
@@ -642,6 +642,13 @@
return; return;
} }
if (msg.type === 'voice_config') {
document.getElementById('diag-default-voice').value = msg.defaultVoice || 'ramona';
document.getElementById('diag-highlight-voice').value = msg.highlightVoice || 'thorsten';
document.getElementById('diag-tts-enabled').checked = msg.ttsEnabled !== false;
return;
}
if (msg.type === 'trigger_list') { if (msg.type === 'trigger_list') {
const textarea = document.getElementById('highlight-triggers'); const textarea = document.getElementById('highlight-triggers');
textarea.value = (msg.triggers || []).join('\n'); textarea.value = (msg.triggers || []).join('\n');
@@ -1587,8 +1594,11 @@
document.querySelectorAll('.main-nav-btn').forEach(b => { document.querySelectorAll('.main-nav-btn').forEach(b => {
if (b.textContent.trim().toLowerCase().includes(tab === 'main' ? 'main' : 'einstellung')) b.classList.add('active'); if (b.textContent.trim().toLowerCase().includes(tab === 'main' ? 'main' : 'einstellung')) b.classList.add('active');
}); });
// Einstellungen: Trigger laden // Einstellungen: Config + Trigger laden
if (tab === 'settings') loadHighlightTriggers(); if (tab === 'settings') {
loadHighlightTriggers();
send({ action: 'get_voice_config' });
}
} }
// ── Einstellungen: Tool-Berechtigungen ────────────────── // ── Einstellungen: Tool-Berechtigungen ──────────────────
+40 -49
View File
@@ -562,54 +562,22 @@ function sendToRVS_raw(msgObj) {
} }
function sendToRVS(text, isPipeline) { function sendToRVS(text, isPipeline) {
if (!RVS_HOST || !RVS_TOKEN) { // Ueber Gateway senden (zuverlaessig) UND an RVS fuer App-Sichtbarkeit
log("error", "rvs", "Nicht konfiguriert"); // Die Bridge empfaengt RVS-Nachrichten von der App zuverlaessig,
if (isPipeline) pipelineEnd(false, "RVS nicht konfiguriert"); // aber die Diagnostic→RVS→Bridge Route hat Zombie-Probleme.
return false; // Deshalb: Gateway fuer ARIA, RVS nur fuer App-Anzeige.
}
// Frische WebSocket-Verbindung fuer jede Nachricht (Zombie-Schutz) // 1. An Gateway senden (damit ARIA antwortet)
const proto = RVS_TLS === "true" ? "wss" : "ws"; const gatewayOk = sendToGateway(text, isPipeline);
const url = `${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`;
const msg = JSON.stringify({ // 2. An RVS senden (damit die App die Nachricht sieht)
sendToRVS_raw({
type: "chat", type: "chat",
payload: { text, sender: "diagnostic" }, payload: { text, sender: "diagnostic" },
timestamp: Date.now(), timestamp: Date.now(),
}); });
log("info", "rvs", `Sende via frische Verbindung: ${url.split('?')[0]}`); return gatewayOk;
const freshWs = new WebSocket(url);
freshWs.on("open", () => {
freshWs.send(msg);
log("info", "rvs", `Gesendet via RVS: "${text}"`);
// Verbindung offen lassen fuer Antwort-Empfang, nach 5min schliessen
setTimeout(() => { try { freshWs.close(); } catch (_) {} }, 300000);
});
freshWs.on("message", (raw) => {
try {
const resp = JSON.parse(raw.toString());
if (resp.type === "chat" && resp.payload) {
const sender = resp.payload.sender || "?";
// Eigene Nachrichten und STT ignorieren (werden von persistenter Verbindung gehandelt)
if (sender === "diagnostic" || sender === "stt") return;
log("info", "rvs", `Chat von ${sender}: "${(resp.payload.text || "").slice(0, 100)}"`);
if (pipelineActive && sender !== "diagnostic") {
pipelineEnd(true, `Antwort via RVS von ${sender}: "${(resp.payload.text || "").slice(0, 120)}"`);
}
broadcast({ type: "rvs_chat", msg: resp });
} else if (resp.type !== "heartbeat") {
log("debug", "rvs", `Nachricht: ${JSON.stringify(resp).slice(0, 150)}`);
}
} catch {}
});
freshWs.on("error", (err) => {
log("error", "rvs", `Sende-Fehler: ${err.message}`);
if (isPipeline) pipelineEnd(false, `RVS Fehler: ${err.message}`);
});
if (isPipeline) plog(`Nachricht an RVS gesendet — warte auf Antwort via RVS...`);
return true;
} }
// ── Claude Proxy Test ──────────────────────────────────── // ── Claude Proxy Test ────────────────────────────────────
@@ -1159,14 +1127,21 @@ wss.on("connection", (ws) => {
if (ws._sshSock) ws._sshSock.write(msg.data); if (ws._sshSock) ws._sshSock.write(msg.data);
} else if (msg.action === "live_ssh_close") { } else if (msg.action === "live_ssh_close") {
if (ws._sshSock) { ws._sshSock.end(); ws._sshSock = null; } if (ws._sshSock) { ws._sshSock.end(); ws._sshSock = null; }
} else if (msg.action === "get_voice_config") {
handleGetVoiceConfig(ws);
} else if (msg.action === "send_voice_config") { } else if (msg.action === "send_voice_config") {
// Stimmen-Config an Bridge via RVS senden // Stimmen-Config persistent speichern + an Bridge via RVS senden
sendToRVS_raw({ type: "config", payload: { const voiceConfig = {
defaultVoice: msg.defaultVoice, defaultVoice: msg.defaultVoice || "ramona",
highlightVoice: msg.highlightVoice, highlightVoice: msg.highlightVoice || "thorsten",
ttsEnabled: msg.ttsEnabled, ttsEnabled: msg.ttsEnabled !== false,
}, timestamp: Date.now() }); };
log("info", "server", `Voice-Config gesendet: default=${msg.defaultVoice}, highlight=${msg.highlightVoice}, tts=${msg.ttsEnabled}`); try {
fs.mkdirSync("/shared/config", { recursive: true });
fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2));
} catch {}
sendToRVS_raw({ type: "config", payload: voiceConfig, timestamp: Date.now() });
log("info", "server", `Voice-Config gespeichert+gesendet: default=${voiceConfig.defaultVoice}, highlight=${voiceConfig.highlightVoice}, tts=${voiceConfig.ttsEnabled}`);
} else if (msg.action === "get_triggers") { } else if (msg.action === "get_triggers") {
handleGetTriggers(ws); handleGetTriggers(ws);
} else if (msg.action === "save_triggers") { } else if (msg.action === "save_triggers") {
@@ -1301,6 +1276,22 @@ function startLiveSSH(clientWs) {
createReq.end(createBody); createReq.end(createBody);
} }
// ── Voice-Config laden ────────────────────────────────
function handleGetVoiceConfig(clientWs) {
try {
const configPath = "/shared/config/voice_config.json";
if (fs.existsSync(configPath)) {
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
clientWs.send(JSON.stringify({ type: "voice_config", ...config }));
} else {
clientWs.send(JSON.stringify({ type: "voice_config", defaultVoice: "ramona", highlightVoice: "thorsten", ttsEnabled: true }));
}
} catch (err) {
clientWs.send(JSON.stringify({ type: "voice_config", defaultVoice: "ramona", highlightVoice: "thorsten", ttsEnabled: true }));
}
}
// ── Highlight-Trigger ───────────────────────────────── // ── Highlight-Trigger ─────────────────────────────────
const TRIGGERS_FILE = "/shared/config/highlight_triggers.json"; const TRIGGERS_FILE = "/shared/config/highlight_triggers.json";
+1 -1
View File
@@ -100,7 +100,7 @@ services:
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
- ./aria-data/config/diag-state:/data # Persistenter State (aktive Session etc.) - ./aria-data/config/diag-state:/data # Persistenter State (aktive Session etc.)
- aria-shared:/shared:ro # Shared Volume (Uploads lesen fuer Vorschau) - aria-shared:/shared # Shared Volume (Uploads + Config)
environment: environment:
- ARIA_AUTH_TOKEN=${ARIA_AUTH_TOKEN:-} - ARIA_AUTH_TOKEN=${ARIA_AUTH_TOKEN:-}
- PROXY_URL=http://proxy:3456 - PROXY_URL=http://proxy:3456
+3 -3
View File
@@ -1,5 +1,5 @@
# erledigt bildupload ghet noch nicht. # erledigt bildupload ghet noch nicht.
# ende
# erledigt # erledigt
sprachnachrichten werden nicht als zweite nachricht dargestellt, damit man weiß was man gesendet hat sprachnachrichten werden nicht als zweite nachricht dargestellt, damit man weiß was man gesendet hat
# ende # ende
@@ -11,8 +11,8 @@ autoload geht nicht
wenn man auf das ohr zum hören klickt stürzt ab wenn man auf das ohr zum hören klickt stürzt ab
aria liest die nachrichten nicht vor # erledigt aria liest die nachrichten nicht vor
#ende
# erledigt autoscroll geht doch noch nicht zur letzten nachricht # erledigt autoscroll geht doch noch nicht zur letzten nachricht
unserer memory brain unserer memory brain