hzeng412 Claude Fable 5 commited on
Commit
a3bf559
·
1 Parent(s): c3f5082

Add audio output device (speaker) selection

Browse files

- devices.py: enumerate output devices, default lookup, validation
- player.py: PyAudio playback path for an explicit output device with
sample-rate fallback (soxr resample); default device keeps playsound
- AudioPlayerService reads the saved output index per sentence, so a
settings change applies to the next utterance without restart
- API: /system/audio-devices now includes output devices;
POST /system/audio-output-device persists immediately;
/system/start accepts output_device_index
- UI: speaker select in settings (main tab), persisted in store,
sent on start

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

assets/www/assets/{index-B0qFtB1t.css → index-CBWIB-ok.css} 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}.ant-modal .ant-modal-content{background:#ffffff9e!important;backdrop-filter:blur(28px) saturate(140%);-webkit-backdrop-filter:blur(28px) saturate(140%);border:1px solid rgba(255,255,255,.6);border-radius:22px!important;box-shadow:0 16px 48px #1f26872e}.ant-modal .ant-modal-header{background:transparent!important}.ant-modal-mask{background:#14161e1f!important;backdrop-filter:blur(14px) saturate(120%);-webkit-backdrop-filter:blur(14px) saturate(120%)}.ant-select .ant-select-selector,.ant-input,textarea.ant-input,.ant-input-affix-wrapper{background:#ffffff73!important;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);border:1px solid rgba(255,255,255,.7)!important}.ant-btn:not(.ant-btn-text):not(.ant-btn-link){box-shadow:0 2px 10px #1f26871a}.ant-btn-default{background:#ffffff80!important;border:1px solid rgba(255,255,255,.75)!important;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)}.ant-btn-text{box-shadow:none!important;background:transparent!important}.ant-radio-group-solid .ant-radio-button-wrapper:first-child{border-top-left-radius:12px;border-bottom-left-radius:12px}.ant-radio-group-solid .ant-radio-button-wrapper:last-child{border-top-right-radius:12px;border-bottom-right-radius:12px}.header-nav[data-v-07594418]{display:flex;align-items:flex-start;justify-content:space-between;width:100vw;height:40px;align-items:center;position:absolute;top:0;left:0;z-index:99;-webkit-app-region:drag;cursor:move}.header-nav .window-controls[data-v-07594418],.header-nav button[data-v-07594418],.header-nav .ant-input-search[data-v-07594418],.header-nav img[data-v-07594418],.header-nav .anticon[data-v-07594418]{-webkit-app-region:no-drag;cursor:pointer}.header-nav .window-controls[data-v-07594418]{top:0;right:0;display:flex;z-index:1000;margin-left:12px}.header-nav .window-controls .window-control-btn[data-v-07594418]{width:46px;height:32px;border:none;background:transparent;color:#666;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background-color .2s}.header-nav .window-controls .window-control-btn[data-v-07594418]:hover{background-color:#0000001a}.header-nav .window-controls .window-control-btn.close[data-v-07594418]:hover{background-color:#e81123;color:#fff}.header-nav .window-controls .close-icon.focus[data-v-07594418]{display:none}.header-nav .window-controls:hover .close-icon.default[data-v-07594418],.header-nav .window-controls:focus-within .close-icon.default[data-v-07594418]{display:none}.header-nav .window-controls:hover .close-icon.focus[data-v-07594418],.header-nav .window-controls:focus-within .close-icon.focus[data-v-07594418]{display:inline}.content[data-v-b8a456cb]{background-color:#fff;margin:0 auto;display:flex;flex-direction:column;align-items:center;justify-content:space-between}.not-found-wrapper[data-v-aef52a59]{height:calc(100vh - 104px)}.tab-body[data-v-44db86f4]{height:360px;overflow-y:auto;padding:4px 8px 4px 2px}.setting-row[data-v-44db86f4]{margin-bottom:20px}.setting-row>label[data-v-44db86f4]{display:block;font-size:15px;font-weight:500;margin-bottom:8px}.setting-row>label .label-icon[data-v-44db86f4]{margin-right:6px;color:#1890ff}.setting-row .hint[data-v-44db86f4]{font-size:12px;color:#999;margin:8px 0 0}.setting-row .row-inline[data-v-44db86f4]{display:flex;align-items:center;justify-content:space-between}.voice-group[data-v-44db86f4]{display:flex;flex-direction:column;margin-top:8px}.about .about-head[data-v-44db86f4]{text-align:center;margin-bottom:24px}.about .about-head .about-name[data-v-44db86f4]{font-size:20px;font-weight:600}.about .about-head .about-ver[data-v-44db86f4]{font-size:13px;color:#888;margin-top:2px}.about .about-head .about-tagline[data-v-44db86f4]{font-size:12px;color:#999;margin-top:4px}.about .about-section[data-v-44db86f4]{margin-bottom:20px}.about .about-section .about-section-title[data-v-44db86f4]{font-size:13px;font-weight:600;color:#666;margin-bottom:10px}.about .about-item[data-v-44db86f4]{margin-bottom:12px}.about .about-item .about-item-label[data-v-44db86f4]{font-size:14px;font-weight:500}.about .about-item .about-item-desc[data-v-44db86f4]{font-size:12px;color:#777;margin-top:2px;line-height:1.6}.about .about-item .about-item-desc a[data-v-44db86f4]{margin-left:6px}.about a[data-v-44db86f4]{color:#1677ff;text-decoration:none}.about a[data-v-44db86f4]:hover{text-decoration:underline}.about .about-link[data-v-44db86f4]{font-size:13px;word-break:break-all}.about .about-copyright[data-v-44db86f4]{margin-top:16px;font-size:11px;color:#aaa;text-align:center}.voice-radio[data-v-44db86f4]{display:flex;align-items:center;height:40px;line-height:40px}.voice-radio .voice-name[data-v-44db86f4]{margin-right:8px}.audio-play-btn[data-v-44db86f4]{padding:0 6px;border-radius:4px}.audio-play-btn.playing[data-v-44db86f4]{background-color:#f6ffed}.asr-chip[data-v-2fa7cbbe]{display:flex;align-items:center;gap:8px;height:38px;padding:0 18px;margin-right:16px;border-radius:19px;color:#000000a6;font-size:13px;background:#ffffff80;border:1px solid rgba(255,255,255,.7);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);box-shadow:0 4px 16px #1f26871f}.settings-btn[data-v-2fa7cbbe]{width:60px;height:60px;margin-right:24px;border-radius:50%!important;background:#ffffff80!important;border:1px solid rgba(255,255,255,.7)!important;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);box-shadow:0 4px 16px #1f26871f;display:flex;align-items:center;justify-content:center}.welcome-wrapper[data-v-2fa7cbbe]{width:100%;height:100%;background-image:url(./bg-BmnA8p_e.png);background-repeat:no-repeat;background-attachment:fixed;background-size:cover;background-position:center;display:flex;flex-direction:column;align-items:center;justify-content:space-between;color:#fff}.welcome-wrapper .content[data-v-2fa7cbbe]{width:100%;height:80vh;display:flex;flex-direction:column;justify-content:space-around;margin-top:64px}.welcome-wrapper .content .inner-content[data-v-2fa7cbbe]{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:20px}.welcome-wrapper .content .inner-content .text-box[data-v-2fa7cbbe]{color:#000;margin-bottom:36px}.welcome-wrapper .content .inner-content .text-box .title[data-v-2fa7cbbe]{font-size:24px;font-weight:600;margin-bottom:24px}.welcome-wrapper .content .inner-content .text-box .sub-title[data-v-2fa7cbbe]{font-size:15px;margin-top:10px}.welcome-wrapper .content .inner-content .btn-box[data-v-2fa7cbbe]{width:224px;height:80px}.welcome-wrapper .actions[data-v-2fa7cbbe]{width:100%;height:100px;margin-bottom:32px;display:flex;align-items:center;justify-content:flex-end}.ball-wrapper[data-v-34c8e583]{width:100%;height:calc(100vh - 100px);display:flex;flex-direction:column;align-items:center;justify-content:space-around}.talk-wrapper[data-v-05da84ae]{width:auto;width:100%;max-width:1000px;margin:0 auto;box-sizing:border-box;height:calc(100vh - 150px);overflow-y:auto;padding:20px 32px 0;display:flex;flex-direction:column;align-items:flex-start;justify-content:flex-start}.talk-wrapper .cont-left[data-v-05da84ae]{width:100%;margin:24px 0;display:flex;justify-content:flex-start;align-items:flex-start}.talk-wrapper .cont-left .text-left[data-v-05da84ae]{max-width:88%;color:#222;font-size:16px;font-weight:400;text-align:left;line-height:1.8;margin-left:12px;margin-top:6px;word-break:break-word}.talk-wrapper .cont-right[data-v-05da84ae]{width:100%;margin:24px 0;display:flex;justify-content:flex-end;align-items:flex-start}.talk-wrapper .cont-right .text-right[data-v-05da84ae]{max-width:80%;color:#444;font-size:16px;font-weight:400;text-align:start;line-height:1.8;margin-right:12px;background:#ccc;border-radius:8px 0 8px 8px;padding:8px 12px;word-break:break-word}.chat-wrapper[data-v-8b035bf4]{width:100%;height:100%;background-image:url(./bg-BmnA8p_e.png);background-repeat:no-repeat;background-attachment:fixed;background-size:cover;background-position:center;display:flex;flex-direction:column;align-items:center;justify-content:space-between;color:#fff}.chat-wrapper .content[data-v-8b035bf4]{width:100%;height:auto;display:flex;flex-direction:column;justify-content:space-around}.chat-wrapper .content .inner-content[data-v-8b035bf4]{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:20px}.chat-wrapper .content .inner-content .text-box[data-v-8b035bf4]{color:#000;margin-bottom:36px}.chat-wrapper .content .inner-content .text-box .title[data-v-8b035bf4]{font-size:24px;font-weight:600;margin-bottom:24px}.chat-wrapper .content .inner-content .text-box .sub-title[data-v-8b035bf4]{font-size:15px;margin-top:10px}.chat-wrapper .content .inner-content .btn-box[data-v-8b035bf4]{width:224px;height:80px}.chat-wrapper .actions[data-v-8b035bf4]{width:100%;height:100px;margin-bottom:32px;display:flex;justify-content:space-between;align-items:center}.chat-wrapper .actions .holder[data-v-8b035bf4]{width:64px;height:48px}.chat-wrapper .actions .btns[data-v-8b035bf4]{width:450px;height:96px;display:flex;justify-content:space-around;align-items:center}.chat-wrapper .actions .btns[data-v-8b035bf4] .ant-btn{border-radius:50%!important;background:#ffffff80!important;border:1px solid rgba(255,255,255,.7)!important;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);box-shadow:0 4px 16px #1f26871f}.chat-wrapper .actions .download-wrapper[data-v-8b035bf4]{width:64px;height:64px;display:flex;justify-content:flex-start;align-items:center;margin-right:0}.chat-wrapper .actions .download-wrapper img[data-v-8b035bf4]{width:24px;height:24px}.content-wrapper[data-v-d41c9ce7]{text-align:left;max-width:800px;min-width:320px;margin-bottom:64px;min-height:calc(100vh - 438px)}.content-wrapper .content-box[data-v-d41c9ce7]{padding:24px;height:240px;background-color:#e8e8e8;border-radius:16px;width:50%;margin:48px auto;min-width:300px}.content-wrapper .video-box[data-v-d41c9ce7]{max-width:800px;min-width:320px;width:90vw;height:auto}
 
1
+ @charset "UTF-8";html,body{width:100%;height:100%}input::-ms-clear,input::-ms-reveal{display:none}*,*:before,*:after{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:rgba(0,0,0,0)}body{margin:0}[tabindex="-1"]:focus{outline:none}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5em;font-weight:500}p{margin-top:0;margin-bottom:1em}abbr[title],abbr[data-original-title]{-webkit-text-decoration:underline dotted;text-decoration:underline;text-decoration:underline dotted;border-bottom:0;cursor:help}address{margin-bottom:1em;font-style:normal;line-height:inherit}input[type=text],input[type=password],input[type=number],textarea{-webkit-appearance:none}ol,ul,dl{margin-top:0;margin-bottom:1em}ol ol,ul ul,ol ul,ul ol{margin-bottom:0}dt{font-weight:500}dd{margin-bottom:.5em;margin-left:0}blockquote{margin:0 0 1em}dfn{font-style:italic}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}pre,code,kbd,samp{font-size:1em;font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,Courier,monospace}pre{margin-top:0;margin-bottom:1em;overflow:auto}figure{margin:0 0 1em}img{vertical-align:middle;border-style:none}a,area,button,[role=button],input:not([type=range]),label,select,summary,textarea{touch-action:manipulation}table{border-collapse:collapse}caption{padding-top:.75em;padding-bottom:.3em;text-align:left;caption-side:bottom}input,button,select,optgroup,textarea{margin:0;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}button,html [type=button],[type=reset],[type=submit]{-webkit-appearance:button}button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{padding:0;border-style:none}input[type=radio],input[type=checkbox]{box-sizing:border-box;padding:0}input[type=date],input[type=time],input[type=datetime-local],input[type=month]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;margin:0;padding:0;border:0}legend{display:block;width:100%;max-width:100%;margin-bottom:.5em;padding:0;color:inherit;font-size:1.5em;line-height:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item}template{display:none}[hidden]{display:none!important}mark{padding:.2em;background-color:#feffe6}:root{font-family:Inter,system-ui,Avenir,Helvetica,Arial,sans-serif;line-height:1.5;font-weight:400;color-scheme:light dark;color:#ffffffde;background-color:#242424;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;-webkit-text-size-adjust:100%}a{font-weight:500;color:#646cff;text-decoration:inherit}a:hover{color:#535bf2}body{margin:0;display:flex;place-items:center;min-width:320px;height:100%;min-height:auto;color:#333;background:#fff}h1{font-size:3.2em;line-height:1.1}button{border-radius:8px;border:1px solid transparent;padding:.6em 1.2em;font-size:1em;font-weight:500;font-family:inherit;background-color:#1a1a1a;cursor:pointer;transition:border-color .25s}.card{border-bottom:solid 2px lightgray;align-items:center;justify-content:center;margin-top:40px;display:flex;max-width:1024px;width:100%}.seg-title{margin:24px 0;font-size:20px;font-weight:500}.seg-co{width:1022px;text-align:left;border-left:solid 6px midnightblue;padding-left:8px;margin-left:2px;margin-top:36px;line-height:24px}#app{margin:0 auto;padding:0;text-align:center;width:100%;height:100%}.ant-btn{padding:4px 12px}@media (prefers-color-scheme: light){:root{color:#213547;background-color:#fff}a:hover{color:#747bff}button{background-color:#f9f9f9}}.ant-card{background:#f5f6fa;height:100%}.ant-card-body{padding:24px 36px 12px!important;border-radius:0 0 8px 8px}.ant-card .ant-card-actions{background-color:#e8e8f8cc!important}.ant-popover{max-width:800px!important}.ant-form-item{background:transparent;margin-bottom:40px!important}.ant-form-item .ant-form-item-explain-error{color:#ff4d4f;text-align:left!important}.ant-form-item-label label{font-size:18px!important;color:#1a1a1a!important;font-weight:500!important}.ant-tooltip{max-width:1022px!important}.ant-page-header-heading{width:1022px!important}.highlight{background:#f8f8ff}.ant-layout-sider-collapsed{width:0!important;min-width:0!important;overflow:hidden}.ant-layout-sider-collapsed .ant-menu-item,.ant-layout-sider-collapsed .ant-menu-submenu-title{display:none}.ant-modal .ant-modal-content{background:#ffffff9e!important;backdrop-filter:blur(28px) saturate(140%);-webkit-backdrop-filter:blur(28px) saturate(140%);border:1px solid rgba(255,255,255,.6);border-radius:22px!important;box-shadow:0 16px 48px #1f26872e}.ant-modal .ant-modal-header{background:transparent!important}.ant-modal-mask{background:#14161e1f!important;backdrop-filter:blur(14px) saturate(120%);-webkit-backdrop-filter:blur(14px) saturate(120%)}.ant-select .ant-select-selector,.ant-input,textarea.ant-input,.ant-input-affix-wrapper{background:#ffffff73!important;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);border:1px solid rgba(255,255,255,.7)!important}.ant-btn:not(.ant-btn-text):not(.ant-btn-link){box-shadow:0 2px 10px #1f26871a}.ant-btn-default{background:#ffffff80!important;border:1px solid rgba(255,255,255,.75)!important;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)}.ant-btn-text{box-shadow:none!important;background:transparent!important}.ant-radio-group-solid .ant-radio-button-wrapper:first-child{border-top-left-radius:12px;border-bottom-left-radius:12px}.ant-radio-group-solid .ant-radio-button-wrapper:last-child{border-top-right-radius:12px;border-bottom-right-radius:12px}.header-nav[data-v-07594418]{display:flex;align-items:flex-start;justify-content:space-between;width:100vw;height:40px;align-items:center;position:absolute;top:0;left:0;z-index:99;-webkit-app-region:drag;cursor:move}.header-nav .window-controls[data-v-07594418],.header-nav button[data-v-07594418],.header-nav .ant-input-search[data-v-07594418],.header-nav img[data-v-07594418],.header-nav .anticon[data-v-07594418]{-webkit-app-region:no-drag;cursor:pointer}.header-nav .window-controls[data-v-07594418]{top:0;right:0;display:flex;z-index:1000;margin-left:12px}.header-nav .window-controls .window-control-btn[data-v-07594418]{width:46px;height:32px;border:none;background:transparent;color:#666;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background-color .2s}.header-nav .window-controls .window-control-btn[data-v-07594418]:hover{background-color:#0000001a}.header-nav .window-controls .window-control-btn.close[data-v-07594418]:hover{background-color:#e81123;color:#fff}.header-nav .window-controls .close-icon.focus[data-v-07594418]{display:none}.header-nav .window-controls:hover .close-icon.default[data-v-07594418],.header-nav .window-controls:focus-within .close-icon.default[data-v-07594418]{display:none}.header-nav .window-controls:hover .close-icon.focus[data-v-07594418],.header-nav .window-controls:focus-within .close-icon.focus[data-v-07594418]{display:inline}.content[data-v-b8a456cb]{background-color:#fff;margin:0 auto;display:flex;flex-direction:column;align-items:center;justify-content:space-between}.not-found-wrapper[data-v-aef52a59]{height:calc(100vh - 104px)}.tab-body[data-v-9cd2a8bd]{height:360px;overflow-y:auto;padding:4px 8px 4px 2px}.setting-row[data-v-9cd2a8bd]{margin-bottom:20px}.setting-row>label[data-v-9cd2a8bd]{display:block;font-size:15px;font-weight:500;margin-bottom:8px}.setting-row>label .label-icon[data-v-9cd2a8bd]{margin-right:6px;color:#1890ff}.setting-row .hint[data-v-9cd2a8bd]{font-size:12px;color:#999;margin:8px 0 0}.setting-row .row-inline[data-v-9cd2a8bd]{display:flex;align-items:center;justify-content:space-between}.voice-group[data-v-9cd2a8bd]{display:flex;flex-direction:column;margin-top:8px}.about .about-head[data-v-9cd2a8bd]{text-align:center;margin-bottom:24px}.about .about-head .about-name[data-v-9cd2a8bd]{font-size:20px;font-weight:600}.about .about-head .about-ver[data-v-9cd2a8bd]{font-size:13px;color:#888;margin-top:2px}.about .about-head .about-tagline[data-v-9cd2a8bd]{font-size:12px;color:#999;margin-top:4px}.about .about-section[data-v-9cd2a8bd]{margin-bottom:20px}.about .about-section .about-section-title[data-v-9cd2a8bd]{font-size:13px;font-weight:600;color:#666;margin-bottom:10px}.about .about-item[data-v-9cd2a8bd]{margin-bottom:12px}.about .about-item .about-item-label[data-v-9cd2a8bd]{font-size:14px;font-weight:500}.about .about-item .about-item-desc[data-v-9cd2a8bd]{font-size:12px;color:#777;margin-top:2px;line-height:1.6}.about .about-item .about-item-desc a[data-v-9cd2a8bd]{margin-left:6px}.about a[data-v-9cd2a8bd]{color:#1677ff;text-decoration:none}.about a[data-v-9cd2a8bd]:hover{text-decoration:underline}.about .about-link[data-v-9cd2a8bd]{font-size:13px;word-break:break-all}.about .about-copyright[data-v-9cd2a8bd]{margin-top:16px;font-size:11px;color:#aaa;text-align:center}.voice-radio[data-v-9cd2a8bd]{display:flex;align-items:center;height:40px;line-height:40px}.voice-radio .voice-name[data-v-9cd2a8bd]{margin-right:8px}.audio-play-btn[data-v-9cd2a8bd]{padding:0 6px;border-radius:4px}.audio-play-btn.playing[data-v-9cd2a8bd]{background-color:#f6ffed}.asr-chip[data-v-ca2e1f17]{display:flex;align-items:center;gap:8px;height:38px;padding:0 18px;margin-right:16px;border-radius:19px;color:#000000a6;font-size:13px;background:#ffffff80;border:1px solid rgba(255,255,255,.7);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);box-shadow:0 4px 16px #1f26871f}.settings-btn[data-v-ca2e1f17]{width:60px;height:60px;margin-right:24px;border-radius:50%!important;background:#ffffff80!important;border:1px solid rgba(255,255,255,.7)!important;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);box-shadow:0 4px 16px #1f26871f;display:flex;align-items:center;justify-content:center}.welcome-wrapper[data-v-ca2e1f17]{width:100%;height:100%;background-image:url(./bg-BmnA8p_e.png);background-repeat:no-repeat;background-attachment:fixed;background-size:cover;background-position:center;display:flex;flex-direction:column;align-items:center;justify-content:space-between;color:#fff}.welcome-wrapper .content[data-v-ca2e1f17]{width:100%;height:80vh;display:flex;flex-direction:column;justify-content:space-around;margin-top:64px}.welcome-wrapper .content .inner-content[data-v-ca2e1f17]{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:20px}.welcome-wrapper .content .inner-content .text-box[data-v-ca2e1f17]{color:#000;margin-bottom:36px}.welcome-wrapper .content .inner-content .text-box .title[data-v-ca2e1f17]{font-size:24px;font-weight:600;margin-bottom:24px}.welcome-wrapper .content .inner-content .text-box .sub-title[data-v-ca2e1f17]{font-size:15px;margin-top:10px}.welcome-wrapper .content .inner-content .btn-box[data-v-ca2e1f17]{width:224px;height:80px}.welcome-wrapper .actions[data-v-ca2e1f17]{width:100%;height:100px;margin-bottom:32px;display:flex;align-items:center;justify-content:flex-end}.ball-wrapper[data-v-34c8e583]{width:100%;height:calc(100vh - 100px);display:flex;flex-direction:column;align-items:center;justify-content:space-around}.talk-wrapper[data-v-05da84ae]{width:auto;width:100%;max-width:1000px;margin:0 auto;box-sizing:border-box;height:calc(100vh - 150px);overflow-y:auto;padding:20px 32px 0;display:flex;flex-direction:column;align-items:flex-start;justify-content:flex-start}.talk-wrapper .cont-left[data-v-05da84ae]{width:100%;margin:24px 0;display:flex;justify-content:flex-start;align-items:flex-start}.talk-wrapper .cont-left .text-left[data-v-05da84ae]{max-width:88%;color:#222;font-size:16px;font-weight:400;text-align:left;line-height:1.8;margin-left:12px;margin-top:6px;word-break:break-word}.talk-wrapper .cont-right[data-v-05da84ae]{width:100%;margin:24px 0;display:flex;justify-content:flex-end;align-items:flex-start}.talk-wrapper .cont-right .text-right[data-v-05da84ae]{max-width:80%;color:#444;font-size:16px;font-weight:400;text-align:start;line-height:1.8;margin-right:12px;background:#ccc;border-radius:8px 0 8px 8px;padding:8px 12px;word-break:break-word}.chat-wrapper[data-v-8b035bf4]{width:100%;height:100%;background-image:url(./bg-BmnA8p_e.png);background-repeat:no-repeat;background-attachment:fixed;background-size:cover;background-position:center;display:flex;flex-direction:column;align-items:center;justify-content:space-between;color:#fff}.chat-wrapper .content[data-v-8b035bf4]{width:100%;height:auto;display:flex;flex-direction:column;justify-content:space-around}.chat-wrapper .content .inner-content[data-v-8b035bf4]{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:20px}.chat-wrapper .content .inner-content .text-box[data-v-8b035bf4]{color:#000;margin-bottom:36px}.chat-wrapper .content .inner-content .text-box .title[data-v-8b035bf4]{font-size:24px;font-weight:600;margin-bottom:24px}.chat-wrapper .content .inner-content .text-box .sub-title[data-v-8b035bf4]{font-size:15px;margin-top:10px}.chat-wrapper .content .inner-content .btn-box[data-v-8b035bf4]{width:224px;height:80px}.chat-wrapper .actions[data-v-8b035bf4]{width:100%;height:100px;margin-bottom:32px;display:flex;justify-content:space-between;align-items:center}.chat-wrapper .actions .holder[data-v-8b035bf4]{width:64px;height:48px}.chat-wrapper .actions .btns[data-v-8b035bf4]{width:450px;height:96px;display:flex;justify-content:space-around;align-items:center}.chat-wrapper .actions .btns[data-v-8b035bf4] .ant-btn{border-radius:50%!important;background:#ffffff80!important;border:1px solid rgba(255,255,255,.7)!important;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);box-shadow:0 4px 16px #1f26871f}.chat-wrapper .actions .download-wrapper[data-v-8b035bf4]{width:64px;height:64px;display:flex;justify-content:flex-start;align-items:center;margin-right:0}.chat-wrapper .actions .download-wrapper img[data-v-8b035bf4]{width:24px;height:24px}.content-wrapper[data-v-d41c9ce7]{text-align:left;max-width:800px;min-width:320px;margin-bottom:64px;min-height:calc(100vh - 438px)}.content-wrapper .content-box[data-v-d41c9ce7]{padding:24px;height:240px;background-color:#e8e8e8;border-radius:16px;width:50%;margin:48px auto;min-width:300px}.content-wrapper .video-box[data-v-d41c9ce7]{max-width:800px;min-width:320px;width:90vw;height:auto}
assets/www/assets/{index-vffghLR3.js → index-I4LwLHV-.js} RENAMED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:1e7941a809c8dc4932a336ed14b979ba9d0401ced88724c584126c9896630683
3
- size 2306190
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7f59527d7ccdc62e1062e71f82e79c06f9622c9deed46b36299e449e5a942700
3
+ size 2307855
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-vffghLR3.js"></script>
9
- <link rel="stylesheet" crossorigin href="./assets/index-B0qFtB1t.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-I4LwLHV-.js"></script>
9
+ <link rel="stylesheet" crossorigin href="./assets/index-CBWIB-ok.css">
10
  </head>
11
  <body>
12
  <div id="app"></div>
frontend/src/i18n/locales/en.ts CHANGED
@@ -51,6 +51,8 @@ export default {
51
  systemDefault: 'System Default',
52
  channelsSuffix: 'ch',
53
  defaultSuffix: 'default',
 
 
54
  echoCancellation: 'Echo Cancellation',
55
  echoCancellationHint: 'Uses the system AEC on the default device. For an external array, echo is handled by the array hardware.',
56
  },
 
51
  systemDefault: 'System Default',
52
  channelsSuffix: 'ch',
53
  defaultSuffix: 'default',
54
+ speaker: 'Speaker (Output Device)',
55
+ speakerHint: 'Choose the output device for voice playback, e.g. an external speaker.',
56
  echoCancellation: 'Echo Cancellation',
57
  echoCancellationHint: 'Uses the system AEC on the default device. For an external array, echo is handled by the array hardware.',
58
  },
frontend/src/i18n/locales/zh.ts CHANGED
@@ -51,6 +51,8 @@ export default {
51
  systemDefault: '系统默认',
52
  channelsSuffix: '声道',
53
  defaultSuffix: '默认',
 
 
54
  echoCancellation: '回音消除',
55
  echoCancellationHint: '默认设备使用系统 AEC;选择外置阵列时,回音由阵列硬件处理。',
56
  },
 
51
  systemDefault: '系统默认',
52
  channelsSuffix: '声道',
53
  defaultSuffix: '默认',
54
+ speaker: '扬声器(输出设备)',
55
+ speakerHint: '选择语音播放的输出设备,例如外置扬声器。',
56
  echoCancellation: '回音消除',
57
  echoCancellationHint: '默认设备使用系统 AEC;选择外置阵列时,回音由阵列硬件处理。',
58
  },
frontend/src/stores/config.ts CHANGED
@@ -12,6 +12,7 @@ export const useSettingsStore = defineStore({
12
  sider_open: true,
13
  echoCancel: true,
14
  inputDeviceIndex: null as number | null,
 
15
  }
16
  },
17
  actions: {
 
12
  sider_open: true,
13
  echoCancel: true,
14
  inputDeviceIndex: null as number | null,
15
+ outputDeviceIndex: null as number | null,
16
  }
17
  },
18
  actions: {
frontend/src/views/Welcome/Components/SettingsModal.vue CHANGED
@@ -25,10 +25,12 @@ const uiLanguage = ref<UiLocale>((settingsStore.$state.uiLanguage as UiLocale) ?
25
  const recognitionLanguage = ref<string>(settingsStore.$state.language || "zh");
26
  const echoCancel = ref<boolean>(settingsStore.$state.echoCancel ?? true);
27
  const inputDeviceIndex = ref<number | null>(settingsStore.$state.inputDeviceIndex ?? null);
 
28
  const role = ref<string>(settingsStore.$state.role || "");
29
 
30
  const languages = reactive<string[]>([]);
31
  const inputDevices = reactive<any[]>([]);
 
32
  const roles = reactive<any[]>([]);
33
 
34
  // ---- Prompt ----
@@ -100,6 +102,12 @@ const fetchInputDevices = async () => {
100
  const exists = saved != null && data.devices.some((d: any) => d.index === saved);
101
  inputDeviceIndex.value = exists ? saved : (data.current_device_index ?? null);
102
  }
 
 
 
 
 
 
103
  } catch (e) {
104
  console.error("Error fetching input devices:", e);
105
  }
@@ -162,6 +170,14 @@ const applySettings = async () => {
162
  settingsStore.$state.role = role.value || "";
163
  settingsStore.$state.echoCancel = echoCancel.value;
164
  settingsStore.$state.inputDeviceIndex = inputDeviceIndex.value;
 
 
 
 
 
 
 
 
165
 
166
  // 2. 下发 TTS 音色 + ASR 语言
167
  if (role.value) {
@@ -205,6 +221,7 @@ const handleCancel = () => {
205
  recognitionLanguage.value = settingsStore.$state.language || "zh";
206
  echoCancel.value = settingsStore.$state.echoCancel ?? true;
207
  inputDeviceIndex.value = settingsStore.$state.inputDeviceIndex ?? null;
 
208
  role.value = settingsStore.$state.role || "";
209
  emit("update:open", false);
210
  };
@@ -294,6 +311,15 @@ onUnmounted(() => {
294
  </a-select-option>
295
  </a-select>
296
  </div>
 
 
 
 
 
 
 
 
 
297
  <div class="setting-row">
298
  <div class="row-inline">
299
  <label>{{ t('settings.audio.echoCancellation') }}</label>
 
25
  const recognitionLanguage = ref<string>(settingsStore.$state.language || "zh");
26
  const echoCancel = ref<boolean>(settingsStore.$state.echoCancel ?? true);
27
  const inputDeviceIndex = ref<number | null>(settingsStore.$state.inputDeviceIndex ?? null);
28
+ const outputDeviceIndex = ref<number | null>(settingsStore.$state.outputDeviceIndex ?? null);
29
  const role = ref<string>(settingsStore.$state.role || "");
30
 
31
  const languages = reactive<string[]>([]);
32
  const inputDevices = reactive<any[]>([]);
33
+ const outputDevices = reactive<any[]>([]);
34
  const roles = reactive<any[]>([]);
35
 
36
  // ---- Prompt ----
 
102
  const exists = saved != null && data.devices.some((d: any) => d.index === saved);
103
  inputDeviceIndex.value = exists ? saved : (data.current_device_index ?? null);
104
  }
105
+ if (data?.output_devices) {
106
+ outputDevices.splice(0, outputDevices.length, ...data.output_devices);
107
+ const saved = settingsStore.$state.outputDeviceIndex;
108
+ const exists = saved != null && data.output_devices.some((d: any) => d.index === saved);
109
+ outputDeviceIndex.value = exists ? saved : (data.current_output_device_index ?? null);
110
+ }
111
  } catch (e) {
112
  console.error("Error fetching input devices:", e);
113
  }
 
170
  settingsStore.$state.role = role.value || "";
171
  settingsStore.$state.echoCancel = echoCancel.value;
172
  settingsStore.$state.inputDeviceIndex = inputDeviceIndex.value;
173
+ settingsStore.$state.outputDeviceIndex = outputDeviceIndex.value;
174
+
175
+ // 输出设备保存即生效(会话中修改下一句生效)
176
+ await fetch(`${base_url}/system/audio-output-device`, {
177
+ method: "POST",
178
+ headers: { "Content-Type": "application/json" },
179
+ body: JSON.stringify({ output_device_index: outputDeviceIndex.value }),
180
+ });
181
 
182
  // 2. 下发 TTS 音色 + ASR 语言
183
  if (role.value) {
 
221
  recognitionLanguage.value = settingsStore.$state.language || "zh";
222
  echoCancel.value = settingsStore.$state.echoCancel ?? true;
223
  inputDeviceIndex.value = settingsStore.$state.inputDeviceIndex ?? null;
224
+ outputDeviceIndex.value = settingsStore.$state.outputDeviceIndex ?? null;
225
  role.value = settingsStore.$state.role || "";
226
  emit("update:open", false);
227
  };
 
311
  </a-select-option>
312
  </a-select>
313
  </div>
314
+ <div class="setting-row">
315
+ <label>{{ t('settings.audio.speaker') }}</label>
316
+ <a-select v-model:value="outputDeviceIndex" style="width: 100%;">
317
+ <a-select-option :value="null">{{ t('settings.audio.systemDefault') }}</a-select-option>
318
+ <a-select-option v-for="dev in outputDevices" :value="dev.index" :key="dev.index">
319
+ {{ dev.name }}<template v-if="dev.is_default"> · {{ t('settings.audio.defaultSuffix') }}</template>
320
+ </a-select-option>
321
+ </a-select>
322
+ </div>
323
  <div class="setting-row">
324
  <div class="row-inline">
325
  <label>{{ t('settings.audio.echoCancellation') }}</label>
frontend/src/views/Welcome/index.vue CHANGED
@@ -37,7 +37,8 @@ const startAudioChat = async () => {
37
  headers: { 'Content-Type': 'application/json' },
38
  body: JSON.stringify({
39
  enable_echo_cancellation: settingsStore.$state.echoCancel ?? true,
40
- input_device_index: settingsStore.$state.inputDeviceIndex ?? null
 
41
  })
42
  });
43
  if (!response.ok) {
 
37
  headers: { 'Content-Type': 'application/json' },
38
  body: JSON.stringify({
39
  enable_echo_cancellation: settingsStore.$state.echoCancel ?? true,
40
+ input_device_index: settingsStore.$state.inputDeviceIndex ?? null,
41
+ output_device_index: settingsStore.$state.outputDeviceIndex ?? null
42
  })
43
  });
44
  if (!response.ok) {
src/voice_dialogue/api/routes/system_routes.py CHANGED
@@ -4,14 +4,21 @@ import time
4
  from fastapi import APIRouter, HTTPException, BackgroundTasks, Request
5
 
6
  from voice_dialogue.audio.capture import resolves_to_native_aec
7
- from voice_dialogue.audio.devices import 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, ASREngineResponse
 
15
  )
16
 
17
  router = APIRouter()
@@ -79,16 +86,34 @@ async def get_audio_devices():
79
  """
80
  try:
81
  devices = [AudioInputDevice(**d) for d in list_input_devices()]
 
82
  return AudioInputDevicesResponse(
83
  devices=devices,
84
  current_device_index=get_input_device_index(),
85
  default_device_index=get_default_input_device_index(),
 
 
 
86
  )
87
  except Exception as e:
88
  logger.error(f"获取音频输入设备失败: {e}", exc_info=True)
89
  raise HTTPException(status_code=500, detail=f"获取音频输入设备失败: {str(e)}")
90
 
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  @router.get("/asr-engine", response_model=ASREngineResponse, summary="获取当前 ASR 引擎")
93
  async def get_asr_engine():
94
  """
@@ -134,6 +159,18 @@ async def start_system(
134
  # 持久化用户选择,供下次启动复用
135
  save_input_device_index(input_device_index)
136
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  # 更新状态
138
  _system_status["status"] = "starting"
139
  session_manager.reset_id()
 
4
  from fastapi import APIRouter, HTTPException, BackgroundTasks, Request
5
 
6
  from voice_dialogue.audio.capture import resolves_to_native_aec
7
+ from voice_dialogue.audio.devices import (
8
+ list_input_devices, get_default_input_device_index, is_valid_input_device,
9
+ list_output_devices, get_default_output_device_index, is_valid_output_device,
10
+ )
11
+ from voice_dialogue.config.audio_config import (
12
+ get_input_device_index, save_input_device_index,
13
+ get_output_device_index, save_output_device_index,
14
+ )
15
  from voice_dialogue.core.constants import session_manager
16
  from voice_dialogue.utils.logger import logger
17
  from ..core.service_factories import get_audio_capture_service_definition, get_speech_monitor_service_definition
18
  from ..schemas.system_schemas import (
19
  SystemStatusResponse, SystemResponse, SystemStartRequest,
20
+ AudioInputDevicesResponse, AudioInputDevice, AudioOutputDevice, ASREngineResponse,
21
+ OutputDeviceRequest
22
  )
23
 
24
  router = APIRouter()
 
86
  """
87
  try:
88
  devices = [AudioInputDevice(**d) for d in list_input_devices()]
89
+ output_devices = [AudioOutputDevice(**d) for d in list_output_devices()]
90
  return AudioInputDevicesResponse(
91
  devices=devices,
92
  current_device_index=get_input_device_index(),
93
  default_device_index=get_default_input_device_index(),
94
+ output_devices=output_devices,
95
+ current_output_device_index=get_output_device_index(),
96
+ default_output_device_index=get_default_output_device_index(),
97
  )
98
  except Exception as e:
99
  logger.error(f"获取音频输入设备失败: {e}", exc_info=True)
100
  raise HTTPException(status_code=500, detail=f"获取音频输入设备失败: {str(e)}")
101
 
102
 
103
+ @router.post("/audio-output-device", response_model=SystemResponse, summary="设置音频输出设备")
104
+ async def set_audio_output_device(request: OutputDeviceRequest):
105
+ """
106
+ 保存输出设备选择。播放服务在每次播放时读取该设置,
107
+ 会话进行中修改也会在下一句生效,无需重启。
108
+ """
109
+ output_device_index = request.output_device_index
110
+ if not is_valid_output_device(output_device_index):
111
+ raise HTTPException(status_code=400, detail=f"无效的输出设备索引: {output_device_index}")
112
+ if not save_output_device_index(output_device_index):
113
+ raise HTTPException(status_code=500, detail="保存输出设备设置失败")
114
+ return SystemResponse(success=True, message="输出设备已更新")
115
+
116
+
117
  @router.get("/asr-engine", response_model=ASREngineResponse, summary="获取当前 ASR 引擎")
118
  async def get_asr_engine():
119
  """
 
159
  # 持久化用户选择,供下次启动复用
160
  save_input_device_index(input_device_index)
161
 
162
+ # 解析输出设备:请求未指定时回退到已保存的设备
163
+ output_device_index = request.output_device_index
164
+ if output_device_index is None:
165
+ output_device_index = get_output_device_index()
166
+
167
+ if not is_valid_output_device(output_device_index):
168
+ logger.warning(f"请求的输出设备 {output_device_index} 无效,回退到系统默认设备")
169
+ output_device_index = None
170
+
171
+ # 播放服务在每次播放时读取该设置,保存即生效
172
+ save_output_device_index(output_device_index)
173
+
174
  # 更新状态
175
  _system_status["status"] = "starting"
176
  session_manager.reset_id()
src/voice_dialogue/api/schemas/system_schemas.py CHANGED
@@ -19,6 +19,7 @@ class SystemStartRequest(BaseModel):
19
  """系统启动请求"""
20
  enable_echo_cancellation: bool = Field(default=True, description="是否启用回声消除(仅在未指定输入设备时使用 macOS 原生 AEC)")
21
  input_device_index: Optional[int] = Field(default=None, description="输入设备索引(如外置麦克风阵列);为空则使用系统默认设备")
 
22
 
23
 
24
  class AudioInputDevice(BaseModel):
@@ -30,11 +31,23 @@ class AudioInputDevice(BaseModel):
30
  is_default: bool = Field(default=False, description="是否为系统默认输入设备")
31
 
32
 
 
 
 
 
 
 
 
 
 
33
  class AudioInputDevicesResponse(BaseModel):
34
- """音频输入设备列表响应"""
35
  devices: List[AudioInputDevice] = Field(default_factory=list, description="可用输入设备列表")
36
  current_device_index: Optional[int] = Field(default=None, description="当前已选择/保存的输入设备索引")
37
  default_device_index: Optional[int] = Field(default=None, description="系统默认输入设备索引")
 
 
 
38
 
39
 
40
  class SystemResponse(BaseModel):
@@ -43,6 +56,11 @@ class SystemResponse(BaseModel):
43
  message: str = Field(..., description="响应消息")
44
 
45
 
 
 
 
 
 
46
  class ASREngineResponse(BaseModel):
47
  """当前 ASR 引擎信息"""
48
  mappings: Dict[str, str] = Field(default_factory=dict, description="语言到 ASR 引擎的映射,如 {'zh': 'qwen'}")
 
19
  """系统启动请求"""
20
  enable_echo_cancellation: bool = Field(default=True, description="是否启用回声消除(仅在未指定输入设备时使用 macOS 原生 AEC)")
21
  input_device_index: Optional[int] = Field(default=None, description="输入设备索引(如外置麦克风阵列);为空则使用系统默认设备")
22
+ output_device_index: Optional[int] = Field(default=None, description="输出设备索引(如外置扬声器);为空则使用系统默认设备")
23
 
24
 
25
  class AudioInputDevice(BaseModel):
 
31
  is_default: bool = Field(default=False, description="是否为系统默认输入设备")
32
 
33
 
34
+ class AudioOutputDevice(BaseModel):
35
+ """音频输出设备信息"""
36
+ index: int = Field(..., description="设备索引")
37
+ name: str = Field(..., description="设备名称")
38
+ max_output_channels: int = Field(..., description="最大输出通道数")
39
+ default_sample_rate: int = Field(..., description="设备默认采样率")
40
+ is_default: bool = Field(default=False, description="是否为系统默认输出设备")
41
+
42
+
43
  class AudioInputDevicesResponse(BaseModel):
44
+ """音频设备列表响应(含输入与输出设备)"""
45
  devices: List[AudioInputDevice] = Field(default_factory=list, description="可用输入设备列表")
46
  current_device_index: Optional[int] = Field(default=None, description="当前已选择/保存的输入设备索引")
47
  default_device_index: Optional[int] = Field(default=None, description="系统默认输入设备索引")
48
+ output_devices: List[AudioOutputDevice] = Field(default_factory=list, description="可用输出设备列表")
49
+ current_output_device_index: Optional[int] = Field(default=None, description="当前已选择/保存的输出设备索引")
50
+ default_output_device_index: Optional[int] = Field(default=None, description="系统默认输出设备索引")
51
 
52
 
53
  class SystemResponse(BaseModel):
 
56
  message: str = Field(..., description="响应消息")
57
 
58
 
59
+ class OutputDeviceRequest(BaseModel):
60
+ """设置输出设备请求"""
61
+ output_device_index: Optional[int] = Field(default=None, description="输出设备索引;为空则使用系统默认设备")
62
+
63
+
64
  class ASREngineResponse(BaseModel):
65
  """当前 ASR 引擎信息"""
66
  mappings: Dict[str, str] = Field(default_factory=dict, description="语言到 ASR 引擎的映射,如 {'zh': 'qwen'}")
src/voice_dialogue/audio/devices.py CHANGED
@@ -1,7 +1,7 @@
1
  """
2
- 音频输入设备枚举工具。
3
 
4
- 提供列出系统可用输入设备(包括外置麦克风/麦克风阵列)的能力,
5
  供 CLI、API 以及前端进行设备选择。
6
  """
7
  from typing import List, Optional, TypedDict
@@ -20,6 +20,15 @@ class InputDeviceInfo(TypedDict):
20
  is_default: bool
21
 
22
 
 
 
 
 
 
 
 
 
 
23
  def _get_default_input_index(p: pyaudio.PyAudio) -> Optional[int]:
24
  """获取系统默认输入设备索引,失败时返回 None。"""
25
  try:
@@ -87,3 +96,72 @@ def is_valid_input_device(index: Optional[int]) -> bool:
87
  if index is None:
88
  return True
89
  return any(d["index"] == index for d in list_input_devices())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ 音频设备枚举工具。
3
 
4
+ 提供列出系统可用输入/输出设备(包括外置麦克风阵列、外置扬声器)的能力,
5
  供 CLI、API 以及前端进行设备选择。
6
  """
7
  from typing import List, Optional, TypedDict
 
20
  is_default: bool
21
 
22
 
23
+ class OutputDeviceInfo(TypedDict):
24
+ """输出设备信息。"""
25
+ index: int
26
+ name: str
27
+ max_output_channels: int
28
+ default_sample_rate: int
29
+ is_default: bool
30
+
31
+
32
  def _get_default_input_index(p: pyaudio.PyAudio) -> Optional[int]:
33
  """获取系统默认输入设备索引,失败时返回 None。"""
34
  try:
 
96
  if index is None:
97
  return True
98
  return any(d["index"] == index for d in list_input_devices())
99
+
100
+
101
+ def _get_default_output_index(p: pyaudio.PyAudio) -> Optional[int]:
102
+ """获取系统默认输出设备索引,失败时返回 None。"""
103
+ try:
104
+ return int(p.get_default_output_device_info().get("index"))
105
+ except Exception:
106
+ return None
107
+
108
+
109
+ def list_output_devices() -> List[OutputDeviceInfo]:
110
+ """
111
+ 列出所有可用的音频输出设备。
112
+
113
+ Returns:
114
+ List[OutputDeviceInfo]: 输出设备列表(仅包含 maxOutputChannels > 0 的设备)。
115
+ """
116
+ devices: List[OutputDeviceInfo] = []
117
+ p = pyaudio.PyAudio()
118
+ try:
119
+ default_index = _get_default_output_index(p)
120
+ for i in range(p.get_device_count()):
121
+ try:
122
+ info = p.get_device_info_by_index(i)
123
+ except Exception as e:
124
+ logger.warning(f"读取音频设备 {i} 信息失败: {e}")
125
+ continue
126
+
127
+ max_output_channels = int(info.get("maxOutputChannels", 0))
128
+ if max_output_channels <= 0:
129
+ continue
130
+
131
+ devices.append(
132
+ OutputDeviceInfo(
133
+ index=int(info.get("index", i)),
134
+ name=str(info.get("name", f"device-{i}")),
135
+ max_output_channels=max_output_channels,
136
+ default_sample_rate=int(info.get("defaultSampleRate", 48000)),
137
+ is_default=(int(info.get("index", i)) == default_index),
138
+ )
139
+ )
140
+ finally:
141
+ p.terminate()
142
+
143
+ return devices
144
+
145
+
146
+ def get_default_output_device_index() -> Optional[int]:
147
+ """获取系统默认输出设备索引。"""
148
+ p = pyaudio.PyAudio()
149
+ try:
150
+ return _get_default_output_index(p)
151
+ finally:
152
+ p.terminate()
153
+
154
+
155
+ def is_valid_output_device(index: Optional[int]) -> bool:
156
+ """
157
+ 校验给定索引是否为有效的输出设备。
158
+
159
+ Args:
160
+ index: 设备索引;None 表示使用系统默认设备,视为有效。
161
+
162
+ Returns:
163
+ bool: 是否有效。
164
+ """
165
+ if index is None:
166
+ return True
167
+ return any(d["index"] == index for d in list_output_devices())
src/voice_dialogue/audio/player.py CHANGED
@@ -1,10 +1,78 @@
1
  import tempfile
 
2
 
 
3
  import soundfile as sf
4
  from playsound import playsound
5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
- def play_audio(audio_data, sample_rate=16000):
8
  with tempfile.NamedTemporaryFile('w+b', suffix='.wav') as soundfile:
9
  sf.write(soundfile, audio_data, samplerate=sample_rate, subtype='PCM_16', closefd=False)
10
  playsound(soundfile.name, block=True)
 
1
  import tempfile
2
+ from typing import Optional
3
 
4
+ import numpy as np
5
  import soundfile as sf
6
  from playsound import playsound
7
 
8
+ from voice_dialogue.utils.logger import logger
9
+
10
+
11
+ def _to_int16(audio_data) -> np.ndarray:
12
+ """将音频数据规整为一维 int16。"""
13
+ audio = np.asarray(audio_data)
14
+ if audio.ndim > 1:
15
+ audio = audio.mean(axis=-1)
16
+ if audio.dtype != np.int16:
17
+ audio = np.clip(audio, -1.0, 1.0)
18
+ audio = (audio * 32767.0).astype(np.int16)
19
+ return audio
20
+
21
+
22
+ def _play_via_pyaudio(audio_data, sample_rate: int, output_device_index: int):
23
+ """通过 PyAudio 输出流播放,支持指定输出设备。"""
24
+ import pyaudio
25
+
26
+ audio = _to_int16(audio_data)
27
+
28
+ p = pyaudio.PyAudio()
29
+ try:
30
+ # 设备不支持该采样率时,重采样到设备默认采样率
31
+ try:
32
+ p.is_format_supported(
33
+ rate=sample_rate,
34
+ output_device=output_device_index,
35
+ output_channels=1,
36
+ output_format=pyaudio.paInt16,
37
+ )
38
+ except Exception:
39
+ device_rate = int(p.get_device_info_by_index(output_device_index).get("defaultSampleRate", 48000))
40
+ logger.info(f"输出设备不支持 {sample_rate}Hz,重采样到 {device_rate}Hz")
41
+ import soxr
42
+ audio = soxr.resample(audio, sample_rate, device_rate).astype(np.int16)
43
+ sample_rate = device_rate
44
+
45
+ stream = p.open(
46
+ format=pyaudio.paInt16,
47
+ channels=1,
48
+ rate=sample_rate,
49
+ output=True,
50
+ output_device_index=output_device_index,
51
+ )
52
+ try:
53
+ stream.write(audio.tobytes())
54
+ finally:
55
+ stream.stop_stream()
56
+ stream.close()
57
+ finally:
58
+ p.terminate()
59
+
60
+
61
+ def play_audio(audio_data, sample_rate=16000, output_device_index: Optional[int] = None):
62
+ """播放音频。
63
+
64
+ Args:
65
+ audio_data: 音频数据
66
+ sample_rate: 采样率
67
+ output_device_index: 输出设备索引;None 表示系统默认设备
68
+ """
69
+ if output_device_index is not None:
70
+ try:
71
+ _play_via_pyaudio(audio_data, sample_rate, output_device_index)
72
+ return
73
+ except Exception as e:
74
+ logger.warning(f"指定输出设备 {output_device_index} 播放失败,回退到系统默认设备: {e}")
75
 
 
76
  with tempfile.NamedTemporaryFile('w+b', suffix='.wav') as soundfile:
77
  sf.write(soundfile, audio_data, samplerate=sample_rate, subtype='PCM_16', closefd=False)
78
  playsound(soundfile.name, block=True)
src/voice_dialogue/config/audio_config.py CHANGED
@@ -12,6 +12,7 @@ from .paths import AUDIO_SETTINGS_PATH
12
  class AudioSettings(TypedDict, total=False):
13
  """音频设置。"""
14
  input_device_index: Optional[int]
 
15
 
16
 
17
  _audio_settings_cache: Optional[AudioSettings] = None
@@ -42,19 +43,35 @@ def get_input_device_index() -> Optional[int]:
42
  return int(value) if value is not None else None
43
 
44
 
45
- def 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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  class AudioSettings(TypedDict, total=False):
13
  """音频设置。"""
14
  input_device_index: Optional[int]
15
+ output_device_index: Optional[int]
16
 
17
 
18
  _audio_settings_cache: Optional[AudioSettings] = None
 
43
  return int(value) if value is not None else None
44
 
45
 
46
+ def _save_audio_setting(key: str, value: Optional[int]) -> bool:
47
+ """保存单项音频置并刷新缓存。"""
48
  global _audio_settings_cache
49
  settings = dict(get_audio_settings())
50
+ settings[key] = value
51
  try:
52
  if not AUDIO_SETTINGS_PATH.parent.exists():
53
  AUDIO_SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
54
  with open(AUDIO_SETTINGS_PATH, "w", encoding="utf-8") as f:
55
  json.dump(settings, f, ensure_ascii=False, indent=4)
56
  _audio_settings_cache = settings # type: ignore[assignment]
57
+ logger.info(f"音频设置已保存: {key}={value}")
58
  return True
59
  except IOError as e:
60
  logger.error(f"无法保存音频设置: {e}")
61
  return False
62
+
63
+
64
+ def save_input_device_index(input_device_index: Optional[int]) -> bool:
65
+ """保存用户选择的输入设备索引。"""
66
+ return _save_audio_setting("input_device_index", input_device_index)
67
+
68
+
69
+ def get_output_device_index() -> Optional[int]:
70
+ """获取已保存的输出设备索引;未配置时返回 None(系统默认设备)。"""
71
+ value = get_audio_settings().get("output_device_index")
72
+ return int(value) if value is not None else None
73
+
74
+
75
+ def save_output_device_index(output_device_index: Optional[int]) -> bool:
76
+ """保存用户选择的输出设备索引。"""
77
+ return _save_audio_setting("output_device_index", output_device_index)
src/voice_dialogue/services/audio_player_service.py CHANGED
@@ -4,6 +4,7 @@ from queue import Empty
4
  from typing import Optional
5
 
6
  from voice_dialogue.audio.player import play_audio
 
7
  from voice_dialogue.core.base import BaseThread
8
  from voice_dialogue.core.constants import voice_state_manager, silence_over_threshold_event
9
  from voice_dialogue.models.voice_task import VoiceTask, AnswerDisplayMessage
@@ -64,7 +65,8 @@ class AudioPlayerService(BaseThread, TaskStatusMixin, HistoryMixin, PerformanceL
64
 
65
  if not self.is_stopped:
66
  audio_data, sample_rate = voice_task.tts_generated_sentence_audio
67
- play_audio(audio_data, sample_rate)
 
68
 
69
  # 任务处理完毕,跳出内部循环
70
  break
 
4
  from typing import Optional
5
 
6
  from voice_dialogue.audio.player import play_audio
7
+ from voice_dialogue.config.audio_config import get_output_device_index
8
  from voice_dialogue.core.base import BaseThread
9
  from voice_dialogue.core.constants import voice_state_manager, silence_over_threshold_event
10
  from voice_dialogue.models.voice_task import VoiceTask, AnswerDisplayMessage
 
65
 
66
  if not self.is_stopped:
67
  audio_data, sample_rate = voice_task.tts_generated_sentence_audio
68
+ # 每次播放时读取保存的输出设备,设置变更后下一句即生效
69
+ play_audio(audio_data, sample_rate, output_device_index=get_output_device_index())
70
 
71
  # 任务处理完毕,跳出内部循环
72
  break