mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
e892e8db35
commit
462ef006f0
6 changed files with 240 additions and 45 deletions
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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?"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
161
services/matrix-mana-bot/src/voice/voice-preferences.store.ts
Normal file
161
services/matrix-mana-bot/src/voice/voice-preferences.store.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue