diff --git a/README.md b/README.md index 4feefb9a5..f5327a459 100644 --- a/README.md +++ b/README.md @@ -77,13 +77,15 @@ MLX-Audio provides a modern web interface with real-time audio visualization cap #### Key Features -- **Voice Customization**: Select from multiple voice presets including AF Heart, AF Nova, AF Bella, and BF Emma +- **Voice Customization**: Auto-matched Kokoro voices per language (e.g., `jf_alpha` for Japanese) with presets like AF Heart, AF Nova, AF Bella, and BF Emma +- **Language Selector**: Choose Kokoro language codes (English, Japanese, Chinese, etc.) directly in the UI; options automatically adjust per-model and “Auto detect” only appears when the selected model supports multiple languages - **Speech Rate Control**: Fine-tune speech generation speed using an intuitive slider (range: 0.5x - 2.0x) - **Dynamic 3D Visualization**: Experience audio through an interactive 3D orb that responds to frequency changes - **Audio Management**: Upload, play, and visualize custom audio files - **Smart Playback**: Optional automatic playback of generated audio - **File Management**: Quick access to the output directory through an integrated file explorer button - **Speech Recognition**: Convert speech to text with support for multiple languages and models +- **Auto Language Detection (STT)**: File uploads automatically detect spoken language (including Japanese/Chinese) before transcription—no manual dropdown needed To start the web interface and API server: UI: @@ -113,10 +115,10 @@ Available command line arguments: - `--host`: Host address to bind the server to (default: 127.0.0.1) - `--port`: Port to bind the server to (default: 8000) -Then open your browser and navigate to: -``` -http://127.0.0.1:8000 -``` +Once both processes are running: + +- Visit `http://localhost:3000` to use the graphical interface. +- The FastAPI backend stays on `http://localhost:8000`; interactive docs are available at `/docs` and the root URL now shows a short status page with setup instructions. #### API Endpoints @@ -142,6 +144,8 @@ The server provides the following REST API endpoints: > Note: Generated audio files are stored in `~/.mlx_audio/outputs` by default, or in a fallback directory if that location is not writable. +> **Kokoro language extras:** Japanese synthesis uses `misaki[ja]` (installs `pyopenjtalk` and depends on `fugashi[unidic-lite]`) and Mandarin Chinese uses `misaki[zh]`. These packages are listed in `requirements.txt`, so `pip install -r requirements.txt` pulls them in, but if you installed earlier re-run the install (or add the extras manually) before selecting those languages in the UI or API. The server automatically points fugashi to the bundled UniDic-lite dictionary, so no additional MeCab setup is needed. + ## Models ### Kokoro diff --git a/mlx_audio/server.py b/mlx_audio/server.py index 6a7f38e58..eb7a1a401 100644 --- a/mlx_audio/server.py +++ b/mlx_audio/server.py @@ -7,21 +7,73 @@ import argparse import asyncio +import importlib import io +import math import os +import sys +import shutil import time +from numbers import Real +from textwrap import dedent from typing import Any, Dict, List, Optional from urllib.parse import unquote import soundfile as sf import uvicorn from fastapi import FastAPI, File, Form, HTTPException, Response, UploadFile +from fastapi.encoders import jsonable_encoder from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import StreamingResponse +from fastapi.responses import HTMLResponse, StreamingResponse from pydantic import BaseModel from mlx_audio.utils import load_model + +def configure_fugashi_dictionary(): + """ + Ensure fugashi (used by misaki Cutlet) always has a dictionary. + + Prefer unidic_lite, fall back to unidic if present. + """ + + dicdir = None + chosen_module = None + for module_name in ("unidic_lite", "unidic"): + try: + module = __import__(module_name) + dicdir = getattr(module, "DICDIR", None) + if dicdir and os.path.isdir(dicdir): + mecabrc_path = os.path.join(dicdir, "mecabrc") + if os.path.exists(mecabrc_path): + chosen_module = module + break + dicdir = None + except ImportError: + continue + + if not dicdir: + return + + # Ensure any code importing `unidic` sees a module whose DICDIR actually exists. + if chosen_module.__name__ == "unidic_lite": + try: + import unidic # type: ignore + + unidic.DICDIR = dicdir # type: ignore[attr-defined] + except ImportError: + sys.modules["unidic"] = chosen_module + + mecabrc_path = os.path.join(dicdir, "mecabrc") + + os.environ["FUGASHI_DICDIR"] = dicdir + os.environ["FUGASHI_ARGS"] = f"-d {dicdir} -r {mecabrc_path}" + if os.path.exists(mecabrc_path): + os.environ["MECABRC"] = mecabrc_path + + +configure_fugashi_dictionary() + MLX_AUDIO_NUM_WORKERS = os.getenv("MLX_AUDIO_NUM_WORKERS", "2") @@ -51,6 +103,80 @@ async def get_available_models(self): app = FastAPI() +@app.get("/", response_class=HTMLResponse) +async def root(): + """ + Simple landing page so the root URL no longer returns a 404. The full GUI + lives in the Next.js project under ``mlx_audio/ui``. + """ + + html = dedent( + """ + + + + + MLX Audio Server + + + +
+

MLX Audio API server is running

+

+ This endpoint exposes the FastAPI backend (OpenAI-compatible TTS / STT APIs). + Open /docs for interactive API documentation + or /redoc for the ReDoc view. +

+

+ Looking for the graphical interface? Start the Next.js app inside + mlx_audio/ui: +

+
cd mlx_audio/ui
+npm install
+export NEXT_PUBLIC_API_BASE_URL=http://localhost
+export NEXT_PUBLIC_API_PORT=8000
+npm run dev
+

+ Then open + http://localhost:3000. +

+
+ + + """ + ) + + return HTMLResponse(content=html) + + def int_or_float(value): try: @@ -104,6 +230,51 @@ def setup_cors(app: FastAPI, allowed_origins: List[str]): setup_cors(app, default_origins) +LANGUAGE_DEPENDENCIES = { + "j": ("misaki.ja", "pip install misaki[ja]"), + "z": ("misaki.zh", "pip install misaki[zh]"), +} + + +def ensure_tts_language_support(lang_code: Optional[str]): + """Ensure extra dependencies for specific languages are installed.""" + code = (lang_code or "").lower() + requirement = LANGUAGE_DEPENDENCIES.get(code) + if requirement is None: + return + + module_name, install_hint = requirement + try: + importlib.import_module(module_name) + except ModuleNotFoundError as exc: + missing = module_name.split(".")[0] + raise HTTPException( + status_code=503, + detail=( + f"Language '{code}' requires `{install_hint}` " + f"(missing dependency: {missing})." + ), + ) from exc + + +def sanitize_jsonable(value: Any): + """ + Recursively sanitize any jsonable structure by replacing NaN/inf values + with ``None`` so the response can be serialized by the JSON encoder. + """ + + if isinstance(value, dict): + return {k: sanitize_jsonable(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [sanitize_jsonable(v) for v in value] + if isinstance(value, Real) and not isinstance(value, bool): + number = float(value) + if math.isnan(number) or math.isinf(number): + return None + return number + return value + + # Request schemas for OpenAI-compatible endpoints class SpeechRequest(BaseModel): model: str @@ -207,6 +378,7 @@ async def generate_audio(model, payload: SpeechRequest, verbose: bool = False): @app.post("/v1/audio/speech") async def tts_speech(payload: SpeechRequest): """Generate speech audio following the OpenAI text-to-speech API.""" + ensure_tts_language_support(payload.lang_code) model = model_provider.load_model(payload.model) return StreamingResponse( generate_audio(model, payload), @@ -230,9 +402,13 @@ async def stt_transcriptions( sf.write(tmp_path, audio, sr) stt_model = model_provider.load_model(model) - result = stt_model.generate(tmp_path) + generate_kwargs = {} + if language: + generate_kwargs["language"] = language + result = stt_model.generate(tmp_path, **generate_kwargs) os.remove(tmp_path) - return result + response = sanitize_jsonable(jsonable_encoder(result)) + return response def main(): diff --git a/mlx_audio/ui/app/speech-to-text/[id]/page.tsx b/mlx_audio/ui/app/speech-to-text/[id]/page.tsx index 38f23ed9a..a744f688c 100644 --- a/mlx_audio/ui/app/speech-to-text/[id]/page.tsx +++ b/mlx_audio/ui/app/speech-to-text/[id]/page.tsx @@ -52,6 +52,7 @@ export default function TranscriptViewerPage() { const [language, setLanguage] = useState("English") const [date, setDate] = useState("yesterday") const [isExportDropdownOpen, setIsExportDropdownOpen] = useState(false) + const [audioSource, setAudioSource] = useState(null) useEffect(() => { // Set up audio player @@ -91,16 +92,53 @@ export default function TranscriptViewerPage() { } }, [isExportDropdownOpen]) - const togglePlayPause = () => { + useEffect(() => { + const audio = audioRef.current + if (audio && audioSource) { + audio.src = audioSource + const handleLoadedMetadata = () => { + const metaDuration = audio.duration + if (!Number.isNaN(metaDuration) && metaDuration > 0) { + setDuration(metaDuration) + } + } + audio.addEventListener("loadedmetadata", handleLoadedMetadata) + return () => { + audio.removeEventListener("loadedmetadata", handleLoadedMetadata) + } + } else if (audio && !audioSource) { + audio.src = "" + } + return undefined + }, [audioSource]) + + const playAudio = async () => { const audio = audioRef.current if (!audio) return + try { + await audio.play() + setIsPlaying(true) + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + return + } + console.error("Error playing audio:", error) + } + } + + const togglePlayPause = () => { + const audio = audioRef.current + if (!audio || !audioSource) { + console.warn("No audio available for playback.") + return + } if (isPlaying) { audio.pause() + setIsPlaying(false) } else { - audio.play().catch((e) => console.error("Error playing audio:", e)) + void playAudio() } - setIsPlaying(!isPlaying) } const handleSeek = (e: React.ChangeEvent) => { @@ -155,6 +193,11 @@ export default function TranscriptViewerPage() { setLanguage(data.language ?? "English") setDate(data.date ?? "yesterday") setDuration(data.duration ?? 8) + if (data.audioDataUrl) { + setAudioSource(data.audioDataUrl) + } else { + setAudioSource(null) + } if (data.segments?.length) { const segments = data.segments.map(seg => ({ @@ -414,7 +457,12 @@ export default function TranscriptViewerPage() { diff --git a/mlx_audio/ui/app/speech-to-text/page.tsx b/mlx_audio/ui/app/speech-to-text/page.tsx index 0761d99e4..39ccf517d 100644 --- a/mlx_audio/ui/app/speech-to-text/page.tsx +++ b/mlx_audio/ui/app/speech-to-text/page.tsx @@ -4,7 +4,7 @@ import type React from "react" import { useState, useRef, useEffect } from "react" import { LayoutWrapper } from "@/components/layout-wrapper" -import { FileText, Upload, MoreVertical, X, ChevronDown } from "lucide-react" +import { FileText, Upload, MoreVertical, X } from "lucide-react" import Link from "next/link" interface TranscriptionFile { @@ -22,7 +22,6 @@ export default function SpeechToTextPage() { }, ]) const [isModalOpen, setIsModalOpen] = useState(false) - const [primaryLanguage, setPrimaryLanguage] = useState("Detect") const [tagAudioEvents, setTagAudioEvents] = useState(false) const [selectedModel, setSelectedModel] = useState("mlx-community/whisper-large-v3-turbo") const fileInputRef = useRef(null) @@ -70,11 +69,19 @@ export default function SpeechToTextPage() { loadStoredTranscriptions() }, []) + const fileToDataUrl = (file: File) => + new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = () => reject(new Error("Failed to read file")) + reader.readAsDataURL(file) + }) + const uploadAndTranscribe = async (file: File, id: string) => { const formData = new FormData() formData.append("file", file) formData.append("model", selectedModel) - formData.append("language", primaryLanguage === "Detect" ? "en" : primaryLanguage.toLowerCase()) + formData.append("language", "") formData.append("response_format", "verbose_json") formData.append("temperature", "0") @@ -84,6 +91,7 @@ export default function SpeechToTextPage() { const API_PORT = process.env.NEXT_PUBLIC_API_PORT || '8000'; try { + const dataUrlPromise = fileToDataUrl(file) const res = await fetch(`${API_BASE_URL}:${API_PORT}/v1/audio/transcriptions`, { method: "POST", body: formData, @@ -92,6 +100,7 @@ export default function SpeechToTextPage() { // Save the transcription to a JSON file via API data.fileName = fileName + data.audioDataUrl = await dataUrlPromise localStorage.setItem(`mlx-audio-transcription-${id}`, JSON.stringify(data)) setFiles((prev) => prev.map((f) => @@ -307,27 +316,11 @@ export default function SpeechToTextPage() {
- -
-
- - +
+

+ Automatically detects the spoken language and transcribes accordingly (supports Japanese, Chinese, and multilingual audio). +

diff --git a/mlx_audio/ui/app/text-to-speech/page.tsx b/mlx_audio/ui/app/text-to-speech/page.tsx index 985b36149..3e4cddef1 100644 --- a/mlx_audio/ui/app/text-to-speech/page.tsx +++ b/mlx_audio/ui/app/text-to-speech/page.tsx @@ -2,10 +2,9 @@ import type React from "react" -import { useState, useRef } from "react" +import { useState, useRef, useMemo, useEffect } from "react" import { ChevronDown, Download, ThumbsUp, ThumbsDown, Play, Pause, RefreshCw } from "lucide-react" import { LayoutWrapper } from "@/components/layout-wrapper" -import { VoiceSelection } from "@/components/voice-selection" // Custom range input component with colored progress function RangeInput({ @@ -48,6 +47,75 @@ function RangeInput({ ) } +const LANGUAGE_OPTIONS = [ + { value: "auto", label: "Auto detect (match text)", defaultVoice: "af_heart" }, + { value: "a", label: "English (American)", defaultVoice: "af_heart" }, + { value: "b", label: "English (British)", defaultVoice: "bf_emma" }, + { value: "e", label: "Spanish", defaultVoice: "ef_dora" }, + { value: "f", label: "French", defaultVoice: "ff_siwis" }, + { value: "h", label: "Hindi", defaultVoice: "hf_alpha" }, + { value: "i", label: "Italian", defaultVoice: "if_sara" }, + { value: "p", label: "Portuguese (Brazil)", defaultVoice: "pf_dora" }, + { value: "j", label: "Japanese", defaultVoice: "jf_alpha" }, + { value: "z", label: "Mandarin Chinese", defaultVoice: "zf_xiaobei" }, +] + +const MODEL_LANGUAGE_SUPPORT: Record = { + marvis: ["a"], + kokoro: LANGUAGE_OPTIONS.filter((option) => option.value !== "auto").map( + (option) => option.value + ), + spark: ["a", "b"], + default: ["a"], +} + +const getModelFamily = (modelId: string) => { + const normalized = modelId.toLowerCase() + if (normalized.includes("marvis")) return "marvis" + if (normalized.includes("kokoro")) return "kokoro" + if (normalized.includes("spark")) return "spark" + return "default" +} + +const isMarvisModel = (modelId: string) => modelId.toLowerCase().includes("marvis") + +const getDefaultVoice = (modelId: string, langCode: string) => { + if (isMarvisModel(modelId)) { + return "conversational_a" + } + const option = LANGUAGE_OPTIONS.find((entry) => entry.value === langCode) + return option?.defaultVoice ?? "af_heart" +} + +const detectLanguageFromText = (text: string): string | null => { + const content = text.trim() + if (!content) { + return null + } + + const hasHiragana = /[\u3040-\u309F]/.test(content) + const hasKatakana = /[\u30A0-\u30FF]/.test(content) + const hasCJK = /[\u4E00-\u9FFF]/.test(content) + if (hasHiragana || hasKatakana) { + return "j" + } + if (hasCJK) { + return "z" + } + + const accentedSpanish = /[áéíóúñüÁÉÍÓÚÑÜ]/.test(content) + if (accentedSpanish) { + return "e" + } + + const accentedFrench = /[àâçéèêëîïôûùüÿÀÂÇÉÈÊËÎÏÔÛÙÜŸ]/.test(content) + if (accentedFrench) { + return "f" + } + + return null +} + export default function SpeechSynthesis() { const [text, setText] = useState("But I also have other interests, such as playing tic-tac-toe.") const [isPlaying, setIsPlaying] = useState(false) @@ -59,11 +127,76 @@ export default function SpeechSynthesis() { const [duration, setDuration] = useState("00:04") const [activeTab, setActiveTab] = useState<"settings" | "history">("settings") const [model, setModel] = useState("Marvis-AI/marvis-tts-100m-v0.2-MLX-6bit") - const [language, setLanguage] = useState("English-detected") + const [language, setLanguage] = useState("auto") const [liked, setLiked] = useState(null) - const [selectedVoice, setSelectedVoice] = useState("conversational_a") const audioRef = useRef(null) + const [currentAudioUrl, setCurrentAudioUrl] = useState(null) + const modelFamily = getModelFamily(model) + const allowedLanguages = useMemo( + () => MODEL_LANGUAGE_SUPPORT[modelFamily] ?? MODEL_LANGUAGE_SUPPORT.default, + [modelFamily] + ) + const autoAllowed = allowedLanguages.length > 1 + + useEffect(() => { + if (!autoAllowed && language === "auto") { + setLanguage(allowedLanguages[0]) + } else if ( + language !== "auto" && + !allowedLanguages.includes(language) + ) { + setLanguage(autoAllowed ? "auto" : allowedLanguages[0]) + } + }, [autoAllowed, allowedLanguages, language]) + + const languageOptionsForModel = useMemo( + () => + LANGUAGE_OPTIONS.filter((option) => + option.value === "auto" + ? autoAllowed + : allowedLanguages.includes(option.value) + ), + [allowedLanguages, autoAllowed] + ) + + const languageLabel = + language === "auto" + ? "Auto detect (match text)" + : LANGUAGE_OPTIONS.find((option) => option.value === language)?.label ?? + "Auto detect (match text)" + + const detectedLanguage = + language === "auto" && autoAllowed ? detectLanguageFromText(text) : null + const resolvedLanguage = + language === "auto" + ? detectedLanguage ?? allowedLanguages[0] + : language + const selectedVoice = getDefaultVoice(model, resolvedLanguage) + + const cleanupAudioUrl = () => { + if (audioRef.current?.src && audioRef.current.src.startsWith("blob:")) { + URL.revokeObjectURL(audioRef.current.src) + } + setCurrentAudioUrl(null) + } + + const handlePlaybackError = (error: unknown) => { + if (error instanceof DOMException && error.name === "AbortError") { + return + } + console.error("Audio playback failed:", error) + } + + const playAudio = async () => { + if (!audioRef.current) return + try { + await audioRef.current.play() + setIsPlaying(true) + } catch (error) { + handlePlaybackError(error) + } + } const handleTextChange = (e: React.ChangeEvent) => { setText(e.target.value) @@ -78,21 +211,24 @@ export default function SpeechSynthesis() { } if (isPlaying) { - audioRef.current?.pause() + audioRef.current.pause() + setIsPlaying(false) } else { - audioRef.current?.play() + void playAudio() } - setIsPlaying(!isPlaying) } const handleGenerate = async () => { if (!audioRef.current) return setIsGenerating(true) + audioRef.current.pause() + setIsPlaying(false) + cleanupAudioUrl() const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost'; const API_PORT = process.env.NEXT_PUBLIC_API_PORT || '8000'; - const voice = (model.includes("marvis") ? "conversational_a" : "af_heart"); + const voice = selectedVoice.trim() || getDefaultVoice(model, resolvedLanguage) try { const response = await fetch(`${API_BASE_URL}:${API_PORT}/v1/audio/speech`, { @@ -105,6 +241,7 @@ export default function SpeechSynthesis() { input: text, voice: voice, speed: speed, + ...(resolvedLanguage ? { lang_code: resolvedLanguage } : {}), // pitch and other parameters can be added here if supported by the backend }), }) @@ -116,12 +253,12 @@ export default function SpeechSynthesis() { const blob = await response.blob() const audioUrl = URL.createObjectURL(blob) audioRef.current.src = audioUrl + setCurrentAudioUrl(audioUrl) audioRef.current.onloadedmetadata = () => { setDuration(formatTime(Math.floor(audioRef.current?.duration || 0))) setCurrentTime("00:00") - setIsPlaying(true) - audioRef.current?.play() + void playAudio() } audioRef.current.ontimeupdate = () => { @@ -132,9 +269,7 @@ export default function SpeechSynthesis() { setIsPlaying(false) setCurrentTime("00:00") // Revoke the object URL to free up resources - if (audioRef.current?.src.startsWith("blob:")) { - URL.revokeObjectURL(audioRef.current.src) - } + cleanupAudioUrl() } } catch (error) { console.error("Error generating speech:", error) @@ -144,9 +279,39 @@ export default function SpeechSynthesis() { } } - const handleDownload = () => { - // In a real app, this would download the audio file - alert("Downloading audio...") + const handleDownload = async () => { + const src = audioRef.current?.src + if (!src || src === window.location.href) { + alert("Generate audio before downloading.") + return + } + + let downloadUrl = src + let revokeAfter = false + + if (!src.startsWith("blob:")) { + try { + const response = await fetch(src) + const blob = await response.blob() + downloadUrl = URL.createObjectURL(blob) + revokeAfter = true + } catch (error) { + console.error("Failed to download audio:", error) + alert("Unable to download audio. Please try regenerating.") + return + } + } + + const link = document.createElement("a") + link.href = downloadUrl + link.download = "mlx-audio.wav" + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + + if (revokeAfter) { + URL.revokeObjectURL(downloadUrl) + } } const handleFeedback = (isPositive: boolean) => { @@ -161,10 +326,6 @@ export default function SpeechSynthesis() { return `00:${seconds.toString().padStart(2, "0")}` } - const handleVoiceChange = (voice: string) => { - setSelectedVoice(voice) - } - return (
@@ -190,11 +351,30 @@ export default function SpeechSynthesis() {
- + +
+

+ {autoAllowed + ? "Auto-detect is available for this model." + : `This model supports: ${allowedLanguages + .map( + (code) => + LANGUAGE_OPTIONS.find((opt) => opt.value === code)?.label ?? + code + ) + .join(", ")}`} +

- -
Speed @@ -409,7 +587,7 @@ export default function SpeechSynthesis() {
- {selectedVoice}: {text.length > 20 ? text.substring(0, 20) + "..." : text} + {languageLabel}: {text.length > 20 ? text.substring(0, 20) + "..." : text}
How did this sound?
diff --git a/requirements.txt b/requirements.txt index 931562203..bfbd0dd7c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,8 @@ misaki[en]>=0.8.2 +misaki[ja]>=0.8.2 +misaki[zh]>=0.8.2 +pyopenjtalk>=0.3.3 +fugashi[unidic-lite]>=1.3.2 loguru>=0.7.3 num2words>=0.5.14 spacy>=3.8.4 @@ -25,4 +29,4 @@ fastrtc[vad, stt] webrtcvad>=2.0.10 dacite>=1.9.2 pytest-asyncio>=1.0.0 -mistral-common[audio] \ No newline at end of file +mistral-common[audio]