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
- Hermes Agent new
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 new
- 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
Add audio output device (speaker) selection
Browse files- devices.py: enumerate output devices, default lookup, validation
- player.py: PyAudio playback path for an explicit output device with
sample-rate fallback (soxr resample); default device keeps playsound
- AudioPlayerService reads the saved output index per sentence, so a
settings change applies to the next utterance without restart
- API: /system/audio-devices now includes output devices;
POST /system/audio-output-device persists immediately;
/system/start accepts output_device_index
- UI: speaker select in settings (main tab), persisted in store,
sent on start
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- assets/www/assets/{index-B0qFtB1t.css → index-CBWIB-ok.css} +1 -1
- assets/www/assets/{index-vffghLR3.js → index-I4LwLHV-.js} +2 -2
- assets/www/index.html +2 -2
- frontend/src/i18n/locales/en.ts +2 -0
- frontend/src/i18n/locales/zh.ts +2 -0
- frontend/src/stores/config.ts +1 -0
- frontend/src/views/Welcome/Components/SettingsModal.vue +26 -0
- frontend/src/views/Welcome/index.vue +2 -1
- src/voice_dialogue/api/routes/system_routes.py +40 -3
- src/voice_dialogue/api/schemas/system_schemas.py +19 -1
- src/voice_dialogue/audio/devices.py +80 -2
- src/voice_dialogue/audio/player.py +69 -1
- src/voice_dialogue/config/audio_config.py +21 -4
- src/voice_dialogue/services/audio_player_service.py +3 -1
|
@@ -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}.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-44db86f4]{height:360px;overflow-y:auto;padding:4px 8px 4px 2px}.setting-row[data-v-44db86f4]{margin-bottom:20px}.setting-row>label[data-v-44db86f4]{display:block;font-size:15px;font-weight:500;margin-bottom:8px}.setting-row>label .label-icon[data-v-44db86f4]{margin-right:6px;color:#1890ff}.setting-row .hint[data-v-44db86f4]{font-size:12px;color:#999;margin:8px 0 0}.setting-row .row-inline[data-v-44db86f4]{display:flex;align-items:center;justify-content:space-between}.voice-group[data-v-44db86f4]{display:flex;flex-direction:column;margin-top:8px}.about .about-head[data-v-44db86f4]{text-align:center;margin-bottom:24px}.about .about-head .about-name[data-v-44db86f4]{font-size:20px;font-weight:600}.about .about-head .about-ver[data-v-44db86f4]{font-size:13px;color:#888;margin-top:2px}.about .about-head .about-tagline[data-v-44db86f4]{font-size:12px;color:#999;margin-top:4px}.about .about-section[data-v-44db86f4]{margin-bottom:20px}.about .about-section .about-section-title[data-v-44db86f4]{font-size:13px;font-weight:600;color:#666;margin-bottom:10px}.about .about-item[data-v-44db86f4]{margin-bottom:12px}.about .about-item .about-item-label[data-v-44db86f4]{font-size:14px;font-weight:500}.about .about-item .about-item-desc[data-v-44db86f4]{font-size:12px;color:#777;margin-top:2px;line-height:1.6}.about .about-item .about-item-desc a[data-v-44db86f4]{margin-left:6px}.about a[data-v-44db86f4]{color:#1677ff;text-decoration:none}.about a[data-v-44db86f4]:hover{text-decoration:underline}.about .about-link[data-v-44db86f4]{font-size:13px;word-break:break-all}.about .about-copyright[data-v-44db86f4]{margin-top:16px;font-size:11px;color:#aaa;text-align:center}.voice-radio[data-v-44db86f4]{display:flex;align-items:center;height:40px;line-height:40px}.voice-radio .voice-name[data-v-44db86f4]{margin-right:8px}.audio-play-btn[data-v-44db86f4]{padding:0 6px;border-radius:4px}.audio-play-btn.playing[data-v-44db86f4]{background-color:#f6ffed}.asr-chip[data-v-2fa7cbbe]{display:flex;align-items:center;gap:8px;height:38px;padding:0 18px;margin-right:16px;border-radius:19px;color:#000000a6;font-size:13px;background:#ffffff80;border:1px solid rgba(255,255,255,.7);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);box-shadow:0 4px 16px #1f26871f}.settings-btn[data-v-2fa7cbbe]{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-2fa7cbbe]{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-2fa7cbbe]{width:100%;height:80vh;display:flex;flex-direction:column;justify-content:space-around;margin-top:64px}.welcome-wrapper .content .inner-content[data-v-2fa7cbbe]{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-2fa7cbbe]{color:#000;margin-bottom:36px}.welcome-wrapper .content .inner-content .text-box .title[data-v-2fa7cbbe]{font-size:24px;font-weight:600;margin-bottom:24px}.welcome-wrapper .content .inner-content .text-box .sub-title[data-v-2fa7cbbe]{font-size:15px;margin-top:10px}.welcome-wrapper .content .inner-content .btn-box[data-v-2fa7cbbe]{width:224px;height:80px}.welcome-wrapper .actions[data-v-2fa7cbbe]{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 |
+
@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-9cd2a8bd]{height:360px;overflow-y:auto;padding:4px 8px 4px 2px}.setting-row[data-v-9cd2a8bd]{margin-bottom:20px}.setting-row>label[data-v-9cd2a8bd]{display:block;font-size:15px;font-weight:500;margin-bottom:8px}.setting-row>label .label-icon[data-v-9cd2a8bd]{margin-right:6px;color:#1890ff}.setting-row .hint[data-v-9cd2a8bd]{font-size:12px;color:#999;margin:8px 0 0}.setting-row .row-inline[data-v-9cd2a8bd]{display:flex;align-items:center;justify-content:space-between}.voice-group[data-v-9cd2a8bd]{display:flex;flex-direction:column;margin-top:8px}.about .about-head[data-v-9cd2a8bd]{text-align:center;margin-bottom:24px}.about .about-head .about-name[data-v-9cd2a8bd]{font-size:20px;font-weight:600}.about .about-head .about-ver[data-v-9cd2a8bd]{font-size:13px;color:#888;margin-top:2px}.about .about-head .about-tagline[data-v-9cd2a8bd]{font-size:12px;color:#999;margin-top:4px}.about .about-section[data-v-9cd2a8bd]{margin-bottom:20px}.about .about-section .about-section-title[data-v-9cd2a8bd]{font-size:13px;font-weight:600;color:#666;margin-bottom:10px}.about .about-item[data-v-9cd2a8bd]{margin-bottom:12px}.about .about-item .about-item-label[data-v-9cd2a8bd]{font-size:14px;font-weight:500}.about .about-item .about-item-desc[data-v-9cd2a8bd]{font-size:12px;color:#777;margin-top:2px;line-height:1.6}.about .about-item .about-item-desc a[data-v-9cd2a8bd]{margin-left:6px}.about a[data-v-9cd2a8bd]{color:#1677ff;text-decoration:none}.about a[data-v-9cd2a8bd]:hover{text-decoration:underline}.about .about-link[data-v-9cd2a8bd]{font-size:13px;word-break:break-all}.about .about-copyright[data-v-9cd2a8bd]{margin-top:16px;font-size:11px;color:#aaa;text-align:center}.voice-radio[data-v-9cd2a8bd]{display:flex;align-items:center;height:40px;line-height:40px}.voice-radio .voice-name[data-v-9cd2a8bd]{margin-right:8px}.audio-play-btn[data-v-9cd2a8bd]{padding:0 6px;border-radius:4px}.audio-play-btn.playing[data-v-9cd2a8bd]{background-color:#f6ffed}.asr-chip[data-v-ca2e1f17]{display:flex;align-items:center;gap:8px;height:38px;padding:0 18px;margin-right:16px;border-radius:19px;color:#000000a6;font-size:13px;background:#ffffff80;border:1px solid rgba(255,255,255,.7);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);box-shadow:0 4px 16px #1f26871f}.settings-btn[data-v-ca2e1f17]{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-ca2e1f17]{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-ca2e1f17]{width:100%;height:80vh;display:flex;flex-direction:column;justify-content:space-around;margin-top:64px}.welcome-wrapper .content .inner-content[data-v-ca2e1f17]{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-ca2e1f17]{color:#000;margin-bottom:36px}.welcome-wrapper .content .inner-content .text-box .title[data-v-ca2e1f17]{font-size:24px;font-weight:600;margin-bottom:24px}.welcome-wrapper .content .inner-content .text-box .sub-title[data-v-ca2e1f17]{font-size:15px;margin-top:10px}.welcome-wrapper .content .inner-content .btn-box[data-v-ca2e1f17]{width:224px;height:80px}.welcome-wrapper .actions[data-v-ca2e1f17]{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:7f59527d7ccdc62e1062e71f82e79c06f9622c9deed46b36299e449e5a942700
|
| 3 |
+
size 2307855
|
|
@@ -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-I4LwLHV-.js"></script>
|
| 9 |
+
<link rel="stylesheet" crossorigin href="./assets/index-CBWIB-ok.css">
|
| 10 |
</head>
|
| 11 |
<body>
|
| 12 |
<div id="app"></div>
|
|
@@ -51,6 +51,8 @@ export default {
|
|
| 51 |
systemDefault: 'System Default',
|
| 52 |
channelsSuffix: 'ch',
|
| 53 |
defaultSuffix: 'default',
|
|
|
|
|
|
|
| 54 |
echoCancellation: 'Echo Cancellation',
|
| 55 |
echoCancellationHint: 'Uses the system AEC on the default device. For an external array, echo is handled by the array hardware.',
|
| 56 |
},
|
|
|
|
| 51 |
systemDefault: 'System Default',
|
| 52 |
channelsSuffix: 'ch',
|
| 53 |
defaultSuffix: 'default',
|
| 54 |
+
speaker: 'Speaker (Output Device)',
|
| 55 |
+
speakerHint: 'Choose the output device for voice playback, e.g. an external speaker.',
|
| 56 |
echoCancellation: 'Echo Cancellation',
|
| 57 |
echoCancellationHint: 'Uses the system AEC on the default device. For an external array, echo is handled by the array hardware.',
|
| 58 |
},
|
|
@@ -51,6 +51,8 @@ export default {
|
|
| 51 |
systemDefault: '系统默认',
|
| 52 |
channelsSuffix: '声道',
|
| 53 |
defaultSuffix: '默认',
|
|
|
|
|
|
|
| 54 |
echoCancellation: '回音消除',
|
| 55 |
echoCancellationHint: '默认设备使用系统 AEC;选择外置阵列时,回音由阵列硬件处理。',
|
| 56 |
},
|
|
|
|
| 51 |
systemDefault: '系统默认',
|
| 52 |
channelsSuffix: '声道',
|
| 53 |
defaultSuffix: '默认',
|
| 54 |
+
speaker: '扬声器(输出设备)',
|
| 55 |
+
speakerHint: '选择语音播放的输出设备,例如外置扬声器。',
|
| 56 |
echoCancellation: '回音消除',
|
| 57 |
echoCancellationHint: '默认设备使用系统 AEC;选择外置阵列时,回音由阵列硬件处理。',
|
| 58 |
},
|
|
@@ -12,6 +12,7 @@ export const useSettingsStore = defineStore({
|
|
| 12 |
sider_open: true,
|
| 13 |
echoCancel: true,
|
| 14 |
inputDeviceIndex: null as number | null,
|
|
|
|
| 15 |
}
|
| 16 |
},
|
| 17 |
actions: {
|
|
|
|
| 12 |
sider_open: true,
|
| 13 |
echoCancel: true,
|
| 14 |
inputDeviceIndex: null as number | null,
|
| 15 |
+
outputDeviceIndex: null as number | null,
|
| 16 |
}
|
| 17 |
},
|
| 18 |
actions: {
|
|
@@ -25,10 +25,12 @@ const uiLanguage = ref<UiLocale>((settingsStore.$state.uiLanguage as UiLocale) ?
|
|
| 25 |
const recognitionLanguage = ref<string>(settingsStore.$state.language || "zh");
|
| 26 |
const echoCancel = ref<boolean>(settingsStore.$state.echoCancel ?? true);
|
| 27 |
const inputDeviceIndex = ref<number | null>(settingsStore.$state.inputDeviceIndex ?? null);
|
|
|
|
| 28 |
const role = ref<string>(settingsStore.$state.role || "");
|
| 29 |
|
| 30 |
const languages = reactive<string[]>([]);
|
| 31 |
const inputDevices = reactive<any[]>([]);
|
|
|
|
| 32 |
const roles = reactive<any[]>([]);
|
| 33 |
|
| 34 |
// ---- Prompt ----
|
|
@@ -100,6 +102,12 @@ const fetchInputDevices = async () => {
|
|
| 100 |
const exists = saved != null && data.devices.some((d: any) => d.index === saved);
|
| 101 |
inputDeviceIndex.value = exists ? saved : (data.current_device_index ?? null);
|
| 102 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
} catch (e) {
|
| 104 |
console.error("Error fetching input devices:", e);
|
| 105 |
}
|
|
@@ -162,6 +170,14 @@ const applySettings = async () => {
|
|
| 162 |
settingsStore.$state.role = role.value || "";
|
| 163 |
settingsStore.$state.echoCancel = echoCancel.value;
|
| 164 |
settingsStore.$state.inputDeviceIndex = inputDeviceIndex.value;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
// 2. 下发 TTS 音色 + ASR 语言
|
| 167 |
if (role.value) {
|
|
@@ -205,6 +221,7 @@ const handleCancel = () => {
|
|
| 205 |
recognitionLanguage.value = settingsStore.$state.language || "zh";
|
| 206 |
echoCancel.value = settingsStore.$state.echoCancel ?? true;
|
| 207 |
inputDeviceIndex.value = settingsStore.$state.inputDeviceIndex ?? null;
|
|
|
|
| 208 |
role.value = settingsStore.$state.role || "";
|
| 209 |
emit("update:open", false);
|
| 210 |
};
|
|
@@ -294,6 +311,15 @@ onUnmounted(() => {
|
|
| 294 |
</a-select-option>
|
| 295 |
</a-select>
|
| 296 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
<div class="setting-row">
|
| 298 |
<div class="row-inline">
|
| 299 |
<label>{{ t('settings.audio.echoCancellation') }}</label>
|
|
|
|
| 25 |
const recognitionLanguage = ref<string>(settingsStore.$state.language || "zh");
|
| 26 |
const echoCancel = ref<boolean>(settingsStore.$state.echoCancel ?? true);
|
| 27 |
const inputDeviceIndex = ref<number | null>(settingsStore.$state.inputDeviceIndex ?? null);
|
| 28 |
+
const outputDeviceIndex = ref<number | null>(settingsStore.$state.outputDeviceIndex ?? null);
|
| 29 |
const role = ref<string>(settingsStore.$state.role || "");
|
| 30 |
|
| 31 |
const languages = reactive<string[]>([]);
|
| 32 |
const inputDevices = reactive<any[]>([]);
|
| 33 |
+
const outputDevices = reactive<any[]>([]);
|
| 34 |
const roles = reactive<any[]>([]);
|
| 35 |
|
| 36 |
// ---- Prompt ----
|
|
|
|
| 102 |
const exists = saved != null && data.devices.some((d: any) => d.index === saved);
|
| 103 |
inputDeviceIndex.value = exists ? saved : (data.current_device_index ?? null);
|
| 104 |
}
|
| 105 |
+
if (data?.output_devices) {
|
| 106 |
+
outputDevices.splice(0, outputDevices.length, ...data.output_devices);
|
| 107 |
+
const saved = settingsStore.$state.outputDeviceIndex;
|
| 108 |
+
const exists = saved != null && data.output_devices.some((d: any) => d.index === saved);
|
| 109 |
+
outputDeviceIndex.value = exists ? saved : (data.current_output_device_index ?? null);
|
| 110 |
+
}
|
| 111 |
} catch (e) {
|
| 112 |
console.error("Error fetching input devices:", e);
|
| 113 |
}
|
|
|
|
| 170 |
settingsStore.$state.role = role.value || "";
|
| 171 |
settingsStore.$state.echoCancel = echoCancel.value;
|
| 172 |
settingsStore.$state.inputDeviceIndex = inputDeviceIndex.value;
|
| 173 |
+
settingsStore.$state.outputDeviceIndex = outputDeviceIndex.value;
|
| 174 |
+
|
| 175 |
+
// 输出设备保存即生效(会话中修改下一句生效)
|
| 176 |
+
await fetch(`${base_url}/system/audio-output-device`, {
|
| 177 |
+
method: "POST",
|
| 178 |
+
headers: { "Content-Type": "application/json" },
|
| 179 |
+
body: JSON.stringify({ output_device_index: outputDeviceIndex.value }),
|
| 180 |
+
});
|
| 181 |
|
| 182 |
// 2. 下发 TTS 音色 + ASR 语言
|
| 183 |
if (role.value) {
|
|
|
|
| 221 |
recognitionLanguage.value = settingsStore.$state.language || "zh";
|
| 222 |
echoCancel.value = settingsStore.$state.echoCancel ?? true;
|
| 223 |
inputDeviceIndex.value = settingsStore.$state.inputDeviceIndex ?? null;
|
| 224 |
+
outputDeviceIndex.value = settingsStore.$state.outputDeviceIndex ?? null;
|
| 225 |
role.value = settingsStore.$state.role || "";
|
| 226 |
emit("update:open", false);
|
| 227 |
};
|
|
|
|
| 311 |
</a-select-option>
|
| 312 |
</a-select>
|
| 313 |
</div>
|
| 314 |
+
<div class="setting-row">
|
| 315 |
+
<label>{{ t('settings.audio.speaker') }}</label>
|
| 316 |
+
<a-select v-model:value="outputDeviceIndex" style="width: 100%;">
|
| 317 |
+
<a-select-option :value="null">{{ t('settings.audio.systemDefault') }}</a-select-option>
|
| 318 |
+
<a-select-option v-for="dev in outputDevices" :value="dev.index" :key="dev.index">
|
| 319 |
+
{{ dev.name }}<template v-if="dev.is_default"> · {{ t('settings.audio.defaultSuffix') }}</template>
|
| 320 |
+
</a-select-option>
|
| 321 |
+
</a-select>
|
| 322 |
+
</div>
|
| 323 |
<div class="setting-row">
|
| 324 |
<div class="row-inline">
|
| 325 |
<label>{{ t('settings.audio.echoCancellation') }}</label>
|
|
@@ -37,7 +37,8 @@ const startAudioChat = async () => {
|
|
| 37 |
headers: { 'Content-Type': 'application/json' },
|
| 38 |
body: JSON.stringify({
|
| 39 |
enable_echo_cancellation: settingsStore.$state.echoCancel ?? true,
|
| 40 |
-
input_device_index: settingsStore.$state.inputDeviceIndex ?? null
|
|
|
|
| 41 |
})
|
| 42 |
});
|
| 43 |
if (!response.ok) {
|
|
|
|
| 37 |
headers: { 'Content-Type': 'application/json' },
|
| 38 |
body: JSON.stringify({
|
| 39 |
enable_echo_cancellation: settingsStore.$state.echoCancel ?? true,
|
| 40 |
+
input_device_index: settingsStore.$state.inputDeviceIndex ?? null,
|
| 41 |
+
output_device_index: settingsStore.$state.outputDeviceIndex ?? null
|
| 42 |
})
|
| 43 |
});
|
| 44 |
if (!response.ok) {
|
|
@@ -4,14 +4,21 @@ import time
|
|
| 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
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, ASREngineResponse
|
|
|
|
| 15 |
)
|
| 16 |
|
| 17 |
router = APIRouter()
|
|
@@ -79,16 +86,34 @@ async def get_audio_devices():
|
|
| 79 |
"""
|
| 80 |
try:
|
| 81 |
devices = [AudioInputDevice(**d) for d in list_input_devices()]
|
|
|
|
| 82 |
return AudioInputDevicesResponse(
|
| 83 |
devices=devices,
|
| 84 |
current_device_index=get_input_device_index(),
|
| 85 |
default_device_index=get_default_input_device_index(),
|
|
|
|
|
|
|
|
|
|
| 86 |
)
|
| 87 |
except Exception as e:
|
| 88 |
logger.error(f"获取音频输入设备失败: {e}", exc_info=True)
|
| 89 |
raise HTTPException(status_code=500, detail=f"获取音频输入设备失败: {str(e)}")
|
| 90 |
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
@router.get("/asr-engine", response_model=ASREngineResponse, summary="获取当前 ASR 引擎")
|
| 93 |
async def get_asr_engine():
|
| 94 |
"""
|
|
@@ -134,6 +159,18 @@ async def start_system(
|
|
| 134 |
# 持久化用户选择,供下次启动复用
|
| 135 |
save_input_device_index(input_device_index)
|
| 136 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
# 更新状态
|
| 138 |
_system_status["status"] = "starting"
|
| 139 |
session_manager.reset_id()
|
|
|
|
| 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 (
|
| 8 |
+
list_input_devices, get_default_input_device_index, is_valid_input_device,
|
| 9 |
+
list_output_devices, get_default_output_device_index, is_valid_output_device,
|
| 10 |
+
)
|
| 11 |
+
from voice_dialogue.config.audio_config import (
|
| 12 |
+
get_input_device_index, save_input_device_index,
|
| 13 |
+
get_output_device_index, save_output_device_index,
|
| 14 |
+
)
|
| 15 |
from voice_dialogue.core.constants import session_manager
|
| 16 |
from voice_dialogue.utils.logger import logger
|
| 17 |
from ..core.service_factories import get_audio_capture_service_definition, get_speech_monitor_service_definition
|
| 18 |
from ..schemas.system_schemas import (
|
| 19 |
SystemStatusResponse, SystemResponse, SystemStartRequest,
|
| 20 |
+
AudioInputDevicesResponse, AudioInputDevice, AudioOutputDevice, ASREngineResponse,
|
| 21 |
+
OutputDeviceRequest
|
| 22 |
)
|
| 23 |
|
| 24 |
router = APIRouter()
|
|
|
|
| 86 |
"""
|
| 87 |
try:
|
| 88 |
devices = [AudioInputDevice(**d) for d in list_input_devices()]
|
| 89 |
+
output_devices = [AudioOutputDevice(**d) for d in list_output_devices()]
|
| 90 |
return AudioInputDevicesResponse(
|
| 91 |
devices=devices,
|
| 92 |
current_device_index=get_input_device_index(),
|
| 93 |
default_device_index=get_default_input_device_index(),
|
| 94 |
+
output_devices=output_devices,
|
| 95 |
+
current_output_device_index=get_output_device_index(),
|
| 96 |
+
default_output_device_index=get_default_output_device_index(),
|
| 97 |
)
|
| 98 |
except Exception as e:
|
| 99 |
logger.error(f"获取音频输入设备失败: {e}", exc_info=True)
|
| 100 |
raise HTTPException(status_code=500, detail=f"获取音频输入设备失败: {str(e)}")
|
| 101 |
|
| 102 |
|
| 103 |
+
@router.post("/audio-output-device", response_model=SystemResponse, summary="设置音频输出设备")
|
| 104 |
+
async def set_audio_output_device(request: OutputDeviceRequest):
|
| 105 |
+
"""
|
| 106 |
+
保存输出设备选择。播放服务在每次播放时读取该设置,
|
| 107 |
+
会话进行中修改也会在下一句生效,无需重启。
|
| 108 |
+
"""
|
| 109 |
+
output_device_index = request.output_device_index
|
| 110 |
+
if not is_valid_output_device(output_device_index):
|
| 111 |
+
raise HTTPException(status_code=400, detail=f"无效的输出设备索引: {output_device_index}")
|
| 112 |
+
if not save_output_device_index(output_device_index):
|
| 113 |
+
raise HTTPException(status_code=500, detail="保存输出设备设置失败")
|
| 114 |
+
return SystemResponse(success=True, message="输出设备已更新")
|
| 115 |
+
|
| 116 |
+
|
| 117 |
@router.get("/asr-engine", response_model=ASREngineResponse, summary="获取当前 ASR 引擎")
|
| 118 |
async def get_asr_engine():
|
| 119 |
"""
|
|
|
|
| 159 |
# 持久化用户选择,供下次启动复用
|
| 160 |
save_input_device_index(input_device_index)
|
| 161 |
|
| 162 |
+
# 解析输出设备:请求未指定时回退到已保存的设备
|
| 163 |
+
output_device_index = request.output_device_index
|
| 164 |
+
if output_device_index is None:
|
| 165 |
+
output_device_index = get_output_device_index()
|
| 166 |
+
|
| 167 |
+
if not is_valid_output_device(output_device_index):
|
| 168 |
+
logger.warning(f"请求的输出设备 {output_device_index} 无效,回退到系统默认设备")
|
| 169 |
+
output_device_index = None
|
| 170 |
+
|
| 171 |
+
# 播放服务在每次播放时读取该设置,保存即生效
|
| 172 |
+
save_output_device_index(output_device_index)
|
| 173 |
+
|
| 174 |
# 更新状态
|
| 175 |
_system_status["status"] = "starting"
|
| 176 |
session_manager.reset_id()
|
|
@@ -19,6 +19,7 @@ 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):
|
|
@@ -30,11 +31,23 @@ class AudioInputDevice(BaseModel):
|
|
| 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):
|
|
@@ -43,6 +56,11 @@ class SystemResponse(BaseModel):
|
|
| 43 |
message: str = Field(..., description="响应消息")
|
| 44 |
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
class ASREngineResponse(BaseModel):
|
| 47 |
"""当前 ASR 引擎信息"""
|
| 48 |
mappings: Dict[str, str] = Field(default_factory=dict, description="语言到 ASR 引擎的映射,如 {'zh': 'qwen'}")
|
|
|
|
| 19 |
"""系统启动请求"""
|
| 20 |
enable_echo_cancellation: bool = Field(default=True, description="是否启用回声消除(仅在未指定输入设备时使用 macOS 原生 AEC)")
|
| 21 |
input_device_index: Optional[int] = Field(default=None, description="输入设备索引(如外置麦克风阵列);为空则使用系统默认设备")
|
| 22 |
+
output_device_index: Optional[int] = Field(default=None, description="输出设备索引(如外置扬声器);为空则使用系统默认设备")
|
| 23 |
|
| 24 |
|
| 25 |
class AudioInputDevice(BaseModel):
|
|
|
|
| 31 |
is_default: bool = Field(default=False, description="是否为系统默认输入设备")
|
| 32 |
|
| 33 |
|
| 34 |
+
class AudioOutputDevice(BaseModel):
|
| 35 |
+
"""音频输出设备信息"""
|
| 36 |
+
index: int = Field(..., description="设备索引")
|
| 37 |
+
name: str = Field(..., description="设备名称")
|
| 38 |
+
max_output_channels: int = Field(..., description="最大输出通道数")
|
| 39 |
+
default_sample_rate: int = Field(..., description="设备默认采样率")
|
| 40 |
+
is_default: bool = Field(default=False, description="是否为系统默认输出设备")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
class AudioInputDevicesResponse(BaseModel):
|
| 44 |
+
"""音频设备列表响应(含输入与输出设备)"""
|
| 45 |
devices: List[AudioInputDevice] = Field(default_factory=list, description="可用输入设备列表")
|
| 46 |
current_device_index: Optional[int] = Field(default=None, description="当前已选择/保存的输入设备索引")
|
| 47 |
default_device_index: Optional[int] = Field(default=None, description="系统默认输入设备索引")
|
| 48 |
+
output_devices: List[AudioOutputDevice] = Field(default_factory=list, description="可用输出设备列表")
|
| 49 |
+
current_output_device_index: Optional[int] = Field(default=None, description="当前已选择/保存的输出设备索引")
|
| 50 |
+
default_output_device_index: Optional[int] = Field(default=None, description="系统默认输出设备索引")
|
| 51 |
|
| 52 |
|
| 53 |
class SystemResponse(BaseModel):
|
|
|
|
| 56 |
message: str = Field(..., description="响应消息")
|
| 57 |
|
| 58 |
|
| 59 |
+
class OutputDeviceRequest(BaseModel):
|
| 60 |
+
"""设置输出设备请求"""
|
| 61 |
+
output_device_index: Optional[int] = Field(default=None, description="输出设备索引;为空则使用系统默认设备")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
class ASREngineResponse(BaseModel):
|
| 65 |
"""当前 ASR 引擎信息"""
|
| 66 |
mappings: Dict[str, str] = Field(default_factory=dict, description="语言到 ASR 引擎的映射,如 {'zh': 'qwen'}")
|
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""
|
| 2 |
-
音频
|
| 3 |
|
| 4 |
-
提供列出系统可用输入设备(包括外置麦克风
|
| 5 |
供 CLI、API 以及前端进行设备选择。
|
| 6 |
"""
|
| 7 |
from typing import List, Optional, TypedDict
|
|
@@ -20,6 +20,15 @@ class InputDeviceInfo(TypedDict):
|
|
| 20 |
is_default: bool
|
| 21 |
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
def _get_default_input_index(p: pyaudio.PyAudio) -> Optional[int]:
|
| 24 |
"""获取系统默认输入设备索引,失败时返回 None。"""
|
| 25 |
try:
|
|
@@ -87,3 +96,72 @@ def is_valid_input_device(index: Optional[int]) -> bool:
|
|
| 87 |
if index is None:
|
| 88 |
return True
|
| 89 |
return any(d["index"] == index for d in list_input_devices())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
音频设备枚举工具。
|
| 3 |
|
| 4 |
+
提供列出系统可用输入/输出设备(包括外置麦克风阵列、外置扬声器)的能力,
|
| 5 |
供 CLI、API 以及前端进行设备选择。
|
| 6 |
"""
|
| 7 |
from typing import List, Optional, TypedDict
|
|
|
|
| 20 |
is_default: bool
|
| 21 |
|
| 22 |
|
| 23 |
+
class OutputDeviceInfo(TypedDict):
|
| 24 |
+
"""输出设备信息。"""
|
| 25 |
+
index: int
|
| 26 |
+
name: str
|
| 27 |
+
max_output_channels: int
|
| 28 |
+
default_sample_rate: int
|
| 29 |
+
is_default: bool
|
| 30 |
+
|
| 31 |
+
|
| 32 |
def _get_default_input_index(p: pyaudio.PyAudio) -> Optional[int]:
|
| 33 |
"""获取系统默认输入设备索引,失败时返回 None。"""
|
| 34 |
try:
|
|
|
|
| 96 |
if index is None:
|
| 97 |
return True
|
| 98 |
return any(d["index"] == index for d in list_input_devices())
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _get_default_output_index(p: pyaudio.PyAudio) -> Optional[int]:
|
| 102 |
+
"""获取系统默认输出设备索引,失败时返回 None。"""
|
| 103 |
+
try:
|
| 104 |
+
return int(p.get_default_output_device_info().get("index"))
|
| 105 |
+
except Exception:
|
| 106 |
+
return None
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def list_output_devices() -> List[OutputDeviceInfo]:
|
| 110 |
+
"""
|
| 111 |
+
列出所有可用的音频输出设备。
|
| 112 |
+
|
| 113 |
+
Returns:
|
| 114 |
+
List[OutputDeviceInfo]: 输出设备列表(仅包含 maxOutputChannels > 0 的设备)。
|
| 115 |
+
"""
|
| 116 |
+
devices: List[OutputDeviceInfo] = []
|
| 117 |
+
p = pyaudio.PyAudio()
|
| 118 |
+
try:
|
| 119 |
+
default_index = _get_default_output_index(p)
|
| 120 |
+
for i in range(p.get_device_count()):
|
| 121 |
+
try:
|
| 122 |
+
info = p.get_device_info_by_index(i)
|
| 123 |
+
except Exception as e:
|
| 124 |
+
logger.warning(f"读取音频设备 {i} 信息失败: {e}")
|
| 125 |
+
continue
|
| 126 |
+
|
| 127 |
+
max_output_channels = int(info.get("maxOutputChannels", 0))
|
| 128 |
+
if max_output_channels <= 0:
|
| 129 |
+
continue
|
| 130 |
+
|
| 131 |
+
devices.append(
|
| 132 |
+
OutputDeviceInfo(
|
| 133 |
+
index=int(info.get("index", i)),
|
| 134 |
+
name=str(info.get("name", f"device-{i}")),
|
| 135 |
+
max_output_channels=max_output_channels,
|
| 136 |
+
default_sample_rate=int(info.get("defaultSampleRate", 48000)),
|
| 137 |
+
is_default=(int(info.get("index", i)) == default_index),
|
| 138 |
+
)
|
| 139 |
+
)
|
| 140 |
+
finally:
|
| 141 |
+
p.terminate()
|
| 142 |
+
|
| 143 |
+
return devices
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def get_default_output_device_index() -> Optional[int]:
|
| 147 |
+
"""获取系统默认输出设备索引。"""
|
| 148 |
+
p = pyaudio.PyAudio()
|
| 149 |
+
try:
|
| 150 |
+
return _get_default_output_index(p)
|
| 151 |
+
finally:
|
| 152 |
+
p.terminate()
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def is_valid_output_device(index: Optional[int]) -> bool:
|
| 156 |
+
"""
|
| 157 |
+
校验给定索引是否为有效的输出设备。
|
| 158 |
+
|
| 159 |
+
Args:
|
| 160 |
+
index: 设备索引;None 表示使用系统默认设备,视为有效。
|
| 161 |
+
|
| 162 |
+
Returns:
|
| 163 |
+
bool: 是否有效。
|
| 164 |
+
"""
|
| 165 |
+
if index is None:
|
| 166 |
+
return True
|
| 167 |
+
return any(d["index"] == index for d in list_output_devices())
|
|
@@ -1,10 +1,78 @@
|
|
| 1 |
import tempfile
|
|
|
|
| 2 |
|
|
|
|
| 3 |
import soundfile as sf
|
| 4 |
from playsound import playsound
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
def play_audio(audio_data, sample_rate=16000):
|
| 8 |
with tempfile.NamedTemporaryFile('w+b', suffix='.wav') as soundfile:
|
| 9 |
sf.write(soundfile, audio_data, samplerate=sample_rate, subtype='PCM_16', closefd=False)
|
| 10 |
playsound(soundfile.name, block=True)
|
|
|
|
| 1 |
import tempfile
|
| 2 |
+
from typing import Optional
|
| 3 |
|
| 4 |
+
import numpy as np
|
| 5 |
import soundfile as sf
|
| 6 |
from playsound import playsound
|
| 7 |
|
| 8 |
+
from voice_dialogue.utils.logger import logger
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _to_int16(audio_data) -> np.ndarray:
|
| 12 |
+
"""将音频数据规整为一维 int16。"""
|
| 13 |
+
audio = np.asarray(audio_data)
|
| 14 |
+
if audio.ndim > 1:
|
| 15 |
+
audio = audio.mean(axis=-1)
|
| 16 |
+
if audio.dtype != np.int16:
|
| 17 |
+
audio = np.clip(audio, -1.0, 1.0)
|
| 18 |
+
audio = (audio * 32767.0).astype(np.int16)
|
| 19 |
+
return audio
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _play_via_pyaudio(audio_data, sample_rate: int, output_device_index: int):
|
| 23 |
+
"""通过 PyAudio 输出流播放,支持指定输出设备。"""
|
| 24 |
+
import pyaudio
|
| 25 |
+
|
| 26 |
+
audio = _to_int16(audio_data)
|
| 27 |
+
|
| 28 |
+
p = pyaudio.PyAudio()
|
| 29 |
+
try:
|
| 30 |
+
# 设备不支持该采样率时,重采样到设备默认采样率
|
| 31 |
+
try:
|
| 32 |
+
p.is_format_supported(
|
| 33 |
+
rate=sample_rate,
|
| 34 |
+
output_device=output_device_index,
|
| 35 |
+
output_channels=1,
|
| 36 |
+
output_format=pyaudio.paInt16,
|
| 37 |
+
)
|
| 38 |
+
except Exception:
|
| 39 |
+
device_rate = int(p.get_device_info_by_index(output_device_index).get("defaultSampleRate", 48000))
|
| 40 |
+
logger.info(f"输出设备不支持 {sample_rate}Hz,重采样到 {device_rate}Hz")
|
| 41 |
+
import soxr
|
| 42 |
+
audio = soxr.resample(audio, sample_rate, device_rate).astype(np.int16)
|
| 43 |
+
sample_rate = device_rate
|
| 44 |
+
|
| 45 |
+
stream = p.open(
|
| 46 |
+
format=pyaudio.paInt16,
|
| 47 |
+
channels=1,
|
| 48 |
+
rate=sample_rate,
|
| 49 |
+
output=True,
|
| 50 |
+
output_device_index=output_device_index,
|
| 51 |
+
)
|
| 52 |
+
try:
|
| 53 |
+
stream.write(audio.tobytes())
|
| 54 |
+
finally:
|
| 55 |
+
stream.stop_stream()
|
| 56 |
+
stream.close()
|
| 57 |
+
finally:
|
| 58 |
+
p.terminate()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def play_audio(audio_data, sample_rate=16000, output_device_index: Optional[int] = None):
|
| 62 |
+
"""播放音频。
|
| 63 |
+
|
| 64 |
+
Args:
|
| 65 |
+
audio_data: 音频数据
|
| 66 |
+
sample_rate: 采样率
|
| 67 |
+
output_device_index: 输出设备索引;None 表示系统默认设备
|
| 68 |
+
"""
|
| 69 |
+
if output_device_index is not None:
|
| 70 |
+
try:
|
| 71 |
+
_play_via_pyaudio(audio_data, sample_rate, output_device_index)
|
| 72 |
+
return
|
| 73 |
+
except Exception as e:
|
| 74 |
+
logger.warning(f"指定输出设备 {output_device_index} 播放失败,回退到系统默认设备: {e}")
|
| 75 |
|
|
|
|
| 76 |
with tempfile.NamedTemporaryFile('w+b', suffix='.wav') as soundfile:
|
| 77 |
sf.write(soundfile, audio_data, samplerate=sample_rate, subtype='PCM_16', closefd=False)
|
| 78 |
playsound(soundfile.name, block=True)
|
|
@@ -12,6 +12,7 @@ from .paths import AUDIO_SETTINGS_PATH
|
|
| 12 |
class AudioSettings(TypedDict, total=False):
|
| 13 |
"""音频设置。"""
|
| 14 |
input_device_index: Optional[int]
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
_audio_settings_cache: Optional[AudioSettings] = None
|
|
@@ -42,19 +43,35 @@ def get_input_device_index() -> Optional[int]:
|
|
| 42 |
return int(value) if value is not None else None
|
| 43 |
|
| 44 |
|
| 45 |
-
def
|
| 46 |
-
"""保存
|
| 47 |
global _audio_settings_cache
|
| 48 |
settings = dict(get_audio_settings())
|
| 49 |
-
settings[
|
| 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"音频设置已保存:
|
| 57 |
return True
|
| 58 |
except IOError as e:
|
| 59 |
logger.error(f"无法保存音频设置: {e}")
|
| 60 |
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
class AudioSettings(TypedDict, total=False):
|
| 13 |
"""音频设置。"""
|
| 14 |
input_device_index: Optional[int]
|
| 15 |
+
output_device_index: Optional[int]
|
| 16 |
|
| 17 |
|
| 18 |
_audio_settings_cache: Optional[AudioSettings] = None
|
|
|
|
| 43 |
return int(value) if value is not None else None
|
| 44 |
|
| 45 |
|
| 46 |
+
def _save_audio_setting(key: str, value: Optional[int]) -> bool:
|
| 47 |
+
"""保存单项音频设置并刷新缓存。"""
|
| 48 |
global _audio_settings_cache
|
| 49 |
settings = dict(get_audio_settings())
|
| 50 |
+
settings[key] = value
|
| 51 |
try:
|
| 52 |
if not AUDIO_SETTINGS_PATH.parent.exists():
|
| 53 |
AUDIO_SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 54 |
with open(AUDIO_SETTINGS_PATH, "w", encoding="utf-8") as f:
|
| 55 |
json.dump(settings, f, ensure_ascii=False, indent=4)
|
| 56 |
_audio_settings_cache = settings # type: ignore[assignment]
|
| 57 |
+
logger.info(f"音频设置已保存: {key}={value}")
|
| 58 |
return True
|
| 59 |
except IOError as e:
|
| 60 |
logger.error(f"无法保存音频设置: {e}")
|
| 61 |
return False
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def save_input_device_index(input_device_index: Optional[int]) -> bool:
|
| 65 |
+
"""保存用户选择的输入设备索引。"""
|
| 66 |
+
return _save_audio_setting("input_device_index", input_device_index)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def get_output_device_index() -> Optional[int]:
|
| 70 |
+
"""获取已保存的输出设备索引;未配置时返回 None(系统默认设备)。"""
|
| 71 |
+
value = get_audio_settings().get("output_device_index")
|
| 72 |
+
return int(value) if value is not None else None
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def save_output_device_index(output_device_index: Optional[int]) -> bool:
|
| 76 |
+
"""保存用户选择的输出设备索引。"""
|
| 77 |
+
return _save_audio_setting("output_device_index", output_device_index)
|
|
@@ -4,6 +4,7 @@ from queue import Empty
|
|
| 4 |
from typing import Optional
|
| 5 |
|
| 6 |
from voice_dialogue.audio.player import play_audio
|
|
|
|
| 7 |
from voice_dialogue.core.base import BaseThread
|
| 8 |
from voice_dialogue.core.constants import voice_state_manager, silence_over_threshold_event
|
| 9 |
from voice_dialogue.models.voice_task import VoiceTask, AnswerDisplayMessage
|
|
@@ -64,7 +65,8 @@ class AudioPlayerService(BaseThread, TaskStatusMixin, HistoryMixin, PerformanceL
|
|
| 64 |
|
| 65 |
if not self.is_stopped:
|
| 66 |
audio_data, sample_rate = voice_task.tts_generated_sentence_audio
|
| 67 |
-
|
|
|
|
| 68 |
|
| 69 |
# 任务处理完毕,跳出内部循环
|
| 70 |
break
|
|
|
|
| 4 |
from typing import Optional
|
| 5 |
|
| 6 |
from voice_dialogue.audio.player import play_audio
|
| 7 |
+
from voice_dialogue.config.audio_config import get_output_device_index
|
| 8 |
from voice_dialogue.core.base import BaseThread
|
| 9 |
from voice_dialogue.core.constants import voice_state_manager, silence_over_threshold_event
|
| 10 |
from voice_dialogue.models.voice_task import VoiceTask, AnswerDisplayMessage
|
|
|
|
| 65 |
|
| 66 |
if not self.is_stopped:
|
| 67 |
audio_data, sample_rate = voice_task.tts_generated_sentence_audio
|
| 68 |
+
# 每次播放时读取保存的输出设备,设置变更后下一句即生效
|
| 69 |
+
play_audio(audio_data, sample_rate, output_device_index=get_output_device_index())
|
| 70 |
|
| 71 |
# 任务处理完毕,跳出内部循环
|
| 72 |
break
|