Instructions to use MoYoYoTech/VoiceDialogue with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use MoYoYoTech/VoiceDialogue with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-to-speech", model="MoYoYoTech/VoiceDialogue") messages = [ {"role": "user", "content": "Who are you?"}, ] pipe(messages)# Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("MoYoYoTech/VoiceDialogue", device_map="auto") - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- llama.cpp
How to use MoYoYoTech/VoiceDialogue with llama.cpp:
Install (macOS, Linux)
curl -LsSf https://llama.app/install.sh | sh # Start a local OpenAI-compatible server with a web UI: llama serve -hf MoYoYoTech/VoiceDialogue:Q6_K # Run inference directly in the terminal: llama cli -hf MoYoYoTech/VoiceDialogue:Q6_K
Install from WinGet (Windows)
winget install llama.cpp # Start a local OpenAI-compatible server with a web UI: llama serve -hf MoYoYoTech/VoiceDialogue:Q6_K # Run inference directly in the terminal: llama cli -hf MoYoYoTech/VoiceDialogue:Q6_K
Use pre-built binary
# Download pre-built binary from: # https://github.com/ggerganov/llama.cpp/releases # Start a local OpenAI-compatible server with a web UI: ./llama-server -hf MoYoYoTech/VoiceDialogue:Q6_K # Run inference directly in the terminal: ./llama-cli -hf MoYoYoTech/VoiceDialogue:Q6_K
Build from source code
git clone https://github.com/ggerganov/llama.cpp.git cd llama.cpp cmake -B build cmake --build build -j --target llama-server llama-cli # Start a local OpenAI-compatible server with a web UI: ./build/bin/llama-server -hf MoYoYoTech/VoiceDialogue:Q6_K # Run inference directly in the terminal: ./build/bin/llama-cli -hf MoYoYoTech/VoiceDialogue:Q6_K
Use Docker
docker model run hf.co/MoYoYoTech/VoiceDialogue:Q6_K
- LM Studio
- Jan
- Ollama
How to use MoYoYoTech/VoiceDialogue with Ollama:
ollama run hf.co/MoYoYoTech/VoiceDialogue:Q6_K
- Unsloth Studio
How to use MoYoYoTech/VoiceDialogue with Unsloth Studio:
Install Unsloth Studio (macOS, Linux, WSL)
curl -fsSL https://unsloth.ai/install.sh | sh # Run unsloth studio unsloth studio -H 0.0.0.0 -p 8888 # Then open http://localhost:8888 in your browser # Search for MoYoYoTech/VoiceDialogue to start chatting
Install Unsloth Studio (Windows)
irm https://unsloth.ai/install.ps1 | iex # Run unsloth studio unsloth studio -H 0.0.0.0 -p 8888 # Then open http://localhost:8888 in your browser # Search for MoYoYoTech/VoiceDialogue to start chatting
Using HuggingFace Spaces for Unsloth
# No setup required # Open https://huggingface.co/spaces/unsloth/studio in your browser # Search for MoYoYoTech/VoiceDialogue to start chatting
- Pi
How to use MoYoYoTech/VoiceDialogue with Pi:
Start the llama.cpp server
# Install llama.cpp: brew install llama.cpp # Start a local OpenAI-compatible server: llama serve -hf MoYoYoTech/VoiceDialogue:Q6_K
Configure the model in Pi
# Install Pi: npm install -g @mariozechner/pi-coding-agent # Add to ~/.pi/agent/models.json: { "providers": { "llama-cpp": { "baseUrl": "http://localhost:8080/v1", "api": "openai-completions", "apiKey": "none", "models": [ { "id": "MoYoYoTech/VoiceDialogue:Q6_K" } ] } } }Run Pi
# Start Pi in your project directory: pi
- OpenClaw new
How to use MoYoYoTech/VoiceDialogue with OpenClaw:
Start the llama.cpp server
# Install llama.cpp: brew install llama.cpp # Start a local OpenAI-compatible server: llama serve -hf MoYoYoTech/VoiceDialogue:Q6_K
Configure OpenClaw
# Install OpenClaw: npm install -g openclaw@latest # Register the local server and set it as the default model: openclaw onboard --non-interactive --mode local \ --auth-choice custom-api-key \ --custom-base-url http://127.0.0.1:8080/v1 \ --custom-model-id "MoYoYoTech/VoiceDialogue:Q6_K" \ --custom-provider-id llama-cpp \ --custom-compatibility openai \ --custom-text-input \ --accept-risk \ --skip-health
Run OpenClaw
openclaw agent --local --agent main --message "Hello from Hugging Face"
- Docker Model Runner
How to use MoYoYoTech/VoiceDialogue with Docker Model Runner:
docker model run hf.co/MoYoYoTech/VoiceDialogue:Q6_K
- Lemonade
How to use MoYoYoTech/VoiceDialogue with Lemonade:
Pull the model
# Download Lemonade from https://lemonade-server.ai/ lemonade pull MoYoYoTech/VoiceDialogue:Q6_K
Run and chat with the model
lemonade run user.VoiceDialogue-Q6_K
List all available models
lemonade list
- Hermes Agent
How to use MoYoYoTech/VoiceDialogue with Hermes Agent:
Start the llama.cpp server
# Install llama.cpp: brew install llama.cpp # Start a local OpenAI-compatible server: llama serve -hf MoYoYoTech/VoiceDialogue:Q6_K
Configure Hermes
# Install Hermes: curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash hermes setup # Point Hermes at the local server: hermes config set model.provider custom hermes config set model.base_url http://127.0.0.1:8080/v1 hermes config set model.default MoYoYoTech/VoiceDialogue:Q6_K
Run Hermes
hermes
- Atomic Chat
Add external mic array support, UI i18n, glass settings redesign, and fixes
Browse filesBackend:
- Audio: input-device selection + multichannel downmix/resample to 16k mono (devices.py, pyaudio_capture.py); facade routes default-device to native macOS AEC, external device to PyAudio (capture/__init__.py)
- API: GET /system/audio-devices, input_device_index in /system/start with persistence (audio_config.py); default system/ASR language set to Chinese (lifespan.py)
- CLI: --input-device and --list-audio-devices
Frontend:
- vue-i18n setup (en/zh), default UI + ASR language Chinese
- Unified tabbed Settings dialog (Main/Language/Prompt) replacing scattered modals; fixed-height tabs; mic + interface/recognition language icons
- Liquid-glass styling (frosted modal, glass buttons, smooth ant-fade open with backdrop-blur background)
- Wider caption area; unified bottom action-button height across Welcome and Home
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- assets/www/assets/{index-CCuJ1lip.css → index-6CUhaZ06.css} +1 -1
- assets/www/assets/{index-ByqsFGbw.js → index-STY1yYrK.js} +2 -2
- assets/www/index.html +2 -2
- frontend/src/App.vue +13 -2
- frontend/src/assets/ball.json +2 -2
- frontend/src/config/client_config.ts +1 -1
- frontend/src/i18n/index.ts +35 -0
- frontend/src/i18n/locales/en.ts +56 -0
- frontend/src/i18n/locales/zh.ts +56 -0
- frontend/src/main.ts +2 -0
- frontend/src/stores/config.ts +2 -0
- frontend/src/style.scss +65 -0
- frontend/src/views/Home/Components/ChatText.vue +15 -7
- frontend/src/views/Home/index.vue +12 -1
- frontend/src/views/Welcome/Components/SettingsModal.vue +409 -0
- frontend/src/views/Welcome/index.vue +41 -422
- main.py +16 -1
- src/voice_dialogue/api/core/lifespan.py +2 -2
- src/voice_dialogue/api/core/service_factories.py +11 -4
- src/voice_dialogue/api/routes/system_routes.py +46 -5
- src/voice_dialogue/api/schemas/system_schemas.py +19 -2
- src/voice_dialogue/audio/capture/__init__.py +53 -7
- src/voice_dialogue/audio/capture/pyaudio_capture.py +104 -10
- src/voice_dialogue/audio/devices.py +89 -0
- src/voice_dialogue/cli/args.py +14 -0
- src/voice_dialogue/config/audio_config.py +60 -0
- src/voice_dialogue/config/paths.py +1 -0
- src/voice_dialogue/core/launcher.py +8 -4
|
@@ -1 +1 @@
|
|
| 1 |
-
@charset "UTF-8";html,body{width:100%;height:100%}input::-ms-clear,input::-ms-reveal{display:none}*,*:before,*:after{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:rgba(0,0,0,0)}body{margin:0}[tabindex="-1"]:focus{outline:none}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5em;font-weight:500}p{margin-top:0;margin-bottom:1em}abbr[title],abbr[data-original-title]{-webkit-text-decoration:underline dotted;text-decoration:underline;text-decoration:underline dotted;border-bottom:0;cursor:help}address{margin-bottom:1em;font-style:normal;line-height:inherit}input[type=text],input[type=password],input[type=number],textarea{-webkit-appearance:none}ol,ul,dl{margin-top:0;margin-bottom:1em}ol ol,ul ul,ol ul,ul ol{margin-bottom:0}dt{font-weight:500}dd{margin-bottom:.5em;margin-left:0}blockquote{margin:0 0 1em}dfn{font-style:italic}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}pre,code,kbd,samp{font-size:1em;font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,Courier,monospace}pre{margin-top:0;margin-bottom:1em;overflow:auto}figure{margin:0 0 1em}img{vertical-align:middle;border-style:none}a,area,button,[role=button],input:not([type=range]),label,select,summary,textarea{touch-action:manipulation}table{border-collapse:collapse}caption{padding-top:.75em;padding-bottom:.3em;text-align:left;caption-side:bottom}input,button,select,optgroup,textarea{margin:0;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}button,html [type=button],[type=reset],[type=submit]{-webkit-appearance:button}button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{padding:0;border-style:none}input[type=radio],input[type=checkbox]{box-sizing:border-box;padding:0}input[type=date],input[type=time],input[type=datetime-local],input[type=month]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;margin:0;padding:0;border:0}legend{display:block;width:100%;max-width:100%;margin-bottom:.5em;padding:0;color:inherit;font-size:1.5em;line-height:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item}template{display:none}[hidden]{display:none!important}mark{padding:.2em;background-color:#feffe6}:root{font-family:Inter,system-ui,Avenir,Helvetica,Arial,sans-serif;line-height:1.5;font-weight:400;color-scheme:light dark;color:#ffffffde;background-color:#242424;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;-webkit-text-size-adjust:100%}a{font-weight:500;color:#646cff;text-decoration:inherit}a:hover{color:#535bf2}body{margin:0;display:flex;place-items:center;min-width:320px;height:100%;min-height:auto;color:#333;background:#fff}h1{font-size:3.2em;line-height:1.1}button{border-radius:8px;border:1px solid transparent;padding:.6em 1.2em;font-size:1em;font-weight:500;font-family:inherit;background-color:#1a1a1a;cursor:pointer;transition:border-color .25s}.card{border-bottom:solid 2px lightgray;align-items:center;justify-content:center;margin-top:40px;display:flex;max-width:1024px;width:100%}.seg-title{margin:24px 0;font-size:20px;font-weight:500}.seg-co{width:1022px;text-align:left;border-left:solid 6px midnightblue;padding-left:8px;margin-left:2px;margin-top:36px;line-height:24px}#app{margin:0 auto;padding:0;text-align:center;width:100%;height:100%}.ant-btn{padding:4px 12px}@media (prefers-color-scheme: light){:root{color:#213547;background-color:#fff}a:hover{color:#747bff}button{background-color:#f9f9f9}}.ant-card{background:#f5f6fa;height:100%}.ant-card-body{padding:24px 36px 12px!important;border-radius:0 0 8px 8px}.ant-card .ant-card-actions{background-color:#e8e8f8cc!important}.ant-popover{max-width:800px!important}.ant-form-item{background:transparent;margin-bottom:40px!important}.ant-form-item .ant-form-item-explain-error{color:#ff4d4f;text-align:left!important}.ant-form-item-label label{font-size:18px!important;color:#1a1a1a!important;font-weight:500!important}.ant-tooltip{max-width:1022px!important}.ant-page-header-heading{width:1022px!important}.highlight{background:#f8f8ff}.ant-layout-sider-collapsed{width:0!important;min-width:0!important;overflow:hidden}.ant-layout-sider-collapsed .ant-menu-item,.ant-layout-sider-collapsed .ant-menu-submenu-title{display:none}.header-nav[data-v-07594418]{display:flex;align-items:flex-start;justify-content:space-between;width:100vw;height:40px;align-items:center;position:absolute;top:0;left:0;z-index:99;-webkit-app-region:drag;cursor:move}.header-nav .window-controls[data-v-07594418],.header-nav button[data-v-07594418],.header-nav .ant-input-search[data-v-07594418],.header-nav img[data-v-07594418],.header-nav .anticon[data-v-07594418]{-webkit-app-region:no-drag;cursor:pointer}.header-nav .window-controls[data-v-07594418]{top:0;right:0;display:flex;z-index:1000;margin-left:12px}.header-nav .window-controls .window-control-btn[data-v-07594418]{width:46px;height:32px;border:none;background:transparent;color:#666;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background-color .2s}.header-nav .window-controls .window-control-btn[data-v-07594418]:hover{background-color:#0000001a}.header-nav .window-controls .window-control-btn.close[data-v-07594418]:hover{background-color:#e81123;color:#fff}.header-nav .window-controls .close-icon.focus[data-v-07594418]{display:none}.header-nav .window-controls:hover .close-icon.default[data-v-07594418],.header-nav .window-controls:focus-within .close-icon.default[data-v-07594418]{display:none}.header-nav .window-controls:hover .close-icon.focus[data-v-07594418],.header-nav .window-controls:focus-within .close-icon.focus[data-v-07594418]{display:inline}.content[data-v-874ca48f]{background-color:#fff;margin:0 auto;display:flex;flex-direction:column;align-items:center;justify-content:space-between}.not-found-wrapper[data-v-aef52a59]{height:calc(100vh - 104px)}.btn-groups[data-v-839398ff]{margin-top:36px;display:flex;justify-content:flex-end;align-items:center}.prompt-title p[data-v-839398ff]{margin:0;font-size:16px;font-weight:500}.prompt-content[data-v-839398ff]{margin-top:16px}.prompt-content .prompt-title[data-v-839398ff]{margin-bottom:24px;font-size:22px;font-weight:500;text-align:center}.prompt-content .language-segment[data-v-839398ff]{display:flex;justify-content:center;margin-bottom:16px}.prompt-content .prompt-item[data-v-839398ff]{margin-top:16px}.languages[data-v-cd713caa]{margin-top:24px;margin-bottom:24px}.languages p[data-v-cd713caa]{font-size:16px;font-weight:500;margin-bottom:8px}.audio-play-btn[data-v-cd713caa]{padding:2px 8px 0;border-radius:4px;transition:all .2s;height:40px}.audio-play-btn[data-v-cd713caa]:hover{background-color:#f0f0f0}.audio-play-btn.playing[data-v-cd713caa]{background-color:#f6ffed;border-color:#1890ff}.audio-play-btn.playing .playing-icon[data-v-cd713caa]{animation:pulse-cd713caa 1.5s infinite}@keyframes pulse-cd713caa{0%{opacity:1;transform:scale(1)}50%{opacity:.7;transform:scale(1.1)}to{opacity:1;transform:scale(1)}}.btn-groups[data-v-cd713caa]{margin-top:36px;display:flex;justify-content:space-between;align-items:center}.custom-popover-list[data-v-cd713caa]{width:92px;margin:0}.custom-popover-list .custom-popover-item[data-v-cd713caa]{font-size:14px;line-height:36px;font-weight:500;color:#1e1e1e;cursor:pointer;border-radius:4px;padding:0 8px;margin:0 -8px;transition:background .2s}.custom-popover-list .custom-popover-item[data-v-cd713caa]:hover,.custom-popover-list .custom-popover-item[data-v-cd713caa]:focus{background:#e5e7eb}.welcome-wrapper[data-v-cd713caa]{width:100%;height:100%;background-image:url(./bg-BmnA8p_e.png);background-repeat:no-repeat;background-attachment:fixed;background-size:cover;background-position:center;display:flex;flex-direction:column;align-items:center;justify-content:space-between;color:#fff}.welcome-wrapper .content[data-v-cd713caa]{width:100%;height:80vh;display:flex;flex-direction:column;justify-content:space-around;margin-top:64px}.welcome-wrapper .content .inner-content[data-v-cd713caa]{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:20px}.welcome-wrapper .content .inner-content .text-box[data-v-cd713caa]{color:#000;margin-bottom:36px}.welcome-wrapper .content .inner-content .text-box .title[data-v-cd713caa]{font-size:24px;font-weight:600;margin-bottom:24px}.welcome-wrapper .content .inner-content .text-box .sub-title[data-v-cd713caa]{font-size:15px;margin-top:10px}.welcome-wrapper .content .inner-content .btn-box[data-v-cd713caa]{width:224px;height:80px}.welcome-wrapper .actions[data-v-cd713caa]{width:100%;height:64px;display:flex;justify-content:flex-end}.ball-wrapper[data-v-34c8e583]{width:100%;height:calc(100vh - 100px);display:flex;flex-direction:column;align-items:center;justify-content:space-around}.talk-wrapper[data-v-1f502814]{width:auto;height:calc(100vh - 100px);overflow-y:scroll;padding:20px 240px 0;display:flex;flex-direction:column;align-items:flex-start;justify-content:flex-start}.talk-wrapper .cont-left[data-v-1f502814]{width:100%;margin:24px 0;display:flex;justify-content:flex-start;align-items:flex-start}.talk-wrapper .cont-left .text-left[data-v-1f502814]{color:#222;font-size:16px;font-weight:400;text-align:left;line-height:2;margin-left:12px;margin-top:6px}.talk-wrapper .cont-right[data-v-1f502814]{width:100%;margin:24px 0;display:flex;justify-content:flex-end;align-items:flex-start}.talk-wrapper .cont-right .text-right[data-v-1f502814]{color:#444;font-size:16px;font-weight:400;text-align:end;line-height:2;margin-right:12px;background:#ccc;border-radius:8px 0 8px 8px;padding:8px}.chat-wrapper[data-v-803600aa]{width:100%;height:100%;background-image:url(./bg-BmnA8p_e.png);background-repeat:no-repeat;background-attachment:fixed;background-size:cover;background-position:center;display:flex;flex-direction:column;align-items:center;justify-content:space-between;color:#fff}.chat-wrapper .content[data-v-803600aa]{width:100%;height:auto;display:flex;flex-direction:column;justify-content:space-around}.chat-wrapper .content .inner-content[data-v-803600aa]{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:20px}.chat-wrapper .content .inner-content .text-box[data-v-803600aa]{color:#000;margin-bottom:36px}.chat-wrapper .content .inner-content .text-box .title[data-v-803600aa]{font-size:24px;font-weight:600;margin-bottom:24px}.chat-wrapper .content .inner-content .text-box .sub-title[data-v-803600aa]{font-size:15px;margin-top:10px}.chat-wrapper .content .inner-content .btn-box[data-v-803600aa]{width:224px;height:80px}.chat-wrapper .actions[data-v-803600aa]{width:100%;height:100px;display:flex;justify-content:space-between;align-items:center}.chat-wrapper .actions .holder[data-v-803600aa]{width:64px;height:48px}.chat-wrapper .actions .btns[data-v-803600aa]{width:450px;height:96px;display:flex;justify-content:space-around;align-items:flex-start}.chat-wrapper .actions .download-wrapper[data-v-803600aa]{width:64px;height:64px;display:flex;justify-content:flex-start;align-items:center;margin-right:0}.chat-wrapper .actions .download-wrapper img[data-v-803600aa]{width:24px;height:24px}.content-wrapper[data-v-d41c9ce7]{text-align:left;max-width:800px;min-width:320px;margin-bottom:64px;min-height:calc(100vh - 438px)}.content-wrapper .content-box[data-v-d41c9ce7]{padding:24px;height:240px;background-color:#e8e8e8;border-radius:16px;width:50%;margin:48px auto;min-width:300px}.content-wrapper .video-box[data-v-d41c9ce7]{max-width:800px;min-width:320px;width:90vw;height:auto}
|
|
|
|
| 1 |
+
@charset "UTF-8";html,body{width:100%;height:100%}input::-ms-clear,input::-ms-reveal{display:none}*,*:before,*:after{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:rgba(0,0,0,0)}body{margin:0}[tabindex="-1"]:focus{outline:none}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5em;font-weight:500}p{margin-top:0;margin-bottom:1em}abbr[title],abbr[data-original-title]{-webkit-text-decoration:underline dotted;text-decoration:underline;text-decoration:underline dotted;border-bottom:0;cursor:help}address{margin-bottom:1em;font-style:normal;line-height:inherit}input[type=text],input[type=password],input[type=number],textarea{-webkit-appearance:none}ol,ul,dl{margin-top:0;margin-bottom:1em}ol ol,ul ul,ol ul,ul ol{margin-bottom:0}dt{font-weight:500}dd{margin-bottom:.5em;margin-left:0}blockquote{margin:0 0 1em}dfn{font-style:italic}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}pre,code,kbd,samp{font-size:1em;font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,Courier,monospace}pre{margin-top:0;margin-bottom:1em;overflow:auto}figure{margin:0 0 1em}img{vertical-align:middle;border-style:none}a,area,button,[role=button],input:not([type=range]),label,select,summary,textarea{touch-action:manipulation}table{border-collapse:collapse}caption{padding-top:.75em;padding-bottom:.3em;text-align:left;caption-side:bottom}input,button,select,optgroup,textarea{margin:0;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}button,html [type=button],[type=reset],[type=submit]{-webkit-appearance:button}button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{padding:0;border-style:none}input[type=radio],input[type=checkbox]{box-sizing:border-box;padding:0}input[type=date],input[type=time],input[type=datetime-local],input[type=month]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;margin:0;padding:0;border:0}legend{display:block;width:100%;max-width:100%;margin-bottom:.5em;padding:0;color:inherit;font-size:1.5em;line-height:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item}template{display:none}[hidden]{display:none!important}mark{padding:.2em;background-color:#feffe6}:root{font-family:Inter,system-ui,Avenir,Helvetica,Arial,sans-serif;line-height:1.5;font-weight:400;color-scheme:light dark;color:#ffffffde;background-color:#242424;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;-webkit-text-size-adjust:100%}a{font-weight:500;color:#646cff;text-decoration:inherit}a:hover{color:#535bf2}body{margin:0;display:flex;place-items:center;min-width:320px;height:100%;min-height:auto;color:#333;background:#fff}h1{font-size:3.2em;line-height:1.1}button{border-radius:8px;border:1px solid transparent;padding:.6em 1.2em;font-size:1em;font-weight:500;font-family:inherit;background-color:#1a1a1a;cursor:pointer;transition:border-color .25s}.card{border-bottom:solid 2px lightgray;align-items:center;justify-content:center;margin-top:40px;display:flex;max-width:1024px;width:100%}.seg-title{margin:24px 0;font-size:20px;font-weight:500}.seg-co{width:1022px;text-align:left;border-left:solid 6px midnightblue;padding-left:8px;margin-left:2px;margin-top:36px;line-height:24px}#app{margin:0 auto;padding:0;text-align:center;width:100%;height:100%}.ant-btn{padding:4px 12px}@media (prefers-color-scheme: light){:root{color:#213547;background-color:#fff}a:hover{color:#747bff}button{background-color:#f9f9f9}}.ant-card{background:#f5f6fa;height:100%}.ant-card-body{padding:24px 36px 12px!important;border-radius:0 0 8px 8px}.ant-card .ant-card-actions{background-color:#e8e8f8cc!important}.ant-popover{max-width:800px!important}.ant-form-item{background:transparent;margin-bottom:40px!important}.ant-form-item .ant-form-item-explain-error{color:#ff4d4f;text-align:left!important}.ant-form-item-label label{font-size:18px!important;color:#1a1a1a!important;font-weight:500!important}.ant-tooltip{max-width:1022px!important}.ant-page-header-heading{width:1022px!important}.highlight{background:#f8f8ff}.ant-layout-sider-collapsed{width:0!important;min-width:0!important;overflow:hidden}.ant-layout-sider-collapsed .ant-menu-item,.ant-layout-sider-collapsed .ant-menu-submenu-title{display:none}.ant-modal .ant-modal-content{background:#ffffff9e!important;backdrop-filter:blur(28px) saturate(140%);-webkit-backdrop-filter:blur(28px) saturate(140%);border:1px solid rgba(255,255,255,.6);border-radius:22px!important;box-shadow:0 16px 48px #1f26872e}.ant-modal .ant-modal-header{background:transparent!important}.ant-modal-mask{background:#14161e1f!important;backdrop-filter:blur(14px) saturate(120%);-webkit-backdrop-filter:blur(14px) saturate(120%)}.ant-select .ant-select-selector,.ant-input,textarea.ant-input,.ant-input-affix-wrapper{background:#ffffff73!important;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);border:1px solid rgba(255,255,255,.7)!important}.ant-btn:not(.ant-btn-text):not(.ant-btn-link){box-shadow:0 2px 10px #1f26871a}.ant-btn-default{background:#ffffff80!important;border:1px solid rgba(255,255,255,.75)!important;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)}.ant-btn-text{box-shadow:none!important;background:transparent!important}.ant-radio-group-solid .ant-radio-button-wrapper:first-child{border-top-left-radius:12px;border-bottom-left-radius:12px}.ant-radio-group-solid .ant-radio-button-wrapper:last-child{border-top-right-radius:12px;border-bottom-right-radius:12px}.header-nav[data-v-07594418]{display:flex;align-items:flex-start;justify-content:space-between;width:100vw;height:40px;align-items:center;position:absolute;top:0;left:0;z-index:99;-webkit-app-region:drag;cursor:move}.header-nav .window-controls[data-v-07594418],.header-nav button[data-v-07594418],.header-nav .ant-input-search[data-v-07594418],.header-nav img[data-v-07594418],.header-nav .anticon[data-v-07594418]{-webkit-app-region:no-drag;cursor:pointer}.header-nav .window-controls[data-v-07594418]{top:0;right:0;display:flex;z-index:1000;margin-left:12px}.header-nav .window-controls .window-control-btn[data-v-07594418]{width:46px;height:32px;border:none;background:transparent;color:#666;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background-color .2s}.header-nav .window-controls .window-control-btn[data-v-07594418]:hover{background-color:#0000001a}.header-nav .window-controls .window-control-btn.close[data-v-07594418]:hover{background-color:#e81123;color:#fff}.header-nav .window-controls .close-icon.focus[data-v-07594418]{display:none}.header-nav .window-controls:hover .close-icon.default[data-v-07594418],.header-nav .window-controls:focus-within .close-icon.default[data-v-07594418]{display:none}.header-nav .window-controls:hover .close-icon.focus[data-v-07594418],.header-nav .window-controls:focus-within .close-icon.focus[data-v-07594418]{display:inline}.content[data-v-b8a456cb]{background-color:#fff;margin:0 auto;display:flex;flex-direction:column;align-items:center;justify-content:space-between}.not-found-wrapper[data-v-aef52a59]{height:calc(100vh - 104px)}.tab-body[data-v-c6781f0b]{height:360px;overflow-y:auto;padding:4px 8px 4px 2px}.setting-row[data-v-c6781f0b]{margin-bottom:20px}.setting-row>label[data-v-c6781f0b]{display:block;font-size:15px;font-weight:500;margin-bottom:8px}.setting-row>label .label-icon[data-v-c6781f0b]{margin-right:6px;color:#1890ff}.setting-row .hint[data-v-c6781f0b]{font-size:12px;color:#999;margin:8px 0 0}.setting-row .row-inline[data-v-c6781f0b]{display:flex;align-items:center;justify-content:space-between}.voice-group[data-v-c6781f0b]{display:flex;flex-direction:column;margin-top:8px}.voice-radio[data-v-c6781f0b]{display:flex;align-items:center;height:40px;line-height:40px}.voice-radio .voice-name[data-v-c6781f0b]{margin-right:8px}.audio-play-btn[data-v-c6781f0b]{padding:0 6px;border-radius:4px}.audio-play-btn.playing[data-v-c6781f0b]{background-color:#f6ffed}.settings-btn[data-v-342c7f16]{width:60px;height:60px;margin-right:24px;border-radius:50%!important;background:#ffffff80!important;border:1px solid rgba(255,255,255,.7)!important;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);box-shadow:0 4px 16px #1f26871f;display:flex;align-items:center;justify-content:center}.welcome-wrapper[data-v-342c7f16]{width:100%;height:100%;background-image:url(./bg-BmnA8p_e.png);background-repeat:no-repeat;background-attachment:fixed;background-size:cover;background-position:center;display:flex;flex-direction:column;align-items:center;justify-content:space-between;color:#fff}.welcome-wrapper .content[data-v-342c7f16]{width:100%;height:80vh;display:flex;flex-direction:column;justify-content:space-around;margin-top:64px}.welcome-wrapper .content .inner-content[data-v-342c7f16]{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:20px}.welcome-wrapper .content .inner-content .text-box[data-v-342c7f16]{color:#000;margin-bottom:36px}.welcome-wrapper .content .inner-content .text-box .title[data-v-342c7f16]{font-size:24px;font-weight:600;margin-bottom:24px}.welcome-wrapper .content .inner-content .text-box .sub-title[data-v-342c7f16]{font-size:15px;margin-top:10px}.welcome-wrapper .content .inner-content .btn-box[data-v-342c7f16]{width:224px;height:80px}.welcome-wrapper .actions[data-v-342c7f16]{width:100%;height:100px;margin-bottom:32px;display:flex;align-items:center;justify-content:flex-end}.ball-wrapper[data-v-34c8e583]{width:100%;height:calc(100vh - 100px);display:flex;flex-direction:column;align-items:center;justify-content:space-around}.talk-wrapper[data-v-05da84ae]{width:auto;width:100%;max-width:1000px;margin:0 auto;box-sizing:border-box;height:calc(100vh - 150px);overflow-y:auto;padding:20px 32px 0;display:flex;flex-direction:column;align-items:flex-start;justify-content:flex-start}.talk-wrapper .cont-left[data-v-05da84ae]{width:100%;margin:24px 0;display:flex;justify-content:flex-start;align-items:flex-start}.talk-wrapper .cont-left .text-left[data-v-05da84ae]{max-width:88%;color:#222;font-size:16px;font-weight:400;text-align:left;line-height:1.8;margin-left:12px;margin-top:6px;word-break:break-word}.talk-wrapper .cont-right[data-v-05da84ae]{width:100%;margin:24px 0;display:flex;justify-content:flex-end;align-items:flex-start}.talk-wrapper .cont-right .text-right[data-v-05da84ae]{max-width:80%;color:#444;font-size:16px;font-weight:400;text-align:start;line-height:1.8;margin-right:12px;background:#ccc;border-radius:8px 0 8px 8px;padding:8px 12px;word-break:break-word}.chat-wrapper[data-v-8b035bf4]{width:100%;height:100%;background-image:url(./bg-BmnA8p_e.png);background-repeat:no-repeat;background-attachment:fixed;background-size:cover;background-position:center;display:flex;flex-direction:column;align-items:center;justify-content:space-between;color:#fff}.chat-wrapper .content[data-v-8b035bf4]{width:100%;height:auto;display:flex;flex-direction:column;justify-content:space-around}.chat-wrapper .content .inner-content[data-v-8b035bf4]{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:20px}.chat-wrapper .content .inner-content .text-box[data-v-8b035bf4]{color:#000;margin-bottom:36px}.chat-wrapper .content .inner-content .text-box .title[data-v-8b035bf4]{font-size:24px;font-weight:600;margin-bottom:24px}.chat-wrapper .content .inner-content .text-box .sub-title[data-v-8b035bf4]{font-size:15px;margin-top:10px}.chat-wrapper .content .inner-content .btn-box[data-v-8b035bf4]{width:224px;height:80px}.chat-wrapper .actions[data-v-8b035bf4]{width:100%;height:100px;margin-bottom:32px;display:flex;justify-content:space-between;align-items:center}.chat-wrapper .actions .holder[data-v-8b035bf4]{width:64px;height:48px}.chat-wrapper .actions .btns[data-v-8b035bf4]{width:450px;height:96px;display:flex;justify-content:space-around;align-items:center}.chat-wrapper .actions .btns[data-v-8b035bf4] .ant-btn{border-radius:50%!important;background:#ffffff80!important;border:1px solid rgba(255,255,255,.7)!important;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);box-shadow:0 4px 16px #1f26871f}.chat-wrapper .actions .download-wrapper[data-v-8b035bf4]{width:64px;height:64px;display:flex;justify-content:flex-start;align-items:center;margin-right:0}.chat-wrapper .actions .download-wrapper img[data-v-8b035bf4]{width:24px;height:24px}.content-wrapper[data-v-d41c9ce7]{text-align:left;max-width:800px;min-width:320px;margin-bottom:64px;min-height:calc(100vh - 438px)}.content-wrapper .content-box[data-v-d41c9ce7]{padding:24px;height:240px;background-color:#e8e8e8;border-radius:16px;width:50%;margin:48px auto;min-width:300px}.content-wrapper .video-box[data-v-d41c9ce7]{max-width:800px;min-width:320px;width:90vw;height:auto}
|
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4049e7175fcba250d7bba7b6753d02e570bc5b2ff472019c32540c30b34f0e93
|
| 3 |
+
size 2300342
|
|
@@ -5,8 +5,8 @@
|
|
| 5 |
<link rel="icon" type="image/svg+xml" href="./favicon.ico" />
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
<title>VoiceDialogue</title>
|
| 8 |
-
<script type="module" crossorigin src="./assets/index-
|
| 9 |
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
| 10 |
</head>
|
| 11 |
<body>
|
| 12 |
<div id="app"></div>
|
|
|
|
| 5 |
<link rel="icon" type="image/svg+xml" href="./favicon.ico" />
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
<title>VoiceDialogue</title>
|
| 8 |
+
<script type="module" crossorigin src="./assets/index-STY1yYrK.js"></script>
|
| 9 |
+
<link rel="stylesheet" crossorigin href="./assets/index-6CUhaZ06.css">
|
| 10 |
</head>
|
| 11 |
<body>
|
| 12 |
<div id="app"></div>
|
|
@@ -1,6 +1,8 @@
|
|
| 1 |
<template>
|
| 2 |
-
<
|
| 3 |
-
|
|
|
|
|
|
|
| 4 |
<!-- <Footer/> -->
|
| 5 |
|
| 6 |
<!-- <a-layout>
|
|
@@ -19,6 +21,15 @@
|
|
| 19 |
import Header from "@/views/Header.vue";
|
| 20 |
import Footer from "@/views/Footer.vue";
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
// import * as api from "@/client";
|
| 23 |
import { onBeforeMount, onMounted, watch, CSSProperties, ref} from "vue";
|
| 24 |
import {useSettingsStore} from "@/stores/config.ts";
|
|
|
|
| 1 |
<template>
|
| 2 |
+
<a-config-provider :theme="appTheme">
|
| 3 |
+
<Header/>
|
| 4 |
+
<router-view class="content" />
|
| 5 |
+
</a-config-provider>
|
| 6 |
<!-- <Footer/> -->
|
| 7 |
|
| 8 |
<!-- <a-layout>
|
|
|
|
| 21 |
import Header from "@/views/Header.vue";
|
| 22 |
import Footer from "@/views/Footer.vue";
|
| 23 |
|
| 24 |
+
// 全局主题:统一圆角与控件高度,配合玻璃拟态(Liquid Glass)
|
| 25 |
+
const appTheme = {
|
| 26 |
+
token: {
|
| 27 |
+
colorPrimary: '#1677ff',
|
| 28 |
+
borderRadius: 14,
|
| 29 |
+
controlHeight: 38,
|
| 30 |
+
},
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
// import * as api from "@/client";
|
| 34 |
import { onBeforeMount, onMounted, watch, CSSProperties, ref} from "vue";
|
| 35 |
import {useSettingsStore} from "@/stores/config.ts";
|
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:91eaffeec742a30f475cf5e777e1605e62d3c1547b64a891c15f9a5431460b8a
|
| 3 |
+
size 22455
|
|
@@ -5,7 +5,7 @@ import router from "@/router";
|
|
| 5 |
|
| 6 |
const { wsCache } = useCache();
|
| 7 |
|
| 8 |
-
export const test_server = '127.0.0.1:
|
| 9 |
// export const test_server = '59.110.18.232:19001'
|
| 10 |
|
| 11 |
axios.defaults.baseURL = import.meta.env.PROD ? '/api/v1' : `http://${test_server}/api/v1`;
|
|
|
|
| 5 |
|
| 6 |
const { wsCache } = useCache();
|
| 7 |
|
| 8 |
+
export const test_server = '127.0.0.1:8000'
|
| 9 |
// export const test_server = '59.110.18.232:19001'
|
| 10 |
|
| 11 |
axios.defaults.baseURL = import.meta.env.PROD ? '/api/v1' : `http://${test_server}/api/v1`;
|
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createI18n } from 'vue-i18n'
|
| 2 |
+
|
| 3 |
+
import en from './locales/en'
|
| 4 |
+
import zh from './locales/zh'
|
| 5 |
+
|
| 6 |
+
export type UiLocale = 'en' | 'zh'
|
| 7 |
+
|
| 8 |
+
// 从持久化的 pinia 设置中读取界面语言,默认英文
|
| 9 |
+
function getInitialLocale(): UiLocale {
|
| 10 |
+
try {
|
| 11 |
+
const raw = localStorage.getItem('settings')
|
| 12 |
+
if (raw) {
|
| 13 |
+
const parsed = JSON.parse(raw)
|
| 14 |
+
const ui = parsed?.uiLanguage
|
| 15 |
+
if (ui === 'en' || ui === 'zh') return ui
|
| 16 |
+
}
|
| 17 |
+
} catch (e) {
|
| 18 |
+
// ignore
|
| 19 |
+
}
|
| 20 |
+
return 'zh'
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
const i18n = createI18n({
|
| 24 |
+
legacy: false,
|
| 25 |
+
globalInjection: true,
|
| 26 |
+
locale: getInitialLocale(),
|
| 27 |
+
fallbackLocale: 'en',
|
| 28 |
+
messages: { en, zh },
|
| 29 |
+
})
|
| 30 |
+
|
| 31 |
+
export function setUiLocale(locale: UiLocale) {
|
| 32 |
+
i18n.global.locale.value = locale
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export default i18n
|
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
common: {
|
| 3 |
+
cancel: 'Cancel',
|
| 4 |
+
confirm: 'Confirm',
|
| 5 |
+
reset: 'Reset',
|
| 6 |
+
save: 'Save',
|
| 7 |
+
error: 'Error',
|
| 8 |
+
},
|
| 9 |
+
lang: {
|
| 10 |
+
zh: 'Chinese',
|
| 11 |
+
en: 'English',
|
| 12 |
+
auto: 'Auto',
|
| 13 |
+
},
|
| 14 |
+
welcome: {
|
| 15 |
+
title: 'Welcome',
|
| 16 |
+
subtitle: 'Click the button below to start a conversation',
|
| 17 |
+
start: 'Start Conversation',
|
| 18 |
+
startFailed: 'Failed to start the voice dialogue system',
|
| 19 |
+
},
|
| 20 |
+
settings: {
|
| 21 |
+
title: 'Settings',
|
| 22 |
+
entry: 'Settings',
|
| 23 |
+
tabs: {
|
| 24 |
+
main: 'Main',
|
| 25 |
+
language: 'Language',
|
| 26 |
+
advanced: 'Prompt',
|
| 27 |
+
},
|
| 28 |
+
general: {
|
| 29 |
+
interfaceLanguage: 'Interface Language',
|
| 30 |
+
interfaceLanguageHint: 'Language of the application interface.',
|
| 31 |
+
},
|
| 32 |
+
audio: {
|
| 33 |
+
microphone: 'Microphone (Input Device)',
|
| 34 |
+
microphoneHint: 'Choose the input device, e.g. an external microphone array.',
|
| 35 |
+
systemDefault: 'System Default',
|
| 36 |
+
channelsSuffix: 'ch',
|
| 37 |
+
defaultSuffix: 'default',
|
| 38 |
+
echoCancellation: 'Echo Cancellation',
|
| 39 |
+
echoCancellationHint: 'Uses the system AEC on the default device. For an external array, echo is handled by the array hardware.',
|
| 40 |
+
},
|
| 41 |
+
recognition: {
|
| 42 |
+
language: 'Recognition Language',
|
| 43 |
+
languageHint: 'Language used for speech recognition (ASR).',
|
| 44 |
+
},
|
| 45 |
+
voice: {
|
| 46 |
+
role: 'Voice',
|
| 47 |
+
roleHint: 'The voice used for speech synthesis (TTS).',
|
| 48 |
+
playSample: 'Play sample',
|
| 49 |
+
},
|
| 50 |
+
prompt: {
|
| 51 |
+
title: 'System Prompt',
|
| 52 |
+
hint: 'Customize the system prompt for each language.',
|
| 53 |
+
},
|
| 54 |
+
applyFailed: 'Failed to apply settings',
|
| 55 |
+
},
|
| 56 |
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
common: {
|
| 3 |
+
cancel: '取消',
|
| 4 |
+
confirm: '确认',
|
| 5 |
+
reset: '重置',
|
| 6 |
+
save: '保存',
|
| 7 |
+
error: '错误',
|
| 8 |
+
},
|
| 9 |
+
lang: {
|
| 10 |
+
zh: '中文',
|
| 11 |
+
en: '英文',
|
| 12 |
+
auto: '自动',
|
| 13 |
+
},
|
| 14 |
+
welcome: {
|
| 15 |
+
title: '欢迎使用',
|
| 16 |
+
subtitle: '点击下方按钮开始对话',
|
| 17 |
+
start: '开始对话',
|
| 18 |
+
startFailed: '启动语音对话系统失败',
|
| 19 |
+
},
|
| 20 |
+
settings: {
|
| 21 |
+
title: '设置',
|
| 22 |
+
entry: '设置',
|
| 23 |
+
tabs: {
|
| 24 |
+
main: '常用',
|
| 25 |
+
language: '语言',
|
| 26 |
+
advanced: 'Prompt',
|
| 27 |
+
},
|
| 28 |
+
general: {
|
| 29 |
+
interfaceLanguage: '界面语言',
|
| 30 |
+
interfaceLanguageHint: '应用界面所使用的语言。',
|
| 31 |
+
},
|
| 32 |
+
audio: {
|
| 33 |
+
microphone: '麦克风(输入设备)',
|
| 34 |
+
microphoneHint: '选择输入设备,例如外置麦克风阵列。',
|
| 35 |
+
systemDefault: '系统默认',
|
| 36 |
+
channelsSuffix: '声道',
|
| 37 |
+
defaultSuffix: '默认',
|
| 38 |
+
echoCancellation: '回音消除',
|
| 39 |
+
echoCancellationHint: '默认设备使用系统 AEC;选择外置阵列时,回音由阵列硬件处理。',
|
| 40 |
+
},
|
| 41 |
+
recognition: {
|
| 42 |
+
language: '识别语言',
|
| 43 |
+
languageHint: '语音识别(ASR)所使用的语言。',
|
| 44 |
+
},
|
| 45 |
+
voice: {
|
| 46 |
+
role: '音色',
|
| 47 |
+
roleHint: '语音合成(TTS)所使用的音色。',
|
| 48 |
+
playSample: '试听',
|
| 49 |
+
},
|
| 50 |
+
prompt: {
|
| 51 |
+
title: '系统提示词',
|
| 52 |
+
hint: '为每种语言自定义系统提示词。',
|
| 53 |
+
},
|
| 54 |
+
applyFailed: '应用设置失败',
|
| 55 |
+
},
|
| 56 |
+
}
|
|
@@ -9,6 +9,7 @@ import './style.scss'
|
|
| 9 |
|
| 10 |
import App from './App.vue'
|
| 11 |
import router from './router'
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
// import * as Sentry from "@sentry/browser";
|
|
@@ -28,4 +29,5 @@ createApp(App)
|
|
| 28 |
.use(router)
|
| 29 |
.use(Antd)
|
| 30 |
.use(Vue3Lottie)
|
|
|
|
| 31 |
.mount('#app')
|
|
|
|
| 9 |
|
| 10 |
import App from './App.vue'
|
| 11 |
import router from './router'
|
| 12 |
+
import i18n from './i18n'
|
| 13 |
|
| 14 |
|
| 15 |
// import * as Sentry from "@sentry/browser";
|
|
|
|
| 29 |
.use(router)
|
| 30 |
.use(Antd)
|
| 31 |
.use(Vue3Lottie)
|
| 32 |
+
.use(i18n)
|
| 33 |
.mount('#app')
|
|
@@ -8,8 +8,10 @@ export const useSettingsStore = defineStore({
|
|
| 8 |
return {
|
| 9 |
role: '',
|
| 10 |
language: 'zh',
|
|
|
|
| 11 |
sider_open: true,
|
| 12 |
echoCancel: true,
|
|
|
|
| 13 |
}
|
| 14 |
},
|
| 15 |
actions: {
|
|
|
|
| 8 |
return {
|
| 9 |
role: '',
|
| 10 |
language: 'zh',
|
| 11 |
+
uiLanguage: 'zh' as 'en' | 'zh',
|
| 12 |
sider_open: true,
|
| 13 |
echoCancel: true,
|
| 14 |
+
inputDeviceIndex: null as number | null,
|
| 15 |
}
|
| 16 |
},
|
| 17 |
actions: {
|
|
@@ -173,3 +173,68 @@ $FormItemWidth: 1022px;
|
|
| 173 |
.ant-layout-sider-collapsed .ant-menu-submenu-title {
|
| 174 |
display: none;
|
| 175 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
.ant-layout-sider-collapsed .ant-menu-submenu-title {
|
| 174 |
display: none;
|
| 175 |
}
|
| 176 |
+
|
| 177 |
+
/* ============================================================
|
| 178 |
+
Liquid Glass —— 苹果风格玻璃拟态(全局)
|
| 179 |
+
半透明 + 背景模糊 + 柔和描边/阴影;圆角由主题 token 统一
|
| 180 |
+
============================================================ */
|
| 181 |
+
|
| 182 |
+
/* 弹窗使用 Ant 内置 fade 过渡(纯 opacity 动画、无 transform),
|
| 183 |
+
避免 transform 动画期间 backdrop-filter 失效导致的闪烁;面板与其模糊一起平滑淡入 */
|
| 184 |
+
|
| 185 |
+
/* 弹窗:磨砂玻璃面板 */
|
| 186 |
+
.ant-modal .ant-modal-content {
|
| 187 |
+
background: rgba(255, 255, 255, 0.62) !important;
|
| 188 |
+
backdrop-filter: blur(28px) saturate(140%);
|
| 189 |
+
-webkit-backdrop-filter: blur(28px) saturate(140%);
|
| 190 |
+
border: 1px solid rgba(255, 255, 255, 0.6);
|
| 191 |
+
border-radius: 22px !important;
|
| 192 |
+
box-shadow: 0 16px 48px rgba(31, 38, 135, 0.18);
|
| 193 |
+
}
|
| 194 |
+
.ant-modal .ant-modal-header {
|
| 195 |
+
background: transparent !important;
|
| 196 |
+
}
|
| 197 |
+
/* 遮罩:整屏磨砂——轻微变暗 + 背景模糊。
|
| 198 |
+
遮罩用 ant-fade(opacity)淡入,模糊随之平滑出现,背景文字与画面一起糊掉,不再"闪出去" */
|
| 199 |
+
.ant-modal-mask {
|
| 200 |
+
background: rgba(20, 22, 30, 0.12) !important;
|
| 201 |
+
backdrop-filter: blur(14px) saturate(120%);
|
| 202 |
+
-webkit-backdrop-filter: blur(14px) saturate(120%);
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
/* 输入类控件:半透明玻璃 */
|
| 206 |
+
.ant-select .ant-select-selector,
|
| 207 |
+
.ant-input,
|
| 208 |
+
textarea.ant-input,
|
| 209 |
+
.ant-input-affix-wrapper {
|
| 210 |
+
background: rgba(255, 255, 255, 0.45) !important;
|
| 211 |
+
backdrop-filter: blur(8px);
|
| 212 |
+
-webkit-backdrop-filter: blur(8px);
|
| 213 |
+
border: 1px solid rgba(255, 255, 255, 0.7) !important;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
/* 按钮:统一形状(圆角来自 token)+ 柔和阴影;默认按钮做玻璃质感,主按钮保持实色
|
| 217 |
+
文本/链接按钮(如音色试听的小喇叭)保持透明无阴影 */
|
| 218 |
+
.ant-btn:not(.ant-btn-text):not(.ant-btn-link) {
|
| 219 |
+
box-shadow: 0 2px 10px rgba(31, 38, 135, 0.10);
|
| 220 |
+
}
|
| 221 |
+
.ant-btn-default {
|
| 222 |
+
background: rgba(255, 255, 255, 0.5) !important;
|
| 223 |
+
border: 1px solid rgba(255, 255, 255, 0.75) !important;
|
| 224 |
+
backdrop-filter: blur(8px);
|
| 225 |
+
-webkit-backdrop-filter: blur(8px);
|
| 226 |
+
}
|
| 227 |
+
.ant-btn-text {
|
| 228 |
+
box-shadow: none !important;
|
| 229 |
+
background: transparent !important;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
/* 分段单选(中文/英文 等)两端圆角,去掉方正感 */
|
| 233 |
+
.ant-radio-group-solid .ant-radio-button-wrapper:first-child {
|
| 234 |
+
border-top-left-radius: 12px;
|
| 235 |
+
border-bottom-left-radius: 12px;
|
| 236 |
+
}
|
| 237 |
+
.ant-radio-group-solid .ant-radio-button-wrapper:last-child {
|
| 238 |
+
border-top-right-radius: 12px;
|
| 239 |
+
border-bottom-right-radius: 12px;
|
| 240 |
+
}
|
|
@@ -69,9 +69,13 @@ watch(() => props.chatContent, (newVal, oldVal) => {
|
|
| 69 |
<style lang="scss" scoped>
|
| 70 |
.talk-wrapper {
|
| 71 |
width: auto;
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
display: flex;
|
| 76 |
flex-direction: column;
|
| 77 |
align-items: flex-start;
|
|
@@ -85,13 +89,15 @@ watch(() => props.chatContent, (newVal, oldVal) => {
|
|
| 85 |
justify-content: flex-start;
|
| 86 |
align-items: flex-start;
|
| 87 |
.text-left {
|
|
|
|
| 88 |
color: #222;
|
| 89 |
font-size: 16px;
|
| 90 |
font-weight: 400;
|
| 91 |
text-align: left;
|
| 92 |
-
line-height:
|
| 93 |
margin-left: 12px;
|
| 94 |
margin-top: 6px;
|
|
|
|
| 95 |
}
|
| 96 |
}
|
| 97 |
|
|
@@ -103,16 +109,18 @@ watch(() => props.chatContent, (newVal, oldVal) => {
|
|
| 103 |
align-items: flex-start;
|
| 104 |
|
| 105 |
.text-right {
|
|
|
|
| 106 |
color: #444;
|
| 107 |
font-size: 16px;
|
| 108 |
font-weight: 400;
|
| 109 |
-
text-align:
|
| 110 |
-
line-height:
|
| 111 |
margin-right: 12px;
|
| 112 |
background: #ccc;
|
| 113 |
border-radius: 8px;
|
| 114 |
border-top-right-radius: 0;
|
| 115 |
-
padding: 8px;
|
|
|
|
| 116 |
}
|
| 117 |
}
|
| 118 |
}
|
|
|
|
| 69 |
<style lang="scss" scoped>
|
| 70 |
.talk-wrapper {
|
| 71 |
width: auto;
|
| 72 |
+
width: 100%;
|
| 73 |
+
max-width: 1000px;
|
| 74 |
+
margin: 0 auto;
|
| 75 |
+
box-sizing: border-box;
|
| 76 |
+
height: calc(100vh - 150px);
|
| 77 |
+
overflow-y: auto;
|
| 78 |
+
padding: 20px 32px 0;
|
| 79 |
display: flex;
|
| 80 |
flex-direction: column;
|
| 81 |
align-items: flex-start;
|
|
|
|
| 89 |
justify-content: flex-start;
|
| 90 |
align-items: flex-start;
|
| 91 |
.text-left {
|
| 92 |
+
max-width: 88%;
|
| 93 |
color: #222;
|
| 94 |
font-size: 16px;
|
| 95 |
font-weight: 400;
|
| 96 |
text-align: left;
|
| 97 |
+
line-height: 1.8;
|
| 98 |
margin-left: 12px;
|
| 99 |
margin-top: 6px;
|
| 100 |
+
word-break: break-word;
|
| 101 |
}
|
| 102 |
}
|
| 103 |
|
|
|
|
| 109 |
align-items: flex-start;
|
| 110 |
|
| 111 |
.text-right {
|
| 112 |
+
max-width: 80%;
|
| 113 |
color: #444;
|
| 114 |
font-size: 16px;
|
| 115 |
font-weight: 400;
|
| 116 |
+
text-align: start;
|
| 117 |
+
line-height: 1.8;
|
| 118 |
margin-right: 12px;
|
| 119 |
background: #ccc;
|
| 120 |
border-radius: 8px;
|
| 121 |
border-top-right-radius: 0;
|
| 122 |
+
padding: 8px 12px;
|
| 123 |
+
word-break: break-word;
|
| 124 |
}
|
| 125 |
}
|
| 126 |
}
|
|
@@ -387,6 +387,7 @@ const toggleText = () => {
|
|
| 387 |
.actions {
|
| 388 |
width: 100%;
|
| 389 |
height: 100px;
|
|
|
|
| 390 |
|
| 391 |
display: flex;
|
| 392 |
justify-content: space-between;
|
|
@@ -401,7 +402,17 @@ const toggleText = () => {
|
|
| 401 |
height: 96px;
|
| 402 |
display: flex;
|
| 403 |
justify-content: space-around;
|
| 404 |
-
align-items:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 405 |
}
|
| 406 |
.download-wrapper {
|
| 407 |
width: 64px;
|
|
|
|
| 387 |
.actions {
|
| 388 |
width: 100%;
|
| 389 |
height: 100px;
|
| 390 |
+
margin-bottom: 32px;
|
| 391 |
|
| 392 |
display: flex;
|
| 393 |
justify-content: space-between;
|
|
|
|
| 402 |
height: 96px;
|
| 403 |
display: flex;
|
| 404 |
justify-content: space-around;
|
| 405 |
+
align-items: center;
|
| 406 |
+
|
| 407 |
+
// Liquid Glass 圆形按钮(与 Welcome 设置按钮统一)
|
| 408 |
+
:deep(.ant-btn) {
|
| 409 |
+
border-radius: 50% !important;
|
| 410 |
+
background: rgba(255, 255, 255, 0.5) !important;
|
| 411 |
+
border: 1px solid rgba(255, 255, 255, 0.7) !important;
|
| 412 |
+
backdrop-filter: blur(10px);
|
| 413 |
+
-webkit-backdrop-filter: blur(10px);
|
| 414 |
+
box-shadow: 0 4px 16px rgba(31, 38, 135, 0.12);
|
| 415 |
+
}
|
| 416 |
}
|
| 417 |
.download-wrapper {
|
| 418 |
width: 64px;
|
|
@@ -0,0 +1,409 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script setup lang="ts">
|
| 2 |
+
import { ref, reactive, computed, watch, onUnmounted } from "vue";
|
| 3 |
+
import { Modal } from "ant-design-vue";
|
| 4 |
+
import { SoundTwoTone, SoundOutlined, TranslationOutlined, AudioOutlined } from "@ant-design/icons-vue";
|
| 5 |
+
import { useI18n } from "vue-i18n";
|
| 6 |
+
import axios from "axios";
|
| 7 |
+
import { useSettingsStore } from "@/stores/config.ts";
|
| 8 |
+
import { setUiLocale, UiLocale } from "@/i18n";
|
| 9 |
+
|
| 10 |
+
const props = defineProps({
|
| 11 |
+
open: { type: Boolean, default: false },
|
| 12 |
+
});
|
| 13 |
+
const emit = defineEmits(["update:open"]);
|
| 14 |
+
|
| 15 |
+
const { t } = useI18n();
|
| 16 |
+
const base_url = axios.defaults.baseURL;
|
| 17 |
+
const settingsStore = useSettingsStore();
|
| 18 |
+
|
| 19 |
+
const activeTab = ref<string>("main");
|
| 20 |
+
const loading = ref<boolean>(false);
|
| 21 |
+
|
| 22 |
+
// ---- 各项设置的本地状态(打开时从 store / 后端同步)----
|
| 23 |
+
const uiLanguage = ref<UiLocale>((settingsStore.$state.uiLanguage as UiLocale) ?? "en");
|
| 24 |
+
const recognitionLanguage = ref<string>(settingsStore.$state.language || "zh");
|
| 25 |
+
const echoCancel = ref<boolean>(settingsStore.$state.echoCancel ?? true);
|
| 26 |
+
const inputDeviceIndex = ref<number | null>(settingsStore.$state.inputDeviceIndex ?? null);
|
| 27 |
+
const role = ref<string>(settingsStore.$state.role || "");
|
| 28 |
+
|
| 29 |
+
const languages = reactive<string[]>([]);
|
| 30 |
+
const inputDevices = reactive<any[]>([]);
|
| 31 |
+
const roles = reactive<any[]>([]);
|
| 32 |
+
|
| 33 |
+
// ---- Prompt ----
|
| 34 |
+
const promptLang = ref<string>("zh");
|
| 35 |
+
const default_prompt_en = ref<string>("");
|
| 36 |
+
const default_prompt_zh = ref<string>("");
|
| 37 |
+
const current_prompt_en = ref<string>("");
|
| 38 |
+
const current_prompt_zh = ref<string>("");
|
| 39 |
+
|
| 40 |
+
const filteredRoles = computed(() => {
|
| 41 |
+
const is_chinese = recognitionLanguage.value === "zh";
|
| 42 |
+
return roles.filter((r) => r["is_chinese_voice"] === is_chinese);
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
// 切换识别语言后,自动选中第一个匹配音色
|
| 46 |
+
watch(
|
| 47 |
+
() => recognitionLanguage.value,
|
| 48 |
+
() => {
|
| 49 |
+
if (filteredRoles.value.length > 0) {
|
| 50 |
+
const exists = filteredRoles.value.find((r) => r["id"] === role.value);
|
| 51 |
+
role.value = exists ? role.value : filteredRoles.value[0]["id"];
|
| 52 |
+
} else {
|
| 53 |
+
role.value = "";
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
);
|
| 57 |
+
|
| 58 |
+
// 界面语言即时生效(让用户立刻看到切换效果)
|
| 59 |
+
watch(uiLanguage, (v) => setUiLocale(v));
|
| 60 |
+
|
| 61 |
+
// ---- 数据加载 ----
|
| 62 |
+
const fetchASRLanguages = async () => {
|
| 63 |
+
try {
|
| 64 |
+
const res = await fetch(`${base_url}/asr/languages`);
|
| 65 |
+
const data = await res.json();
|
| 66 |
+
if (data?.languages) {
|
| 67 |
+
languages.splice(0, languages.length, ...data.languages);
|
| 68 |
+
// 优先沿用本地已保存/默认的识别语言(默认中文),不被后端当前值覆盖
|
| 69 |
+
const saved = settingsStore.$state.language;
|
| 70 |
+
recognitionLanguage.value = saved && data.languages.includes(saved)
|
| 71 |
+
? saved
|
| 72 |
+
: (data.languages.includes('zh') ? 'zh' : data.languages[0]);
|
| 73 |
+
}
|
| 74 |
+
} catch (e) {
|
| 75 |
+
console.error("Error fetching ASR languages:", e);
|
| 76 |
+
}
|
| 77 |
+
};
|
| 78 |
+
|
| 79 |
+
const fetchTTSRoles = async () => {
|
| 80 |
+
try {
|
| 81 |
+
const res = await fetch(`${base_url}/tts/models`);
|
| 82 |
+
const data = await res.json();
|
| 83 |
+
if (data?.models) {
|
| 84 |
+
roles.splice(0, roles.length, ...data.models);
|
| 85 |
+
if (data.current_model_id) role.value = data.current_model_id;
|
| 86 |
+
}
|
| 87 |
+
} catch (e) {
|
| 88 |
+
console.error("Error fetching TTS roles:", e);
|
| 89 |
+
}
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
const fetchInputDevices = async () => {
|
| 93 |
+
try {
|
| 94 |
+
const res = await fetch(`${base_url}/system/audio-devices`);
|
| 95 |
+
const data = await res.json();
|
| 96 |
+
if (data?.devices) {
|
| 97 |
+
inputDevices.splice(0, inputDevices.length, ...data.devices);
|
| 98 |
+
const saved = settingsStore.$state.inputDeviceIndex;
|
| 99 |
+
const exists = saved != null && data.devices.some((d: any) => d.index === saved);
|
| 100 |
+
inputDeviceIndex.value = exists ? saved : (data.current_device_index ?? null);
|
| 101 |
+
}
|
| 102 |
+
} catch (e) {
|
| 103 |
+
console.error("Error fetching input devices:", e);
|
| 104 |
+
}
|
| 105 |
+
};
|
| 106 |
+
|
| 107 |
+
const fetchPrompts = async () => {
|
| 108 |
+
try {
|
| 109 |
+
const [cur, def] = await Promise.all([
|
| 110 |
+
fetch(`${base_url}/settings/settings/prompts`).then((r) => r.json()),
|
| 111 |
+
fetch(`${base_url}/settings/settings/prompts/default`).then((r) => r.json()),
|
| 112 |
+
]);
|
| 113 |
+
if (cur) {
|
| 114 |
+
current_prompt_en.value = cur.english_prompt;
|
| 115 |
+
current_prompt_zh.value = cur.chinese_prompt;
|
| 116 |
+
}
|
| 117 |
+
if (def) {
|
| 118 |
+
default_prompt_en.value = def.english_prompt;
|
| 119 |
+
default_prompt_zh.value = def.chinese_prompt;
|
| 120 |
+
}
|
| 121 |
+
} catch (e) {
|
| 122 |
+
console.error("Error fetching prompts:", e);
|
| 123 |
+
}
|
| 124 |
+
};
|
| 125 |
+
|
| 126 |
+
const resetPrompt = (lang: string) => {
|
| 127 |
+
if (lang === "en") current_prompt_en.value = default_prompt_en.value;
|
| 128 |
+
else current_prompt_zh.value = default_prompt_zh.value;
|
| 129 |
+
};
|
| 130 |
+
|
| 131 |
+
// ---- 提交 / 取消 ----
|
| 132 |
+
const applySettings = async () => {
|
| 133 |
+
loading.value = true;
|
| 134 |
+
try {
|
| 135 |
+
// 1. 持久化到本地 store
|
| 136 |
+
settingsStore.$state.uiLanguage = uiLanguage.value;
|
| 137 |
+
settingsStore.$state.language = recognitionLanguage.value;
|
| 138 |
+
settingsStore.$state.role = role.value || "";
|
| 139 |
+
settingsStore.$state.echoCancel = echoCancel.value;
|
| 140 |
+
settingsStore.$state.inputDeviceIndex = inputDeviceIndex.value;
|
| 141 |
+
|
| 142 |
+
// 2. 下发 TTS 音色 + ASR 语言
|
| 143 |
+
if (role.value) {
|
| 144 |
+
const r1 = await fetch(`${base_url}/tts/models/load`, {
|
| 145 |
+
method: "POST",
|
| 146 |
+
headers: { "Content-Type": "application/json" },
|
| 147 |
+
body: JSON.stringify({ model_id: role.value }),
|
| 148 |
+
});
|
| 149 |
+
if (!r1.ok) throw new Error(`TTS load failed: ${r1.status}`);
|
| 150 |
+
}
|
| 151 |
+
const r2 = await fetch(`${base_url}/asr/instance/create`, {
|
| 152 |
+
method: "POST",
|
| 153 |
+
headers: { "Content-Type": "application/json" },
|
| 154 |
+
body: JSON.stringify({ language: recognitionLanguage.value }),
|
| 155 |
+
});
|
| 156 |
+
if (!r2.ok) throw new Error(`ASR set failed: ${r2.status}`);
|
| 157 |
+
|
| 158 |
+
// 3. 保存 Prompt
|
| 159 |
+
await fetch(`${base_url}/settings/settings/prompts`, {
|
| 160 |
+
method: "POST",
|
| 161 |
+
headers: { "Content-Type": "application/json" },
|
| 162 |
+
body: JSON.stringify({
|
| 163 |
+
chinese_prompt: current_prompt_zh.value,
|
| 164 |
+
english_prompt: current_prompt_en.value,
|
| 165 |
+
}),
|
| 166 |
+
});
|
| 167 |
+
|
| 168 |
+
emit("update:open", false);
|
| 169 |
+
} catch (err) {
|
| 170 |
+
console.error("Error applying settings:", err);
|
| 171 |
+
Modal.error({ title: t("common.error"), content: t("settings.applyFailed") });
|
| 172 |
+
} finally {
|
| 173 |
+
loading.value = false;
|
| 174 |
+
}
|
| 175 |
+
};
|
| 176 |
+
|
| 177 |
+
const handleCancel = () => {
|
| 178 |
+
// 还原本地状态与界面语言
|
| 179 |
+
uiLanguage.value = (settingsStore.$state.uiLanguage as UiLocale) ?? "en";
|
| 180 |
+
setUiLocale(uiLanguage.value);
|
| 181 |
+
recognitionLanguage.value = settingsStore.$state.language || "zh";
|
| 182 |
+
echoCancel.value = settingsStore.$state.echoCancel ?? true;
|
| 183 |
+
inputDeviceIndex.value = settingsStore.$state.inputDeviceIndex ?? null;
|
| 184 |
+
role.value = settingsStore.$state.role || "";
|
| 185 |
+
emit("update:open", false);
|
| 186 |
+
};
|
| 187 |
+
|
| 188 |
+
watch(
|
| 189 |
+
() => props.open,
|
| 190 |
+
(isOpen) => {
|
| 191 |
+
if (isOpen) {
|
| 192 |
+
activeTab.value = "main";
|
| 193 |
+
uiLanguage.value = (settingsStore.$state.uiLanguage as UiLocale) ?? "en";
|
| 194 |
+
fetchASRLanguages();
|
| 195 |
+
fetchTTSRoles();
|
| 196 |
+
fetchInputDevices();
|
| 197 |
+
fetchPrompts();
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
+
);
|
| 201 |
+
|
| 202 |
+
// ---- 音色试听 ----
|
| 203 |
+
const currentPlayingId = ref<string | null>(null);
|
| 204 |
+
const currentAudio = ref<HTMLAudioElement | null>(null);
|
| 205 |
+
const isPlaying = (id: string) => currentPlayingId.value === id;
|
| 206 |
+
|
| 207 |
+
const playRefAudio = async (id: string, e: Event) => {
|
| 208 |
+
e.stopPropagation();
|
| 209 |
+
e.preventDefault();
|
| 210 |
+
try {
|
| 211 |
+
if (currentPlayingId.value === id && currentAudio.value) {
|
| 212 |
+
currentAudio.value.pause();
|
| 213 |
+
currentAudio.value = null;
|
| 214 |
+
currentPlayingId.value = null;
|
| 215 |
+
return;
|
| 216 |
+
}
|
| 217 |
+
if (currentAudio.value) {
|
| 218 |
+
currentAudio.value.pause();
|
| 219 |
+
currentAudio.value = null;
|
| 220 |
+
}
|
| 221 |
+
const audio = new Audio(`${base_url}/tts/models/${id}/reference-audio`);
|
| 222 |
+
audio.addEventListener("ended", () => {
|
| 223 |
+
currentPlayingId.value = null;
|
| 224 |
+
currentAudio.value = null;
|
| 225 |
+
});
|
| 226 |
+
await audio.play();
|
| 227 |
+
currentPlayingId.value = id;
|
| 228 |
+
currentAudio.value = audio;
|
| 229 |
+
} catch (err) {
|
| 230 |
+
currentPlayingId.value = null;
|
| 231 |
+
currentAudio.value = null;
|
| 232 |
+
}
|
| 233 |
+
};
|
| 234 |
+
|
| 235 |
+
onUnmounted(() => {
|
| 236 |
+
if (currentAudio.value) currentAudio.value.pause();
|
| 237 |
+
});
|
| 238 |
+
</script>
|
| 239 |
+
|
| 240 |
+
<template>
|
| 241 |
+
<a-modal
|
| 242 |
+
:open="props.open"
|
| 243 |
+
:title="t('settings.title')"
|
| 244 |
+
:mask-closable="false"
|
| 245 |
+
:closable="true"
|
| 246 |
+
:width="600"
|
| 247 |
+
centered
|
| 248 |
+
transition-name="ant-fade"
|
| 249 |
+
@cancel="handleCancel"
|
| 250 |
+
@update:open="(v: boolean) => emit('update:open', v)"
|
| 251 |
+
>
|
| 252 |
+
<template #footer>
|
| 253 |
+
<a-button key="back" @click="handleCancel">{{ t('common.cancel') }}</a-button>
|
| 254 |
+
<a-button key="confirm" type="primary" :loading="loading" @click="applySettings">
|
| 255 |
+
{{ t('common.confirm') }}
|
| 256 |
+
</a-button>
|
| 257 |
+
</template>
|
| 258 |
+
|
| 259 |
+
<a-tabs v-model:activeKey="activeTab" class="settings-tabs">
|
| 260 |
+
<!-- 常用:输入源 + 回音消除 + 音色(大家最关心的) -->
|
| 261 |
+
<a-tab-pane key="main" :tab="t('settings.tabs.main')">
|
| 262 |
+
<div class="tab-body">
|
| 263 |
+
<div class="setting-row">
|
| 264 |
+
<label>{{ t('settings.audio.microphone') }}</label>
|
| 265 |
+
<a-select v-model:value="inputDeviceIndex" style="width: 100%;">
|
| 266 |
+
<a-select-option :value="null">{{ t('settings.audio.systemDefault') }}</a-select-option>
|
| 267 |
+
<a-select-option v-for="dev in inputDevices" :value="dev.index" :key="dev.index">
|
| 268 |
+
{{ dev.name }}<template v-if="dev.max_input_channels > 1"> ({{ dev.max_input_channels }}{{ t('settings.audio.channelsSuffix') }})</template><template v-if="dev.is_default"> · {{ t('settings.audio.defaultSuffix') }}</template>
|
| 269 |
+
</a-select-option>
|
| 270 |
+
</a-select>
|
| 271 |
+
</div>
|
| 272 |
+
<div class="setting-row">
|
| 273 |
+
<div class="row-inline">
|
| 274 |
+
<label>{{ t('settings.audio.echoCancellation') }}</label>
|
| 275 |
+
<a-switch v-model:checked="echoCancel" />
|
| 276 |
+
</div>
|
| 277 |
+
</div>
|
| 278 |
+
<div class="setting-row">
|
| 279 |
+
<label>{{ t('settings.voice.role') }}</label>
|
| 280 |
+
<a-radio-group v-model:value="role" class="voice-group">
|
| 281 |
+
<a-radio v-for="r in filteredRoles" :value="r['id']" :key="r['id']" class="voice-radio">
|
| 282 |
+
<span class="voice-name">{{ r['character_name'] }}</span>
|
| 283 |
+
<a-button
|
| 284 |
+
type="text"
|
| 285 |
+
class="audio-play-btn"
|
| 286 |
+
:class="{ playing: isPlaying(r['id']) }"
|
| 287 |
+
@click="playRefAudio(r['id'], $event)"
|
| 288 |
+
>
|
| 289 |
+
<SoundTwoTone v-if="isPlaying(r['id'])" style="font-size: 16px; color: #52c41a;" />
|
| 290 |
+
<SoundOutlined v-else style="font-size: 16px; color: #1890ff;" />
|
| 291 |
+
</a-button>
|
| 292 |
+
</a-radio>
|
| 293 |
+
</a-radio-group>
|
| 294 |
+
</div>
|
| 295 |
+
</div>
|
| 296 |
+
</a-tab-pane>
|
| 297 |
+
|
| 298 |
+
<!-- 语言:界面语言 + 识别语言 -->
|
| 299 |
+
<a-tab-pane key="language" :tab="t('settings.tabs.language')">
|
| 300 |
+
<div class="tab-body">
|
| 301 |
+
<div class="setting-row">
|
| 302 |
+
<label><TranslationOutlined class="label-icon" />{{ t('settings.general.interfaceLanguage') }}</label>
|
| 303 |
+
<a-select v-model:value="uiLanguage" style="width: 100%;">
|
| 304 |
+
<a-select-option value="zh">{{ t('lang.zh') }}</a-select-option>
|
| 305 |
+
<a-select-option value="en">{{ t('lang.en') }}</a-select-option>
|
| 306 |
+
</a-select>
|
| 307 |
+
<p class="hint">{{ t('settings.general.interfaceLanguageHint') }}</p>
|
| 308 |
+
</div>
|
| 309 |
+
<div class="setting-row">
|
| 310 |
+
<label><AudioOutlined class="label-icon" />{{ t('settings.recognition.language') }}</label>
|
| 311 |
+
<a-select v-model:value="recognitionLanguage" style="width: 100%;">
|
| 312 |
+
<a-select-option v-for="lan in languages" :value="lan" :key="lan">
|
| 313 |
+
{{ t('lang.' + lan) }}
|
| 314 |
+
</a-select-option>
|
| 315 |
+
</a-select>
|
| 316 |
+
<p class="hint">{{ t('settings.recognition.languageHint') }}</p>
|
| 317 |
+
</div>
|
| 318 |
+
</div>
|
| 319 |
+
</a-tab-pane>
|
| 320 |
+
|
| 321 |
+
<!-- 高级:系统提示词 -->
|
| 322 |
+
<a-tab-pane key="advanced" :tab="t('settings.tabs.advanced')">
|
| 323 |
+
<div class="tab-body">
|
| 324 |
+
<div class="setting-row">
|
| 325 |
+
<label>{{ t('settings.prompt.title') }}</label>
|
| 326 |
+
<a-radio-group button-style="solid" size="small" v-model:value="promptLang" style="margin-bottom: 12px;">
|
| 327 |
+
<a-radio-button value="zh">{{ t('lang.zh') }}</a-radio-button>
|
| 328 |
+
<a-radio-button value="en">{{ t('lang.en') }}</a-radio-button>
|
| 329 |
+
</a-radio-group>
|
| 330 |
+
<div v-show="promptLang === 'zh'">
|
| 331 |
+
<a-textarea v-model:value="current_prompt_zh" :placeholder="default_prompt_zh"
|
| 332 |
+
:auto-size="{ minRows: 6, maxRows: 10 }" show-count :maxlength="2000" allow-clear />
|
| 333 |
+
<a-button size="small" @click="resetPrompt('zh')" style="margin-top: 12px;">{{ t('common.reset') }}</a-button>
|
| 334 |
+
</div>
|
| 335 |
+
<div v-show="promptLang === 'en'">
|
| 336 |
+
<a-textarea v-model:value="current_prompt_en" :placeholder="default_prompt_en"
|
| 337 |
+
:auto-size="{ minRows: 6, maxRows: 10 }" show-count :maxlength="2000" allow-clear />
|
| 338 |
+
<a-button size="small" @click="resetPrompt('en')" style="margin-top: 12px;">{{ t('common.reset') }}</a-button>
|
| 339 |
+
</div>
|
| 340 |
+
</div>
|
| 341 |
+
</div>
|
| 342 |
+
</a-tab-pane>
|
| 343 |
+
</a-tabs>
|
| 344 |
+
</a-modal>
|
| 345 |
+
</template>
|
| 346 |
+
|
| 347 |
+
<style lang="scss" scoped>
|
| 348 |
+
// 固定内容区高度,切换 Tab 时横条不再跳动
|
| 349 |
+
.tab-body {
|
| 350 |
+
height: 360px;
|
| 351 |
+
overflow-y: auto;
|
| 352 |
+
padding: 4px 8px 4px 2px;
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
.setting-row {
|
| 356 |
+
margin-bottom: 20px;
|
| 357 |
+
|
| 358 |
+
// 仅作用于字段标题(直接子 label),避免影响嵌套的 radio-button 等 <label>
|
| 359 |
+
> label {
|
| 360 |
+
display: block;
|
| 361 |
+
font-size: 15px;
|
| 362 |
+
font-weight: 500;
|
| 363 |
+
margin-bottom: 8px;
|
| 364 |
+
|
| 365 |
+
.label-icon {
|
| 366 |
+
margin-right: 6px;
|
| 367 |
+
color: #1890ff;
|
| 368 |
+
}
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
.hint {
|
| 372 |
+
font-size: 12px;
|
| 373 |
+
color: #999;
|
| 374 |
+
margin: 8px 0 0;
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
.row-inline {
|
| 378 |
+
display: flex;
|
| 379 |
+
align-items: center;
|
| 380 |
+
justify-content: space-between;
|
| 381 |
+
}
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
.voice-group {
|
| 385 |
+
display: flex;
|
| 386 |
+
flex-direction: column;
|
| 387 |
+
margin-top: 8px;
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
.voice-radio {
|
| 391 |
+
display: flex;
|
| 392 |
+
align-items: center;
|
| 393 |
+
height: 40px;
|
| 394 |
+
line-height: 40px;
|
| 395 |
+
|
| 396 |
+
.voice-name {
|
| 397 |
+
margin-right: 8px;
|
| 398 |
+
}
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
.audio-play-btn {
|
| 402 |
+
padding: 0 6px;
|
| 403 |
+
border-radius: 4px;
|
| 404 |
+
|
| 405 |
+
&.playing {
|
| 406 |
+
background-color: #f6ffed;
|
| 407 |
+
}
|
| 408 |
+
}
|
| 409 |
+
</style>
|
|
@@ -2,37 +2,18 @@
|
|
| 2 |
|
| 3 |
import router from "@/router.ts";
|
| 4 |
import { useSettingsStore } from "@/stores/config.ts";
|
| 5 |
-
import {
|
| 6 |
import { Modal } from 'ant-design-vue';
|
| 7 |
-
import {
|
| 8 |
import axios from "axios";
|
| 9 |
-
import
|
|
|
|
| 10 |
|
| 11 |
-
const
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
const
|
| 14 |
-
|
| 15 |
-
import setting from "@/assets/setting.png"
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
onMounted(async () => {
|
| 19 |
-
await fetchASRLanguages();
|
| 20 |
-
await fetchTTSRoles();
|
| 21 |
-
});
|
| 22 |
-
|
| 23 |
-
const chatAction = async () => {
|
| 24 |
-
const state = await startAudioChat();
|
| 25 |
-
if (!state) {
|
| 26 |
-
console.error('Failed to start audio chat system service');
|
| 27 |
-
|
| 28 |
-
Modal.error({
|
| 29 |
-
title: 'Error',
|
| 30 |
-
content: 'Failed to start audio chat system service',
|
| 31 |
-
});
|
| 32 |
-
return;
|
| 33 |
-
}
|
| 34 |
-
router.replace('/home')
|
| 35 |
-
}
|
| 36 |
const chatLoading = ref<boolean>(false);
|
| 37 |
|
| 38 |
const startAudioChat = async () => {
|
|
@@ -40,265 +21,33 @@ const startAudioChat = async () => {
|
|
| 40 |
chatLoading.value = true;
|
| 41 |
const response = await fetch(`${base_url}/system/start`, {
|
| 42 |
method: 'POST',
|
| 43 |
-
headers: {
|
| 44 |
-
'Content-Type': 'application/json',
|
| 45 |
-
},
|
| 46 |
body: JSON.stringify({
|
| 47 |
-
enable_echo_cancellation:
|
|
|
|
| 48 |
})
|
| 49 |
});
|
| 50 |
if (!response.ok) {
|
| 51 |
throw new Error(`HTTP error! status: ${response.status}`);
|
| 52 |
}
|
| 53 |
-
|
| 54 |
-
console.log('ASR Instance started successfully:', data);
|
| 55 |
return true;
|
| 56 |
} catch (error) {
|
| 57 |
-
console.error('Error starting
|
| 58 |
return false;
|
| 59 |
} finally {
|
| 60 |
chatLoading.value = false;
|
| 61 |
}
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
const voiceModelOpen = ref<boolean>(false);
|
| 66 |
-
const modalLoading = ref<boolean>(false);
|
| 67 |
-
|
| 68 |
-
const handleVoiceModalCancel = () => {
|
| 69 |
-
voiceModelOpen.value = false;
|
| 70 |
-
role.value = settingsStore.$state.role;
|
| 71 |
-
language.value = settingsStore.$state.language;
|
| 72 |
-
};
|
| 73 |
-
|
| 74 |
-
const handleVoiceModalSubmit = async () => {
|
| 75 |
-
console.log('Selected Language:', language.value);
|
| 76 |
-
console.log('Selected Role:', role.value);
|
| 77 |
-
console.log('Echo Cancel:', echoCancel.value);
|
| 78 |
-
settingsStore.$state.language = language.value;
|
| 79 |
-
settingsStore.$state.role = role.value || '';
|
| 80 |
-
settingsStore.$state.echoCancel = echoCancel.value;
|
| 81 |
-
|
| 82 |
-
await pushConfig(settingsStore.$state.role);
|
| 83 |
-
};
|
| 84 |
-
|
| 85 |
-
const pushConfig = async (model_id: string) => {
|
| 86 |
-
try {
|
| 87 |
-
modalLoading.value = true;
|
| 88 |
-
const response = await fetch(`${base_url}/tts/models/load`, {
|
| 89 |
-
method: 'POST',
|
| 90 |
-
headers: {
|
| 91 |
-
'Content-Type': 'application/json',
|
| 92 |
-
},
|
| 93 |
-
body: JSON.stringify({
|
| 94 |
-
"model_id": model_id,
|
| 95 |
-
})
|
| 96 |
-
});
|
| 97 |
-
if (!response.ok) {
|
| 98 |
-
throw new Error(`HTTP error! status: ${response.status}`);
|
| 99 |
-
}
|
| 100 |
-
const data = await response.json();
|
| 101 |
-
console.log('Config pushed successfully:', data);
|
| 102 |
-
|
| 103 |
-
const response2 = await fetch(`${base_url}/asr/instance/create`, {
|
| 104 |
-
method: 'POST',
|
| 105 |
-
headers: {
|
| 106 |
-
'Content-Type': 'application/json',
|
| 107 |
-
},
|
| 108 |
-
body: JSON.stringify({
|
| 109 |
-
"language": language.value,
|
| 110 |
-
})
|
| 111 |
-
});
|
| 112 |
-
if (!response2.ok) {
|
| 113 |
-
throw new Error(`HTTP error! status: ${response2.status}`);
|
| 114 |
-
}
|
| 115 |
-
const data2 = await response2.json();
|
| 116 |
-
console.log('ASR Language set successfully:', data2);
|
| 117 |
-
|
| 118 |
-
} catch (err) {
|
| 119 |
-
console.error('Error pushing config:', err);
|
| 120 |
-
Modal.error({
|
| 121 |
-
title: 'Error',
|
| 122 |
-
content: "Error config: " + JSON.stringify(err),
|
| 123 |
-
});
|
| 124 |
-
} finally {
|
| 125 |
-
modalLoading.value = false;
|
| 126 |
-
voiceModelOpen.value = false;
|
| 127 |
-
}
|
| 128 |
-
|
| 129 |
-
console.log('Selected Language:', language.value);
|
| 130 |
-
console.log('Selected Role:', role.value);
|
| 131 |
-
}
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
const language = ref<string>(settingsStore.$state.language || 'zh');
|
| 135 |
-
const languages = reactive([]);
|
| 136 |
-
const languageOptions = {
|
| 137 |
-
'zh': 'Chinese',
|
| 138 |
-
'en': 'English',
|
| 139 |
-
'auto': 'Auto',
|
| 140 |
-
};
|
| 141 |
-
const role = ref<string>(settingsStore.$state.role || '');
|
| 142 |
-
const roles = reactive([])
|
| 143 |
-
const echoCancel = ref<boolean>(settingsStore.$state.echoCancel ?? true);
|
| 144 |
-
|
| 145 |
-
const radioStyle = reactive({
|
| 146 |
-
display: 'flex',
|
| 147 |
-
height: '40px',
|
| 148 |
-
lineHeight: '40px',
|
| 149 |
-
fontSize: '16px',
|
| 150 |
-
marginBottom: '8px',
|
| 151 |
-
});
|
| 152 |
-
|
| 153 |
-
const filteredRoles = computed(() => {
|
| 154 |
-
const is_chinese = language.value == 'zh';
|
| 155 |
-
return roles.filter(ro => ro['is_chinese_voice'] == is_chinese);
|
| 156 |
-
});
|
| 157 |
-
|
| 158 |
-
watch(
|
| 159 |
-
() => language.value,
|
| 160 |
-
(newLang) => {
|
| 161 |
-
// 语言切换后,自动选中第一个可用角色
|
| 162 |
-
if (filteredRoles.value.length > 0) {
|
| 163 |
-
const current_role_id = settingsStore.$state.role;
|
| 164 |
-
const current_role = filteredRoles.value.find(ro => ro['id'] == current_role_id);
|
| 165 |
-
if (current_role) {
|
| 166 |
-
role.value = current_role_id;
|
| 167 |
-
} else {
|
| 168 |
-
role.value = filteredRoles.value[0]['id'];
|
| 169 |
-
}
|
| 170 |
-
} else {
|
| 171 |
-
role.value = "";
|
| 172 |
-
}
|
| 173 |
-
}
|
| 174 |
-
);
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
const fetchTTSRoles = async () => {
|
| 178 |
-
try {
|
| 179 |
-
const response = await fetch(`${base_url}/tts/models`);
|
| 180 |
-
const data = await response.json()
|
| 181 |
-
if (data && data.models) {
|
| 182 |
-
// @ts-ignore
|
| 183 |
-
roles.splice(0, data.length, ...data.models)
|
| 184 |
-
console.log('Fetched TTS Roles:', roles);
|
| 185 |
-
|
| 186 |
-
if (data.current_model_id) {
|
| 187 |
-
role.value = data.current_model_id;
|
| 188 |
-
}
|
| 189 |
-
}
|
| 190 |
-
} catch (error) {
|
| 191 |
-
console.error('Error fetching TTS roles:', error);
|
| 192 |
-
}
|
| 193 |
-
};
|
| 194 |
-
|
| 195 |
-
const fetchASRLanguages = async () => {
|
| 196 |
-
try {
|
| 197 |
-
const response = await fetch(`${base_url}/asr/languages`);
|
| 198 |
-
const data = await response.json();
|
| 199 |
-
if (data && data.languages) {
|
| 200 |
-
// @ts-ignore
|
| 201 |
-
languages.splice(0, languages.length, ...data.languages);
|
| 202 |
-
console.log('Fetched ASR Languages:', data.languages);
|
| 203 |
-
|
| 204 |
-
if (data.current_asr_language) {
|
| 205 |
-
language.value = data.current_asr_language;
|
| 206 |
-
}
|
| 207 |
-
}
|
| 208 |
-
} catch (error) {
|
| 209 |
-
console.error('Error fetching ASR languages:', error);
|
| 210 |
-
}
|
| 211 |
};
|
| 212 |
|
| 213 |
-
const
|
| 214 |
-
|
| 215 |
-
if (
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
promptModelOpen.value = true;
|
| 219 |
-
}
|
| 220 |
-
};
|
| 221 |
-
|
| 222 |
-
const popoverVisible = ref<boolean>(false);
|
| 223 |
-
const promptModelOpen = ref<boolean>(false);
|
| 224 |
-
|
| 225 |
-
// 音频播放状态管理
|
| 226 |
-
const currentPlayingId = ref<string | null>(null);
|
| 227 |
-
const currentAudio = ref<HTMLAudioElement | null>(null);
|
| 228 |
-
|
| 229 |
-
// 修改音频播放逻辑
|
| 230 |
-
const playRefAudio = async (id: string, e: Event) => {
|
| 231 |
-
console.log('Playing reference audio for role:', id);
|
| 232 |
-
|
| 233 |
-
e.stopPropagation();
|
| 234 |
-
e.preventDefault();
|
| 235 |
-
|
| 236 |
-
try {
|
| 237 |
-
// 如果点击的是当前正在播放的音频,则停止播放
|
| 238 |
-
if (currentPlayingId.value === id && currentAudio.value) {
|
| 239 |
-
currentAudio.value.pause();
|
| 240 |
-
currentAudio.value = null;
|
| 241 |
-
currentPlayingId.value = null;
|
| 242 |
-
console.log('Audio stopped');
|
| 243 |
-
return;
|
| 244 |
-
}
|
| 245 |
-
|
| 246 |
-
// 如果有其他音频正在播放,先停止它
|
| 247 |
-
if (currentAudio.value) {
|
| 248 |
-
currentAudio.value.pause();
|
| 249 |
-
currentAudio.value = null;
|
| 250 |
-
}
|
| 251 |
-
|
| 252 |
-
// 创建新的音频实例
|
| 253 |
-
const audio = new Audio(`${base_url}/tts/models/${id}/reference-audio`);
|
| 254 |
-
|
| 255 |
-
// 设置音频事件监听
|
| 256 |
-
audio.addEventListener('ended', () => {
|
| 257 |
-
currentPlayingId.value = null;
|
| 258 |
-
currentAudio.value = null;
|
| 259 |
-
});
|
| 260 |
-
|
| 261 |
-
audio.addEventListener('error', (error) => {
|
| 262 |
-
console.error('Audio playback error:', error);
|
| 263 |
-
currentPlayingId.value = null;
|
| 264 |
-
currentAudio.value = null;
|
| 265 |
-
Modal.error({
|
| 266 |
-
title: 'Error',
|
| 267 |
-
content: 'Failed to play reference audio',
|
| 268 |
-
});
|
| 269 |
-
});
|
| 270 |
-
|
| 271 |
-
// 开始播放
|
| 272 |
-
await audio.play();
|
| 273 |
-
currentPlayingId.value = id;
|
| 274 |
-
currentAudio.value = audio;
|
| 275 |
-
console.log('Audio played successfully');
|
| 276 |
-
|
| 277 |
-
} catch (error) {
|
| 278 |
-
console.error('Error playing audio:', error);
|
| 279 |
-
currentPlayingId.value = null;
|
| 280 |
-
currentAudio.value = null;
|
| 281 |
-
Modal.error({
|
| 282 |
-
title: 'Error',
|
| 283 |
-
content: 'Failed to play reference audio',
|
| 284 |
-
});
|
| 285 |
-
}
|
| 286 |
-
};
|
| 287 |
-
|
| 288 |
-
// 组件卸载时清理音频
|
| 289 |
-
onUnmounted(() => {
|
| 290 |
-
if (currentAudio.value) {
|
| 291 |
-
currentAudio.value.pause();
|
| 292 |
-
currentAudio.value = null;
|
| 293 |
}
|
| 294 |
-
|
| 295 |
-
});
|
| 296 |
-
|
| 297 |
-
// 计算属性:判断是否正在播放
|
| 298 |
-
const isPlaying = (id: string) => {
|
| 299 |
-
return currentPlayingId.value === id;
|
| 300 |
};
|
| 301 |
-
|
| 302 |
</script>
|
| 303 |
|
| 304 |
<template>
|
|
@@ -306,178 +55,46 @@ const isPlaying = (id: string) => {
|
|
| 306 |
<div class="content">
|
| 307 |
<div class="inner-content">
|
| 308 |
<div class="text-box">
|
| 309 |
-
<div class="title">
|
| 310 |
-
|
| 311 |
-
</div>
|
| 312 |
-
<div class="sub-title">
|
| 313 |
-
点击下方按钮开始对话
|
| 314 |
-
</div>
|
| 315 |
</div>
|
| 316 |
<div class="btn-box">
|
| 317 |
<a-button @click="chatAction" block :loading="chatLoading" type="primary" size="large">
|
| 318 |
-
<span>
|
| 319 |
</a-button>
|
| 320 |
</div>
|
| 321 |
</div>
|
| 322 |
</div>
|
| 323 |
|
| 324 |
<div class="actions">
|
| 325 |
-
<
|
| 326 |
-
|
| 327 |
-
<a-button v-if="false" type="text" @click="voiceModelOpen = true"
|
| 328 |
-
style="width:44px; height: 44px; margin-right:24px;margin-bottom: 24px;">
|
| 329 |
<template #icon>
|
| 330 |
<img :src="setting" width="28" height="28" alt="settings" />
|
| 331 |
</template>
|
| 332 |
</a-button>
|
| 333 |
-
<a-popover v-if="true" v-model:open="popoverVisible" trigger="click" ok-text="Yes" cancel-text="No" placement="bottomRight">
|
| 334 |
-
<template #content>
|
| 335 |
-
<div class="custom-popover-list">
|
| 336 |
-
<div class="custom-popover-item" @click="togglePopover('voice')">
|
| 337 |
-
选择音色</div>
|
| 338 |
-
<div class="custom-popover-item" @click="togglePopover('prompt')">Prompt调试</div>
|
| 339 |
-
</div>
|
| 340 |
-
</template>
|
| 341 |
-
<img :src="setting" alt="item actions" style="width: 28px; height: 28px; margin-right:24px;margin-top: 16px;">
|
| 342 |
-
</a-popover>
|
| 343 |
</div>
|
| 344 |
|
| 345 |
-
<
|
| 346 |
-
<template #footer>
|
| 347 |
-
<a-button key="back" @click="handleVoiceModalCancel">Cancel</a-button>
|
| 348 |
-
<a-button key="submit" type="primary" :loading="modalLoading" @click="handleVoiceModalSubmit">Submit</a-button>
|
| 349 |
-
</template>
|
| 350 |
-
<div class="languages">
|
| 351 |
-
<div class="echo-cancel-item">
|
| 352 |
-
<div style="display: flex; justify-content: space-between; align-items: center;">
|
| 353 |
-
<p style="margin: 0;">Enable Echo Cancellation:</p>
|
| 354 |
-
<a-switch v-model:checked="echoCancel" />
|
| 355 |
-
</div>
|
| 356 |
-
</div>
|
| 357 |
-
</div>
|
| 358 |
-
<div class="languages">
|
| 359 |
-
<div class="language-item">
|
| 360 |
-
<p>Select Language:</p>
|
| 361 |
-
<a-select v-model:value="language" style="width: 100%;">
|
| 362 |
-
<a-select-option v-for="lan in languages" :value="lan" :key="lan">
|
| 363 |
-
{{ languageOptions[lan] }}
|
| 364 |
-
</a-select-option>
|
| 365 |
-
</a-select>
|
| 366 |
-
</div>
|
| 367 |
-
</div>
|
| 368 |
-
<div class="languages">
|
| 369 |
-
<div class="role-item">
|
| 370 |
-
<p>Select voice Role:</p>
|
| 371 |
-
<a-radio-group size="large" v-model:value="role">
|
| 372 |
-
<a-radio v-for="r in filteredRoles" :style="radioStyle" :value="r['id']" :key="r['id']">
|
| 373 |
-
<div style="display: flex; justify-content: space-between; align-items: center; width:450px;">
|
| 374 |
-
{{ r['character_name'] }}
|
| 375 |
-
<a-button
|
| 376 |
-
:key="r['id']"
|
| 377 |
-
type="text"
|
| 378 |
-
@click="playRefAudio(r['id'], $event)"
|
| 379 |
-
class="audio-play-btn"
|
| 380 |
-
:class="{ 'playing': isPlaying(r['id']) }"
|
| 381 |
-
>
|
| 382 |
-
<SoundTwoTone
|
| 383 |
-
v-if="isPlaying(r['id'])"
|
| 384 |
-
style="font-size: 18px; color: #52c41a;"
|
| 385 |
-
class="playing-icon"
|
| 386 |
-
/>
|
| 387 |
-
<SoundOutlined
|
| 388 |
-
v-else
|
| 389 |
-
style="font-size: 18px; color: #1890ff;"
|
| 390 |
-
/>
|
| 391 |
-
</a-button>
|
| 392 |
-
</div>
|
| 393 |
-
|
| 394 |
-
</a-radio>
|
| 395 |
-
</a-radio-group>
|
| 396 |
-
|
| 397 |
-
</div>
|
| 398 |
-
</div>
|
| 399 |
-
</a-modal>
|
| 400 |
-
|
| 401 |
-
<PromptText v-model:open="promptModelOpen" />
|
| 402 |
</div>
|
| 403 |
</template>
|
| 404 |
|
| 405 |
<style lang="scss" scoped>
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
margin-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
}
|
| 417 |
-
|
| 418 |
-
.audio-play-btn {
|
| 419 |
-
padding: 0px 8px;
|
| 420 |
-
padding-top:2px;
|
| 421 |
-
border-radius: 4px;
|
| 422 |
-
transition: all 0.2s;
|
| 423 |
-
height: 40px;
|
| 424 |
-
|
| 425 |
-
&:hover {
|
| 426 |
-
background-color: #f0f0f0;
|
| 427 |
-
}
|
| 428 |
-
|
| 429 |
-
&.playing {
|
| 430 |
-
background-color: #f6ffed;
|
| 431 |
-
border-color: #1890ff;
|
| 432 |
-
|
| 433 |
-
.playing-icon {
|
| 434 |
-
animation: pulse 1.5s infinite;
|
| 435 |
-
}
|
| 436 |
-
}
|
| 437 |
-
}
|
| 438 |
-
|
| 439 |
-
@keyframes pulse {
|
| 440 |
-
0% {
|
| 441 |
-
opacity: 1;
|
| 442 |
-
transform: scale(1);
|
| 443 |
-
}
|
| 444 |
-
50% {
|
| 445 |
-
opacity: 0.7;
|
| 446 |
-
transform: scale(1.1);
|
| 447 |
-
}
|
| 448 |
-
100% {
|
| 449 |
-
opacity: 1;
|
| 450 |
-
transform: scale(1);
|
| 451 |
-
}
|
| 452 |
-
}
|
| 453 |
-
|
| 454 |
-
.btn-groups {
|
| 455 |
-
margin-top: 36px;
|
| 456 |
display: flex;
|
| 457 |
-
justify-content: space-between;
|
| 458 |
align-items: center;
|
|
|
|
| 459 |
}
|
| 460 |
|
| 461 |
-
.custom-popover-list {
|
| 462 |
-
width: 92px;
|
| 463 |
-
margin: 0;
|
| 464 |
-
.custom-popover-item {
|
| 465 |
-
font-size: 14px;
|
| 466 |
-
line-height: 36px;
|
| 467 |
-
font-weight: 500;
|
| 468 |
-
color: #1e1e1e;
|
| 469 |
-
cursor: pointer;
|
| 470 |
-
border-radius: 4px;
|
| 471 |
-
padding: 0 8px;
|
| 472 |
-
margin: 0px -8px;
|
| 473 |
-
transition: background 0.2s;
|
| 474 |
-
}
|
| 475 |
-
.custom-popover-item:hover, .custom-popover-item:focus {
|
| 476 |
-
background: #e5e7eb;
|
| 477 |
-
}
|
| 478 |
-
}
|
| 479 |
-
|
| 480 |
-
|
| 481 |
.welcome-wrapper {
|
| 482 |
width: 100%;
|
| 483 |
height: 100%;
|
|
@@ -523,6 +140,7 @@ const isPlaying = (id: string) => {
|
|
| 523 |
margin-top: 10px;
|
| 524 |
}
|
| 525 |
}
|
|
|
|
| 526 |
.btn-box {
|
| 527 |
width: 224px;
|
| 528 |
height: 80px;
|
|
@@ -531,10 +149,11 @@ const isPlaying = (id: string) => {
|
|
| 531 |
}
|
| 532 |
|
| 533 |
.actions {
|
| 534 |
-
width: 100%;
|
| 535 |
-
height:
|
| 536 |
-
|
| 537 |
display: flex;
|
|
|
|
| 538 |
justify-content: flex-end;
|
| 539 |
}
|
| 540 |
}
|
|
|
|
| 2 |
|
| 3 |
import router from "@/router.ts";
|
| 4 |
import { useSettingsStore } from "@/stores/config.ts";
|
| 5 |
+
import { ref } from "vue";
|
| 6 |
import { Modal } from 'ant-design-vue';
|
| 7 |
+
import { useI18n } from "vue-i18n";
|
| 8 |
import axios from "axios";
|
| 9 |
+
import SettingsModal from "./Components/SettingsModal.vue";
|
| 10 |
+
import setting from "@/assets/setting.png";
|
| 11 |
|
| 12 |
+
const { t } = useI18n();
|
| 13 |
+
const base_url = axios.defaults.baseURL;
|
| 14 |
+
const settingsStore = useSettingsStore();
|
| 15 |
|
| 16 |
+
const settingsOpen = ref<boolean>(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
const chatLoading = ref<boolean>(false);
|
| 18 |
|
| 19 |
const startAudioChat = async () => {
|
|
|
|
| 21 |
chatLoading.value = true;
|
| 22 |
const response = await fetch(`${base_url}/system/start`, {
|
| 23 |
method: 'POST',
|
| 24 |
+
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
|
| 25 |
body: JSON.stringify({
|
| 26 |
+
enable_echo_cancellation: settingsStore.$state.echoCancel ?? true,
|
| 27 |
+
input_device_index: settingsStore.$state.inputDeviceIndex ?? null
|
| 28 |
})
|
| 29 |
});
|
| 30 |
if (!response.ok) {
|
| 31 |
throw new Error(`HTTP error! status: ${response.status}`);
|
| 32 |
}
|
| 33 |
+
await response.json();
|
|
|
|
| 34 |
return true;
|
| 35 |
} catch (error) {
|
| 36 |
+
console.error('Error starting audio chat:', error);
|
| 37 |
return false;
|
| 38 |
} finally {
|
| 39 |
chatLoading.value = false;
|
| 40 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
};
|
| 42 |
|
| 43 |
+
const chatAction = async () => {
|
| 44 |
+
const ok = await startAudioChat();
|
| 45 |
+
if (!ok) {
|
| 46 |
+
Modal.error({ title: t('common.error'), content: t('welcome.startFailed') });
|
| 47 |
+
return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
}
|
| 49 |
+
router.replace('/home');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
};
|
|
|
|
| 51 |
</script>
|
| 52 |
|
| 53 |
<template>
|
|
|
|
| 55 |
<div class="content">
|
| 56 |
<div class="inner-content">
|
| 57 |
<div class="text-box">
|
| 58 |
+
<div class="title">{{ t('welcome.title') }}</div>
|
| 59 |
+
<div class="sub-title">{{ t('welcome.subtitle') }}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
</div>
|
| 61 |
<div class="btn-box">
|
| 62 |
<a-button @click="chatAction" block :loading="chatLoading" type="primary" size="large">
|
| 63 |
+
<span>{{ t('welcome.start') }}</span>
|
| 64 |
</a-button>
|
| 65 |
</div>
|
| 66 |
</div>
|
| 67 |
</div>
|
| 68 |
|
| 69 |
<div class="actions">
|
| 70 |
+
<a-button type="text" @click="settingsOpen = true" class="settings-btn"
|
| 71 |
+
:title="t('settings.entry')">
|
|
|
|
|
|
|
| 72 |
<template #icon>
|
| 73 |
<img :src="setting" width="28" height="28" alt="settings" />
|
| 74 |
</template>
|
| 75 |
</a-button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
</div>
|
| 77 |
|
| 78 |
+
<SettingsModal v-model:open="settingsOpen" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
</div>
|
| 80 |
</template>
|
| 81 |
|
| 82 |
<style lang="scss" scoped>
|
| 83 |
+
.settings-btn {
|
| 84 |
+
width: 60px;
|
| 85 |
+
height: 60px;
|
| 86 |
+
margin-right: 24px;
|
| 87 |
+
border-radius: 50% !important;
|
| 88 |
+
background: rgba(255, 255, 255, 0.5) !important;
|
| 89 |
+
border: 1px solid rgba(255, 255, 255, 0.7) !important;
|
| 90 |
+
backdrop-filter: blur(10px);
|
| 91 |
+
-webkit-backdrop-filter: blur(10px);
|
| 92 |
+
box-shadow: 0 4px 16px rgba(31, 38, 135, 0.12);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
display: flex;
|
|
|
|
| 94 |
align-items: center;
|
| 95 |
+
justify-content: center;
|
| 96 |
}
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
.welcome-wrapper {
|
| 99 |
width: 100%;
|
| 100 |
height: 100%;
|
|
|
|
| 140 |
margin-top: 10px;
|
| 141 |
}
|
| 142 |
}
|
| 143 |
+
|
| 144 |
.btn-box {
|
| 145 |
width: 224px;
|
| 146 |
height: 80px;
|
|
|
|
| 149 |
}
|
| 150 |
|
| 151 |
.actions {
|
| 152 |
+
width: 100%;
|
| 153 |
+
height: 100px;
|
| 154 |
+
margin-bottom: 32px;
|
| 155 |
display: flex;
|
| 156 |
+
align-items: center;
|
| 157 |
justify-content: flex-end;
|
| 158 |
}
|
| 159 |
}
|
|
@@ -63,6 +63,19 @@ def main():
|
|
| 63 |
parser = create_argument_parser()
|
| 64 |
args = parser.parse_args()
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
set_debug_mode(args.debug)
|
| 67 |
|
| 68 |
print(f"""
|
|
@@ -78,8 +91,10 @@ VoiceDialogue - 语音对话系统
|
|
| 78 |
if args.mode == 'cli':
|
| 79 |
print(f"语言设置: {args.language}")
|
| 80 |
print(f"说话人: {args.speaker}")
|
|
|
|
|
|
|
| 81 |
print("正在启动命令行语音对话系统...")
|
| 82 |
-
launch_system(args.language, args.speaker, args.disable_echo_cancellation)
|
| 83 |
|
| 84 |
elif args.mode == 'api':
|
| 85 |
launch_api_server(
|
|
|
|
| 63 |
parser = create_argument_parser()
|
| 64 |
args = parser.parse_args()
|
| 65 |
|
| 66 |
+
# 列出音频输入设备后退出
|
| 67 |
+
if getattr(args, 'list_audio_devices', False):
|
| 68 |
+
from voice_dialogue.audio.devices import list_input_devices
|
| 69 |
+
devices = list_input_devices()
|
| 70 |
+
print(f"\n可用音频输入设备 ({len(devices)}):")
|
| 71 |
+
print(f"{'索引':>4} {'通道':>4} {'采样率':>7} {'默认':>4} 名称")
|
| 72 |
+
for d in devices:
|
| 73 |
+
default_mark = '✓' if d['is_default'] else ''
|
| 74 |
+
print(f"{d['index']:>4} {d['max_input_channels']:>4} "
|
| 75 |
+
f"{d['default_sample_rate']:>7} {default_mark:>4} {d['name']}")
|
| 76 |
+
print("\n使用 --input-device <索引> 选择设备。")
|
| 77 |
+
sys.exit(0)
|
| 78 |
+
|
| 79 |
set_debug_mode(args.debug)
|
| 80 |
|
| 81 |
print(f"""
|
|
|
|
| 91 |
if args.mode == 'cli':
|
| 92 |
print(f"语言设置: {args.language}")
|
| 93 |
print(f"说话人: {args.speaker}")
|
| 94 |
+
if args.input_device is not None:
|
| 95 |
+
print(f"输入设备索引: {args.input_device}")
|
| 96 |
print("正在启动命令行语音对话系统...")
|
| 97 |
+
launch_system(args.language, args.speaker, args.disable_echo_cancellation, args.input_device)
|
| 98 |
|
| 99 |
elif args.mode == 'api':
|
| 100 |
launch_api_server(
|
|
@@ -24,8 +24,8 @@ class LifespanManager:
|
|
| 24 |
startup_start_time = time.time()
|
| 25 |
|
| 26 |
try:
|
| 27 |
-
# 初始化系统语言
|
| 28 |
-
system_language =
|
| 29 |
logger.info(f"系统默认语言: {system_language}")
|
| 30 |
|
| 31 |
# 初始化TTS配置
|
|
|
|
| 24 |
startup_start_time = time.time()
|
| 25 |
|
| 26 |
try:
|
| 27 |
+
# 初始化系统语言:产品默认使用中文(不随操作系统语言变化)
|
| 28 |
+
system_language = 'zh'
|
| 29 |
logger.info(f"系统默认语言: {system_language}")
|
| 30 |
|
| 31 |
# 初始化TTS配置
|
|
@@ -12,11 +12,15 @@ class ServiceFactories:
|
|
| 12 |
"""服务工厂类,封装所有服务的创建逻辑"""
|
| 13 |
|
| 14 |
@staticmethod
|
| 15 |
-
def create_audio_capture(
|
|
|
|
|
|
|
|
|
|
| 16 |
"""创建音频捕获服务"""
|
| 17 |
return AudioCapture(
|
| 18 |
audio_frames_queue=audio_frames_queue,
|
| 19 |
-
enable_echo_cancellation=enable_echo_cancellation
|
|
|
|
| 20 |
)
|
| 21 |
|
| 22 |
@staticmethod
|
|
@@ -130,11 +134,14 @@ def get_core_voice_service_definitions(system_language: str, tts_config: BaseTTS
|
|
| 130 |
]
|
| 131 |
|
| 132 |
|
| 133 |
-
def get_audio_capture_service_definition(
|
|
|
|
|
|
|
|
|
|
| 134 |
"""获取音频捕获服务定义"""
|
| 135 |
return ServiceDefinition(
|
| 136 |
name="audio_capture",
|
| 137 |
-
factory=lambda: ServiceFactories.create_audio_capture(enable_echo_cancellation),
|
| 138 |
dependencies=[],
|
| 139 |
health_check=lambda service: hasattr(service, 'is_ready') and service.is_ready
|
| 140 |
)
|
|
|
|
| 12 |
"""服务工厂类,封装所有服务的创建逻辑"""
|
| 13 |
|
| 14 |
@staticmethod
|
| 15 |
+
def create_audio_capture(
|
| 16 |
+
enable_echo_cancellation: bool = True,
|
| 17 |
+
input_device_index: int = None,
|
| 18 |
+
) -> AudioCapture:
|
| 19 |
"""创建音频捕获服务"""
|
| 20 |
return AudioCapture(
|
| 21 |
audio_frames_queue=audio_frames_queue,
|
| 22 |
+
enable_echo_cancellation=enable_echo_cancellation,
|
| 23 |
+
input_device_index=input_device_index,
|
| 24 |
)
|
| 25 |
|
| 26 |
@staticmethod
|
|
|
|
| 134 |
]
|
| 135 |
|
| 136 |
|
| 137 |
+
def get_audio_capture_service_definition(
|
| 138 |
+
enable_echo_cancellation: bool = True,
|
| 139 |
+
input_device_index: int = None,
|
| 140 |
+
) -> ServiceDefinition:
|
| 141 |
"""获取音频捕获服务定义"""
|
| 142 |
return ServiceDefinition(
|
| 143 |
name="audio_capture",
|
| 144 |
+
factory=lambda: ServiceFactories.create_audio_capture(enable_echo_cancellation, input_device_index),
|
| 145 |
dependencies=[],
|
| 146 |
health_check=lambda service: hasattr(service, 'is_ready') and service.is_ready
|
| 147 |
)
|
|
@@ -3,11 +3,15 @@ import time
|
|
| 3 |
|
| 4 |
from fastapi import APIRouter, HTTPException, BackgroundTasks, Request
|
| 5 |
|
|
|
|
|
|
|
|
|
|
| 6 |
from voice_dialogue.core.constants import session_manager
|
| 7 |
from voice_dialogue.utils.logger import logger
|
| 8 |
from ..core.service_factories import get_audio_capture_service_definition, get_speech_monitor_service_definition
|
| 9 |
from ..schemas.system_schemas import (
|
| 10 |
-
SystemStatusResponse, SystemResponse, SystemStartRequest
|
|
|
|
| 11 |
)
|
| 12 |
|
| 13 |
router = APIRouter()
|
|
@@ -60,6 +64,24 @@ async def get_system_status(request: Request):
|
|
| 60 |
raise HTTPException(status_code=500, detail=f"获取系统状态失败: {str(e)}")
|
| 61 |
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
@router.post("/start", response_model=SystemResponse, summary="启动系统")
|
| 64 |
async def start_system(
|
| 65 |
request: SystemStartRequest,
|
|
@@ -76,6 +98,18 @@ async def start_system(
|
|
| 76 |
message="系统已经在运行中或正在启动"
|
| 77 |
)
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
# 更新状态
|
| 80 |
_system_status["status"] = "starting"
|
| 81 |
session_manager.reset_id()
|
|
@@ -84,7 +118,8 @@ async def start_system(
|
|
| 84 |
background_tasks.add_task(
|
| 85 |
_start_system_background,
|
| 86 |
fastapi_request,
|
| 87 |
-
request.enable_echo_cancellation
|
|
|
|
| 88 |
)
|
| 89 |
|
| 90 |
return SystemResponse(
|
|
@@ -214,7 +249,11 @@ async def restart_system(
|
|
| 214 |
raise HTTPException(status_code=500, detail=f"系统重启失败: {str(e)}")
|
| 215 |
|
| 216 |
|
| 217 |
-
async def _start_system_background(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
"""
|
| 219 |
后台启动系统的实际逻辑 - 创建并启动audio_capture服务
|
| 220 |
"""
|
|
@@ -257,7 +296,9 @@ async def _start_system_background(request: Request, enable_echo_cancellation: b
|
|
| 257 |
logger.info("语音监控服务已在运行")
|
| 258 |
else:
|
| 259 |
# 创建语音监控服务定义
|
| 260 |
-
|
|
|
|
|
|
|
| 261 |
speech_monitor_def = get_speech_monitor_service_definition(enable_vad)
|
| 262 |
|
| 263 |
# 启动语音监控服务
|
|
@@ -271,7 +312,7 @@ async def _start_system_background(request: Request, enable_echo_cancellation: b
|
|
| 271 |
logger.info("音频捕获服务已在运行")
|
| 272 |
else:
|
| 273 |
# 创建audio_capture服务定义
|
| 274 |
-
audio_capture_def = get_audio_capture_service_definition(enable_echo_cancellation)
|
| 275 |
|
| 276 |
# 启动audio_capture服务
|
| 277 |
success = service_manager.start_service(audio_capture_def)
|
|
|
|
| 3 |
|
| 4 |
from fastapi import APIRouter, HTTPException, BackgroundTasks, Request
|
| 5 |
|
| 6 |
+
from voice_dialogue.audio.capture import resolves_to_native_aec
|
| 7 |
+
from voice_dialogue.audio.devices import list_input_devices, get_default_input_device_index, is_valid_input_device
|
| 8 |
+
from voice_dialogue.config.audio_config import get_input_device_index, save_input_device_index
|
| 9 |
from voice_dialogue.core.constants import session_manager
|
| 10 |
from voice_dialogue.utils.logger import logger
|
| 11 |
from ..core.service_factories import get_audio_capture_service_definition, get_speech_monitor_service_definition
|
| 12 |
from ..schemas.system_schemas import (
|
| 13 |
+
SystemStatusResponse, SystemResponse, SystemStartRequest,
|
| 14 |
+
AudioInputDevicesResponse, AudioInputDevice
|
| 15 |
)
|
| 16 |
|
| 17 |
router = APIRouter()
|
|
|
|
| 64 |
raise HTTPException(status_code=500, detail=f"获取系统状态失败: {str(e)}")
|
| 65 |
|
| 66 |
|
| 67 |
+
@router.get("/audio-devices", response_model=AudioInputDevicesResponse, summary="获取可用音频输入设备")
|
| 68 |
+
async def get_audio_devices():
|
| 69 |
+
"""
|
| 70 |
+
列出系统所有可用的音频输入设备(含外置麦克风/麦克风阵列),
|
| 71 |
+
供前端选择采集设备。
|
| 72 |
+
"""
|
| 73 |
+
try:
|
| 74 |
+
devices = [AudioInputDevice(**d) for d in list_input_devices()]
|
| 75 |
+
return AudioInputDevicesResponse(
|
| 76 |
+
devices=devices,
|
| 77 |
+
current_device_index=get_input_device_index(),
|
| 78 |
+
default_device_index=get_default_input_device_index(),
|
| 79 |
+
)
|
| 80 |
+
except Exception as e:
|
| 81 |
+
logger.error(f"获取音频输入设备失败: {e}", exc_info=True)
|
| 82 |
+
raise HTTPException(status_code=500, detail=f"获取音频输入设备失败: {str(e)}")
|
| 83 |
+
|
| 84 |
+
|
| 85 |
@router.post("/start", response_model=SystemResponse, summary="启动系统")
|
| 86 |
async def start_system(
|
| 87 |
request: SystemStartRequest,
|
|
|
|
| 98 |
message="系统已经在运行中或正在启动"
|
| 99 |
)
|
| 100 |
|
| 101 |
+
# 解析输入设备:请求未指定时回退到已保存的设备
|
| 102 |
+
input_device_index = request.input_device_index
|
| 103 |
+
if input_device_index is None:
|
| 104 |
+
input_device_index = get_input_device_index()
|
| 105 |
+
|
| 106 |
+
if not is_valid_input_device(input_device_index):
|
| 107 |
+
logger.warning(f"请求的输入设备 {input_device_index} 无效,回退到系统默认设备")
|
| 108 |
+
input_device_index = None
|
| 109 |
+
|
| 110 |
+
# 持久化用户选择,供下次启动复用
|
| 111 |
+
save_input_device_index(input_device_index)
|
| 112 |
+
|
| 113 |
# 更新状态
|
| 114 |
_system_status["status"] = "starting"
|
| 115 |
session_manager.reset_id()
|
|
|
|
| 118 |
background_tasks.add_task(
|
| 119 |
_start_system_background,
|
| 120 |
fastapi_request,
|
| 121 |
+
request.enable_echo_cancellation,
|
| 122 |
+
input_device_index,
|
| 123 |
)
|
| 124 |
|
| 125 |
return SystemResponse(
|
|
|
|
| 249 |
raise HTTPException(status_code=500, detail=f"系统重启失败: {str(e)}")
|
| 250 |
|
| 251 |
|
| 252 |
+
async def _start_system_background(
|
| 253 |
+
request: Request,
|
| 254 |
+
enable_echo_cancellation: bool = True,
|
| 255 |
+
input_device_index: int = None,
|
| 256 |
+
):
|
| 257 |
"""
|
| 258 |
后台启动系统的实际逻辑 - 创建并启动audio_capture服务
|
| 259 |
"""
|
|
|
|
| 296 |
logger.info("语音监控服务已在运行")
|
| 297 |
else:
|
| 298 |
# 创建语音监控服务定义
|
| 299 |
+
# 仅当走 macOS 原生 AEC(自带 VAD)时关闭软件 VAD;
|
| 300 |
+
# 选择了外置设备走 PyAudio 时,必须启用软件 VAD。
|
| 301 |
+
enable_vad = not resolves_to_native_aec(enable_echo_cancellation, input_device_index)
|
| 302 |
speech_monitor_def = get_speech_monitor_service_definition(enable_vad)
|
| 303 |
|
| 304 |
# 启动语音监控服务
|
|
|
|
| 312 |
logger.info("音频捕获服务已在运行")
|
| 313 |
else:
|
| 314 |
# 创建audio_capture服务定义
|
| 315 |
+
audio_capture_def = get_audio_capture_service_definition(enable_echo_cancellation, input_device_index)
|
| 316 |
|
| 317 |
# 启动audio_capture服务
|
| 318 |
success = service_manager.start_service(audio_capture_def)
|
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from typing import Optional, Literal, Dict, Any
|
| 2 |
|
| 3 |
from pydantic import BaseModel, Field
|
| 4 |
|
|
@@ -17,7 +17,24 @@ class SystemStatusResponse(BaseModel):
|
|
| 17 |
|
| 18 |
class SystemStartRequest(BaseModel):
|
| 19 |
"""系统启动请求"""
|
| 20 |
-
enable_echo_cancellation: bool = Field(default=True, description="是否启用回声消除")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
class SystemResponse(BaseModel):
|
|
|
|
| 1 |
+
from typing import Optional, Literal, Dict, Any, List
|
| 2 |
|
| 3 |
from pydantic import BaseModel, Field
|
| 4 |
|
|
|
|
| 17 |
|
| 18 |
class SystemStartRequest(BaseModel):
|
| 19 |
"""系统启动请求"""
|
| 20 |
+
enable_echo_cancellation: bool = Field(default=True, description="是否启用回声消除(仅在未指定输入设备时使用 macOS 原生 AEC)")
|
| 21 |
+
input_device_index: Optional[int] = Field(default=None, description="输入设备索引(如外置麦克风阵列);为空则使用系统默认设备")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class AudioInputDevice(BaseModel):
|
| 25 |
+
"""音频输入设备信息"""
|
| 26 |
+
index: int = Field(..., description="设备索引")
|
| 27 |
+
name: str = Field(..., description="设备名称")
|
| 28 |
+
max_input_channels: int = Field(..., description="最大输入通道数")
|
| 29 |
+
default_sample_rate: int = Field(..., description="设备默认采样率")
|
| 30 |
+
is_default: bool = Field(default=False, description="是否为系统默认输入设备")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class AudioInputDevicesResponse(BaseModel):
|
| 34 |
+
"""音频输入设备列表响应"""
|
| 35 |
+
devices: List[AudioInputDevice] = Field(default_factory=list, description="可用输入设备列表")
|
| 36 |
+
current_device_index: Optional[int] = Field(default=None, description="当前已选择/保存的输入设备索引")
|
| 37 |
+
default_device_index: Optional[int] = Field(default=None, description="系统默认输入设备索引")
|
| 38 |
|
| 39 |
|
| 40 |
class SystemResponse(BaseModel):
|
|
@@ -4,12 +4,43 @@
|
|
| 4 |
根据配置选择并管理具体的音频捕获策略。
|
| 5 |
"""
|
| 6 |
from multiprocessing import Queue
|
|
|
|
| 7 |
|
| 8 |
from voice_dialogue.utils.logger import logger
|
| 9 |
from .aec_capture import AecCapture
|
| 10 |
from .pyaudio_capture import PyAudioCapture
|
| 11 |
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
class AudioCapture:
|
| 14 |
"""
|
| 15 |
音频捕获器门面 (Facade)。
|
|
@@ -23,29 +54,44 @@ class AudioCapture:
|
|
| 23 |
self,
|
| 24 |
audio_frames_queue: Queue,
|
| 25 |
enable_echo_cancellation: bool = True,
|
|
|
|
|
|
|
| 26 |
):
|
| 27 |
"""
|
| 28 |
初始化音频捕获器。
|
| 29 |
|
| 30 |
Args:
|
| 31 |
audio_frames_queue (Queue): 用于存放捕获的音频帧的队列。
|
| 32 |
-
enable_echo_cancellation (bool): 是否启用回声消除功能。
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
"""
|
|
|
|
| 36 |
self._strategy = None
|
| 37 |
try:
|
| 38 |
-
if
|
| 39 |
self._strategy = AecCapture(audio_frames_queue=audio_frames_queue)
|
| 40 |
else:
|
| 41 |
-
self._strategy = PyAudioCapture(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
logger.info(f"音频捕获策略已选择: {self._strategy.__class__.__name__}")
|
| 43 |
except Exception as e:
|
| 44 |
logger.error(
|
| 45 |
-
f"初始化 {AecCapture.__name__ if
|
| 46 |
# 只有在尝试 AEC 失败时才回退
|
| 47 |
if not isinstance(self._strategy, PyAudioCapture):
|
| 48 |
-
self._strategy = PyAudioCapture(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
logger.info(f"已回退到音频捕获策略: {self._strategy.__class__.__name__}")
|
| 50 |
|
| 51 |
def start(self):
|
|
|
|
| 4 |
根据配置选择并管理具体的音频捕获策略。
|
| 5 |
"""
|
| 6 |
from multiprocessing import Queue
|
| 7 |
+
from typing import Optional
|
| 8 |
|
| 9 |
from voice_dialogue.utils.logger import logger
|
| 10 |
from .aec_capture import AecCapture
|
| 11 |
from .pyaudio_capture import PyAudioCapture
|
| 12 |
|
| 13 |
|
| 14 |
+
def resolves_to_native_aec(
|
| 15 |
+
enable_echo_cancellation: bool,
|
| 16 |
+
input_device_index: Optional[int] = None,
|
| 17 |
+
) -> bool:
|
| 18 |
+
"""
|
| 19 |
+
判断在给定配置下是否会使用 macOS 原生 AEC 采集策略。
|
| 20 |
+
|
| 21 |
+
原生 AEC 库作用于系统默认输入设备,且自带 VAD。因此当满足以下任一情况时使用原生 AEC:
|
| 22 |
+
- 启用回声消除且未指定具体输入设备(隐式使用默认设备);
|
| 23 |
+
- 启用回声消除且所选设备恰好就是系统默认输入设备
|
| 24 |
+
(原生 AEC 本就采集默认设备,等价覆盖)。
|
| 25 |
+
|
| 26 |
+
只有当选择了"非默认"输入设备(如外置麦克风阵列)时,才退化为 PyAudio 策略——
|
| 27 |
+
此时回声消除依赖设备自身硬件,语音活动检测改用软件 VAD。
|
| 28 |
+
|
| 29 |
+
上层据此决定 SpeechStateMonitor 是否需要启用软件 VAD
|
| 30 |
+
(enable_vad = not resolves_to_native_aec(...))。
|
| 31 |
+
"""
|
| 32 |
+
if not enable_echo_cancellation:
|
| 33 |
+
return False
|
| 34 |
+
if input_device_index is None:
|
| 35 |
+
return True
|
| 36 |
+
# 所选设备即系统默认设备时,仍可使用原生 AEC
|
| 37 |
+
try:
|
| 38 |
+
from voice_dialogue.audio.devices import get_default_input_device_index
|
| 39 |
+
return input_device_index == get_default_input_device_index()
|
| 40 |
+
except Exception:
|
| 41 |
+
return False
|
| 42 |
+
|
| 43 |
+
|
| 44 |
class AudioCapture:
|
| 45 |
"""
|
| 46 |
音频捕获器门面 (Facade)。
|
|
|
|
| 54 |
self,
|
| 55 |
audio_frames_queue: Queue,
|
| 56 |
enable_echo_cancellation: bool = True,
|
| 57 |
+
input_device_index: Optional[int] = None,
|
| 58 |
+
channels: Optional[int] = None,
|
| 59 |
):
|
| 60 |
"""
|
| 61 |
初始化音频捕获器。
|
| 62 |
|
| 63 |
Args:
|
| 64 |
audio_frames_queue (Queue): 用于存放捕获的音频帧的队列。
|
| 65 |
+
enable_echo_cancellation (bool): 是否启用回声消除功能。仅在未指定
|
| 66 |
+
input_device_index 时生效(使用 macOS
|
| 67 |
+
原生 AEC 库,作用于系统默认输入设备)。
|
| 68 |
+
input_device_index (Optional[int]): 指定的输入设备索引(如外置麦克风阵列)。
|
| 69 |
+
一旦指定,则使用 PyAudio 策略采集该设备,
|
| 70 |
+
回声消除依赖设备硬件。
|
| 71 |
+
channels (Optional[int]): 采集通道数(仅 PyAudio 策略生效,多通道会降混为单声道)。
|
| 72 |
"""
|
| 73 |
+
use_native_aec = resolves_to_native_aec(enable_echo_cancellation, input_device_index)
|
| 74 |
self._strategy = None
|
| 75 |
try:
|
| 76 |
+
if use_native_aec:
|
| 77 |
self._strategy = AecCapture(audio_frames_queue=audio_frames_queue)
|
| 78 |
else:
|
| 79 |
+
self._strategy = PyAudioCapture(
|
| 80 |
+
audio_frames_queue=audio_frames_queue,
|
| 81 |
+
input_device_index=input_device_index,
|
| 82 |
+
channels=channels,
|
| 83 |
+
)
|
| 84 |
logger.info(f"音频捕获策略已选择: {self._strategy.__class__.__name__}")
|
| 85 |
except Exception as e:
|
| 86 |
logger.error(
|
| 87 |
+
f"初始化 {AecCapture.__name__ if use_native_aec else PyAudioCapture.__name__} 失败: {e}, 将回退到 PyAudio。")
|
| 88 |
# 只有在尝试 AEC 失败时才回退
|
| 89 |
if not isinstance(self._strategy, PyAudioCapture):
|
| 90 |
+
self._strategy = PyAudioCapture(
|
| 91 |
+
audio_frames_queue=audio_frames_queue,
|
| 92 |
+
input_device_index=input_device_index,
|
| 93 |
+
channels=channels,
|
| 94 |
+
)
|
| 95 |
logger.info(f"已回退到音频捕获策略: {self._strategy.__class__.__name__}")
|
| 96 |
|
| 97 |
def start(self):
|
|
@@ -1,41 +1,130 @@
|
|
| 1 |
from multiprocessing import Queue
|
|
|
|
| 2 |
|
|
|
|
| 3 |
import pyaudio
|
| 4 |
|
| 5 |
from voice_dialogue.utils.logger import logger
|
| 6 |
from .base_capture import BaseCapture
|
| 7 |
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
class PyAudioCapture(BaseCapture):
|
| 10 |
"""
|
| 11 |
使用 PyAudio 进行标准的音频采集策略。
|
|
|
|
|
|
|
|
|
|
| 12 |
"""
|
| 13 |
|
| 14 |
-
def __init__(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
super().__init__(audio_frames_queue=audio_frames_queue, **kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
def _init_pyaudio(self):
|
| 18 |
"""初始化 PyAudio 并返回实例和配置。"""
|
| 19 |
p = pyaudio.PyAudio()
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
-
def _open_stream(self, p, chunk, sample_rate):
|
| 25 |
"""打开 PyAudio 音频流。"""
|
| 26 |
return p.open(
|
| 27 |
format=pyaudio.paInt16,
|
| 28 |
-
channels=
|
| 29 |
rate=sample_rate,
|
| 30 |
input=True,
|
|
|
|
| 31 |
frames_per_buffer=chunk,
|
| 32 |
)
|
| 33 |
|
| 34 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
"""PyAudio 音频捕获的主循环。"""
|
| 36 |
logger.info("使用 PyAudio 开始音频采集...")
|
| 37 |
self.is_ready = True
|
| 38 |
|
|
|
|
|
|
|
| 39 |
while not self.is_exited:
|
| 40 |
data = stream.read(chunk, exception_on_overflow=False)
|
| 41 |
if data is None:
|
|
@@ -44,6 +133,11 @@ class PyAudioCapture(BaseCapture):
|
|
| 44 |
if self.is_paused:
|
| 45 |
continue
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
self.audio_frames_queue.put(data)
|
| 48 |
|
| 49 |
def _cleanup(self, stream, p):
|
|
@@ -57,11 +151,11 @@ class PyAudioCapture(BaseCapture):
|
|
| 57 |
"""
|
| 58 |
线程主循环,执行 PyAudio 音频采集。
|
| 59 |
"""
|
| 60 |
-
p, chunk, sample_rate = self._init_pyaudio()
|
| 61 |
stream = None
|
| 62 |
try:
|
| 63 |
-
stream = self._open_stream(p, chunk, sample_rate)
|
| 64 |
-
self._capture_loop(stream, chunk)
|
| 65 |
except Exception as e:
|
| 66 |
logger.error(f'PyAudio 音频捕获器运行时发生错误: {e}')
|
| 67 |
finally:
|
|
|
|
| 1 |
from multiprocessing import Queue
|
| 2 |
+
from typing import Optional
|
| 3 |
|
| 4 |
+
import numpy as np
|
| 5 |
import pyaudio
|
| 6 |
|
| 7 |
from voice_dialogue.utils.logger import logger
|
| 8 |
from .base_capture import BaseCapture
|
| 9 |
|
| 10 |
+
# 下游 ASR / VAD 统一要求 16kHz 单声道 int16 音频
|
| 11 |
+
TARGET_SAMPLE_RATE = 16000
|
| 12 |
+
|
| 13 |
|
| 14 |
class PyAudioCapture(BaseCapture):
|
| 15 |
"""
|
| 16 |
使用 PyAudio 进行标准的音频采集策略。
|
| 17 |
+
|
| 18 |
+
支持选择指定的输入设备(如外置麦克风阵列),并自动将多通道、
|
| 19 |
+
非 16kHz 的输入降混并重采样为下游所需的 16kHz 单声道 int16 数据。
|
| 20 |
"""
|
| 21 |
|
| 22 |
+
def __init__(
|
| 23 |
+
self,
|
| 24 |
+
audio_frames_queue: Queue,
|
| 25 |
+
input_device_index: Optional[int] = None,
|
| 26 |
+
channels: Optional[int] = None,
|
| 27 |
+
**kwargs
|
| 28 |
+
):
|
| 29 |
+
"""
|
| 30 |
+
Args:
|
| 31 |
+
audio_frames_queue (Queue): 用于存放捕获的音频帧的队列。
|
| 32 |
+
input_device_index (Optional[int]): 输入设备索引;None 表示使用系统默认设备。
|
| 33 |
+
channels (Optional[int]): 采集通道数;None 表示自动使用设备支持的最大通道数
|
| 34 |
+
(麦克风阵列通常为多通道,采集后会降混为单声道)。
|
| 35 |
+
"""
|
| 36 |
super().__init__(audio_frames_queue=audio_frames_queue, **kwargs)
|
| 37 |
+
self.input_device_index = input_device_index
|
| 38 |
+
self.requested_channels = channels
|
| 39 |
+
|
| 40 |
+
def _resolve_device_params(self, p: pyaudio.PyAudio):
|
| 41 |
+
"""根据所选设备解析采集通道数与采集采样率。"""
|
| 42 |
+
# 默认参数(系统默认设备、单声道、16kHz)
|
| 43 |
+
device_index = self.input_device_index
|
| 44 |
+
channels = self.requested_channels or 1
|
| 45 |
+
sample_rate = TARGET_SAMPLE_RATE
|
| 46 |
+
|
| 47 |
+
try:
|
| 48 |
+
if device_index is None:
|
| 49 |
+
device_index = int(p.get_default_input_device_info().get("index"))
|
| 50 |
+
info = p.get_device_info_by_index(device_index)
|
| 51 |
+
max_channels = int(info.get("maxInputChannels", 1)) or 1
|
| 52 |
+
# 未显式指定通道数时,采集设备的全部通道再降混(适配麦克风阵列)
|
| 53 |
+
if self.requested_channels is None:
|
| 54 |
+
channels = max_channels
|
| 55 |
+
else:
|
| 56 |
+
channels = min(self.requested_channels, max_channels)
|
| 57 |
+
|
| 58 |
+
# 优先尝试 16kHz;若设备不支持则采用设备默认采样率,后续重采样
|
| 59 |
+
device_rate = int(info.get("defaultSampleRate", TARGET_SAMPLE_RATE))
|
| 60 |
+
if not p.is_format_supported(
|
| 61 |
+
rate=TARGET_SAMPLE_RATE,
|
| 62 |
+
input_device=device_index,
|
| 63 |
+
input_channels=channels,
|
| 64 |
+
input_format=pyaudio.paInt16,
|
| 65 |
+
):
|
| 66 |
+
sample_rate = device_rate
|
| 67 |
+
except Exception as e:
|
| 68 |
+
logger.warning(f"解析输入设备参数失败,回退到默认设备/单声道/16kHz: {e}")
|
| 69 |
+
device_index = self.input_device_index
|
| 70 |
+
channels = 1
|
| 71 |
+
sample_rate = TARGET_SAMPLE_RATE
|
| 72 |
+
|
| 73 |
+
return device_index, channels, sample_rate
|
| 74 |
|
| 75 |
def _init_pyaudio(self):
|
| 76 |
"""初始化 PyAudio 并返回实例和配置。"""
|
| 77 |
p = pyaudio.PyAudio()
|
| 78 |
+
device_index, channels, sample_rate = self._resolve_device_params(p)
|
| 79 |
+
# 采集块大小按采集采样率取约 64ms,保证重采样后帧长足够 VAD 处理
|
| 80 |
+
chunk = max(1024, int(sample_rate * 0.064))
|
| 81 |
+
logger.info(
|
| 82 |
+
f"PyAudio 采集配置: device_index={device_index}, channels={channels}, "
|
| 83 |
+
f"sample_rate={sample_rate} -> {TARGET_SAMPLE_RATE}, chunk={chunk}"
|
| 84 |
+
)
|
| 85 |
+
return p, chunk, sample_rate, channels, device_index
|
| 86 |
|
| 87 |
+
def _open_stream(self, p, chunk, sample_rate, channels, device_index):
|
| 88 |
"""打开 PyAudio 音频流。"""
|
| 89 |
return p.open(
|
| 90 |
format=pyaudio.paInt16,
|
| 91 |
+
channels=channels,
|
| 92 |
rate=sample_rate,
|
| 93 |
input=True,
|
| 94 |
+
input_device_index=device_index,
|
| 95 |
frames_per_buffer=chunk,
|
| 96 |
)
|
| 97 |
|
| 98 |
+
def _to_mono_16k(self, data: bytes, channels: int, sample_rate: int) -> Optional[bytes]:
|
| 99 |
+
"""将原始多通道/任意采样率的 int16 数据降混并重采样为 16kHz 单声道 int16。"""
|
| 100 |
+
samples = np.frombuffer(data, dtype=np.int16)
|
| 101 |
+
if samples.size == 0:
|
| 102 |
+
return None
|
| 103 |
+
|
| 104 |
+
# 多通道降混为单声道(按通道求平均)
|
| 105 |
+
if channels > 1:
|
| 106 |
+
frame_count = samples.size // channels
|
| 107 |
+
if frame_count == 0:
|
| 108 |
+
return None
|
| 109 |
+
samples = samples[:frame_count * channels].reshape(-1, channels)
|
| 110 |
+
mono = samples.astype(np.float32).mean(axis=1)
|
| 111 |
+
else:
|
| 112 |
+
mono = samples.astype(np.float32)
|
| 113 |
+
|
| 114 |
+
# 重采样到 16kHz
|
| 115 |
+
if sample_rate != TARGET_SAMPLE_RATE:
|
| 116 |
+
import soxr
|
| 117 |
+
mono = soxr.resample(mono, sample_rate, TARGET_SAMPLE_RATE)
|
| 118 |
+
|
| 119 |
+
return np.clip(mono, -32768, 32767).astype(np.int16).tobytes()
|
| 120 |
+
|
| 121 |
+
def _capture_loop(self, stream, chunk, channels, sample_rate):
|
| 122 |
"""PyAudio 音频捕获的主循环。"""
|
| 123 |
logger.info("使用 PyAudio 开始音频采集...")
|
| 124 |
self.is_ready = True
|
| 125 |
|
| 126 |
+
needs_processing = channels > 1 or sample_rate != TARGET_SAMPLE_RATE
|
| 127 |
+
|
| 128 |
while not self.is_exited:
|
| 129 |
data = stream.read(chunk, exception_on_overflow=False)
|
| 130 |
if data is None:
|
|
|
|
| 133 |
if self.is_paused:
|
| 134 |
continue
|
| 135 |
|
| 136 |
+
if needs_processing:
|
| 137 |
+
data = self._to_mono_16k(data, channels, sample_rate)
|
| 138 |
+
if data is None:
|
| 139 |
+
continue
|
| 140 |
+
|
| 141 |
self.audio_frames_queue.put(data)
|
| 142 |
|
| 143 |
def _cleanup(self, stream, p):
|
|
|
|
| 151 |
"""
|
| 152 |
线程主循环,执行 PyAudio 音频采集。
|
| 153 |
"""
|
| 154 |
+
p, chunk, sample_rate, channels, device_index = self._init_pyaudio()
|
| 155 |
stream = None
|
| 156 |
try:
|
| 157 |
+
stream = self._open_stream(p, chunk, sample_rate, channels, device_index)
|
| 158 |
+
self._capture_loop(stream, chunk, channels, sample_rate)
|
| 159 |
except Exception as e:
|
| 160 |
logger.error(f'PyAudio 音频捕获器运行时发生错误: {e}')
|
| 161 |
finally:
|
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
音频输入设备枚举工具。
|
| 3 |
+
|
| 4 |
+
提供列出系统可用输入设备(包括外置麦克风/麦克风阵列)的能力,
|
| 5 |
+
供 CLI、API 以及前端进行设备选择。
|
| 6 |
+
"""
|
| 7 |
+
from typing import List, Optional, TypedDict
|
| 8 |
+
|
| 9 |
+
import pyaudio
|
| 10 |
+
|
| 11 |
+
from voice_dialogue.utils.logger import logger
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class InputDeviceInfo(TypedDict):
|
| 15 |
+
"""输入设备信息。"""
|
| 16 |
+
index: int
|
| 17 |
+
name: str
|
| 18 |
+
max_input_channels: int
|
| 19 |
+
default_sample_rate: int
|
| 20 |
+
is_default: bool
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _get_default_input_index(p: pyaudio.PyAudio) -> Optional[int]:
|
| 24 |
+
"""获取系统默认输入设备索引,失败时返回 None。"""
|
| 25 |
+
try:
|
| 26 |
+
return int(p.get_default_input_device_info().get("index"))
|
| 27 |
+
except Exception:
|
| 28 |
+
return None
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def list_input_devices() -> List[InputDeviceInfo]:
|
| 32 |
+
"""
|
| 33 |
+
列出所有可用的音频输入设备。
|
| 34 |
+
|
| 35 |
+
Returns:
|
| 36 |
+
List[InputDeviceInfo]: 输入设备列表(仅包含 maxInputChannels > 0 的设备)。
|
| 37 |
+
"""
|
| 38 |
+
devices: List[InputDeviceInfo] = []
|
| 39 |
+
p = pyaudio.PyAudio()
|
| 40 |
+
try:
|
| 41 |
+
default_index = _get_default_input_index(p)
|
| 42 |
+
for i in range(p.get_device_count()):
|
| 43 |
+
try:
|
| 44 |
+
info = p.get_device_info_by_index(i)
|
| 45 |
+
except Exception as e:
|
| 46 |
+
logger.warning(f"读取音频设备 {i} 信息失败: {e}")
|
| 47 |
+
continue
|
| 48 |
+
|
| 49 |
+
max_input_channels = int(info.get("maxInputChannels", 0))
|
| 50 |
+
if max_input_channels <= 0:
|
| 51 |
+
continue
|
| 52 |
+
|
| 53 |
+
devices.append(
|
| 54 |
+
InputDeviceInfo(
|
| 55 |
+
index=int(info.get("index", i)),
|
| 56 |
+
name=str(info.get("name", f"device-{i}")),
|
| 57 |
+
max_input_channels=max_input_channels,
|
| 58 |
+
default_sample_rate=int(info.get("defaultSampleRate", 16000)),
|
| 59 |
+
is_default=(int(info.get("index", i)) == default_index),
|
| 60 |
+
)
|
| 61 |
+
)
|
| 62 |
+
finally:
|
| 63 |
+
p.terminate()
|
| 64 |
+
|
| 65 |
+
return devices
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def get_default_input_device_index() -> Optional[int]:
|
| 69 |
+
"""获取系统默认输入设备索引。"""
|
| 70 |
+
p = pyaudio.PyAudio()
|
| 71 |
+
try:
|
| 72 |
+
return _get_default_input_index(p)
|
| 73 |
+
finally:
|
| 74 |
+
p.terminate()
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def is_valid_input_device(index: Optional[int]) -> bool:
|
| 78 |
+
"""
|
| 79 |
+
校验给定索引是否为有效的输入设备。
|
| 80 |
+
|
| 81 |
+
Args:
|
| 82 |
+
index: 设备索引;None 表示使用系统默认设备,视为有效。
|
| 83 |
+
|
| 84 |
+
Returns:
|
| 85 |
+
bool: 是否有效。
|
| 86 |
+
"""
|
| 87 |
+
if index is None:
|
| 88 |
+
return True
|
| 89 |
+
return any(d["index"] == index for d in list_input_devices())
|
|
@@ -74,6 +74,20 @@ def create_argument_parser():
|
|
| 74 |
default=False,
|
| 75 |
help='禁用回声消除功能 (默认: 不禁用)'
|
| 76 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
# API服务器模式参数
|
| 79 |
api_group = parser.add_argument_group('API服务器模式参数')
|
|
|
|
| 74 |
default=False,
|
| 75 |
help='禁用回声消除功能 (默认: 不禁用)'
|
| 76 |
)
|
| 77 |
+
cli_group.add_argument(
|
| 78 |
+
'--input-device', '-i',
|
| 79 |
+
type=int,
|
| 80 |
+
default=None,
|
| 81 |
+
metavar='INDEX',
|
| 82 |
+
help='指定输入设备索引(如外置麦克风阵列)。多通道会自动降混为单声道;'
|
| 83 |
+
'指定后回声消除依赖设备硬件。用 --list-audio-devices 查看可用索引。'
|
| 84 |
+
)
|
| 85 |
+
cli_group.add_argument(
|
| 86 |
+
'--list-audio-devices',
|
| 87 |
+
action='store_true',
|
| 88 |
+
default=False,
|
| 89 |
+
help='列出可用的音频输入设备及其索引后退出'
|
| 90 |
+
)
|
| 91 |
|
| 92 |
# API服务器模式参数
|
| 93 |
api_group = parser.add_argument_group('API服务器模式参数')
|
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""音频设备配置管理模块。
|
| 2 |
+
|
| 3 |
+
持久化用户选择的输入设备(如外置麦克风阵列),在重启后自动复用。
|
| 4 |
+
"""
|
| 5 |
+
import json
|
| 6 |
+
from typing import Optional, TypedDict
|
| 7 |
+
|
| 8 |
+
from voice_dialogue.utils.logger import logger
|
| 9 |
+
from .paths import AUDIO_SETTINGS_PATH
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class AudioSettings(TypedDict, total=False):
|
| 13 |
+
"""音频设置。"""
|
| 14 |
+
input_device_index: Optional[int]
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
_audio_settings_cache: Optional[AudioSettings] = None
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def get_audio_settings() -> AudioSettings:
|
| 21 |
+
"""加载用户音频设置(带内存缓存)。"""
|
| 22 |
+
global _audio_settings_cache
|
| 23 |
+
if _audio_settings_cache is not None:
|
| 24 |
+
return _audio_settings_cache
|
| 25 |
+
|
| 26 |
+
if not AUDIO_SETTINGS_PATH.exists():
|
| 27 |
+
_audio_settings_cache = {}
|
| 28 |
+
return _audio_settings_cache
|
| 29 |
+
|
| 30 |
+
try:
|
| 31 |
+
with open(AUDIO_SETTINGS_PATH, "r", encoding="utf-8") as f:
|
| 32 |
+
_audio_settings_cache = json.load(f)
|
| 33 |
+
except (json.JSONDecodeError, IOError) as e:
|
| 34 |
+
logger.error(f"无法加载音频设置,使用空配置: {e}")
|
| 35 |
+
_audio_settings_cache = {}
|
| 36 |
+
return _audio_settings_cache
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def get_input_device_index() -> Optional[int]:
|
| 40 |
+
"""获取已保存的输入设备索引;未配置时返回 None(系统默认设备)。"""
|
| 41 |
+
value = get_audio_settings().get("input_device_index")
|
| 42 |
+
return int(value) if value is not None else None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def save_input_device_index(input_device_index: Optional[int]) -> bool:
|
| 46 |
+
"""保存用户选择的输入设备索引。"""
|
| 47 |
+
global _audio_settings_cache
|
| 48 |
+
settings = dict(get_audio_settings())
|
| 49 |
+
settings["input_device_index"] = input_device_index
|
| 50 |
+
try:
|
| 51 |
+
if not AUDIO_SETTINGS_PATH.parent.exists():
|
| 52 |
+
AUDIO_SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 53 |
+
with open(AUDIO_SETTINGS_PATH, "w", encoding="utf-8") as f:
|
| 54 |
+
json.dump(settings, f, ensure_ascii=False, indent=4)
|
| 55 |
+
_audio_settings_cache = settings # type: ignore[assignment]
|
| 56 |
+
logger.info(f"音频设置已保存: input_device_index={input_device_index}")
|
| 57 |
+
return True
|
| 58 |
+
except IOError as e:
|
| 59 |
+
logger.error(f"无法保存音频设置: {e}")
|
| 60 |
+
return False
|
|
@@ -46,6 +46,7 @@ APP_DATA_PATH = get_app_data_path()
|
|
| 46 |
if not APP_DATA_PATH.exists():
|
| 47 |
APP_DATA_PATH.mkdir(parents=True, exist_ok=True)
|
| 48 |
USER_PROMPTS_PATH = APP_DATA_PATH / "user_prompts.json"
|
|
|
|
| 49 |
|
| 50 |
|
| 51 |
def load_third_party():
|
|
|
|
| 46 |
if not APP_DATA_PATH.exists():
|
| 47 |
APP_DATA_PATH.mkdir(parents=True, exist_ok=True)
|
| 48 |
USER_PROMPTS_PATH = APP_DATA_PATH / "user_prompts.json"
|
| 49 |
+
AUDIO_SETTINGS_PATH = APP_DATA_PATH / "audio_settings.json"
|
| 50 |
|
| 51 |
|
| 52 |
def load_third_party():
|
|
@@ -6,7 +6,7 @@
|
|
| 6 |
|
| 7 |
import time
|
| 8 |
|
| 9 |
-
from voice_dialogue.audio.capture import AudioCapture
|
| 10 |
from voice_dialogue.config.speaker_config import get_tts_config_by_speaker_name, get_available_speaker_names
|
| 11 |
from voice_dialogue.core.constants import (
|
| 12 |
audio_frames_queue,
|
|
@@ -23,6 +23,7 @@ def launch_system(
|
|
| 23 |
user_language: str,
|
| 24 |
speaker: str,
|
| 25 |
disable_echo_cancellation: bool = False,
|
|
|
|
| 26 |
) -> None:
|
| 27 |
"""
|
| 28 |
启动完整的语音对话系统
|
|
@@ -100,7 +101,10 @@ def launch_system(
|
|
| 100 |
threads.append(audio_player)
|
| 101 |
|
| 102 |
# 语音状态监测
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
| 104 |
speech_monitor = SpeechStateMonitor(
|
| 105 |
audio_frame_queue=audio_frames_queue,
|
| 106 |
user_voice_queue=user_voice_queue,
|
|
@@ -111,10 +115,10 @@ def launch_system(
|
|
| 111 |
threads.append(speech_monitor)
|
| 112 |
|
| 113 |
# 音频采集
|
| 114 |
-
enable_echo_cancellation = not disable_echo_cancellation
|
| 115 |
audio_capture = AudioCapture(
|
| 116 |
audio_frames_queue=audio_frames_queue,
|
| 117 |
-
enable_echo_cancellation=enable_echo_cancellation
|
|
|
|
| 118 |
)
|
| 119 |
audio_capture.daemon = True
|
| 120 |
audio_capture.start()
|
|
|
|
| 6 |
|
| 7 |
import time
|
| 8 |
|
| 9 |
+
from voice_dialogue.audio.capture import AudioCapture, resolves_to_native_aec
|
| 10 |
from voice_dialogue.config.speaker_config import get_tts_config_by_speaker_name, get_available_speaker_names
|
| 11 |
from voice_dialogue.core.constants import (
|
| 12 |
audio_frames_queue,
|
|
|
|
| 23 |
user_language: str,
|
| 24 |
speaker: str,
|
| 25 |
disable_echo_cancellation: bool = False,
|
| 26 |
+
input_device_index: int = None,
|
| 27 |
) -> None:
|
| 28 |
"""
|
| 29 |
启动完整的语音对话系统
|
|
|
|
| 101 |
threads.append(audio_player)
|
| 102 |
|
| 103 |
# 语音状态监测
|
| 104 |
+
# 仅当走 macOS 原生 AEC(自带 VAD)时关闭软件 VAD;
|
| 105 |
+
# 指定外置设备走 PyAudio 时,必须启用软件 VAD。
|
| 106 |
+
enable_echo_cancellation = not disable_echo_cancellation
|
| 107 |
+
enable_vad = not resolves_to_native_aec(enable_echo_cancellation, input_device_index)
|
| 108 |
speech_monitor = SpeechStateMonitor(
|
| 109 |
audio_frame_queue=audio_frames_queue,
|
| 110 |
user_voice_queue=user_voice_queue,
|
|
|
|
| 115 |
threads.append(speech_monitor)
|
| 116 |
|
| 117 |
# 音频采集
|
|
|
|
| 118 |
audio_capture = AudioCapture(
|
| 119 |
audio_frames_queue=audio_frames_queue,
|
| 120 |
+
enable_echo_cancellation=enable_echo_cancellation,
|
| 121 |
+
input_device_index=input_device_index,
|
| 122 |
)
|
| 123 |
audio_capture.daemon = True
|
| 124 |
audio_capture.start()
|