hzeng412 Claude Opus 4.8 (1M context) commited on
Commit
afd6c89
·
1 Parent(s): 2f3888a

Add external mic array support, UI i18n, glass settings redesign, and fixes

Browse files

Backend:
- 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} RENAMED
@@ -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}
assets/www/assets/{index-ByqsFGbw.js → index-STY1yYrK.js} RENAMED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:215e0b4a6eee243715941860012a0d3bbee778f8880df45b0ddc8b090993405b
3
- size 2215701
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4049e7175fcba250d7bba7b6753d02e570bc5b2ff472019c32540c30b34f0e93
3
+ size 2300342
assets/www/index.html CHANGED
@@ -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-ByqsFGbw.js"></script>
9
- <link rel="stylesheet" crossorigin href="./assets/index-CCuJ1lip.css">
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>
frontend/src/App.vue CHANGED
@@ -1,6 +1,8 @@
1
  <template>
2
- <Header/>
3
- <router-view class="content" />
 
 
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";
frontend/src/assets/ball.json CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:edd650ec984e26b5fde217f273e6758d0862fc856b5333e678fa0b578374e8b9
3
- size 23084
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:91eaffeec742a30f475cf5e777e1605e62d3c1547b64a891c15f9a5431460b8a
3
+ size 22455
frontend/src/config/client_config.ts CHANGED
@@ -5,7 +5,7 @@ import router from "@/router";
5
 
6
  const { wsCache } = useCache();
7
 
8
- export const test_server = '127.0.0.1:8848'
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`;
frontend/src/i18n/index.ts ADDED
@@ -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
frontend/src/i18n/locales/en.ts ADDED
@@ -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
+ }
frontend/src/i18n/locales/zh.ts ADDED
@@ -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
+ }
frontend/src/main.ts CHANGED
@@ -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')
frontend/src/stores/config.ts CHANGED
@@ -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: {
frontend/src/style.scss CHANGED
@@ -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
+ }
frontend/src/views/Home/Components/ChatText.vue CHANGED
@@ -69,9 +69,13 @@ watch(() => props.chatContent, (newVal, oldVal) => {
69
  <style lang="scss" scoped>
70
  .talk-wrapper {
71
  width: auto;
72
- height: calc(100vh - 100px);
73
- overflow-y: scroll;
74
- padding: 20px 240px 0 240px;
 
 
 
 
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: 2;
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: end;
110
- line-height: 2;
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
  }
frontend/src/views/Home/index.vue CHANGED
@@ -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: flex-start;
 
 
 
 
 
 
 
 
 
 
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;
frontend/src/views/Welcome/Components/SettingsModal.vue ADDED
@@ -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>
frontend/src/views/Welcome/index.vue CHANGED
@@ -2,37 +2,18 @@
2
 
3
  import router from "@/router.ts";
4
  import { useSettingsStore } from "@/stores/config.ts";
5
- import { onMounted, onUnmounted, ref, reactive, computed, watch, h } from "vue";
6
  import { Modal } from 'ant-design-vue';
7
- import { SoundTwoTone, SoundOutlined } from "@ant-design/icons-vue";
8
  import axios from "axios";
9
- import PromptText from "./Components/PromptText.vue";
 
10
 
11
- const base_url = axios.defaults.baseURL
 
 
12
 
13
- const settingsStore = useSettingsStore()
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: echoCancel.value
 
48
  })
49
  });
50
  if (!response.ok) {
51
  throw new Error(`HTTP error! status: ${response.status}`);
52
  }
53
- const data = await response.json();
54
- console.log('ASR Instance started successfully:', data);
55
  return true;
56
  } catch (error) {
57
- console.error('Error starting ASR instance:', error);
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 togglePopover = (item: string) => {
214
- popoverVisible.value = !popoverVisible.value;
215
- if (item == 'voice') {
216
- voiceModelOpen.value = true;
217
- } else if (item == 'prompt') {
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
- currentPlayingId.value = null;
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>开始对话</span>
319
  </a-button>
320
  </div>
321
  </div>
322
  </div>
323
 
324
  <div class="actions">
325
- <!-- <a-button type="text" @click="toggleSider">sider</a-button> -->
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
- <a-modal v-model:open="voiceModelOpen" :title="null" :mask-closable="false" :closable="false" centered>
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
- .languages {
408
- margin-top: 24px;
409
- margin-bottom: 24px;
410
-
411
- p {
412
- font-size: 16px;
413
- font-weight: 500;
414
- margin-bottom: 8px;
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: 64px;
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
  }
main.py CHANGED
@@ -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(
src/voice_dialogue/api/core/lifespan.py CHANGED
@@ -24,8 +24,8 @@ class LifespanManager:
24
  startup_start_time = time.time()
25
 
26
  try:
27
- # 初始化系统语言
28
- system_language = get_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配置
src/voice_dialogue/api/core/service_factories.py CHANGED
@@ -12,11 +12,15 @@ class ServiceFactories:
12
  """服务工厂类,封装所有服务的创建逻辑"""
13
 
14
  @staticmethod
15
- def create_audio_capture(enable_echo_cancellation: bool = True) -> AudioCapture:
 
 
 
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(enable_echo_cancellation: bool = True) -> ServiceDefinition:
 
 
 
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
  )
src/voice_dialogue/api/routes/system_routes.py CHANGED
@@ -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(request: Request, enable_echo_cancellation: bool = True):
 
 
 
 
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
- enable_vad = not enable_echo_cancellation
 
 
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)
src/voice_dialogue/api/schemas/system_schemas.py CHANGED
@@ -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):
src/voice_dialogue/audio/capture/__init__.py CHANGED
@@ -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
- 若为 True,则使用 AEC 原生库;
34
- 否则使 PyAudio
 
 
 
 
35
  """
 
36
  self._strategy = None
37
  try:
38
- if enable_echo_cancellation:
39
  self._strategy = AecCapture(audio_frames_queue=audio_frames_queue)
40
  else:
41
- self._strategy = PyAudioCapture(audio_frames_queue=audio_frames_queue)
 
 
 
 
42
  logger.info(f"音频捕获策略已选择: {self._strategy.__class__.__name__}")
43
  except Exception as e:
44
  logger.error(
45
- f"初始化 {AecCapture.__name__ if enable_echo_cancellation else PyAudioCapture.__name__} 失败: {e}, 将回退到 PyAudio。")
46
  # 只有在尝试 AEC 失败时才回退
47
  if not isinstance(self._strategy, PyAudioCapture):
48
- self._strategy = PyAudioCapture(audio_frames_queue=audio_frames_queue)
 
 
 
 
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):
src/voice_dialogue/audio/capture/pyaudio_capture.py CHANGED
@@ -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__(self, audio_frames_queue: Queue, **kwargs):
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  super().__init__(audio_frames_queue=audio_frames_queue, **kwargs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
  def _init_pyaudio(self):
18
  """初始化 PyAudio 并返回实例和配置。"""
19
  p = pyaudio.PyAudio()
20
- chunk = 1024
21
- sample_rate = 16000
22
- return p, chunk, sample_rate
 
 
 
 
 
23
 
24
- def _open_stream(self, p, chunk, sample_rate):
25
  """打开 PyAudio 音频流。"""
26
  return p.open(
27
  format=pyaudio.paInt16,
28
- channels=1,
29
  rate=sample_rate,
30
  input=True,
 
31
  frames_per_buffer=chunk,
32
  )
33
 
34
- def _capture_loop(self, stream, chunk):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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:
src/voice_dialogue/audio/devices.py ADDED
@@ -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())
src/voice_dialogue/cli/args.py CHANGED
@@ -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服务器模式参数')
src/voice_dialogue/config/audio_config.py ADDED
@@ -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
src/voice_dialogue/config/paths.py CHANGED
@@ -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():
src/voice_dialogue/core/launcher.py CHANGED
@@ -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
- enable_vad = disable_echo_cancellation
 
 
 
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()