1use audio::{AudioSettings, CHANNEL_COUNT, RodioExt, SAMPLE_RATE};
2use cpal::DeviceId;
3use gpui::{
4 App, Context, Entity, FocusHandle, Focusable, Render, Size, Tiling, Window, WindowBounds,
5 WindowKind, WindowOptions, prelude::*, px,
6};
7use platform_title_bar::PlatformTitleBar;
8use release_channel::ReleaseChannel;
9use rodio::Source;
10use settings::{AudioInputDeviceName, AudioOutputDeviceName, Settings};
11use std::{
12 any::Any,
13 sync::{
14 Arc,
15 atomic::{AtomicBool, Ordering},
16 },
17 thread,
18 time::Duration,
19};
20use ui::{Button, ButtonStyle, Label, prelude::*};
21use util::ResultExt;
22use workspace::client_side_decorations;
23
24use super::audio_input_output_setup::render_audio_device_dropdown;
25use crate::{SettingsUiFile, update_settings_file};
26
27pub struct AudioTestWindow {
28 title_bar: Option<Entity<PlatformTitleBar>>,
29 input_device_id: Option<DeviceId>,
30 output_device_id: Option<DeviceId>,
31 focus_handle: FocusHandle,
32 _stop_playback: Option<Box<dyn Any + Send>>,
33}
34
35impl AudioTestWindow {
36 pub fn new(cx: &mut Context<Self>) -> Self {
37 let title_bar = if !cfg!(target_os = "macos") {
38 Some(cx.new(|cx| PlatformTitleBar::new("audio-test-title-bar", cx)))
39 } else {
40 None
41 };
42
43 let audio_settings = AudioSettings::get_global(cx);
44 let input_device_id = audio_settings.input_audio_device.clone();
45 let output_device_id = audio_settings.output_audio_device.clone();
46
47 Self {
48 title_bar,
49 input_device_id,
50 output_device_id,
51 focus_handle: cx.focus_handle(),
52 _stop_playback: None,
53 }
54 }
55
56 fn toggle_testing(&mut self, cx: &mut Context<Self>) {
57 if let Some(_cb) = self._stop_playback.take() {
58 cx.notify();
59 return;
60 }
61
62 if let Some(cb) =
63 start_test_playback(self.input_device_id.clone(), self.output_device_id.clone()).ok()
64 {
65 self._stop_playback = Some(cb);
66 }
67
68 cx.notify();
69 }
70}
71
72fn start_test_playback(
73 input_device_id: Option<DeviceId>,
74 output_device_id: Option<DeviceId>,
75) -> anyhow::Result<Box<dyn Any + Send>> {
76 let stop_signal = Arc::new(AtomicBool::new(false));
77
78 thread::Builder::new()
79 .name("AudioTestPlayback".to_string())
80 .spawn({
81 let stop_signal = stop_signal.clone();
82 move || {
83 let microphone = match open_test_microphone(input_device_id, stop_signal.clone()) {
84 Ok(mic) => mic,
85 Err(e) => {
86 log::error!("Could not open microphone for audio test: {e}");
87 return;
88 }
89 };
90
91 let Ok(output) = audio::open_output_stream(output_device_id) else {
92 log::error!("Could not open output device for audio test");
93 return;
94 };
95
96 // let microphone = rx.recv().unwrap();
97 output.mixer().add(microphone);
98
99 // Keep thread (and output device) alive until stop signal
100 while !stop_signal.load(Ordering::Relaxed) {
101 thread::sleep(Duration::from_millis(100));
102 }
103 }
104 })?;
105
106 Ok(Box::new(util::defer(move || {
107 stop_signal.store(true, Ordering::Relaxed);
108 })))
109}
110
111fn open_test_microphone(
112 input_device_id: Option<DeviceId>,
113 stop_signal: Arc<AtomicBool>,
114) -> anyhow::Result<impl Source> {
115 let stream = audio::open_input_stream(input_device_id)?;
116 let stream = stream
117 .possibly_disconnected_channels_to_mono()
118 .constant_samplerate(SAMPLE_RATE)
119 .constant_params(CHANNEL_COUNT, SAMPLE_RATE)
120 .stoppable()
121 .periodic_access(
122 Duration::from_millis(50),
123 move |stoppable: &mut rodio::source::Stoppable<_>| {
124 if stop_signal.load(Ordering::Relaxed) {
125 stoppable.stop();
126 }
127 },
128 );
129 Ok(stream)
130}
131
132impl Render for AudioTestWindow {
133 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
134 let is_testing = self._stop_playback.is_some();
135 let button_text = if is_testing {
136 "Stop Testing"
137 } else {
138 "Start Testing"
139 };
140
141 let button_style = if is_testing {
142 ButtonStyle::Tinted(ui::TintColor::Error)
143 } else {
144 ButtonStyle::Filled
145 };
146
147 let weak_entity = cx.entity().downgrade();
148 let input_dropdown = {
149 let weak_entity = weak_entity.clone();
150 render_audio_device_dropdown(
151 "audio-test-input-dropdown",
152 self.input_device_id.clone(),
153 true,
154 move |device_id, window, cx| {
155 weak_entity
156 .update(cx, |this, cx| {
157 this.input_device_id = device_id.clone();
158 cx.notify();
159 })
160 .log_err();
161 let value: Option<AudioInputDeviceName> =
162 device_id.map(|id| AudioInputDeviceName(Some(id.to_string())));
163 update_settings_file(
164 SettingsUiFile::User,
165 Some("audio.experimental.input_audio_device"),
166 window,
167 cx,
168 move |settings, _cx| {
169 settings.audio.get_or_insert_default().input_audio_device = value;
170 },
171 )
172 .log_err();
173 },
174 window,
175 cx,
176 )
177 };
178
179 let output_dropdown = render_audio_device_dropdown(
180 "audio-test-output-dropdown",
181 self.output_device_id.clone(),
182 false,
183 move |device_id, window, cx| {
184 weak_entity
185 .update(cx, |this, cx| {
186 this.output_device_id = device_id.clone();
187 cx.notify();
188 })
189 .log_err();
190 let value: Option<AudioOutputDeviceName> =
191 device_id.map(|id| AudioOutputDeviceName(Some(id.to_string())));
192 update_settings_file(
193 SettingsUiFile::User,
194 Some("audio.experimental.output_audio_device"),
195 window,
196 cx,
197 move |settings, _cx| {
198 settings.audio.get_or_insert_default().output_audio_device = value;
199 },
200 )
201 .log_err();
202 },
203 window,
204 cx,
205 );
206
207 let content = v_flex()
208 .id("audio-test-window")
209 .track_focus(&self.focus_handle)
210 .size_full()
211 .p_4()
212 .when(cfg!(target_os = "macos"), |this| this.pt_10())
213 .gap_4()
214 .bg(cx.theme().colors().editor_background)
215 .child(
216 v_flex()
217 .gap_1()
218 .child(Label::new("Output Device"))
219 .child(output_dropdown),
220 )
221 .child(
222 v_flex()
223 .gap_1()
224 .child(Label::new("Input Device"))
225 .child(input_dropdown),
226 )
227 .child(
228 h_flex().w_full().justify_center().pt_4().child(
229 Button::new("test-audio-toggle", button_text)
230 .style(button_style)
231 .on_click(cx.listener(|this, _, _, cx| this.toggle_testing(cx))),
232 ),
233 );
234
235 client_side_decorations(
236 v_flex()
237 .size_full()
238 .text_color(cx.theme().colors().text)
239 .children(self.title_bar.clone())
240 .child(content),
241 window,
242 cx,
243 Tiling::default(),
244 )
245 }
246}
247
248impl Focusable for AudioTestWindow {
249 fn focus_handle(&self, _cx: &App) -> FocusHandle {
250 self.focus_handle.clone()
251 }
252}
253
254impl Drop for AudioTestWindow {
255 fn drop(&mut self) {
256 let _ = self._stop_playback.take();
257 }
258}
259
260pub fn open_audio_test_window(_window: &mut Window, cx: &mut App) {
261 let existing = cx
262 .windows()
263 .into_iter()
264 .find_map(|w| w.downcast::<AudioTestWindow>());
265
266 if let Some(existing) = existing {
267 existing
268 .update(cx, |_, window, _| window.activate_window())
269 .log_err();
270 return;
271 }
272
273 let app_id = ReleaseChannel::global(cx).app_id();
274 let window_size = Size {
275 width: px(640.0),
276 height: px(300.0),
277 };
278 let window_min_size = Size {
279 width: px(400.0),
280 height: px(240.0),
281 };
282
283 cx.open_window(
284 WindowOptions {
285 titlebar: Some(gpui::TitlebarOptions {
286 title: Some("Audio Test".into()),
287 appears_transparent: true,
288 traffic_light_position: Some(gpui::point(px(12.0), px(12.0))),
289 }),
290 focus: true,
291 show: true,
292 is_movable: true,
293 kind: WindowKind::Normal,
294 window_background: cx.theme().window_background_appearance(),
295 app_id: Some(app_id.to_owned()),
296 window_decorations: Some(gpui::WindowDecorations::Client),
297 window_bounds: Some(WindowBounds::centered(window_size, cx)),
298 window_min_size: Some(window_min_size),
299 ..Default::default()
300 },
301 |_, cx| cx.new(AudioTestWindow::new),
302 )
303 .log_err();
304}