f
This commit is contained in:
parent
4e2ec9d8c7
commit
b50a00a65f
7 changed files with 128 additions and 6 deletions
|
|
@ -6,7 +6,6 @@ import {
|
||||||
type CreateThemeDto,
|
type CreateThemeDto,
|
||||||
} from '@/api/themes'
|
} from '@/api/themes'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import {
|
import {
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight, Upload } from 'lucide-react'
|
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight, Upload } from 'lucide-react'
|
||||||
import { ThemeEditorDialog } from '@/components/ThemeEditorDialog'
|
import { ThemeEditorDialog } from '@/components/ThemeEditorDialog'
|
||||||
import { ThemeImportDialog } from '@/components/ThemeImportDialog'
|
import { ThemeImportDialog } from '@/components/ThemeImportDialog'
|
||||||
import type { CreateThemeDto } from '@/api/themes'
|
|
||||||
|
|
||||||
export default function ThemesPage() {
|
export default function ThemesPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
|
||||||
|
|
@ -102,8 +102,7 @@ export class RoomsService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Не возвращаем пароль в ответе
|
// Не возвращаем пароль в ответе
|
||||||
const roomWithoutPassword = { ...room };
|
const { password, ...roomWithoutPassword } = room;
|
||||||
delete roomWithoutPassword.password;
|
|
||||||
return roomWithoutPassword;
|
return roomWithoutPassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,29 @@ export class VoiceController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('test')
|
||||||
|
async testVoice(@Res() res: Response) {
|
||||||
|
this.logger.log('POST /voice/test - Test voice request received');
|
||||||
|
try {
|
||||||
|
const testText = 'Привет! Это тестовое сообщение голосового режима.';
|
||||||
|
const audioBuffer = await this.voiceService.generateTTS(testText);
|
||||||
|
|
||||||
|
this.logger.log(`POST /voice/test - Success, sending ${audioBuffer.length} bytes`);
|
||||||
|
res.setHeader('Content-Type', 'audio/mpeg');
|
||||||
|
res.setHeader('Content-Length', audioBuffer.length.toString());
|
||||||
|
res.send(audioBuffer);
|
||||||
|
} catch (error) {
|
||||||
|
const status = error instanceof HttpException
|
||||||
|
? error.getStatus()
|
||||||
|
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
const message = error.message || 'Failed to generate test speech';
|
||||||
|
this.logger.error(`POST /voice/test - Error: ${message} (status: ${status})`);
|
||||||
|
return res.status(status).json({
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Get('effects/:effectType')
|
@Get('effects/:effectType')
|
||||||
async getEffect(
|
async getEffect(
|
||||||
@Param('effectType') effectType: string,
|
@Param('effectType') effectType: string,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
import VoicePlayer from './VoicePlayer'
|
import VoicePlayer from './VoicePlayer'
|
||||||
|
import { useVoice } from '../hooks/useVoice'
|
||||||
import './Answer.css'
|
import './Answer.css'
|
||||||
|
|
||||||
const Answer = ({ answer, onClick, isRevealed, roomId, questionId }) => {
|
const Answer = ({ answer, onClick, isRevealed, roomId, questionId }) => {
|
||||||
|
const { autoPlayAnswers, speak } = useVoice()
|
||||||
|
const wasRevealedRef = useRef(false)
|
||||||
|
|
||||||
const getAnswerClass = () => {
|
const getAnswerClass = () => {
|
||||||
if (!isRevealed) return 'answer-hidden'
|
if (!isRevealed) return 'answer-hidden'
|
||||||
return 'answer-revealed'
|
return 'answer-revealed'
|
||||||
|
|
@ -15,6 +20,18 @@ const Answer = ({ answer, onClick, isRevealed, roomId, questionId }) => {
|
||||||
return '#ff6b6b'
|
return '#ff6b6b'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-play answer when it becomes revealed
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRevealed && !wasRevealedRef.current && autoPlayAnswers && roomId && questionId && answer.id) {
|
||||||
|
console.log('[Answer] Auto-playing answer:', { roomId, questionId, answerId: answer.id })
|
||||||
|
speak({ roomId, questionId, contentType: 'answer', answerId: answer.id })
|
||||||
|
wasRevealedRef.current = true
|
||||||
|
}
|
||||||
|
if (!isRevealed) {
|
||||||
|
wasRevealedRef.current = false
|
||||||
|
}
|
||||||
|
}, [isRevealed, autoPlayAnswers, roomId, questionId, answer.id, speak])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`answer-button ${getAnswerClass()}`}
|
className={`answer-button ${getAnswerClass()}`}
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,19 @@ const VoiceSettings = () => {
|
||||||
isEnabled,
|
isEnabled,
|
||||||
volume,
|
volume,
|
||||||
effectsVolume,
|
effectsVolume,
|
||||||
|
autoPlayAnswers,
|
||||||
setIsEnabled,
|
setIsEnabled,
|
||||||
setVolume,
|
setVolume,
|
||||||
setEffectsVolume,
|
setEffectsVolume,
|
||||||
speak,
|
setAutoPlayAnswers,
|
||||||
|
testVoice,
|
||||||
playEffect,
|
playEffect,
|
||||||
} = useVoice();
|
} = useVoice();
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const handleTestVoice = () => {
|
const handleTestVoice = () => {
|
||||||
speak('Привет! Это тестовое сообщение голосового режима.');
|
testVoice();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTestEffect = (effectType) => {
|
const handleTestEffect = (effectType) => {
|
||||||
|
|
@ -78,6 +80,20 @@ const VoiceSettings = () => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-play Answers */}
|
||||||
|
<div className="voice-settings-section">
|
||||||
|
<label className="voice-settings-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoPlayAnswers}
|
||||||
|
onChange={(e) => setAutoPlayAnswers(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="voice-settings-toggle-label">
|
||||||
|
Озвучивать ответы при открытии
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Effects Volume */}
|
{/* Effects Volume */}
|
||||||
<div className="voice-settings-section">
|
<div className="voice-settings-section">
|
||||||
<label className="voice-settings-label">
|
<label className="voice-settings-label">
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,11 @@ export function useVoice() {
|
||||||
return saved ? parseFloat(saved) : 0.6;
|
return saved ? parseFloat(saved) : 0.6;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [autoPlayAnswers, setAutoPlayAnswers] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('voice-auto-play-answers');
|
||||||
|
return saved ? JSON.parse(saved) : false;
|
||||||
|
});
|
||||||
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [currentText, setCurrentText] = useState(null);
|
const [currentText, setCurrentText] = useState(null);
|
||||||
|
|
||||||
|
|
@ -40,6 +45,10 @@ export function useVoice() {
|
||||||
localStorage.setItem('effects-volume', effectsVolume.toString());
|
localStorage.setItem('effects-volume', effectsVolume.toString());
|
||||||
}, [effectsVolume]);
|
}, [effectsVolume]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('voice-auto-play-answers', JSON.stringify(autoPlayAnswers));
|
||||||
|
}, [autoPlayAnswers]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate speech from question/answer IDs
|
* Generate speech from question/answer IDs
|
||||||
* @param {Object} params - Parameters
|
* @param {Object} params - Parameters
|
||||||
|
|
@ -222,6 +231,63 @@ export function useVoice() {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test voice with a constant phrase
|
||||||
|
*/
|
||||||
|
const testVoice = useCallback(async () => {
|
||||||
|
if (!isEnabled) {
|
||||||
|
console.warn('[useVoice] Voice is disabled, cannot test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[useVoice] Testing voice with test endpoint');
|
||||||
|
const response = await fetch(`${API_URL}/voice/test`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[useVoice] Test voice response status:', response.status, response.statusText);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text().catch(() => 'Unable to read error response');
|
||||||
|
console.error('[useVoice] Test voice request failed:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
errorText,
|
||||||
|
});
|
||||||
|
throw new Error(`Voice test error: ${response.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
console.log('[useVoice] Received test audio blob, size:', blob.size, 'bytes, type:', blob.type);
|
||||||
|
const audioUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const audio = new Audio(audioUrl);
|
||||||
|
audio.volume = volume;
|
||||||
|
|
||||||
|
audio.onended = () => {
|
||||||
|
URL.revokeObjectURL(audioUrl);
|
||||||
|
console.log('[useVoice] Test voice playback ended');
|
||||||
|
};
|
||||||
|
|
||||||
|
audio.onerror = () => {
|
||||||
|
console.error('[useVoice] Test voice audio playback error');
|
||||||
|
URL.revokeObjectURL(audioUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[useVoice] Playing test voice');
|
||||||
|
await audio.play();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useVoice] Failed to test voice:', error);
|
||||||
|
console.error('[useVoice] Error details:', {
|
||||||
|
message: error?.message,
|
||||||
|
stack: error?.stack,
|
||||||
|
API_URL,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isEnabled, volume]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Play sound effect
|
* Play sound effect
|
||||||
* @param {string} effectType - Type of effect (correct/error/victory)
|
* @param {string} effectType - Type of effect (correct/error/victory)
|
||||||
|
|
@ -296,13 +362,16 @@ export function useVoice() {
|
||||||
currentText,
|
currentText,
|
||||||
volume,
|
volume,
|
||||||
effectsVolume,
|
effectsVolume,
|
||||||
|
autoPlayAnswers,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setIsEnabled,
|
setIsEnabled,
|
||||||
setVolume,
|
setVolume,
|
||||||
setEffectsVolume,
|
setEffectsVolume,
|
||||||
|
setAutoPlayAnswers,
|
||||||
speak,
|
speak,
|
||||||
stop,
|
stop,
|
||||||
|
testVoice,
|
||||||
playEffect,
|
playEffect,
|
||||||
preload,
|
preload,
|
||||||
preloadBatch,
|
preloadBatch,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue