From 8e8f120a3ec4365954b57c0bc55cb027fe069747 Mon Sep 17 00:00:00 2001 From: remsky Date: Thu, 23 Jan 2025 02:00:46 -0700 Subject: [PATCH] Update configuration to disable local voice saving, enhance voice validation logic, and remove deprecated test file --- api/depr_tests/test_endpoints.py | 206 ------- api/depr_tests/test_main.py | 104 ---- api/depr_tests/test_managers.py | 190 ------- api/depr_tests/test_text_processing.py | 139 ----- api/depr_tests/test_tts_service.py | 118 ---- api/src/core/config.py | 2 +- api/src/inference/voice_manager.py | 75 +-- api/src/routers/openai_compatible.py | 21 +- api/tests/test_voice_manager.py | 185 +++++-- web/app.js | 428 +++++++++++---- web/favicon.svg | 47 ++ web/index.html | 98 +++- web/siriwave.js | 131 +++++ web/styles-clean.css | 266 +++++++++ web/styles.css | 731 ++++++++++++++++++++++--- web/styles/base.css | 102 ++++ web/styles/forms.css | 225 ++++++++ web/styles/header.css | 90 +++ web/styles/layout.css | 60 ++ web/styles/player.css | 194 +++++++ web/styles/responsive.css | 95 ++++ 21 files changed, 2463 insertions(+), 1044 deletions(-) delete mode 100644 api/depr_tests/test_endpoints.py delete mode 100644 api/depr_tests/test_main.py delete mode 100644 api/depr_tests/test_managers.py delete mode 100644 api/depr_tests/test_text_processing.py delete mode 100644 api/depr_tests/test_tts_service.py create mode 100644 web/favicon.svg create mode 100644 web/siriwave.js create mode 100644 web/styles-clean.css create mode 100644 web/styles/base.css create mode 100644 web/styles/forms.css create mode 100644 web/styles/header.css create mode 100644 web/styles/layout.css create mode 100644 web/styles/player.css create mode 100644 web/styles/responsive.css diff --git a/api/depr_tests/test_endpoints.py b/api/depr_tests/test_endpoints.py deleted file mode 100644 index b5af29e..0000000 --- a/api/depr_tests/test_endpoints.py +++ /dev/null @@ -1,206 +0,0 @@ -"""Tests for API endpoints""" -import pytest -import torch -from fastapi.testclient import TestClient - -from ..src.main import app - -# Create test client for non-async tests -client = TestClient(app) - - -def test_health_check(): - """Test the health check endpoint""" - response = client.get("/health") - assert response.status_code == 200 - assert response.json() == {"status": "healthy"} - - -@pytest.mark.asyncio -async def test_openai_speech_endpoint(async_client, mock_tts_service): - """Test the OpenAI-compatible speech endpoint""" - # Setup mocks - mock_tts_service._voice_manager.list_voices.return_value = ["bm_lewis"] - mock_tts_service.generate_audio.return_value = (torch.zeros(48000).numpy(), 1.0) - mock_tts_service._voice_manager.load_voice.return_value = torch.zeros(192) - - # Mock voice validation - mock_tts_service._voice_manager.get_voice_path.return_value = "/mock/voices/bm_lewis.pt" - - test_request = { - "model": "kokoro", - "input": "Hello world", - "voice": "bm_lewis", - "response_format": "wav", - "speed": 1.0, - "stream": False, - } - response = await async_client.post("/v1/audio/speech", json=test_request) - assert response.status_code == 200 - assert response.headers["content-type"] == "audio/wav" - assert response.headers["content-disposition"] == "attachment; filename=speech.wav" - mock_tts_service.generate_audio.assert_called_once() - - -@pytest.mark.asyncio -async def test_openai_speech_invalid_voice(async_client, mock_tts_service): - """Test the OpenAI-compatible speech endpoint with invalid voice""" - # Setup mocks - mock_tts_service._voice_manager.list_voices.return_value = ["af", "bm_lewis"] - mock_tts_service._voice_manager.get_voice_path.return_value = None - - test_request = { - "model": "kokoro", - "input": "Hello world", - "voice": "invalid_voice", - "response_format": "wav", - "speed": 1.0, - "stream": False, - } - response = await async_client.post("/v1/audio/speech", json=test_request) - assert response.status_code == 400 - assert "not found" in response.json()["detail"]["message"] - - -@pytest.mark.asyncio -async def test_openai_speech_generation_error(async_client, mock_tts_service): - """Test error handling in speech generation""" - # Setup mocks - mock_tts_service._voice_manager.list_voices.return_value = ["af"] - mock_tts_service.generate_audio.side_effect = RuntimeError("Generation failed") - mock_tts_service._voice_manager.load_voice.return_value = torch.zeros(192) - mock_tts_service._voice_manager.get_voice_path.return_value = "/mock/voices/af.pt" - - test_request = { - "model": "kokoro", - "input": "Hello world", - "voice": "af", - "response_format": "wav", - "speed": 1.0, - "stream": False, - } - response = await async_client.post("/v1/audio/speech", json=test_request) - assert response.status_code == 500 - assert "Generation failed" in response.json()["detail"]["message"] - - -@pytest.mark.asyncio -async def test_combine_voices_list_success(async_client, mock_tts_service): - """Test successful voice combination using list format""" - # Setup mocks - mock_tts_service._voice_manager.list_voices.return_value = ["af_bella", "af_sarah"] - mock_tts_service._voice_manager.combine_voices.return_value = "af_bella_af_sarah" - mock_tts_service._voice_manager.load_voice.return_value = torch.zeros(192) - mock_tts_service._voice_manager.get_voice_path.return_value = "/mock/voices/af_bella.pt" - - test_voices = ["af_bella", "af_sarah"] - response = await async_client.post("/v1/audio/voices/combine", json=test_voices) - - assert response.status_code == 200 - assert response.json()["voice"] == "af_bella_af_sarah" - mock_tts_service._voice_manager.combine_voices.assert_called_once() - - -@pytest.mark.asyncio -async def test_combine_voices_empty_list(async_client, mock_tts_service): - """Test combining empty voice list returns error""" - test_voices = [] - response = await async_client.post("/v1/audio/voices/combine", json=test_voices) - assert response.status_code == 400 - assert "No voices provided" in response.json()["detail"]["message"] - - -@pytest.mark.asyncio -async def test_speech_streaming_with_combined_voice(async_client, mock_tts_service): - """Test streaming speech with combined voice using + syntax""" - # Setup mocks - mock_tts_service._voice_manager.list_voices.return_value = ["af_bella", "af_sarah"] - mock_tts_service._voice_manager.combine_voices.return_value = "af_bella_af_sarah" - mock_tts_service._voice_manager.load_voice.return_value = torch.zeros(192) - mock_tts_service._voice_manager.get_voice_path.return_value = "/mock/voices/af_bella.pt" - - async def mock_stream(): - yield b"chunk1" - yield b"chunk2" - - mock_tts_service.generate_audio_stream.return_value = mock_stream() - - test_request = { - "model": "kokoro", - "input": "Hello world", - "voice": "af_bella+af_sarah", - "response_format": "mp3", - "stream": True, - } - - headers = {"x-raw-response": "stream"} - response = await async_client.post( - "/v1/audio/speech", json=test_request, headers=headers - ) - - assert response.status_code == 200 - assert response.headers["content-type"] == "audio/mpeg" - assert response.headers["content-disposition"] == "attachment; filename=speech.mp3" - - -@pytest.mark.asyncio -async def test_openai_speech_pcm_streaming(async_client, mock_tts_service): - """Test streaming PCM audio for real-time playback""" - # Setup mocks - mock_tts_service._voice_manager.list_voices.return_value = ["af"] - mock_tts_service._voice_manager.load_voice.return_value = torch.zeros(192) - mock_tts_service._voice_manager.get_voice_path.return_value = "/mock/voices/af.pt" - - async def mock_stream(): - yield b"chunk1" - yield b"chunk2" - - mock_tts_service.generate_audio_stream.return_value = mock_stream() - - test_request = { - "model": "kokoro", - "input": "Hello world", - "voice": "af", - "response_format": "pcm", - "stream": True, - } - - headers = {"x-raw-response": "stream"} - response = await async_client.post( - "/v1/audio/speech", json=test_request, headers=headers - ) - - assert response.status_code == 200 - assert response.headers["content-type"] == "audio/pcm" - - -@pytest.mark.asyncio -async def test_openai_speech_streaming_mp3(async_client, mock_tts_service): - """Test streaming MP3 audio to file""" - # Setup mocks - mock_tts_service._voice_manager.list_voices.return_value = ["af"] - mock_tts_service._voice_manager.load_voice.return_value = torch.zeros(192) - mock_tts_service._voice_manager.get_voice_path.return_value = "/mock/voices/af.pt" - - async def mock_stream(): - yield b"chunk1" - yield b"chunk2" - - mock_tts_service.generate_audio_stream.return_value = mock_stream() - - test_request = { - "model": "kokoro", - "input": "Hello world", - "voice": "af", - "response_format": "mp3", - "stream": True, - } - - headers = {"x-raw-response": "stream"} - response = await async_client.post( - "/v1/audio/speech", json=test_request, headers=headers - ) - - assert response.status_code == 200 - assert response.headers["content-type"] == "audio/mpeg" - assert response.headers["content-disposition"] == "attachment; filename=speech.mp3" diff --git a/api/depr_tests/test_main.py b/api/depr_tests/test_main.py deleted file mode 100644 index dd5ac12..0000000 --- a/api/depr_tests/test_main.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Tests for FastAPI application""" -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -import torch -from fastapi.testclient import TestClient - -from api.src.main import app, lifespan - - -@pytest.fixture -def test_client(): - """Create a test client""" - return TestClient(app) - - -def test_health_check(test_client): - """Test health check endpoint""" - response = test_client.get("/health") - assert response.status_code == 200 - assert response.json() == {"status": "healthy"} - - -@pytest.mark.asyncio -async def test_lifespan_successful_warmup(): - """Test successful model warmup in lifespan""" - with patch("api.src.inference.model_manager.get_manager") as mock_get_model, \ - patch("api.src.inference.voice_manager.get_manager") as mock_get_voice, \ - patch("api.src.main.logger") as mock_logger, \ - patch("os.path.exists") as mock_exists, \ - patch("torch.cuda.is_available") as mock_cuda: - - # Setup mocks - mock_model = AsyncMock() - mock_voice = AsyncMock() - mock_get_model.return_value = mock_model - mock_get_voice.return_value = mock_voice - mock_exists.return_value = True - mock_cuda.return_value = False - - # Setup model manager - mock_backend = MagicMock() - mock_backend.device = "cpu" - mock_model.get_backend.return_value = mock_backend - mock_model.load_model = AsyncMock() - - # Setup voice manager - mock_voice_tensor = torch.zeros(192) - mock_voice.load_voice = AsyncMock(return_value=mock_voice_tensor) - mock_voice.list_voices = AsyncMock(return_value=["af", "af_bella", "af_sarah"]) - - # Create an async generator from the lifespan context manager - async_gen = lifespan(MagicMock()) - - # Start the context manager - await async_gen.__aenter__() - - # Verify managers were initialized - mock_get_model.assert_called_once() - mock_get_voice.assert_called_once() - mock_model.load_model.assert_called_once() - - # Clean up - await async_gen.__aexit__(None, None, None) - - -@pytest.mark.asyncio -async def test_lifespan_failed_warmup(): - """Test failed model warmup in lifespan""" - with patch("api.src.inference.model_manager.get_manager") as mock_get_model: - # Mock the model manager to fail - mock_get_model.side_effect = RuntimeError("Failed to initialize model") - - # Create an async generator from the lifespan context manager - async_gen = lifespan(MagicMock()) - - # Verify the exception is raised - with pytest.raises(RuntimeError, match="Failed to initialize model"): - await async_gen.__aenter__() - - # Clean up - await async_gen.__aexit__(None, None, None) - - -@pytest.mark.asyncio -async def test_lifespan_voice_manager_failure(): - """Test failure when voice manager fails to initialize""" - with patch("api.src.inference.model_manager.get_manager") as mock_get_model, \ - patch("api.src.inference.voice_manager.get_manager") as mock_get_voice: - - # Setup model manager success but voice manager failure - mock_model = AsyncMock() - mock_get_model.return_value = mock_model - mock_get_voice.side_effect = RuntimeError("Failed to initialize voice manager") - - # Create an async generator from the lifespan context manager - async_gen = lifespan(MagicMock()) - - # Verify the exception is raised - with pytest.raises(RuntimeError, match="Failed to initialize voice manager"): - await async_gen.__aenter__() - - # Clean up - await async_gen.__aexit__(None, None, None) diff --git a/api/depr_tests/test_managers.py b/api/depr_tests/test_managers.py deleted file mode 100644 index 64bb8c6..0000000 --- a/api/depr_tests/test_managers.py +++ /dev/null @@ -1,190 +0,0 @@ -"""Tests for model and voice managers""" -import os -import numpy as np -import pytest -import torch -from unittest.mock import AsyncMock, MagicMock, Mock, patch - -from api.src.inference.model_manager import get_manager as get_model_manager -from api.src.inference.voice_manager import get_manager as get_voice_manager - -# Get project root path -PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -MOCK_VOICES_DIR = os.path.join(PROJECT_ROOT, "api", "src", "voices") -MOCK_MODEL_DIR = os.path.join(PROJECT_ROOT, "api", "src", "models") - - -@pytest.mark.asyncio -async def test_model_manager_initialization(): - """Test model manager initialization""" - with patch("api.src.inference.model_manager.settings") as mock_settings, \ - patch("api.src.core.paths.get_model_path") as mock_get_path: - - mock_settings.model_dir = MOCK_MODEL_DIR - mock_settings.onnx_model_path = "model.onnx" - mock_get_path.return_value = os.path.join(MOCK_MODEL_DIR, "model.onnx") - - manager = await get_model_manager() - assert manager is not None - backend = manager.get_backend() - assert backend is not None - - -@pytest.mark.asyncio -async def test_model_manager_generate(): - """Test model generation""" - with patch("api.src.inference.model_manager.settings") as mock_settings, \ - patch("api.src.core.paths.get_model_path") as mock_get_path, \ - patch("torch.load") as mock_torch_load: - - mock_settings.model_dir = MOCK_MODEL_DIR - mock_settings.onnx_model_path = "model.onnx" - mock_settings.use_onnx = True - mock_settings.use_gpu = False - mock_get_path.return_value = os.path.join(MOCK_MODEL_DIR, "model.onnx") - - # Mock torch load to return a tensor - mock_torch_load.return_value = torch.zeros(192) - - manager = await get_model_manager() - - # Set up mock backend - mock_backend = AsyncMock() - mock_backend.is_loaded = True - mock_backend.device = "cpu" - - # Create audio tensor and ensure it's properly mocked - audio_data = torch.zeros(48000, dtype=torch.float32) - async def mock_generate(*args, **kwargs): - return audio_data - mock_backend.generate.side_effect = mock_generate - - # Set up manager with mock backend - manager._backends['onnx_cpu'] = mock_backend - manager._current_backend = 'onnx_cpu' - - # Generate audio - tokens = [1, 2, 3] - voice_tensor = torch.zeros(192) - audio = await manager.generate(tokens, voice_tensor, speed=1.0) - - assert isinstance(audio, torch.Tensor), "Generated audio should be torch tensor" - assert audio.dtype == torch.float32, "Audio should be 32-bit float" - assert audio.shape == (48000,), "Audio should have 48000 samples" - assert mock_backend.generate.call_count == 1 - - -@pytest.mark.asyncio -async def test_voice_manager_initialization(): - """Test voice manager initialization""" - with patch("api.src.inference.voice_manager.settings") as mock_settings, \ - patch("os.path.exists") as mock_exists: - - mock_settings.voices_dir = MOCK_VOICES_DIR - mock_exists.return_value = True - - manager = await get_voice_manager() - assert manager is not None - - -@pytest.mark.asyncio -async def test_voice_manager_list_voices(): - """Test listing available voices""" - with patch("api.src.inference.voice_manager.settings") as mock_settings, \ - patch("os.listdir") as mock_listdir, \ - patch("os.makedirs") as mock_makedirs, \ - patch("os.path.exists") as mock_exists: - - mock_settings.voices_dir = MOCK_VOICES_DIR - mock_listdir.return_value = ["af_bella.pt", "af_sarah.pt", "bm_lewis.pt"] - mock_exists.return_value = True - - manager = await get_voice_manager() - voices = await manager.list_voices() - - assert isinstance(voices, list) - assert len(voices) == 3, f"Expected 3 voices but got {len(voices)}" - assert sorted(voices) == ["af_bella", "af_sarah", "bm_lewis"] - mock_listdir.assert_called_once() - - -@pytest.mark.asyncio -async def test_voice_manager_load_voice(): - """Test loading a voice""" - with patch("api.src.inference.voice_manager.settings") as mock_settings, \ - patch("torch.load") as mock_torch_load, \ - patch("os.path.exists") as mock_exists: - - mock_settings.voices_dir = MOCK_VOICES_DIR - mock_exists.return_value = True - - # Create a mock tensor - mock_tensor = torch.zeros(192) - mock_torch_load.return_value = mock_tensor - - manager = await get_voice_manager() - voice_tensor = await manager.load_voice("af_bella", device="cpu") - - assert isinstance(voice_tensor, torch.Tensor) - assert voice_tensor.shape == (192,) - mock_torch_load.assert_called_once() - - -@pytest.mark.asyncio -async def test_voice_manager_combine_voices(): - """Test combining voices""" - with patch("api.src.inference.voice_manager.settings") as mock_settings, \ - patch("torch.load") as mock_load, \ - patch("torch.save") as mock_save, \ - patch("os.makedirs") as mock_makedirs, \ - patch("os.path.exists") as mock_exists: - - mock_settings.voices_dir = MOCK_VOICES_DIR - mock_exists.return_value = True - - # Create mock tensors - mock_tensor1 = torch.ones(192) - mock_tensor2 = torch.ones(192) * 2 - mock_load.side_effect = [mock_tensor1, mock_tensor2] - - manager = await get_voice_manager() - combined_name = await manager.combine_voices(["af_bella", "af_sarah"]) - - assert combined_name == "af_bella_af_sarah" - assert mock_load.call_count == 2 - mock_save.assert_called_once() - - # Verify the combined tensor was saved - saved_tensor = mock_save.call_args[0][0] - assert isinstance(saved_tensor, torch.Tensor) - assert saved_tensor.shape == (192,) - # Should be average of the two tensors - assert torch.allclose(saved_tensor, torch.ones(192) * 1.5) - - -@pytest.mark.asyncio -async def test_voice_manager_invalid_voice(): - """Test loading invalid voice""" - with patch("api.src.inference.voice_manager.settings") as mock_settings, \ - patch("os.path.exists") as mock_exists: - - mock_settings.voices_dir = MOCK_VOICES_DIR - mock_exists.return_value = False - - manager = await get_voice_manager() - with pytest.raises(RuntimeError, match="Voice not found"): - await manager.load_voice("invalid_voice", device="cpu") - - -@pytest.mark.asyncio -async def test_voice_manager_combine_invalid_voices(): - """Test combining with invalid voices""" - with patch("api.src.inference.voice_manager.settings") as mock_settings, \ - patch("os.path.exists") as mock_exists: - - mock_settings.voices_dir = MOCK_VOICES_DIR - mock_exists.return_value = False - - manager = await get_voice_manager() - with pytest.raises(RuntimeError, match="Voice not found"): - await manager.combine_voices(["invalid_voice1", "invalid_voice2"]) \ No newline at end of file diff --git a/api/depr_tests/test_text_processing.py b/api/depr_tests/test_text_processing.py deleted file mode 100644 index 7e63491..0000000 --- a/api/depr_tests/test_text_processing.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Tests for text processing endpoints""" -import os -import pytest -import torch -from fastapi.testclient import TestClient - -from ..src.main import app - -# Get project root path -PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -MOCK_VOICES_DIR = os.path.join(PROJECT_ROOT, "api", "src", "voices") - -client = TestClient(app) - - -@pytest.mark.asyncio -async def test_generate_from_phonemes(async_client, mock_tts_service): - """Test generating audio from phonemes""" - # Setup mocks - mock_tts_service._voice_manager.list_voices.return_value = ["af_bella"] - mock_tts_service.generate_audio.return_value = (torch.zeros(48000).numpy(), 1.0) - mock_tts_service._voice_manager.load_voice.return_value = torch.zeros(192) - mock_tts_service._voice_manager.get_voice_path.return_value = "/mock/voices/af_bella.pt" - - test_request = { - "model": "kokoro", - "input": "h @ l oU w r= l d", - "voice": "af_bella", - "response_format": "wav", - "speed": 1.0, - "stream": False, - } - - response = await async_client.post("/v1/audio/speech", json=test_request) - assert response.status_code == 200 - assert response.headers["content-type"] == "audio/wav" - assert response.headers["content-disposition"] == "attachment; filename=speech.wav" - mock_tts_service.generate_audio.assert_called_once() - - -@pytest.mark.asyncio -async def test_generate_from_phonemes_invalid_voice(async_client, mock_tts_service): - """Test generating audio from phonemes with invalid voice""" - # Setup mocks - mock_tts_service._voice_manager.list_voices.return_value = ["af_bella"] - mock_tts_service._voice_manager.get_voice_path.return_value = None - - test_request = { - "model": "kokoro", - "input": "h @ l oU w r= l d", - "voice": "invalid_voice", - "response_format": "wav", - "speed": 1.0, - "stream": False, - } - - response = await async_client.post("/v1/audio/speech", json=test_request) - assert response.status_code == 400 - assert "Voice not found" in response.json()["detail"]["message"] - - -@pytest.mark.asyncio -async def test_generate_from_phonemes_chunked(async_client, mock_tts_service): - """Test generating chunked audio from phonemes""" - # Setup mocks - mock_tts_service._voice_manager.list_voices.return_value = ["af_bella"] - mock_tts_service._voice_manager.load_voice.return_value = torch.zeros(192) - mock_tts_service._voice_manager.get_voice_path.return_value = "/mock/voices/af_bella.pt" - - async def mock_stream(): - yield b"chunk1" - yield b"chunk2" - - mock_tts_service.generate_audio_stream.return_value = mock_stream() - - test_request = { - "model": "kokoro", - "input": "h @ l oU w r= l d", - "voice": "af_bella", - "response_format": "mp3", - "stream": True, - } - - headers = {"x-raw-response": "stream"} - response = await async_client.post( - "/v1/audio/speech", json=test_request, headers=headers - ) - - assert response.status_code == 200 - assert response.headers["content-type"] == "audio/mpeg" - assert response.headers["content-disposition"] == "attachment; filename=speech.mp3" - - -@pytest.mark.asyncio -async def test_invalid_phonemes(async_client, mock_tts_service): - """Test handling invalid phonemes""" - # Setup mocks - mock_tts_service._voice_manager.list_voices.return_value = ["af_bella"] - mock_tts_service._voice_manager.load_voice.return_value = torch.zeros(192) - mock_tts_service._voice_manager.get_voice_path.return_value = "/mock/voices/af_bella.pt" - - test_request = { - "model": "kokoro", - "input": "", # Empty input - "voice": "af_bella", - "response_format": "wav", - "speed": 1.0, - "stream": False, - } - - response = await async_client.post("/v1/audio/speech", json=test_request) - assert response.status_code == 400 - assert "Text is empty" in response.json()["detail"]["message"] - - -@pytest.mark.asyncio -async def test_phonemes_with_combined_voice(async_client, mock_tts_service): - """Test generating audio from phonemes with combined voice""" - # Setup mocks - mock_tts_service._voice_manager.list_voices.return_value = ["af_bella", "af_sarah"] - mock_tts_service._voice_manager.combine_voices.return_value = "af_bella_af_sarah" - mock_tts_service._voice_manager.load_voice.return_value = torch.zeros(192) - mock_tts_service._voice_manager.get_voice_path.return_value = "/mock/voices/af_bella_af_sarah.pt" - mock_tts_service.generate_audio.return_value = (torch.zeros(48000).numpy(), 1.0) - - test_request = { - "model": "kokoro", - "input": "h @ l oU w r= l d", - "voice": "af_bella+af_sarah", - "response_format": "wav", - "speed": 1.0, - "stream": False, - } - - response = await async_client.post("/v1/audio/speech", json=test_request) - assert response.status_code == 200 - assert response.headers["content-type"] == "audio/wav" - mock_tts_service._voice_manager.combine_voices.assert_called_once() - mock_tts_service.generate_audio.assert_called_once() diff --git a/api/depr_tests/test_tts_service.py b/api/depr_tests/test_tts_service.py deleted file mode 100644 index ac33c5f..0000000 --- a/api/depr_tests/test_tts_service.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Tests for TTSService""" -import os -import numpy as np -import pytest -import torch -from unittest.mock import AsyncMock, MagicMock, Mock, patch - -from api.src.services.tts_service import TTSService - -# Get project root path -PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -MOCK_VOICES_DIR = os.path.join(PROJECT_ROOT, "api", "src", "voices") -MOCK_MODEL_DIR = os.path.join(PROJECT_ROOT, "api", "src", "models") - - -@pytest.mark.asyncio -async def test_service_initialization(mock_model_manager, mock_voice_manager): - """Test TTSService initialization""" - # Create service using factory method - with patch("api.src.services.tts_service.get_model_manager", return_value=mock_model_manager), \ - patch("api.src.services.tts_service.get_voice_manager", return_value=mock_voice_manager): - service = await TTSService.create() - assert service is not None - assert service.model_manager == mock_model_manager - assert service._voice_manager == mock_voice_manager - - -@pytest.mark.asyncio -async def test_generate_audio_basic(mock_tts_service): - """Test basic audio generation""" - text = "Hello world" - voice = "af" - audio, duration = await mock_tts_service.generate_audio(text, voice) - assert isinstance(audio, np.ndarray) - assert duration > 0 - - -@pytest.mark.asyncio -async def test_generate_audio_empty_text(mock_tts_service): - """Test handling empty text input""" - with pytest.raises(ValueError, match="Text is empty after preprocessing"): - await mock_tts_service.generate_audio("", "af") - - -@pytest.mark.asyncio -async def test_generate_audio_stream(mock_tts_service): - """Test streaming audio generation""" - text = "Hello world" - voice = "af" - - # Setup mock stream - async def mock_stream(): - yield b"chunk1" - yield b"chunk2" - mock_tts_service.generate_audio_stream.return_value = mock_stream() - - # Test streaming - stream = mock_tts_service.generate_audio_stream(text, voice) - chunks = [] - async for chunk in await stream: - chunks.append(chunk) - - assert len(chunks) > 0 - assert all(isinstance(chunk, bytes) for chunk in chunks) - - -@pytest.mark.asyncio -async def test_list_voices(mock_tts_service): - """Test listing available voices""" - with patch("api.src.inference.voice_manager.settings") as mock_settings: - mock_settings.voices_dir = MOCK_VOICES_DIR - voices = await mock_tts_service.list_voices() - assert isinstance(voices, list) - assert len(voices) == 4 # ["af", "af_bella", "af_sarah", "bm_lewis"] - assert all(isinstance(voice, str) for voice in voices) - - -@pytest.mark.asyncio -async def test_combine_voices(mock_tts_service): - """Test combining voices""" - with patch("api.src.inference.voice_manager.settings") as mock_settings: - mock_settings.voices_dir = MOCK_VOICES_DIR - voices = ["af_bella", "af_sarah"] - result = await mock_tts_service.combine_voices(voices) - assert isinstance(result, str) - assert result == "af_bella_af_sarah" - - -@pytest.mark.asyncio -async def test_audio_to_bytes(mock_tts_service): - """Test converting audio to bytes""" - audio = np.zeros(48000, dtype=np.float32) - audio_bytes = mock_tts_service._audio_to_bytes(audio) - assert isinstance(audio_bytes, bytes) - assert len(audio_bytes) > 0 - - -@pytest.mark.asyncio -async def test_voice_loading(mock_tts_service): - """Test voice loading""" - with patch("api.src.inference.voice_manager.settings") as mock_settings, \ - patch("os.path.exists", return_value=True), \ - patch("torch.load", return_value=torch.zeros(192)): - mock_settings.voices_dir = MOCK_VOICES_DIR - voice = await mock_tts_service._voice_manager.load_voice("af", device="cpu") - assert isinstance(voice, torch.Tensor) - assert voice.shape == (192,) - - -@pytest.mark.asyncio -async def test_model_generation(mock_tts_service): - """Test model generation""" - tokens = [1, 2, 3] - voice_tensor = torch.zeros(192) - audio = await mock_tts_service.model_manager.generate(tokens, voice_tensor) - assert isinstance(audio, torch.Tensor) - assert audio.shape == (48000,) - assert audio.dtype == torch.float32 diff --git a/api/src/core/config.py b/api/src/core/config.py index 4c8fbbd..8588fc7 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -15,7 +15,7 @@ class Settings(BaseSettings): default_voice: str = "af" use_gpu: bool = False # Whether to use GPU acceleration if available use_onnx: bool = True # Whether to use ONNX runtime - allow_local_voice_saving: bool = True # Whether to allow saving combined voices locally + allow_local_voice_saving: bool = False # Whether to allow saving combined voices locally # Container absolute paths model_dir: str = "/app/api/src/models" # Absolute path in container diff --git a/api/src/inference/voice_manager.py b/api/src/inference/voice_manager.py index 55644fd..de9765c 100644 --- a/api/src/inference/voice_manager.py +++ b/api/src/inference/voice_manager.py @@ -49,11 +49,29 @@ class VoiceManager: Raises: RuntimeError: If voice loading fails """ + # Check if it's a combined voice request + if "+" in voice_name: + voices = [v.strip() for v in voice_name.split("+") if v.strip()] + if len(voices) < 2: + raise RuntimeError(f"Invalid combined voice name: {voice_name}") + + # Load and combine voices + voice_tensors = [] + for voice in voices: + try: + voice_tensor = await self.load_voice(voice, device) + voice_tensors.append(voice_tensor) + except Exception as e: + raise RuntimeError(f"Failed to load base voice {voice}: {e}") + + return torch.mean(torch.stack(voice_tensors), dim=0) + + # Handle single voice voice_path = self.get_voice_path(voice_name) if not voice_path: raise RuntimeError(f"Voice not found: {voice_name}") - # Check cache first + # Check cache cache_key = f"{voice_path}_{device}" if self._config.use_cache and cache_key in self._voice_cache: return self._voice_cache[cache_key] @@ -98,48 +116,39 @@ class VoiceManager: if len(voices) < 2: raise ValueError("At least 2 voices are required for combination") - # Load voices - voice_tensors: List[torch.Tensor] = [] - for voice in voices: - try: - voice_tensor = await self.load_voice(voice, device) - voice_tensors.append(voice_tensor) - except Exception as e: - raise RuntimeError(f"Failed to load voice {voice}: {e}") + # Create combined name using + as separator + combined_name = "+".join(voices) - try: - # Combine voices - combined_name = "_".join(voices) - combined_tensor = torch.mean(torch.stack(voice_tensors), dim=0) - - # Get api directory path - api_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) - voices_dir = os.path.join(api_dir, settings.voices_dir) - os.makedirs(voices_dir, exist_ok=True) - - # Only save to disk if local voice saving is allowed - if settings.allow_local_voice_saving: + # If saving is enabled, try to save the combination + if settings.allow_local_voice_saving: + try: + # Load and combine voices + combined_tensor = await self.load_voice(combined_name, device) + + # Save to disk + api_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) + voices_dir = os.path.join(api_dir, settings.voices_dir) + os.makedirs(voices_dir, exist_ok=True) + combined_path = os.path.join(voices_dir, f"{combined_name}.pt") try: torch.save(combined_tensor, combined_path) - # Cache the new combined voice with disk path + # Cache with path-based key self._voice_cache[f"{combined_path}_{device}"] = combined_tensor except Exception as e: raise RuntimeError(f"Failed to save combined voice: {e}") - else: - # Just cache the combined voice in memory without saving to disk - self._voice_cache[f"{combined_name}_{device}"] = combined_tensor - return combined_name + except Exception as e: + logger.warning(f"Failed to save combined voice: {e}") + # Continue without saving - will be combined on-the-fly when needed - except Exception as e: - raise RuntimeError(f"Failed to combine voices: {e}") + return combined_name async def list_voices(self) -> List[str]: """List available voices. Returns: - List of voice names, including both disk-saved and in-memory combined voices + List of voice names """ voices = set() # Use set to avoid duplicates try: @@ -151,14 +160,6 @@ class VoiceManager: for entry in os.listdir(voices_dir): if entry.endswith(".pt"): voices.add(entry[:-3]) - - # Add in-memory combined voices from cache - for cache_key in self._voice_cache: - # Extract voice name from cache key (format: "name_device" or "path_device") - voice_name = cache_key.split("_")[0] - if "/" in voice_name: # It's a path - voice_name = os.path.basename(voice_name)[:-3] # Remove .pt extension - voices.add(voice_name) except Exception as e: logger.error(f"Error listing voices: {e}") diff --git a/api/src/routers/openai_compatible.py b/api/src/routers/openai_compatible.py index 9a34297..69573b5 100644 --- a/api/src/routers/openai_compatible.py +++ b/api/src/routers/openai_compatible.py @@ -8,6 +8,7 @@ from ..services.audio import AudioService from ..services.tts_service import TTSService from ..structures.schemas import OpenAISpeechRequest + router = APIRouter( tags=["OpenAI Compatible TTS"], responses={404: {"description": "Not found"}}, @@ -17,6 +18,7 @@ router = APIRouter( _tts_service = None _init_lock = None + async def get_tts_service() -> TTSService: """Get global TTSService instance""" global _tts_service, _init_lock @@ -50,19 +52,24 @@ async def process_voices( if not voices: raise ValueError("No voices provided") - # Check if all voices exist + # If single voice, validate and return it + if len(voices) == 1: + available_voices = await tts_service.list_voices() + if voices[0] not in available_voices: + raise ValueError( + f"Voice '{voices[0]}' not found. Available voices: {', '.join(sorted(available_voices))}" + ) + return voices[0] + + # For multiple voices, validate base voices exist available_voices = await tts_service.list_voices() for voice in voices: if voice not in available_voices: raise ValueError( - f"Voice '{voice}' not found. Available voices: {', '.join(sorted(available_voices))}" + f"Base voice '{voice}' not found. Available voices: {', '.join(sorted(available_voices))}" ) - # If single voice, return it directly - if len(voices) == 1: - return voices[0] - - # Otherwise combine voices + # Combine voices return await tts_service.combine_voices(voices=voices) diff --git a/api/tests/test_voice_manager.py b/api/tests/test_voice_manager.py index 2c129e8..0205486 100644 --- a/api/tests/test_voice_manager.py +++ b/api/tests/test_voice_manager.py @@ -1,82 +1,149 @@ import pytest -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, patch, MagicMock import torch from pathlib import Path -@pytest.mark.asyncio -async def test_list_available_voices(mock_voice_manager): - """Test listing available voices""" - voices = await mock_voice_manager.list_voices() - assert len(voices) == 2 - assert "voice1" in voices - assert "voice2" in voices +from ..src.inference.voice_manager import VoiceManager +from ..src.structures.model_schemas import VoiceConfig + + +@pytest.fixture +def mock_voice_tensor(): + return torch.randn(10, 10) # Dummy tensor + + +@pytest.fixture +def voice_manager(): + return VoiceManager(VoiceConfig()) + @pytest.mark.asyncio -async def test_get_voice_path(mock_voice_manager): - """Test getting path for a specific voice""" - voice_path = mock_voice_manager.get_voice_path("voice1") - assert voice_path == "/mock/path/voice.pt" +async def test_load_voice(voice_manager, mock_voice_tensor): + """Test loading a single voice""" + with patch("api.src.core.paths.load_voice_tensor", new_callable=AsyncMock) as mock_load: + mock_load.return_value = mock_voice_tensor + with patch("os.path.exists", return_value=True): + voice = await voice_manager.load_voice("af_bella", "cpu") + assert torch.equal(voice, mock_voice_tensor) - # Test invalid voice - mock_voice_manager.get_voice_path.return_value = None - assert mock_voice_manager.get_voice_path("invalid_voice") is None @pytest.mark.asyncio -async def test_load_voice(mock_voice_manager, mock_voice_tensor): - """Test loading a voice tensor""" - voice_tensor = await mock_voice_manager.load_voice("voice1") - assert torch.equal(voice_tensor, mock_voice_tensor) - mock_voice_manager.load_voice.assert_called_once_with("voice1") - -@pytest.mark.asyncio -async def test_load_voice_not_found(mock_voice_manager): +async def test_load_voice_not_found(voice_manager): """Test loading non-existent voice""" - mock_voice_manager.get_voice_path.return_value = None - mock_voice_manager.load_voice.side_effect = ValueError("Voice not found: invalid_voice") - - with pytest.raises(ValueError, match="Voice not found: invalid_voice"): - await mock_voice_manager.load_voice("invalid_voice") + with patch("os.path.exists", return_value=False): + with pytest.raises(RuntimeError, match="Voice not found: invalid_voice"): + await voice_manager.load_voice("invalid_voice", "cpu") + @pytest.mark.asyncio -async def test_combine_voices(mock_voice_manager): - """Test combining two voices""" - voices = ["voice1", "voice2"] - weights = [0.5, 0.5] - - combined_id = await mock_voice_manager.combine_voices(voices, weights) - assert combined_id == "voice1_voice2" - mock_voice_manager.combine_voices.assert_called_once_with(voices, weights) +async def test_combine_voices_with_saving(voice_manager, mock_voice_tensor): + """Test combining voices with local saving enabled""" + with patch("api.src.core.paths.load_voice_tensor", new_callable=AsyncMock) as mock_load, \ + patch("torch.save") as mock_save, \ + patch("os.makedirs"), \ + patch("os.path.exists", return_value=True): + + # Setup mocks + mock_load.return_value = mock_voice_tensor + + # Mock settings + with patch("api.src.core.config.settings") as mock_settings: + mock_settings.allow_local_voice_saving = True + mock_settings.voices_dir = "/mock/voices" + + # Combine voices + combined = await voice_manager.combine_voices(["af_bella", "af_sarah"], "cpu") + assert combined == "af_bella+af_sarah" # Note: using + separator + + # Verify voice was saved + mock_save.assert_called_once() + @pytest.mark.asyncio -async def test_combine_voices_invalid_weights(mock_voice_manager): - """Test combining voices with invalid weights""" - voices = ["voice1", "voice2"] - weights = [0.3, 0.3] # Doesn't sum to 1 - - mock_voice_manager.combine_voices.side_effect = ValueError("Weights must sum to 1") - with pytest.raises(ValueError, match="Weights must sum to 1"): - await mock_voice_manager.combine_voices(voices, weights) +async def test_combine_voices_without_saving(voice_manager, mock_voice_tensor): + """Test combining voices without local saving""" + with patch("api.src.core.paths.load_voice_tensor", new_callable=AsyncMock) as mock_load, \ + patch("torch.save") as mock_save, \ + patch("os.makedirs"), \ + patch("os.path.exists", return_value=True): + + # Setup mocks + mock_load.return_value = mock_voice_tensor + + # Mock settings + with patch("api.src.core.config.settings") as mock_settings: + mock_settings.allow_local_voice_saving = False + mock_settings.voices_dir = "/mock/voices" + + # Combine voices + combined = await voice_manager.combine_voices(["af_bella", "af_sarah"], "cpu") + assert combined == "af_bella+af_sarah" # Note: using + separator + + # Verify voice was not saved + mock_save.assert_not_called() + @pytest.mark.asyncio -async def test_combine_voices_single_voice(mock_voice_manager): +async def test_combine_voices_single_voice(voice_manager): """Test combining with single voice""" - voices = ["voice1"] - weights = [1.0] - - mock_voice_manager.combine_voices.side_effect = ValueError("At least 2 voices are required") with pytest.raises(ValueError, match="At least 2 voices are required"): - await mock_voice_manager.combine_voices(voices, weights) + await voice_manager.combine_voices(["af_bella"], "cpu") + @pytest.mark.asyncio -async def test_cache_management(mock_voice_manager, mock_voice_tensor): +async def test_list_voices(voice_manager): + """Test listing available voices""" + with patch("os.listdir", return_value=["af_bella.pt", "af_sarah.pt", "af_bella+af_sarah.pt"]), \ + patch("os.makedirs"): + voices = await voice_manager.list_voices() + assert len(voices) == 3 + assert "af_bella" in voices + assert "af_sarah" in voices + assert "af_bella+af_sarah" in voices + + +@pytest.mark.asyncio +async def test_load_combined_voice(voice_manager, mock_voice_tensor): + """Test loading a combined voice""" + with patch("api.src.core.paths.load_voice_tensor", new_callable=AsyncMock) as mock_load: + mock_load.return_value = mock_voice_tensor + with patch("os.path.exists", return_value=True): + voice = await voice_manager.load_voice("af_bella+af_sarah", "cpu") + assert torch.equal(voice, mock_voice_tensor) + + +def test_cache_management(voice_manager, mock_voice_tensor): """Test voice cache management""" - # Mock cache info - mock_voice_manager.cache_info = {"size": 1, "max_size": 10} + # Set small cache size + voice_manager._config.cache_size = 2 - # Load voice to test caching - await mock_voice_manager.load_voice("voice1") + # Add items to cache + voice_manager._voice_cache = { + "voice1_cpu": torch.randn(5, 5), + "voice2_cpu": torch.randn(5, 5), + } - # Check cache info - cache_info = mock_voice_manager.cache_info - assert cache_info["size"] == 1 - assert cache_info["max_size"] == 10 \ No newline at end of file + # Try adding another item + voice_manager._manage_cache() + + # Check cache size maintained + assert len(voice_manager._voice_cache) <= 2 + + +@pytest.mark.asyncio +async def test_voice_loading_with_cache(voice_manager, mock_voice_tensor): + """Test voice loading with cache enabled""" + with patch("api.src.core.paths.load_voice_tensor", new_callable=AsyncMock) as mock_load, \ + patch("os.path.exists", return_value=True): + + mock_load.return_value = mock_voice_tensor + + # First load should hit disk + voice1 = await voice_manager.load_voice("af_bella", "cpu") + assert mock_load.call_count == 1 + + # Second load should hit cache + voice2 = await voice_manager.load_voice("af_bella", "cpu") + assert mock_load.call_count == 1 # Still 1 + + assert torch.equal(voice1, voice2) \ No newline at end of file diff --git a/web/app.js b/web/app.js index bc44e9a..5e122a6 100644 --- a/web/app.js +++ b/web/app.js @@ -2,21 +2,88 @@ class KokoroPlayer { constructor() { this.elements = { textInput: document.getElementById('text-input'), - voiceSelect: document.getElementById('voice-select'), - streamToggle: document.getElementById('stream-toggle'), + voiceSearch: document.getElementById('voice-search'), + voiceDropdown: document.getElementById('voice-dropdown'), + voiceOptions: document.getElementById('voice-options'), + selectedVoices: document.getElementById('selected-voices'), autoplayToggle: document.getElementById('autoplay-toggle'), + formatSelect: document.getElementById('format-select'), generateBtn: document.getElementById('generate-btn'), - audioPlayer: document.getElementById('audio-player'), + cancelBtn: document.getElementById('cancel-btn'), + playPauseBtn: document.getElementById('play-pause-btn'), + waveContainer: document.getElementById('wave-container'), + timeDisplay: document.getElementById('time-display'), + downloadBtn: document.getElementById('download-btn'), status: document.getElementById('status') }; this.isGenerating = false; + this.availableVoices = []; + this.selectedVoiceSet = new Set(); + this.currentController = null; + this.audioChunks = []; + this.sound = null; + this.wave = null; this.init(); } async init() { await this.loadVoices(); + this.setupWave(); this.setupEventListeners(); + this.setupAudioControls(); + } + + setupWave() { + this.wave = new SiriWave({ + container: this.elements.waveContainer, + width: this.elements.waveContainer.clientWidth, + height: 50, + style: 'ios', + color: '#6366f1', + speed: 0.02, + amplitude: 0.7, + frequency: 4 + }); + } + + formatTime(secs) { + const minutes = Math.floor(secs / 60); + const seconds = Math.floor(secs % 60); + return `${minutes}:${seconds.toString().padStart(2, '0')}`; + } + + updateTimeDisplay() { + if (!this.sound) return; + const seek = this.sound.seek() || 0; + const duration = this.sound.duration() || 0; + this.elements.timeDisplay.textContent = `${this.formatTime(seek)} / ${this.formatTime(duration)}`; + + // Update seek slider + const seekSlider = document.getElementById('seek-slider'); + seekSlider.value = (seek / duration) * 100 || 0; + + if (this.sound.playing()) { + requestAnimationFrame(() => this.updateTimeDisplay()); + } + } + + setupAudioControls() { + const seekSlider = document.getElementById('seek-slider'); + const volumeSlider = document.getElementById('volume-slider'); + + seekSlider.addEventListener('input', (e) => { + if (!this.sound) return; + const duration = this.sound.duration(); + const seekTime = (duration * e.target.value) / 100; + this.sound.seek(seekTime); + }); + + volumeSlider.addEventListener('input', (e) => { + if (!this.sound) return; + const volume = e.target.value / 100; + this.sound.volume(volume); + }); } async loadVoices() { @@ -32,27 +99,132 @@ class KokoroPlayer { throw new Error('No voices available'); } - this.elements.voiceSelect.innerHTML = data.voices - .map(voice => ``) - .join(''); + this.availableVoices = data.voices; + this.renderVoiceOptions(this.availableVoices); - // Select first voice by default - if (data.voices.length > 0) { - this.elements.voiceSelect.value = data.voices[0]; + if (this.selectedVoiceSet.size === 0) { + const firstVoice = this.availableVoices.find(voice => voice && voice.trim()); + if (firstVoice) { + this.addSelectedVoice(firstVoice); + } } this.showStatus('Voices loaded successfully', 'success'); } catch (error) { this.showStatus('Failed to load voices: ' + error.message, 'error'); - // Disable generate button if no voices this.elements.generateBtn.disabled = true; } } + renderVoiceOptions(voices) { + this.elements.voiceOptions.innerHTML = voices + .map(voice => ` + + `) + .join(''); + this.updateSelectedVoicesDisplay(); + } + + updateSelectedVoicesDisplay() { + this.elements.selectedVoices.innerHTML = Array.from(this.selectedVoiceSet) + .map(voice => ` + + ${voice} + × + + `) + .join(''); + + if (this.selectedVoiceSet.size > 0) { + this.elements.voiceSearch.placeholder = 'Search voices...'; + } else { + this.elements.voiceSearch.placeholder = 'Search and select voices...'; + } + } + + addSelectedVoice(voice) { + this.selectedVoiceSet.add(voice); + this.updateSelectedVoicesDisplay(); + } + + removeSelectedVoice(voice) { + this.selectedVoiceSet.delete(voice); + this.updateSelectedVoicesDisplay(); + const checkbox = this.elements.voiceOptions.querySelector(`input[value="${voice}"]`); + if (checkbox) checkbox.checked = false; + } + + filterVoices(searchTerm) { + const filtered = this.availableVoices.filter(voice => + voice.toLowerCase().includes(searchTerm.toLowerCase()) + ); + this.renderVoiceOptions(filtered); + } + setupEventListeners() { + window.addEventListener('beforeunload', () => { + if (this.currentController) { + this.currentController.abort(); + } + if (this.sound) { + this.sound.unload(); + } + }); + + this.elements.voiceSearch.addEventListener('input', (e) => { + this.filterVoices(e.target.value); + }); + + this.elements.voiceOptions.addEventListener('change', (e) => { + if (e.target.type === 'checkbox') { + if (e.target.checked) { + this.addSelectedVoice(e.target.value); + } else { + this.removeSelectedVoice(e.target.value); + } + } + }); + + this.elements.selectedVoices.addEventListener('click', (e) => { + if (e.target.classList.contains('remove-voice')) { + const voice = e.target.dataset.voice; + this.removeSelectedVoice(voice); + } + }); + this.elements.generateBtn.addEventListener('click', () => this.generateSpeech()); - this.elements.audioPlayer.addEventListener('ended', () => { - this.elements.generateBtn.disabled = false; + this.elements.cancelBtn.addEventListener('click', () => this.cancelGeneration()); + this.elements.playPauseBtn.addEventListener('click', () => this.togglePlayPause()); + this.elements.downloadBtn.addEventListener('click', () => this.downloadAudio()); + + document.addEventListener('click', (e) => { + if (!this.elements.voiceSearch.contains(e.target) && + !this.elements.voiceDropdown.contains(e.target)) { + this.elements.voiceDropdown.style.display = 'none'; + } + }); + + this.elements.voiceSearch.addEventListener('focus', () => { + this.elements.voiceDropdown.style.display = 'block'; + if (!this.elements.voiceSearch.value) { + this.elements.voiceSearch.placeholder = 'Search voices...'; + } + }); + + this.elements.voiceSearch.addEventListener('blur', () => { + if (!this.elements.voiceSearch.value && this.selectedVoiceSet.size === 0) { + this.elements.voiceSearch.placeholder = 'Search and select voices...'; + } + }); + + window.addEventListener('resize', () => { + if (this.wave) { + this.wave.width = this.elements.waveContainer.clientWidth; + } }); } @@ -67,7 +239,8 @@ class KokoroPlayer { setLoading(loading) { this.isGenerating = loading; this.elements.generateBtn.disabled = loading; - this.elements.generateBtn.className = loading ? 'primary loading' : 'primary'; + this.elements.generateBtn.className = loading ? 'loading' : ''; + this.elements.cancelBtn.style.display = loading ? 'block' : 'none'; } validateInput() { @@ -77,8 +250,7 @@ class KokoroPlayer { return false; } - const voice = this.elements.voiceSelect.value; - if (!voice) { + if (this.selectedVoiceSet.size === 0) { this.showStatus('Please select a voice', 'error'); return false; } @@ -86,89 +258,68 @@ class KokoroPlayer { return true; } - async generateSpeech() { - if (this.isGenerating || !this.validateInput()) return; - - const text = this.elements.textInput.value.trim(); - const voice = this.elements.voiceSelect.value; - const stream = this.elements.streamToggle.checked; - - this.setLoading(true); - - try { - if (stream) { - await this.handleStreamingAudio(text, voice); - } else { - await this.handleNonStreamingAudio(text, voice); + cancelGeneration() { + if (this.currentController) { + this.currentController.abort(); + this.currentController = null; + if (this.sound) { + this.sound.unload(); + this.sound = null; } - } catch (error) { - this.showStatus('Error generating speech: ' + error.message, 'error'); - } finally { + this.wave.stop(); + this.showStatus('Generation cancelled', 'info'); this.setLoading(false); } } - async handleStreamingAudio(text, voice) { - this.showStatus('Initializing audio stream...', 'info'); + togglePlayPause() { + if (!this.sound) return; - const response = await fetch('/v1/audio/speech', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - input: text, - voice: voice, - response_format: 'mp3', - stream: true - }) - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.detail?.message || 'Failed to generate speech'); + if (this.sound.playing()) { + this.sound.pause(); + this.wave.stop(); + this.elements.playPauseBtn.textContent = 'Play'; + } else { + this.sound.play(); + this.wave.start(); + this.elements.playPauseBtn.textContent = 'Pause'; + this.updateTimeDisplay(); } - - const mediaSource = new MediaSource(); - this.elements.audioPlayer.src = URL.createObjectURL(mediaSource); - - return new Promise((resolve, reject) => { - mediaSource.addEventListener('sourceopen', async () => { - try { - const sourceBuffer = mediaSource.addSourceBuffer('audio/mpeg'); - const reader = response.body.getReader(); - let totalChunks = 0; - - while (true) { - const {done, value} = await reader.read(); - if (done) break; - - // Wait for the buffer to be ready - if (sourceBuffer.updating) { - await new Promise(resolve => { - sourceBuffer.addEventListener('updateend', resolve, {once: true}); - }); - } - - sourceBuffer.appendBuffer(value); - totalChunks++; - this.showStatus(`Received chunk ${totalChunks}...`, 'info'); - } -mediaSource.endOfStream(); -if (this.elements.autoplayToggle.checked) { - await this.elements.audioPlayer.play(); -} -this.showStatus('Audio stream ready', 'success'); - this.showStatus('Audio stream ready', 'success'); - resolve(); - } catch (error) { - mediaSource.endOfStream(); - this.showStatus('Error during streaming: ' + error.message, 'error'); - reject(error); - } - }); - }); } - async handleNonStreamingAudio(text, voice) { + async generateSpeech() { + if (this.isGenerating || !this.validateInput()) return; + + if (this.sound) { + this.sound.unload(); + this.sound = null; + } + this.wave.stop(); + + this.elements.downloadBtn.style.display = 'none'; + this.audioChunks = []; + + const text = this.elements.textInput.value.trim(); + const voice = Array.from(this.selectedVoiceSet).join('+'); + + this.setLoading(true); + this.currentController = new AbortController(); + + try { + await this.handleAudio(text, voice); + } catch (error) { + if (error.name === 'AbortError') { + this.showStatus('Generation cancelled', 'info'); + } else { + this.showStatus('Error generating speech: ' + error.message, 'error'); + } + } finally { + this.currentController = null; + this.setLoading(false); + } + } + + async handleAudio(text, voice) { this.showStatus('Generating audio...', 'info'); const response = await fetch('/v1/audio/speech', { @@ -178,8 +329,9 @@ this.showStatus('Audio stream ready', 'success'); input: text, voice: voice, response_format: 'mp3', - stream: false - }) + stream: true + }), + signal: this.currentController.signal }); if (!response.ok) { @@ -187,17 +339,97 @@ this.showStatus('Audio stream ready', 'success'); throw new Error(error.detail?.message || 'Failed to generate speech'); } - const blob = await response.blob(); - const url = URL.createObjectURL(blob); - this.elements.audioPlayer.src = url; - if (this.elements.autoplayToggle.checked) { - await this.elements.audioPlayer.play(); + const chunks = []; + const reader = response.body.getReader(); + let totalChunks = 0; + + try { + while (true) { + const {value, done} = await reader.read(); + + if (done) { + this.showStatus('Processing complete', 'success'); + break; + } + + chunks.push(value); + this.audioChunks.push(value.slice(0)); + totalChunks++; + + if (totalChunks % 5 === 0) { + this.showStatus(`Received ${totalChunks} chunks...`, 'info'); + } + } + + const blob = new Blob(chunks, { type: 'audio/mpeg' }); + const url = URL.createObjectURL(blob); + + if (this.sound) { + this.sound.unload(); + } + + this.sound = new Howl({ + src: [url], + format: ['mp3'], + html5: true, + onplay: () => { + this.elements.playPauseBtn.textContent = 'Pause'; + this.wave.start(); + this.updateTimeDisplay(); + }, + onpause: () => { + this.elements.playPauseBtn.textContent = 'Play'; + this.wave.stop(); + }, + onend: () => { + this.elements.playPauseBtn.textContent = 'Play'; + this.wave.stop(); + this.elements.generateBtn.disabled = false; + }, + onload: () => { + URL.revokeObjectURL(url); + this.showStatus('Audio ready', 'success'); + this.enableDownload(); + if (this.elements.autoplayToggle.checked) { + this.sound.play(); + } + }, + onloaderror: () => { + URL.revokeObjectURL(url); + this.showStatus('Error loading audio', 'error'); + } + }); + + } catch (error) { + if (error.name === 'AbortError') { + throw error; + } + console.error('Streaming error:', error); + this.showStatus('Error during streaming', 'error'); + throw error; } - this.showStatus('Audio ready', 'success'); + } + + enableDownload() { + this.elements.downloadBtn.style.display = 'flex'; + } + + downloadAudio() { + if (this.audioChunks.length === 0) return; + + const format = this.elements.formatSelect.value; + const blob = new Blob(this.audioChunks, { type: `audio/${format}` }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `generated-speech.${format}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); } } -// Initialize the player when the page loads document.addEventListener('DOMContentLoaded', () => { new KokoroPlayer(); }); \ No newline at end of file diff --git a/web/favicon.svg b/web/favicon.svg new file mode 100644 index 0000000..ae7545d --- /dev/null +++ b/web/favicon.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/index.html b/web/index.html index e22bc52..e9c5616 100644 --- a/web/index.html +++ b/web/index.html @@ -3,13 +3,35 @@ - FastKoko: Kokoro-based TTS Player - + FastKoko: Kokoro-based TTS + + + + + + + + + + + + +
+
+
+
-

Kokoro TTS

+
+

FastKoko

+
+
+
+
+
+

Kokoro-FastAPI TTS System

@@ -17,35 +39,77 @@
- +
+ +
+
+ +
+
+
+ +
+
- + +
+
+ +
-
- +
+
+
+ + +
+ + + + +
+ 0:00 +
+
+
+ +
- \ No newline at end of file + diff --git a/web/siriwave.js b/web/siriwave.js new file mode 100644 index 0000000..4a1612e --- /dev/null +++ b/web/siriwave.js @@ -0,0 +1,131 @@ +(function() { +function SiriWave(opt) { + opt = opt || {}; + + this.phase = 0; + this.run = false; + + // UI vars + this.ratio = opt.ratio || window.devicePixelRatio || 1; + this.width = this.ratio * (opt.width || 320); + this.width_2 = this.width / 2; + this.width_4 = this.width / 4; + this.height = this.ratio * (opt.height || 50); + this.height_2 = this.height / 2; + this.MAX = (this.height_2) - 4; + + // Constructor opt + this.amplitude = opt.amplitude || 1; + this.speed = opt.speed || 0.2; + this.frequency = opt.frequency || 6; + this.color = (function hex2rgb(hex){ + var shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; + hex = hex.replace(shorthandRegex, function(m,r,g,b) { return r + r + g + g + b + b; }); + var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? + parseInt(result[1],16).toString()+','+parseInt(result[2], 16).toString()+','+parseInt(result[3], 16).toString() + : null; + })(opt.color || '#6366f1') || '99,102,241'; + + // Canvas + this.canvas = document.createElement('canvas'); + this.canvas.width = this.width; + this.canvas.height = this.height; + + this.canvas.style.width = '100%'; + this.canvas.style.height = '100%'; + this.canvas.style.borderRadius = '4px'; + + this.container = opt.container || document.body; + this.container.appendChild(this.canvas); + this.ctx = this.canvas.getContext('2d'); + + // Start + if (opt.autostart) { + this.start(); + } +} + +SiriWave.prototype._GATF_cache = {}; +SiriWave.prototype._globAttFunc = function(x) { + if (SiriWave.prototype._GATF_cache[x] == null) { + SiriWave.prototype._GATF_cache[x] = Math.pow(4/(4+Math.pow(x,4)), 4); + } + return SiriWave.prototype._GATF_cache[x]; +}; + +SiriWave.prototype._xpos = function(i) { + return this.width_2 + i * this.width_4; +}; + +SiriWave.prototype._ypos = function(i, attenuation) { + var att = (this.MAX * this.amplitude) / attenuation; + return this.height_2 + this._globAttFunc(i) * att * Math.sin(this.frequency * i - this.phase); +}; + +SiriWave.prototype._drawLine = function(attenuation, color, width){ + this.ctx.moveTo(0,0); + this.ctx.beginPath(); + this.ctx.strokeStyle = color; + this.ctx.lineWidth = width || 1; + + var i = -2; + while ((i += 0.01) <= 2) { + var y = this._ypos(i, attenuation); + if (Math.abs(i) >= 1.90) y = this.height_2; + this.ctx.lineTo(this._xpos(i), y); + } + + this.ctx.stroke(); +}; + +SiriWave.prototype._clear = function() { + this.ctx.globalCompositeOperation = 'destination-out'; + this.ctx.fillRect(0, 0, this.width, this.height); + this.ctx.globalCompositeOperation = 'source-over'; +}; + +SiriWave.prototype._draw = function() { + if (this.run === false) return; + + this.phase = (this.phase + Math.PI*this.speed) % (2*Math.PI); + + this._clear(); + this._drawLine(-2, 'rgba(' + this.color + ',0.1)'); + this._drawLine(-6, 'rgba(' + this.color + ',0.2)'); + this._drawLine(4, 'rgba(' + this.color + ',0.4)'); + this._drawLine(2, 'rgba(' + this.color + ',0.6)'); + this._drawLine(1, 'rgba(' + this.color + ',1)', 1.5); + + if (window.requestAnimationFrame) { + requestAnimationFrame(this._draw.bind(this)); + return; + }; + setTimeout(this._draw.bind(this), 20); +}; + +SiriWave.prototype.start = function() { + this.phase = 0; + this.run = true; + this._draw(); +}; + +SiriWave.prototype.stop = function() { + this.phase = 0; + this.run = false; +}; + +SiriWave.prototype.setSpeed = function(v) { + this.speed = v; +}; + +SiriWave.prototype.setNoise = SiriWave.prototype.setAmplitude = function(v) { + this.amplitude = Math.max(Math.min(v, 1), 0); +}; + +if (typeof define === 'function' && define.amd) { + define(function(){ return SiriWave; }); + return; +}; +window.SiriWave = SiriWave; +})(); \ No newline at end of file diff --git a/web/styles-clean.css b/web/styles-clean.css new file mode 100644 index 0000000..035fb7c --- /dev/null +++ b/web/styles-clean.css @@ -0,0 +1,266 @@ +:root { + --bg-color: #0f172a; + --fg-color: #6366f1; + --surface: rgba(30, 41, 59, 1); /* Made opaque */ + --text: #f8fafc; + --text-light: #cbd5e1; + --border: rgba(148, 163, 184, 0.2); + --error: #ef4444; + --success: #22c55e; + --font-family: 'Inter', system-ui, sans-serif; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: var(--font-family); + line-height: 1.6; + color: var(--text); + background: radial-gradient(circle at top right, + var(--fg-color) 0%, + var(--bg-color) 100%); + min-height: 100vh; + position: relative; +} + +.container { + max-width: 800px; + margin: 0 auto; + padding: 4rem 1.5rem; +} + +header { + margin-bottom: 3rem; + text-align: center; +} + +h1 { + font-size: 3rem; + font-weight: 700; + color: var(--text); + margin-bottom: 0.5rem; +} + +.subtitle { + color: var(--text-light); + font-size: 1.1rem; +} + +.input-section, .player-section { + background: var(--surface); + padding: 2rem; + border-radius: 1rem; + border: 1px solid var(--border); + backdrop-filter: blur(12px); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); + margin-bottom: 2rem; +} + +textarea { + width: 100%; + min-height: 120px; + padding: 1rem; + border: 1px solid var(--border); + border-radius: 0.5rem; + background: rgba(15, 23, 42, 0.3); + color: var(--text); + font-size: 1rem; + transition: border-color 0.2s ease; + font-family: var(--font-family); + resize: vertical; +} + +textarea:focus { + outline: none; + border-color: var(--fg-color); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2); +} + +textarea::placeholder { + color: var(--text-light); +} + +.controls { + margin-top: 1.5rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.voice-select-container { + position: relative; + display: flex; + align-items: center; + gap: 1rem; +} + +.voice-search { + flex: 1; + padding: 0.75rem 1rem; + border: 1px solid var(--border); + border-radius: 0.5rem; + background: rgba(15, 23, 42, 0.3); + color: var(--text); + font-size: 1rem; + transition: all 0.2s ease; +} + +.voice-search:focus { + outline: none; + border-color: var(--fg-color); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2); +} + +.voice-search::placeholder { + color: var(--text-light); +} + +.voice-dropdown { + display: none; + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 0.5rem; + margin-top: 0.5rem; + max-height: 200px; + overflow-y: auto; + z-index: 1000; /* Increased z-index */ + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); +} + +.voice-select-container:focus-within .voice-dropdown, +.voice-dropdown:hover { + display: block; +} + +.voice-option { + display: flex; + align-items: center; + padding: 0.75rem; + cursor: pointer; + border-radius: 0.25rem; + transition: background-color 0.2s ease; + color: var(--text); +} + +.voice-option:hover { + background: rgba(99, 102, 241, 0.1); +} + +.selected-voice-tag { + background: rgba(99, 102, 241, 0.2); + padding: 0.25rem 0.75rem; + border-radius: 1rem; + font-size: 0.875rem; + border: 1px solid rgba(99, 102, 241, 0.3); +} + +.options { + display: flex; + gap: 2rem; + flex-wrap: wrap; +} + +.options label { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text-light); + cursor: pointer; + white-space: nowrap; +} + +button { + background: var(--fg-color); + color: var(--text); + padding: 1rem; + border-radius: 0.5rem; + border: none; + font-weight: 500; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +button:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2); +} + +button:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.theme-toggle { + position: fixed; + top: 20px; + right: 20px; + width: 60px; + height: 60px; + cursor: pointer; + z-index: 100; + border-radius: 50%; + background: var(--fg-color); + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2); + transition: transform 0.3s ease; +} + +.theme-toggle:hover { + transform: scale(1.1); +} + +/* Make audio player full width */ +audio { + width: 100%; + margin-top: 1rem; +} + +@media (max-width: 640px) { + .container { + padding: 2rem 1rem; + } + + h1 { + font-size: 2rem; + } + + .subtitle { + font-size: 1rem; + } + + .input-section, .player-section { + padding: 1.5rem; + } + + .voice-select-container { + flex-direction: column; + align-items: stretch; + } + + .options { + flex-direction: column; + gap: 1rem; + } + + .theme-toggle { + width: 50px; + height: 50px; + top: 10px; + right: 10px; + } +} \ No newline at end of file diff --git a/web/styles.css b/web/styles.css index e0e39f1..86c24c3 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1,14 +1,13 @@ :root { - --primary-color: #007bff; - --primary-hover: #0056b3; - --background: #ffffff; - --surface: #f8f9fa; - --text: #212529; - --text-light: #6c757d; - --border: #dee2e6; - --error: #dc3545; - --success: #28a745; - --font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --bg-color: #0f172a; + --fg-color: #6366f1; + --surface: rgba(30, 41, 59, 1); + --text: #f8fafc; + --text-light: #cbd5e1; + --border: rgba(148, 163, 184, 0.2); + --error: #ef4444; + --success: #22c55e; + --font-family: 'Inter', system-ui, sans-serif; } * { @@ -21,110 +20,427 @@ body { font-family: var(--font-family); line-height: 1.6; color: var(--text); - background: var(--background); + background: radial-gradient(circle at top right, + var(--fg-color) 0%, + var(--bg-color) 100%); + min-height: 100vh; + position: relative; +} + +.overlay { + position: fixed; + inset: 0; + background-image: + repeating-linear-gradient(0deg, + rgba(255,255,255,0.03) 0px, + rgba(255,255,255,0.03) 1px, + transparent 1px, + transparent 20px), + repeating-linear-gradient(90deg, + rgba(255,255,255,0.03) 0px, + rgba(255,255,255,0.03) 1px, + transparent 1px, + transparent 20px); + pointer-events: none; +} + +.sun { + position: fixed; + top: 20px; + right: 20px; + width: 100px; + height: 100px; + border-radius: 50%; + background: radial-gradient(circle at center, + rgba(99, 102, 241, 0.2) 0%, + transparent 70%); + pointer-events: none; + z-index: 0; +} + +.scanline { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 2px; + background: rgba(99, 102, 241, 0.1); + animation: scan 4s linear infinite; +} + +@keyframes scan { + 0% { transform: translateY(0); } + 100% { transform: translateY(100px); } } .container { - max-width: 800px; + max-width: 1200px; margin: 0 auto; - padding: 2rem 1rem; + padding: 4rem 1.5rem; + position: relative; + z-index: 1; } header { - margin-bottom: 2rem; + margin-bottom: 3rem; text-align: center; } -h1 { - color: var(--primary-color); - font-size: 2rem; - font-weight: 600; +.logo-container { + display: flex; + align-items: center; + justify-content: center; + gap: 20px; } -.input-section { +h1 { + font-size: 5rem; + font-weight: 700; + margin: 0; + background: linear-gradient(rgba(255,255,255,0.1) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,0.1) 1px, transparent 1px); + background-size: 10px 10px; + -webkit-background-clip: text; + background-clip: text; + color: var(--text); + text-shadow: + -2px -2px 0 rgba(0,0,0,0.5), + 2px -2px 0 rgba(0,0,0,0.5), + -2px 2px 0 rgba(0,0,0,0.5), + 2px 2px 0 rgba(0,0,0,0.5), + 3px 3px var(--fg-color); +} + +.subtitle { + color: var(--text-light); + font-size: 1.5rem; + opacity: 0.9; + margin-top: 0.5rem; +} + +.cup { + width: 40px; + height: 50px; + border: 3px solid var(--text); + border-radius: 0 0 20px 20px; + position: relative; + animation: float 3s ease-in-out 2; + animation-fill-mode: forwards; +} + +.handle { + width: 15px; + height: 25px; + border: 3px solid var(--text); + border-radius: 0 10px 10px 0; + position: absolute; + right: -15px; + top: 10px; +} + +.steam { + position: absolute; + top: -15px; + left: 5px; + right: 5px; + height: 15px; + display: flex; + justify-content: space-between; +} + +.steam::before, +.steam::after { + content: ""; + width: 10px; + height: 100%; + background: rgba(255,255,255,0.7); + border-radius: 10px; + animation: steam 2s 2; + animation-fill-mode: forwards; +} + +@keyframes steam { + to { + transform: translateY(-10px) scale(1.5); + opacity: 0; + } +} + +@keyframes float { + 50% { + transform: translateY(-5px); + } +} + +main { + display: flex; + flex-direction: column; + gap: 2rem; + min-height: 600px; +} + +@media (min-width: 1024px) { + main { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + align-items: stretch; + } + + .input-section, .player-section { + height: 100%; + display: flex; + flex-direction: column; + } + + .input-section textarea { + flex: 1; + min-height: 200px; + } + + .player-section { + display: flex; + flex-direction: column; + } + + .audio-controls { + flex: 1; + display: flex; + flex-direction: column; + } + + .wave-container { + flex: 1; + min-height: 200px; + } +} + +.input-section, .player-section { background: var(--surface); - padding: 1.5rem; - border-radius: 8px; - margin-bottom: 1.5rem; + padding: 2rem; + border-radius: 1rem; + border: 1px solid var(--border); + backdrop-filter: blur(12px); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); } textarea { width: 100%; - padding: 0.75rem; + min-height: 120px; + padding: 1rem; border: 1px solid var(--border); - border-radius: 4px; - font-family: inherit; + border-radius: 0.5rem; + background: rgba(15, 23, 42, 0.3); + color: var(--text); font-size: 1rem; + transition: border-color 0.2s ease; + font-family: var(--font-family); resize: vertical; - margin-bottom: 1rem; } textarea:focus { outline: none; - border-color: var(--primary-color); + border-color: var(--fg-color); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2); +} + +textarea::placeholder { + color: var(--text-light); } .controls { + margin-top: 1.5rem; display: flex; - gap: 1rem; - align-items: center; - flex-wrap: wrap; + flex-direction: column; + gap: 1.5rem; } -select { - padding: 0.5rem; +.voice-select-container { + position: relative; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.voice-search { + width: 100%; + padding: 0.75rem 1rem; border: 1px solid var(--border); - border-radius: 4px; + border-radius: 0.5rem; + background: rgba(15, 23, 42, 0.3); + color: var(--text); font-size: 1rem; - min-width: 200px; + transition: all 0.2s ease; +} + +.voice-search:focus { + outline: none; + border-color: var(--fg-color); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2); +} + +.voice-search::placeholder { + color: var(--text-light); +} + +.voice-dropdown { + display: none; + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 0.5rem; + margin-top: 0.5rem; + max-height: 200px; + overflow-y: auto; + z-index: 1000; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); +} + +.voice-select-container:focus-within .voice-dropdown, +.voice-dropdown:hover { + display: block; +} + +.voice-option { + display: flex; + align-items: center; + padding: 0.75rem; + cursor: pointer; + border-radius: 0.25rem; + transition: background-color 0.2s ease; + color: var(--text); +} + +.voice-option:hover { + background: rgba(99, 102, 241, 0.1); +} + +.selected-voices { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.selected-voice-tag { + background: rgba(99, 102, 241, 0.2); + padding: 0.25rem 0.75rem; + border-radius: 1rem; + font-size: 0.875rem; + display: flex; + align-items: center; + gap: 0.5rem; + border: 1px solid rgba(99, 102, 241, 0.3); +} + +.remove-voice { + cursor: pointer; + opacity: 0.7; + transition: opacity 0.2s ease; +} + +.remove-voice:hover { + opacity: 1; } .options { display: flex; align-items: center; + justify-content: space-between; + gap: 2rem; + flex-wrap: wrap; +} + +.options label { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text-light); + cursor: pointer; +} + +.format-select { + background: rgba(15, 23, 42, 0.3); + color: var(--text); + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 0.5rem 1rem; + font-family: var(--font-family); + font-size: 0.875rem; + cursor: pointer; + transition: all 0.2s ease; + min-width: 100px; +} + +.format-select:hover { + border-color: var(--fg-color); +} + +.format-select:focus { + outline: none; + border-color: var(--fg-color); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2); +} + +.format-select option { + background: var(--surface); + color: var(--text); +} + +.button-group { + display: flex; gap: 1rem; } button { - padding: 0.5rem 1rem; + background: var(--fg-color); + color: var(--text); + padding: 1rem; + border-radius: 0.5rem; border: none; - border-radius: 4px; - font-size: 1rem; + font-weight: 500; cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; display: flex; align-items: center; + justify-content: center; gap: 0.5rem; - transition: background-color 0.2s; + flex: 1; } -button.primary { - background: var(--primary-color); - color: white; -} - -button.primary:hover { - background: var(--primary-hover); +button:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2); } button:disabled { opacity: 0.7; cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.cancel-btn { + background: var(--error); } .loader { display: none; width: 16px; height: 16px; - border: 2px solid #ffffff; + border: 2px solid var(--text); border-radius: 50%; border-top-color: transparent; animation: spin 1s linear infinite; } @keyframes spin { - to { - transform: rotate(360deg); - } + to { transform: rotate(360deg); } } .loading .loader { @@ -135,42 +451,321 @@ button:disabled { display: none; } -.player-section { +.audio-controls { + display: flex; + flex-direction: column; + gap: 1rem; + margin-top: 1rem; +} + +.player-container { + display: flex; + flex-direction: column; + gap: 1rem; + background: rgba(15, 23, 42, 0.3); + padding: 1rem; + border-radius: 0.5rem; + border: 1px solid var(--border); +} + +.player-controls { + display: flex; + align-items: center; + gap: 1rem; + width: 100%; + background: rgba(15, 23, 42, 0.3); + padding: 0.5rem; + border-radius: 0.5rem; +} + +.seek-slider, +.volume-slider { + -webkit-appearance: none; + height: 4px; + border-radius: 2px; + background: rgba(99, 102, 241, 0.2); + outline: none; + cursor: pointer; + transition: height 0.2s ease-in-out; +} + +.seek-slider { + flex: 1; +} + +.volume-slider { + width: 100px; +} + +.seek-slider::-webkit-slider-thumb, +.volume-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--fg-color); + cursor: pointer; + transition: transform 0.2s ease; +} + +.seek-slider::-webkit-slider-thumb:hover, +.volume-slider::-webkit-slider-thumb:hover { + transform: scale(1.2); +} + +.seek-slider::-moz-range-thumb, +.volume-slider::-moz-range-thumb { + width: 12px; + height: 12px; + border: none; + border-radius: 50%; + background: var(--fg-color); + cursor: pointer; + transition: transform 0.2s ease; +} + +.seek-slider::-moz-range-thumb:hover, +.volume-slider::-moz-range-thumb:hover { + transform: scale(1.2); +} + +.volume-control { + display: flex; + align-items: center; + gap: 0.5rem; + padding-left: 0.5rem; + border-left: 1px solid var(--border); +} + +.volume-icon { + color: var(--fg-color); + opacity: 0.8; + transition: opacity 0.2s ease; +} + +.volume-icon:hover { + opacity: 1; +} + +.player-btn { + background: var(--fg-color); + color: var(--text); + padding: 0.5rem 1rem; + border-radius: 0.5rem; + border: none; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + flex: 0 0 auto; + min-width: 80px; +} + +.player-btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2); +} + +.wave-container { + width: 100%; + height: 120px; + background: rgba(15, 23, 42, 0.3); + border-radius: 0.25rem; + overflow: hidden; + position: relative; + margin-top: 0.5rem; +} + +.wave-container canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.time-display { + color: var(--text-light); + font-size: 0.875rem; + min-width: 100px; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.download-button { + position: relative; + width: 40px; + height: 40px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.2s ease; + align-self: flex-end; +} + +.download-glow { + position: absolute; + inset: -15%; + background: conic-gradient( + from 0deg, + var(--fg-color), + var(--success), + var(--fg-color) + ); + border-radius: 4px; + animation: rotate 4s linear infinite; + filter: blur(8px); + opacity: 0.5; +} + +.download-icon { + width: 40px; + height: 40px; + position: relative; + z-index: 2; background: var(--surface); - padding: 1.5rem; - border-radius: 8px; + border: 1px solid var(--border); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.download-button:hover { + transform: scale(1.05); +} + +.download-button:hover .download-icon { + box-shadow: 0 0 15px rgba(34, 197, 94, 0.3); +} + +@keyframes rotate { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } } .status { + padding: 0.75rem 1rem; + border-radius: 0.5rem; margin-bottom: 1rem; - padding: 0.5rem; - border-radius: 4px; - display: none; + transition: all 0.3s ease; + opacity: 0; + font-weight: 500; + text-align: center; +} + +.status.info { + background: rgba(99, 102, 241, 0.1); + border: 1px solid rgba(99, 102, 241, 0.2); + opacity: 1; } .status.error { - display: block; - background: rgba(220, 53, 69, 0.1); - color: var(--error); + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.2); + opacity: 1; } .status.success { - display: block; - background: rgba(40, 167, 69, 0.1); - color: var(--success); + background: rgba(34, 197, 94, 0.1); + border: 1px solid rgba(34, 197, 94, 0.2); + opacity: 1; } -audio { - width: 100%; -} +@media (max-width: 1023px) { + .container { + padding: 2rem 1rem; + } -@media (max-width: 600px) { - .controls { + h1 { + font-size: 3rem; + } + + .subtitle { + font-size: 1.2rem; + } + + .cup { + width: 30px; + height: 40px; + } + + .handle { + width: 12px; + height: 20px; + right: -12px; + top: 8px; + } + + .steam { + top: -12px; + } + + .steam::before, + .steam::after { + width: 6px; + } + + .input-section, .player-section { + padding: 1.5rem; + } + + .voice-select-container { flex-direction: column; align-items: stretch; } - - select, button { + + .options { + flex-direction: column; + gap: 1rem; + } + + .sun { + width: 80px; + height: 80px; + top: 10px; + right: 10px; + } + + .button-group { + flex-direction: column; + } + + .player-container { + flex-direction: column; + align-items: stretch; + gap: 0.75rem; + } + + .player-controls { + flex-direction: column; + gap: 0.75rem; + } + + .player-btn { width: 100%; } + + .volume-control { + border-left: none; + border-top: 1px solid var(--border); + padding-left: 0; + padding-top: 0.75rem; + width: 100%; + } + + .volume-slider { + flex: 1; + width: auto; + } + + .wave-container { + height: 80px; + } + + .time-display { + text-align: center; + } } \ No newline at end of file diff --git a/web/styles/base.css b/web/styles/base.css new file mode 100644 index 0000000..739bbfe --- /dev/null +++ b/web/styles/base.css @@ -0,0 +1,102 @@ +:root { + --bg-color: #0f172a; + --fg-color: #6366f1; + --surface: rgba(30, 41, 59, 1); + --text: #f8fafc; + --text-light: #cbd5e1; + --border: rgba(148, 163, 184, 0.2); + --error: #ef4444; + --success: #22c55e; + --font-family: 'Inter', system-ui, sans-serif; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: var(--font-family); + line-height: 1.6; + color: var(--text); + background: radial-gradient(circle at top right, + var(--fg-color) 0%, + var(--bg-color) 100%); + min-height: 100vh; + position: relative; +} + +.overlay { + position: fixed; + inset: 0; + background-image: + repeating-linear-gradient(0deg, + rgba(255,255,255,0.03) 0px, + rgba(255,255,255,0.03) 1px, + transparent 1px, + transparent 20px), + repeating-linear-gradient(90deg, + rgba(255,255,255,0.03) 0px, + rgba(255,255,255,0.03) 1px, + transparent 1px, + transparent 20px); + pointer-events: none; +} + +.sun { + position: fixed; + top: 20px; + right: 20px; + width: 100px; + height: 100px; + border-radius: 50%; + background: radial-gradient(circle at center, + rgba(99, 102, 241, 0.2) 0%, + transparent 70%); + pointer-events: none; + z-index: 0; +} + +.scanline { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 2px; + background: rgba(99, 102, 241, 0.1); + animation: scan 4s linear infinite; +} + +@keyframes scan { + 0% { transform: translateY(0); } + 100% { transform: translateY(100px); } +} + +.status { + padding: 0.75rem 1rem; + border-radius: 0.5rem; + margin-bottom: 1rem; + transition: all 0.3s ease; + opacity: 0; + font-weight: 500; + text-align: center; +} + +.status.info { + background: rgba(99, 102, 241, 0.1); + border: 1px solid rgba(99, 102, 241, 0.2); + opacity: 1; +} + +.status.error { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.2); + opacity: 1; +} + +.status.success { + background: rgba(34, 197, 94, 0.1); + border: 1px solid rgba(34, 197, 94, 0.2); + opacity: 1; +} \ No newline at end of file diff --git a/web/styles/forms.css b/web/styles/forms.css new file mode 100644 index 0000000..df33672 --- /dev/null +++ b/web/styles/forms.css @@ -0,0 +1,225 @@ +textarea { + width: 100%; + min-height: 120px; + padding: 1rem; + border: 1px solid var(--border); + border-radius: 0.5rem; + background: rgba(15, 23, 42, 0.3); + color: var(--text); + font-size: 1rem; + transition: border-color 0.2s ease; + font-family: var(--font-family); + resize: vertical; +} + +textarea:focus { + outline: none; + border-color: var(--fg-color); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2); +} + +textarea::placeholder { + color: var(--text-light); +} + +.controls { + margin-top: 1.5rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.voice-select-container { + position: relative; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.voice-search { + width: 100%; + padding: 0.75rem 1rem; + border: 1px solid var(--border); + border-radius: 0.5rem; + background: rgba(15, 23, 42, 0.3); + color: var(--text); + font-size: 1rem; + transition: all 0.2s ease; +} + +.voice-search:focus { + outline: none; + border-color: var(--fg-color); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2); +} + +.voice-search::placeholder { + color: var(--text-light); +} + +.voice-dropdown { + display: none; + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 0.5rem; + margin-top: 0.5rem; + max-height: 200px; + overflow-y: auto; + z-index: 1000; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); +} + +.voice-select-container:focus-within .voice-dropdown, +.voice-dropdown:hover { + display: block; +} + +.voice-option { + display: flex; + align-items: center; + padding: 0.75rem; + cursor: pointer; + border-radius: 0.25rem; + transition: background-color 0.2s ease; + color: var(--text); +} + +.voice-option:hover { + background: rgba(99, 102, 241, 0.1); +} + +.selected-voices { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.selected-voice-tag { + background: rgba(99, 102, 241, 0.2); + padding: 0.25rem 0.75rem; + border-radius: 1rem; + font-size: 0.875rem; + display: flex; + align-items: center; + gap: 0.5rem; + border: 1px solid rgba(99, 102, 241, 0.3); +} + +.remove-voice { + cursor: pointer; + opacity: 0.7; + transition: opacity 0.2s ease; +} + +.remove-voice:hover { + opacity: 1; +} + +.options { + display: flex; + align-items: center; + justify-content: space-between; + gap: 2rem; + flex-wrap: wrap; +} + +.options label { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text-light); + cursor: pointer; +} + +.format-select { + background: rgba(15, 23, 42, 0.3); + color: var(--text); + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 0.5rem 1rem; + font-family: var(--font-family); + font-size: 0.875rem; + cursor: pointer; + transition: all 0.2s ease; + min-width: 100px; +} + +.format-select:hover { + border-color: var(--fg-color); +} + +.format-select:focus { + outline: none; + border-color: var(--fg-color); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2); +} + +.format-select option { + background: var(--surface); + color: var(--text); +} + +.button-group { + display: flex; + gap: 1rem; +} + +button { + background: var(--fg-color); + color: var(--text); + padding: 1rem; + border-radius: 0.5rem; + border: none; + font-weight: 500; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + flex: 1; +} + +button:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2); +} + +button:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.cancel-btn { + background: var(--error); +} + +.loader { + display: none; + width: 16px; + height: 16px; + border: 2px solid var(--text); + border-radius: 50%; + border-top-color: transparent; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.loading .loader { + display: inline-block; +} + +.loading .btn-text { + display: none; +} \ No newline at end of file diff --git a/web/styles/header.css b/web/styles/header.css new file mode 100644 index 0000000..7305a96 --- /dev/null +++ b/web/styles/header.css @@ -0,0 +1,90 @@ +header { + margin-bottom: 3rem; + text-align: center; +} + +.logo-container { + display: flex; + align-items: center; + justify-content: center; + gap: 20px; +} + +h1 { + font-size: 5rem; + font-weight: 700; + margin: 0; + background: linear-gradient(rgba(255,255,255,0.1) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,0.1) 1px, transparent 1px); + background-size: 10px 10px; + -webkit-background-clip: text; + background-clip: text; + color: var(--text); + text-shadow: + -2px -2px 0 rgba(0,0,0,0.5), + 2px -2px 0 rgba(0,0,0,0.5), + -2px 2px 0 rgba(0,0,0,0.5), + 2px 2px 0 rgba(0,0,0,0.5), + 3px 3px var(--fg-color); +} + +.subtitle { + color: var(--text-light); + font-size: 1.5rem; + opacity: 0.9; + margin-top: 0.5rem; +} + +.cup { + width: 40px; + height: 50px; + border: 3px solid var(--text); + border-radius: 0 0 20px 20px; + position: relative; + animation: float 3s ease-in-out 2; + animation-fill-mode: forwards; +} + +.handle { + width: 15px; + height: 25px; + border: 3px solid var(--text); + border-radius: 0 10px 10px 0; + position: absolute; + right: -15px; + top: 10px; +} + +.steam { + position: absolute; + top: -15px; + left: 5px; + right: 5px; + height: 15px; + display: flex; + justify-content: space-between; +} + +.steam::before, +.steam::after { + content: ""; + width: 10px; + height: 100%; + background: rgba(255,255,255,0.7); + border-radius: 10px; + animation: steam 2s 2; + animation-fill-mode: forwards; +} + +@keyframes steam { + to { + transform: translateY(-10px) scale(1.5); + opacity: 0; + } +} + +@keyframes float { + 50% { + transform: translateY(-5px); + } +} \ No newline at end of file diff --git a/web/styles/layout.css b/web/styles/layout.css new file mode 100644 index 0000000..ed5388c --- /dev/null +++ b/web/styles/layout.css @@ -0,0 +1,60 @@ +.container { + max-width: 1200px; + margin: 0 auto; + padding: 4rem 1.5rem; + position: relative; + z-index: 1; +} + +main { + display: flex; + flex-direction: column; + gap: 2rem; + min-height: 600px; +} + +@media (min-width: 1024px) { + main { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + align-items: stretch; + } + + .input-section, .player-section { + height: 100%; + display: flex; + flex-direction: column; + } + + .input-section textarea { + flex: 1; + min-height: 200px; + } + + .player-section { + display: flex; + flex-direction: column; + } + + .audio-controls { + flex: 1; + display: flex; + flex-direction: column; + } + + .wave-container { + flex: 1; + min-height: 200px; + } +} + +.input-section, .player-section { + background: var(--surface); + padding: 2rem; + border-radius: 1rem; + border: 1px solid var(--border); + backdrop-filter: blur(12px); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); +} \ No newline at end of file diff --git a/web/styles/player.css b/web/styles/player.css new file mode 100644 index 0000000..d70add8 --- /dev/null +++ b/web/styles/player.css @@ -0,0 +1,194 @@ +.audio-controls { + display: flex; + flex-direction: column; + gap: 1rem; + margin-top: 1rem; +} + +.player-container { + display: flex; + flex-direction: column; + gap: 1rem; + background: rgba(15, 23, 42, 0.3); + padding: 1rem; + border-radius: 0.5rem; + border: 1px solid var(--border); +} + +.player-controls { + display: flex; + align-items: center; + gap: 1rem; + width: 100%; + background: rgba(15, 23, 42, 0.3); + padding: 0.5rem; + border-radius: 0.5rem; +} + +.seek-slider, +.volume-slider { + -webkit-appearance: none; + height: 4px; + border-radius: 2px; + background: rgba(99, 102, 241, 0.2); + outline: none; + cursor: pointer; + transition: height 0.2s ease-in-out; +} + +.seek-slider { + flex: 1; +} + +.volume-slider { + width: 100px; +} + +.seek-slider::-webkit-slider-thumb, +.volume-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--fg-color); + cursor: pointer; + transition: transform 0.2s ease; +} + +.seek-slider::-webkit-slider-thumb:hover, +.volume-slider::-webkit-slider-thumb:hover { + transform: scale(1.2); +} + +.seek-slider::-moz-range-thumb, +.volume-slider::-moz-range-thumb { + width: 12px; + height: 12px; + border: none; + border-radius: 50%; + background: var(--fg-color); + cursor: pointer; + transition: transform 0.2s ease; +} + +.seek-slider::-moz-range-thumb:hover, +.volume-slider::-moz-range-thumb:hover { + transform: scale(1.2); +} + +.volume-control { + display: flex; + align-items: center; + gap: 0.5rem; + padding-left: 0.5rem; + border-left: 1px solid var(--border); +} + +.volume-icon { + color: var(--fg-color); + opacity: 0.8; + transition: opacity 0.2s ease; +} + +.volume-icon:hover { + opacity: 1; +} + +.player-btn { + background: var(--fg-color); + color: var(--text); + padding: 0.5rem 1rem; + border-radius: 0.5rem; + border: none; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + flex: 0 0 auto; + min-width: 80px; +} + +.player-btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2); +} + +.wave-container { + width: 100%; + height: 120px; + background: rgba(15, 23, 42, 0.3); + border-radius: 0.25rem; + overflow: hidden; + position: relative; + margin-top: 0.5rem; +} + +.wave-container canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.time-display { + color: var(--text-light); + font-size: 0.875rem; + min-width: 100px; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.download-button { + position: relative; + width: 40px; + height: 40px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.2s ease; + align-self: flex-end; +} + +.download-glow { + position: absolute; + inset: -15%; + background: conic-gradient( + from 0deg, + var(--fg-color), + var(--success), + var(--fg-color) + ); + border-radius: 4px; + animation: rotate 4s linear infinite; + filter: blur(8px); + opacity: 0.5; +} + +.download-icon { + width: 40px; + height: 40px; + position: relative; + z-index: 2; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.download-button:hover { + transform: scale(1.05); +} + +.download-button:hover .download-icon { + box-shadow: 0 0 15px rgba(34, 197, 94, 0.3); +} + +@keyframes rotate { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} \ No newline at end of file diff --git a/web/styles/responsive.css b/web/styles/responsive.css new file mode 100644 index 0000000..b339fc6 --- /dev/null +++ b/web/styles/responsive.css @@ -0,0 +1,95 @@ +@media (max-width: 1023px) { + .container { + padding: 2rem 1rem; + } + + h1 { + font-size: 3rem; + } + + .subtitle { + font-size: 1.2rem; + } + + .cup { + width: 30px; + height: 40px; + } + + .handle { + width: 12px; + height: 20px; + right: -12px; + top: 8px; + } + + .steam { + top: -12px; + } + + .steam::before, + .steam::after { + width: 6px; + } + + .input-section, .player-section { + padding: 1.5rem; + } + + .voice-select-container { + flex-direction: column; + align-items: stretch; + } + + .options { + flex-direction: column; + gap: 1rem; + } + + .sun { + width: 80px; + height: 80px; + top: 10px; + right: 10px; + } + + .button-group { + flex-direction: column; + } + + .player-container { + flex-direction: column; + align-items: stretch; + gap: 0.75rem; + } + + .player-controls { + flex-direction: column; + gap: 0.75rem; + } + + .player-btn { + width: 100%; + } + + .volume-control { + border-left: none; + border-top: 1px solid var(--border); + padding-left: 0; + padding-top: 0.75rem; + width: 100%; + } + + .volume-slider { + flex: 1; + width: auto; + } + + .wave-container { + height: 80px; + } + + .time-display { + text-align: center; + } +} \ No newline at end of file