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 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-02-01 03:08:52 +01:00
parent e892e8db35
commit 462ef006f0
6 changed files with 240 additions and 45 deletions

View file

@ -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',
},
];
}

View file

@ -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?"

View file

@ -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<string> {
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
*/

View file

@ -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>): 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<void> {
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}`);
}
}
}

View file

@ -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 {}

View file

@ -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<string, VoicePreferences>();
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<VoicePreferences>): void {
const current = this.getUserPreferences(userId);
this.userPreferences.set(userId, { ...current, ...prefs });
setUserPreferences(userId: string, prefs: Partial<VoicePreferences>): 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);
}
}