From 462ef006f00c0b60b86684f10495ff58ac60a0c2 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sun, 1 Feb 2026 03:08:52 +0100 Subject: [PATCH] feat(matrix-mana-bot): add persistent voice preferences (Phase 4) - Create VoicePreferencesStore for file-based persistence - Preferences saved to data/voice-preferences.json - Add autoVoiceReply setting for voice message auto-response - Add !speed command for speech speed control (0.5-2.0x) - Add !voice auto an/aus for auto-reply toggle - Update HELP_TEXT with voice command documentation - Debounced save with 1s delay for performance Co-Authored-By: Claude Opus 4.5 --- .../src/bot/command-router.service.ts | 5 + .../src/config/configuration.ts | 10 +- .../src/handlers/voice.handler.ts | 42 ++++- .../src/voice/voice-preferences.store.ts | 161 ++++++++++++++++++ .../matrix-mana-bot/src/voice/voice.module.ts | 5 +- .../src/voice/voice.service.ts | 62 +++---- 6 files changed, 240 insertions(+), 45 deletions(-) create mode 100644 services/matrix-mana-bot/src/voice/voice-preferences.store.ts diff --git a/services/matrix-mana-bot/src/bot/command-router.service.ts b/services/matrix-mana-bot/src/bot/command-router.service.ts index 7baef3342..0359f3a08 100644 --- a/services/matrix-mana-bot/src/bot/command-router.service.ts +++ b/services/matrix-mana-bot/src/bot/command-router.service.ts @@ -216,6 +216,11 @@ export class CommandRouterService { handler: (ctx, args) => this.voiceHandler.setVoice(ctx, args), description: 'Set voice', }, + { + patterns: ['!speed', '!tempo', '!geschwindigkeit'], + handler: (ctx, args) => this.voiceHandler.setSpeed(ctx, args), + description: 'Set speech speed', + }, ]; } diff --git a/services/matrix-mana-bot/src/config/configuration.ts b/services/matrix-mana-bot/src/config/configuration.ts index f16eac9bd..8d7dc4686 100644 --- a/services/matrix-mana-bot/src/config/configuration.ts +++ b/services/matrix-mana-bot/src/config/configuration.ts @@ -30,6 +30,7 @@ export default () => ({ defaultVoice: process.env.DEFAULT_VOICE || 'de-DE-ConradNeural', defaultSpeed: parseFloat(process.env.DEFAULT_SPEED) || 1.0, enabled: process.env.VOICE_ENABLED !== 'false', + preferencesPath: process.env.VOICE_PREFERENCES_PATH || './data/voice-preferences.json', }, }); @@ -64,11 +65,14 @@ Schreib einfach eine Nachricht - ich antworte! • \`!summary\` - Tages-Zusammenfassung (AI) • \`!ai-todo [text]\` - AI extrahiert Todos aus Text -**🎤 Spracheingabe** +**🎤 Sprache & Voice** Sende eine Sprachnachricht - ich verstehe dich! • Natürliche Befehle: "Was steht heute an?" -• Aufgaben: "Neue Aufgabe: Einkaufen gehen" -• Timer: "Timer 25 Minuten" +• \`!voice\` - Voice-Einstellungen anzeigen +• \`!voice an/aus\` - Sprachantworten aktivieren +• \`!stimmen\` - Verfügbare Stimmen +• \`!stimme [name]\` - Stimme wählen +• \`!speed [0.5-2.0]\` - Geschwindigkeit ändern **💡 Tipps** • Natürliche Sprache funktioniert: "Was sind meine Todos?" diff --git a/services/matrix-mana-bot/src/handlers/voice.handler.ts b/services/matrix-mana-bot/src/handlers/voice.handler.ts index b70df7bf5..0a639801c 100644 --- a/services/matrix-mana-bot/src/handlers/voice.handler.ts +++ b/services/matrix-mana-bot/src/handlers/voice.handler.ts @@ -24,19 +24,34 @@ export class VoiceHandler { return '🔇 Sprachantworten deaktiviert.'; } + // Toggle auto-reply + if (arg === 'auto an' || arg === 'auto on') { + this.voiceService.setAutoVoiceReply(ctx.userId, true); + return '🔊 Auto-Sprachantwort aktiviert (bei Sprachnachrichten).'; + } + + if (arg === 'auto aus' || arg === 'auto off') { + this.voiceService.setAutoVoiceReply(ctx.userId, false); + return '🔇 Auto-Sprachantwort deaktiviert.'; + } + // Show current settings const status = prefs.voiceEnabled ? '✅ Aktiviert' : '❌ Deaktiviert'; + const autoReply = prefs.autoVoiceReply ? '✅ Aktiviert' : '❌ Deaktiviert'; return `**🎤 Voice-Einstellungen** **Status:** ${status} +**Auto-Antwort:** ${autoReply} **Stimme:** ${prefs.voice} **Geschwindigkeit:** ${prefs.speed}x **Befehle:** • \`!voice an\` / \`!voice aus\` - Aktivieren/Deaktivieren +• \`!voice auto an/aus\` - Auto-Antwort bei Sprachnachrichten • \`!stimme [name]\` - Stimme wählen -• \`!stimmen\` - Verfügbare Stimmen anzeigen`; +• \`!stimmen\` - Verfügbare Stimmen anzeigen +• \`!speed [0.5-2.0]\` - Geschwindigkeit ändern`; } /** @@ -99,6 +114,31 @@ ${voiceList} return `✅ Stimme geändert zu **${voiceName}**`; } + /** + * Set speech speed + */ + async setSpeed(ctx: CommandContext, args: string): Promise { + const speedStr = args.trim(); + + if (!speedStr) { + const prefs = this.voiceService.getUserPreferences(ctx.userId); + return `Aktuelle Geschwindigkeit: **${prefs.speed}x**\n\nNutze \`!speed [0.5-2.0]\` zum Ändern.\n• 0.5 = langsam\n• 1.0 = normal\n• 1.5 = schnell\n• 2.0 = sehr schnell`; + } + + const speed = parseFloat(speedStr); + + if (isNaN(speed)) { + return '❌ Bitte gib eine Zahl zwischen 0.5 und 2.0 an.'; + } + + if (speed < 0.5 || speed > 2.0) { + return '❌ Die Geschwindigkeit muss zwischen 0.5 und 2.0 liegen.'; + } + + this.voiceService.setSpeed(ctx.userId, speed); + return `✅ Geschwindigkeit geändert zu **${speed}x**`; + } + /** * Check voice service health */ diff --git a/services/matrix-mana-bot/src/voice/voice-preferences.store.ts b/services/matrix-mana-bot/src/voice/voice-preferences.store.ts new file mode 100644 index 000000000..b83c8b425 --- /dev/null +++ b/services/matrix-mana-bot/src/voice/voice-preferences.store.ts @@ -0,0 +1,161 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as fs from 'fs'; +import * as path from 'path'; + +export interface VoicePreferences { + voiceEnabled: boolean; + voice: string; + speed: number; + autoVoiceReply: boolean; // Auto-enable voice replies when user sends voice +} + +interface StoredPreferences { + [userId: string]: VoicePreferences; +} + +@Injectable() +export class VoicePreferencesStore implements OnModuleInit { + private readonly logger = new Logger(VoicePreferencesStore.name); + private readonly storagePath: string; + private readonly defaultVoice: string; + private readonly defaultSpeed: number; + + private preferences: StoredPreferences = {}; + private saveTimeout: NodeJS.Timeout | null = null; + + constructor(private configService: ConfigService) { + this.storagePath = + this.configService.get('voice.preferencesPath') || './data/voice-preferences.json'; + this.defaultVoice = this.configService.get('voice.defaultVoice') || 'de-DE-ConradNeural'; + this.defaultSpeed = this.configService.get('voice.defaultSpeed') || 1.0; + } + + async onModuleInit() { + await this.load(); + } + + /** + * Get preferences for a user + */ + get(userId: string): VoicePreferences { + if (this.preferences[userId]) { + return { ...this.preferences[userId] }; + } + + return this.getDefaults(); + } + + /** + * Get default preferences + */ + getDefaults(): VoicePreferences { + return { + voiceEnabled: true, + voice: this.defaultVoice, + speed: this.defaultSpeed, + autoVoiceReply: true, + }; + } + + /** + * Update preferences for a user + */ + set(userId: string, updates: Partial): VoicePreferences { + const current = this.get(userId); + const updated = { ...current, ...updates }; + + // Validate speed range + if (updated.speed !== undefined) { + updated.speed = Math.max(0.5, Math.min(2.0, updated.speed)); + } + + this.preferences[userId] = updated; + this.scheduleSave(); + + return updated; + } + + /** + * Enable/disable voice responses + */ + setVoiceEnabled(userId: string, enabled: boolean): void { + this.set(userId, { voiceEnabled: enabled }); + } + + /** + * Set preferred voice + */ + setVoice(userId: string, voice: string): void { + this.set(userId, { voice }); + } + + /** + * Set speech speed + */ + setSpeed(userId: string, speed: number): void { + this.set(userId, { speed }); + } + + /** + * Set auto voice reply + */ + setAutoVoiceReply(userId: string, enabled: boolean): void { + this.set(userId, { autoVoiceReply: enabled }); + } + + /** + * Load preferences from disk + */ + private async load(): Promise { + try { + // Ensure directory exists + const dir = path.dirname(this.storagePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + if (fs.existsSync(this.storagePath)) { + const data = fs.readFileSync(this.storagePath, 'utf-8'); + this.preferences = JSON.parse(data); + this.logger.log(`Loaded voice preferences for ${Object.keys(this.preferences).length} users`); + } else { + this.preferences = {}; + this.logger.log('No voice preferences file found, starting fresh'); + } + } catch (error) { + this.logger.error(`Failed to load voice preferences: ${error}`); + this.preferences = {}; + } + } + + /** + * Save preferences to disk (debounced) + */ + private scheduleSave(): void { + if (this.saveTimeout) { + clearTimeout(this.saveTimeout); + } + + this.saveTimeout = setTimeout(() => { + this.save(); + }, 1000); + } + + /** + * Save preferences to disk immediately + */ + private save(): void { + try { + const dir = path.dirname(this.storagePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + fs.writeFileSync(this.storagePath, JSON.stringify(this.preferences, null, 2)); + this.logger.debug(`Saved voice preferences for ${Object.keys(this.preferences).length} users`); + } catch (error) { + this.logger.error(`Failed to save voice preferences: ${error}`); + } + } +} diff --git a/services/matrix-mana-bot/src/voice/voice.module.ts b/services/matrix-mana-bot/src/voice/voice.module.ts index e3e81b56a..ff0d0ebb8 100644 --- a/services/matrix-mana-bot/src/voice/voice.module.ts +++ b/services/matrix-mana-bot/src/voice/voice.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; import { VoiceService } from './voice.service'; import { VoiceFormatterService } from './voice-formatter.service'; +import { VoicePreferencesStore } from './voice-preferences.store'; @Module({ - providers: [VoiceService, VoiceFormatterService], - exports: [VoiceService, VoiceFormatterService], + providers: [VoicePreferencesStore, VoiceService, VoiceFormatterService], + exports: [VoiceService, VoiceFormatterService, VoicePreferencesStore], }) export class VoiceModule {} diff --git a/services/matrix-mana-bot/src/voice/voice.service.ts b/services/matrix-mana-bot/src/voice/voice.service.ts index a9f813636..d1520cb25 100644 --- a/services/matrix-mana-bot/src/voice/voice.service.ts +++ b/services/matrix-mana-bot/src/voice/voice.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { VoicePreferencesStore, VoicePreferences } from './voice-preferences.store'; export interface TranscriptionResult { text: string; @@ -7,28 +8,21 @@ export interface TranscriptionResult { duration?: number; } -export interface VoicePreferences { - voiceEnabled: boolean; - voice: string; - speed: number; -} +// Re-export for convenience +export { VoicePreferences }; @Injectable() export class VoiceService { private readonly logger = new Logger(VoiceService.name); private readonly sttUrl: string; private readonly voiceBotUrl: string; - private readonly defaultVoice: string; - private readonly defaultSpeed: number; - // User preferences (in-memory for now) - private userPreferences = new Map(); - - constructor(private configService: ConfigService) { + constructor( + private configService: ConfigService, + private preferencesStore: VoicePreferencesStore + ) { this.sttUrl = this.configService.get('voice.sttUrl') || 'http://localhost:3020'; this.voiceBotUrl = this.configService.get('voice.voiceBotUrl') || 'http://localhost:3050'; - this.defaultVoice = this.configService.get('voice.defaultVoice') || 'de-DE-ConradNeural'; - this.defaultSpeed = this.configService.get('voice.defaultSpeed') || 1.0; this.logger.log(`Voice Service initialized`); this.logger.log(`STT URL: ${this.sttUrl}`); @@ -154,57 +148,47 @@ export class VoiceService { } /** - * Get user voice preferences + * Get user voice preferences (persistent) */ getUserPreferences(userId?: string): VoicePreferences { if (!userId) { - return { - voiceEnabled: true, - voice: this.defaultVoice, - speed: this.defaultSpeed, - }; + return this.preferencesStore.getDefaults(); } - - const prefs = this.userPreferences.get(userId); - if (prefs) { - return prefs; - } - - // Default preferences - return { - voiceEnabled: true, - voice: this.defaultVoice, - speed: this.defaultSpeed, - }; + return this.preferencesStore.get(userId); } /** - * Update user voice preferences + * Update user voice preferences (persistent) */ - setUserPreferences(userId: string, prefs: Partial): void { - const current = this.getUserPreferences(userId); - this.userPreferences.set(userId, { ...current, ...prefs }); + setUserPreferences(userId: string, prefs: Partial): VoicePreferences { + return this.preferencesStore.set(userId, prefs); } /** * Enable/disable voice responses for user */ setVoiceEnabled(userId: string, enabled: boolean): void { - this.setUserPreferences(userId, { voiceEnabled: enabled }); + this.preferencesStore.setVoiceEnabled(userId, enabled); } /** * Set user's preferred voice */ setVoice(userId: string, voice: string): void { - this.setUserPreferences(userId, { voice }); + this.preferencesStore.setVoice(userId, voice); } /** * Set user's preferred speed */ setSpeed(userId: string, speed: number): void { - const clampedSpeed = Math.max(0.5, Math.min(2.0, speed)); - this.setUserPreferences(userId, { speed: clampedSpeed }); + this.preferencesStore.setSpeed(userId, speed); + } + + /** + * Set auto voice reply setting + */ + setAutoVoiceReply(userId: string, enabled: boolean): void { + this.preferencesStore.setAutoVoiceReply(userId, enabled); } }