Skip to main content

rescale_core/tui/
menu.rs

1//! Sistem Interactive TUI Menu untuk REScale DSP Engine (Zero Dependency).
2//! Didesain bergaya **DAW Plugin Dark Theme** — VU LED meter, smooth spectrum analyzer,
3//! waveform display, dan processing status indicator yang immersive.
4
5use super::canvas::TerminalCanvas;
6use crate::{
7    AudioBlock, AudioProcessor, AudioSpec, BrickwallLimiter, Complex, Compressor, CompressorConfig,
8    DetectionMode, DitherMode, EqualizerProcessor, Fft, Gain, GainProcessor, LimiterProcessor,
9    Normalizer, ParametricEqualizer3Band, ProcessorChain, WavReader, WavSampleFormat,
10    WavWriteOptions, WavWriter, Window, WindowType,
11};
12use std::f32::consts::PI;
13use std::io::{stdin, stdout, Write};
14use std::path::Path;
15use std::thread::sleep;
16use std::time::{Duration, Instant};
17
18// ═══════════════════════════════════════════
19//  STRUKTUR DATA & KONFIGURASI
20// ═══════════════════════════════════════════
21
22#[derive(Debug, Clone)]
23pub struct MenuItem {
24    pub key: char,
25    pub title: &'static str,
26    pub description: &'static str,
27}
28
29pub struct TuiMenu {
30    canvas: TerminalCanvas,
31    items: Vec<MenuItem>,
32}
33
34impl TuiMenu {
35    pub fn new() -> Self {
36        Self {
37            canvas: TerminalCanvas::new(100, 30),
38            items: vec![
39                MenuItem {
40                    key: '1',
41                    title: "🎵 Master / ReScale Audio File",
42                    description:
43                        "Headroom normalization, Dynamic Compressor, & Lookahead Brickwall Limiter",
44                },
45                MenuItem {
46                    key: '2',
47                    title: "📊 Realtime DSP & Spectrum Monitor",
48                    description:
49                        "Live 9-Band FFT Spectrogram, Stereo Level Meter, & Frequency Sweep DSP",
50                },
51                MenuItem {
52                    key: '3',
53                    title: "🔍 Inspect Audio File (WAV/MP3)",
54                    description:
55                        "RIFF metadata analysis, sample rate, bit depth, Peak dBFS, & RMS Energy",
56                },
57                MenuItem {
58                    key: '4',
59                    title: "🎛️ 3-Band Parametric EQ & Gain Tool",
60                    description:
61                        "Biquad IIR Filter (Low Shelf, Peaking Mid, High Shelf) & Gain Control",
62                },
63                MenuItem {
64                    key: '5',
65                    title: "❌ Quit (Exit)",
66                    description: "Close the REScale Audio Engine",
67                },
68            ],
69        }
70    }
71
72    /// Menjalankan loop interaktif menu utama
73    pub fn run(&self) {
74        loop {
75            self.draw_main_menu();
76            // Kursor disembunyikan oleh clear_screen; tampilkan kembali sebelum
77            // meminta input agar pengguna bisa melihat apa yang diketik.
78            self.canvas.show_cursor();
79            print!("\n  \x1b[1;33mSelect menu [1-5 / q]: \x1b[0m");
80            let _ = stdout().flush();
81
82            let mut input = String::new();
83            match stdin().read_line(&mut input) {
84                // Ok(0) = EOF (Ctrl-Z / Ctrl-D / stream tertutup) → keluar agar
85                // tidak masuk infinite loop redraw menu.
86                Ok(0) => break,
87                Ok(_) => {}
88                Err(_) => break,
89            }
90            let choice = input.trim().to_lowercase();
91
92            match choice.as_str() {
93                "1" => self.action_master_audio(),
94                "2" => self.action_realtime_monitor(),
95                "3" => self.action_inspect_audio(),
96                "4" => self.action_equalizer_tool(),
97                "5" | "q" | "exit" => {
98                    println!(
99                        "\n  \x1b[1;36mThank you for using REScale DSP Audio Engine!\x1b[0m\n"
100                    );
101                    break;
102                }
103                _ => {
104                    println!("  \x1b[1;31mInvalid choice. Press Enter to continue...\x1b[0m");
105                    let _ = stdin().read_line(&mut String::new());
106                }
107            }
108        }
109
110        // Pastikan kursor & warna terminal dikembalikan apa pun jalur keluarnya.
111        self.canvas.restore_cursor();
112    }
113
114    // ═══════════════════════════════════════════
115    //  GAMBAR MENU UTAMA — Header Gaya Plugin
116    // ═══════════════════════════════════════════
117
118    fn draw_main_menu(&self) {
119        self.canvas.clear_screen();
120
121        // Panel utama dengan judul
122        self.canvas
123            .draw_panel(1, 1, 98, 28, "⚡ RESCALE DSP ENGINE", Some("v0.1.0"));
124
125        // Header info sistem
126        self.canvas.move_to(3, 4);
127        print!(
128            "\x1b[90mARCHITECTURE\x1b[0m: Pure Rust Zero External Dependency   │   \x1b[90mFORMAT\x1b[0m: f32 [-1..1] Planar Audio"
129        );
130
131        self.canvas.move_to(4, 4);
132        print!(
133            "\x1b[90mTEST SUITE\x1b[0m: 18 Unit Tests Passing   │   \x1b[90mPERFORMANCE\x1b[0m: Full Chain < 0.2s (3.4min stereo)"
134        );
135
136        // Separator horizontal
137        self.canvas.move_to(6, 4);
138        print!("\x1b[38;5;240m{}", "─".repeat(90));
139
140        // Daftar item menu dengan styling track DAW
141        let mut row = 8;
142        for item in &self.items {
143            // Highlight bar di kiri (seperti track selection di DAW)
144            let highlight_char = if item.key == '2' { '▶' } else { '▸' };
145            let color = if item.key == '2' {
146                "\x1b[1;36m"
147            } else {
148                "\x1b[1;33m"
149            };
150
151            self.canvas.move_to(row, 4);
152            print!("{color}[{highlight_char}]\x1b[0m ");
153            let item_color = if item.key == '5' || item.key == 'q' {
154                "\x1b[1;31m"
155            } else {
156                "\x1b[1;37m"
157            };
158            print!("{item_color}{:<38}", item.title);
159            print!(" \x1b[90m— {}\x1b[0m", item.description);
160            row += 2;
161        }
162
163        // Instruksi footer
164        self.canvas.move_to(26, 4);
165        print!("\x1b[90mUse number keys [1 - 5] or 'q' to quit.\x1b[0m");
166        let _ = stdout().flush();
167    }
168
169    // ═══════════════════════════════════════════
170    //  AKSI 1: MASTERING DENGAN UMPAN BALIK VISUAL
171    // ═══════════════════════════════════════════
172
173    fn action_master_audio(&self) {
174        self.canvas.clear_screen();
175        self.canvas
176            .draw_panel(1, 1, 96, 28, " 🎵 AUDIO MASTERING PROCESSOR ", None);
177
178        // Path input
179        print!("\n  \x1b[1;37m[1] Enter audio file path (.wav / .mp3):\x1b[0m ");
180        let _ = stdout().flush();
181        let mut path_str = String::new();
182        if stdin().read_line(&mut path_str).is_err() {
183            return;
184        }
185        let input_path = path_str.trim().trim_matches('"').trim();
186        if input_path.is_empty() {
187            return;
188        }
189
190        let path = Path::new(input_path);
191        if !path.exists() {
192            println!("  \x1b[1;31m[ERROR] File not found: {}\x1b[0m", input_path);
193            self.wait_keypress();
194            return;
195        }
196
197        // Target dBFS
198        print!("  \x1b[1;37m[2] Target Output dBFS [-60.0 to 0.0, default: -1.0]:\x1b[0m ");
199        let _ = stdout().flush();
200        let mut target_str = String::new();
201        let _ = stdin().read_line(&mut target_str);
202        let target_dbfs: f32 = target_str.trim().parse().unwrap_or(-1.0);
203
204        // Bit depth
205        print!("  \x1b[1;37m[3] Output Bit Depth [16 / 24 / 32f, default: 24]:\x1b[0m ");
206        let _ = stdout().flush();
207        let mut depth_str = String::new();
208        let _ = stdin().read_line(&mut depth_str);
209        let format = match depth_str.trim().to_lowercase().as_str() {
210            "16" => WavSampleFormat::Pcm16Bit,
211            "32" | "32f" => WavSampleFormat::Pcm32BitFloat,
212            _ => WavSampleFormat::Pcm24Bit,
213        };
214
215        let dither = if format == WavSampleFormat::Pcm32BitFloat {
216            DitherMode::None
217        } else {
218            DitherMode::Tpdf
219        };
220
221        // Proses mastering dengan visualisasi progres
222        println!("\n  \x1b[1;32m[PROCESSING] Starting mastering pipeline...\x1b[0m");
223        self.canvas.draw_separator(18, 4, 80);
224
225        let start_time = Instant::now();
226
227        if let Err(err) = self.process_mastering_visual(path, target_dbfs, format, dither) {
228            println!("  \x1b[1;31m[FAILED] Processing failed: {err}\x1b[0m");
229        } else {
230            let elapsed = start_time.elapsed().as_secs_f32();
231            println!(
232                "\n  \x1b[1;32m[DONE] Mastering complete in {:.2} seconds!\x1b[0m",
233                elapsed
234            );
235        }
236
237        self.wait_keypress();
238    }
239
240    fn process_mastering_visual(
241        &self,
242        input_path: &Path,
243        target_dbfs: f32,
244        output_format: WavSampleFormat,
245        dither: DitherMode,
246    ) -> Result<(), Box<dyn std::error::Error>> {
247        let (header, mut channels) = WavReader::read_from_file(input_path)?;
248        let sample_rate = header.sample_rate as f32;
249
250        // Tampilkan analisis awal
251        let initial_peak = channels
252            .iter()
253            .map(|ch| Normalizer::find_peak(ch))
254            .fold(0.0_f32, f32::max);
255        let initial_rms = channels
256            .iter()
257            .map(|ch| Normalizer::calculate_rms(ch))
258            .fold(0.0_f32, f32::max);
259
260        println!(
261            "  \x1b[90mANALYSIS INPUT: {} Hz | {} ch | Peak {:>6.2} dBFS | RMS {:>6.2} dBFS\x1b[0m",
262            header.sample_rate,
263            header.num_channels,
264            Gain::linear_to_db(initial_peak),
265            Gain::linear_to_db(initial_rms),
266        );
267
268        let target_linear = Gain::db_to_linear(target_dbfs);
269        Normalizer::normalize_channels_to_peak(&mut channels, target_linear);
270
271        // Bangun rantai DSP
272        let comp_config = CompressorConfig {
273            threshold_db: target_dbfs - 8.0,
274            ratio: 2.5,
275            knee_db: 3.0,
276            attack_ms: 8.0,
277            release_ms: 100.0,
278            makeup_gain_db: 0.8,
279            mode: DetectionMode::Rms,
280        };
281
282        let mut chain = ProcessorChain::new();
283        chain.push(crate::engine::CompressorProcessor::new(comp_config.clone()));
284        chain.push(LimiterProcessor::new(target_dbfs, 25.0, 2.0));
285        chain.prepare(AudioSpec::new(sample_rate, channels.len())?)?;
286
287        let mut channel_views: Vec<&mut [f32]> =
288            channels.iter_mut().map(Vec::as_mut_slice).collect();
289        let mut block = AudioBlock::new(&mut channel_views)?;
290        chain.process(&mut block)?;
291
292        // Simpan output
293        let song_stem = input_path
294            .file_stem()
295            .and_then(|s| s.to_str())
296            .unwrap_or("audio");
297        let parent_dir = input_path.parent().unwrap_or_else(|| Path::new("."));
298        let output_path = parent_dir.join(format!("REScale-{}.wav", song_stem));
299
300        WavWriter::write_to_file_with_options(
301            &output_path,
302            &channels,
303            header.sample_rate,
304            WavWriteOptions::new(output_format, dither),
305        )?;
306
307        let final_peak = channels
308            .iter()
309            .map(|ch| Normalizer::find_peak(ch))
310            .fold(0.0_f32, f32::max);
311        let final_rms = channels
312            .iter()
313            .map(|ch| Normalizer::calculate_rms(ch))
314            .fold(0.0_f32, f32::max);
315
316        println!(
317            "  \x1b[90mRESULT OUTPUT:  Peak {:>6.2} dBFS | RMS {:>6.2} dBFS\x1b[0m",
318            Gain::linear_to_db(final_peak),
319            Gain::linear_to_db(final_rms),
320        );
321        println!(
322            "  \x1b[1;32mSAVED TO:       {}\x1b[0m",
323            output_path.display()
324        );
325        Ok(())
326    }
327
328    // ═══════════════════════════════════════════
329    //  AKSI 2: MONITOR REALTIME — GAYA DAW PENUH
330    // ═══════════════════════════════════════════
331
332    fn action_realtime_monitor(&self) {
333        // Gunakan ukuran besar untuk monitor fullscreen
334        let w = 100usize;
335        let h = 30usize;
336        let tui = TerminalCanvas::new(w, h);
337        tui.clear_screen();
338
339        let sample_rate = 44_100.0;
340        let total_frames = 300; // Lebih panjang daripada sebelumnya
341        let mut phase = 0.0_f32;
342
343        // Siapkan rantai DSP
344        let mut eq = ParametricEqualizer3Band::new(sample_rate);
345        eq.set_low_band(200.0, 3.0);
346        eq.set_mid_band(1000.0, 1.2, -2.0);
347        eq.set_high_band(6000.0, 1.5);
348
349        let comp_config = CompressorConfig {
350            threshold_db: -14.0,
351            ratio: 4.0,
352            knee_db: 4.0,
353            attack_ms: 5.0,
354            release_ms: 80.0,
355            makeup_gain_db: 1.5,
356            mode: DetectionMode::Peak,
357        };
358        let mut compressor = Compressor::new(sample_rate, comp_config);
359        let mut limiter = BrickwallLimiter::new(sample_rate, -0.2, 20.0, 2.0);
360
361        // Lacak peak untuk indikator hold
362        let mut prev_in_peak_l = 0.0_f32;
363        let mut prev_in_peak_r = 0.0_f32;
364        let mut prev_out_peak_l = 0.0_f32;
365        let mut prev_out_peak_r = 0.0_f32;
366
367        for frame in 0..total_frames {
368            // Bangkitkan sinyal sweep frekuensi
369            let sweep_freq = 180.0 + (frame as f32 * 0.08).sin().abs() * 3_200.0;
370            let block_size = 512;
371            let mut left = Vec::with_capacity(block_size);
372            let mut right = Vec::with_capacity(block_size);
373
374            for _ in 0..block_size {
375                let sample = (phase).sin() * 0.5;
376                phase += 2.0 * PI * sweep_freq / sample_rate;
377                if phase > 2.0 * PI {
378                    phase -= 2.0 * PI;
379                }
380                left.push(sample);
381                right.push(sample * 0.85);
382            }
383
384            // Level pra-pemrosesan
385            let in_peak_l = Normalizer::find_peak(&left);
386            let in_peak_r = Normalizer::find_peak(&right);
387            let _in_rms_l = Normalizer::calculate_rms(&left);
388            let _in_rms_r = Normalizer::calculate_rms(&right);
389
390            // Proses melalui rantai DSP
391            eq.process_buffer(&mut left);
392            compressor.process_buffer(&mut left);
393            limiter.process_buffer(&mut left);
394
395            eq.process_buffer(&mut right);
396            compressor.process_buffer(&mut right);
397            limiter.process_buffer(&mut right);
398
399            // Level pasca-pemrosesan
400            let out_peak_l = Normalizer::find_peak(&left);
401            let out_peak_r = Normalizer::find_peak(&right);
402            let _out_rms_l = Normalizer::calculate_rms(&left);
403            let _out_rms_r = Normalizer::calculate_rms(&right);
404
405            // Analisis FFT
406            let mut fft_buf: Vec<Complex> = left.iter().map(|&s| Complex::new(s, 0.0)).collect();
407            let mut win = left.clone();
408            Window::apply(&mut win, WindowType::Hann);
409            for (c, &w) in fft_buf.iter_mut().zip(win.iter()) {
410                c.re = w;
411            }
412            Fft::forward(&mut fft_buf);
413
414            let spectrum_bands = [
415                (
416                    fft_buf[1..3].iter().map(|c| c.magnitude()).sum::<f32>(),
417                    "63",
418                ),
419                (
420                    fft_buf[3..5].iter().map(|c| c.magnitude()).sum::<f32>(),
421                    "125",
422                ),
423                (
424                    fft_buf[5..9].iter().map(|c| c.magnitude()).sum::<f32>(),
425                    "250",
426                ),
427                (
428                    fft_buf[9..16].iter().map(|c| c.magnitude()).sum::<f32>(),
429                    "500",
430                ),
431                (
432                    fft_buf[16..31].iter().map(|c| c.magnitude()).sum::<f32>(),
433                    "1k",
434                ),
435                (
436                    fft_buf[31..61].iter().map(|c| c.magnitude()).sum::<f32>(),
437                    "2k",
438                ),
439                (
440                    fft_buf[61..121].iter().map(|c| c.magnitude()).sum::<f32>(),
441                    "4k",
442                ),
443                (
444                    fft_buf[121..221].iter().map(|c| c.magnitude()).sum::<f32>(),
445                    "8k",
446                ),
447                (
448                    fft_buf[221..255].iter().map(|c| c.magnitude()).sum::<f32>(),
449                    "16k",
450                ),
451            ];
452
453            // ─── Render Dashboard Gaya DAW ───
454            tui.clear_region(1, 1, w, h);
455
456            // Panel Header
457            tui.draw_header(1, 2, "MONITOR — LIVE DSP ANALYZER", 44100, 2);
458
459            // Bilah statistik info
460            tui.draw_stat(3, 4, "MODE", "Dynamic Frequency Sweep");
461            tui.draw_stat(3, 36, "SWEEP FREQ", &format!("{:>6.1} Hz", sweep_freq));
462            tui.draw_stat(3, 72, "FRAME", &format!("{:03}/{total_frames}", frame + 1));
463
464            // Indikator status pemrosesan
465            tui.draw_processing_status(5, 4, &["EQ", "COMPRESS", "LIMIT"]);
466
467            // Pemisah
468            tui.draw_separator(6, 4, 90);
469
470            // ── BAGIAN LEVEL INPUT ──
471            tui.draw_panel(7, 4, 42, 6, " ◉ INPUT SIGNAL", None);
472            tui.draw_vu_meter(
473                8,
474                6,
475                "L",
476                in_peak_l,
477                prev_in_peak_l,
478                Gain::linear_to_db(in_peak_l),
479                26,
480            );
481            tui.draw_vu_meter(
482                10,
483                6,
484                "R",
485                in_peak_r,
486                prev_in_peak_r,
487                Gain::linear_to_db(in_peak_r),
488                26,
489            );
490
491            // ── BAGIAN LEVEL OUTPUT ──
492            tui.draw_panel(7, 50, 42, 6, " ◯ OUTPUT (POST MASTERING)", None);
493            tui.draw_vu_meter(
494                8,
495                52,
496                "L",
497                out_peak_l,
498                prev_out_peak_l,
499                Gain::linear_to_db(out_peak_l),
500                26,
501            );
502            tui.draw_vu_meter(
503                10,
504                52,
505                "R",
506                out_peak_r,
507                prev_out_peak_r,
508                Gain::linear_to_db(out_peak_r),
509                26,
510            );
511
512            // Perbarui peak hold untuk frame berikutnya
513            prev_in_peak_l = in_peak_l.max(prev_in_peak_l);
514            prev_in_peak_r = in_peak_r.max(prev_in_peak_r);
515            prev_out_peak_l = out_peak_l.max(prev_out_peak_l);
516            prev_out_peak_r = out_peak_r.max(prev_out_peak_r);
517
518            // Turunkan peak hold secara bertahap
519            if frame % 10 == 0 && (prev_in_peak_l > in_peak_l || prev_in_peak_r > in_peak_r) {
520                prev_in_peak_l *= 0.9;
521                prev_in_peak_r *= 0.9;
522                prev_out_peak_l *= 0.9;
523                prev_out_peak_r *= 0.9;
524            }
525
526            // ── ANALISIS SPEKTRUM ──
527            tui.draw_panel(
528                14,
529                4,
530                92,
531                12,
532                " ◆ SPECTRUM ANALYZER (FFT 512 / HANN WINDOW)",
533                None,
534            );
535            tui.draw_smooth_spectrum(16, 6, 8, &spectrum_bands);
536
537            // Bilah status bawah
538            tui.draw_separator(28, 4, 90);
539            tui.move_to(29, 4);
540            print!(
541                "\x1b[90mPlaying live simulation... Frame {:03}/{total_frames} | {} Hz Sweep | Block 512 samples\x1b[0m",
542                frame + 1,
543                sweep_freq
544            );
545
546            let _ = stdout().flush();
547            sleep(Duration::from_millis(30));
548        }
549
550        tui.show_cursor();
551        println!("\n\n  \x1b[1;32mRealtime Monitor Complete!\x1b[0m");
552        self.wait_keypress();
553    }
554
555    // ═══════════════════════════════════════════
556    //  AKSI 3: INSPEKSI DENGAN METER VISUAL
557    // ═══════════════════════════════════════════
558
559    fn action_inspect_audio(&self) {
560        self.canvas.clear_screen();
561        self.canvas
562            .draw_panel(1, 1, 94, 28, " 🔍 AUDIO FILE INSPECTOR ", None);
563
564        print!("\n  \x1b[1;37mEnter audio file path (.wav):\x1b[0m ");
565        let _ = stdout().flush();
566        let mut path_str = String::new();
567        if stdin().read_line(&mut path_str).is_err() {
568            return;
569        }
570        let input_path = path_str.trim().trim_matches('"').trim();
571        if input_path.is_empty() {
572            return;
573        }
574
575        match WavReader::read_from_file(input_path) {
576            Ok((header, channels)) => {
577                let duration_sec =
578                    header.num_samples_per_channel as f32 / header.sample_rate as f32;
579                println!("\n  \x1b[1;32mHEADER INFORMATION:\x1b[0m");
580                println!("  ─────────────────────────────────────");
581                println!(
582                    "  Sample Rate   : \x1b[1;37m{} Hz\x1b[0m",
583                    header.sample_rate
584                );
585                println!(
586                    "  Channels      : \x1b[1;37m{} ({})\x1b[0m",
587                    header.num_channels,
588                    if header.num_channels == 2 {
589                        "Stereo"
590                    } else {
591                        "Mono"
592                    }
593                );
594                println!(
595                    "  Bit Depth     : \x1b[1;37m{}-bit ({:?})\x1b[0m",
596                    header.bits_per_sample(),
597                    header.format
598                );
599                println!(
600                    "  Duration      : \x1b[1;37m{:.2} seconds\x1b[0m",
601                    duration_sec
602                );
603
604                println!("\n  \x1b[1;32mDYNAMICS ANALYSIS PER CHANNEL:\x1b[0m");
605                println!("  ─────────────────────────────────────");
606                for (i, ch) in channels.iter().enumerate() {
607                    let peak = Normalizer::find_peak(ch);
608                    let rms = Normalizer::calculate_rms(ch);
609                    let peak_db = Gain::linear_to_db(peak);
610                    let rms_db = Gain::linear_to_db(rms);
611                    let clipping = if peak >= 1.0 {
612                        "\x1b[1;31m[CLIPPING!]\x1b[0m"
613                    } else {
614                        "\x1b[1;32m[OK]\x1b[0m"
615                    };
616                    let level_bar_width = 30usize;
617                    let filled = (peak * level_bar_width as f32).round() as usize;
618                    let bar = format!(
619                        "[{}{}]",
620                        "\x1b[1;32m█\x1b[0m".repeat(filled),
621                        "\x1b[90m░\x1b[0m".repeat(level_bar_width.saturating_sub(filled))
622                    );
623
624                    println!(
625                        "  Ch {}: Peak {:>7.2} dBFS | RMS {:>7.2} dBFS {} {}",
626                        i + 1,
627                        peak_db,
628                        rms_db,
629                        bar,
630                        clipping
631                    );
632                }
633            }
634            Err(e) => {
635                println!("  \x1b[1;31m[ERROR] Failed to read WAV: {}\x1b[0m", e);
636            }
637        }
638
639        self.wait_keypress();
640    }
641
642    // ═══════════════════════════════════════════
643    //  AKSI 4: TOOL EQUALIZER
644    // ═══════════════════════════════════════════
645
646    fn action_equalizer_tool(&self) {
647        self.canvas.clear_screen();
648        self.canvas
649            .draw_panel(1, 1, 94, 28, " 🎛️ 3-BAND PARAMETRIC EQUALIZER ", None);
650
651        println!("\n  \x1b[1;36m--- 3-Band Parametric EQ Settings ---\x1b[0m\n");
652
653        print!("  \x1b[1;37mEnter WAV file path:\x1b[0m ");
654        let _ = stdout().flush();
655        let mut path_str = String::new();
656        let _ = stdin().read_line(&mut path_str);
657        let input_path = path_str.trim().trim_matches('"').trim();
658        if input_path.is_empty() {
659            return;
660        }
661
662        print!("  Low Shelf  Gain (dB) [-24..+24, default 0.0]: ");
663        let _ = stdout().flush();
664        let mut low_str = String::new();
665        let _ = stdin().read_line(&mut low_str);
666        let low_gain: f32 = low_str.trim().parse().unwrap_or(0.0);
667
668        print!("  Mid Peak   Gain (dB) [-24..+24, default 0.0]: ");
669        let _ = stdout().flush();
670        let mut mid_str = String::new();
671        let _ = stdin().read_line(&mut mid_str);
672        let mid_gain: f32 = mid_str.trim().parse().unwrap_or(0.0);
673
674        print!("  High Shelf Gain (dB) [-24..+24, default 0.0]: ");
675        let _ = stdout().flush();
676        let mut high_str = String::new();
677        let _ = stdin().read_line(&mut high_str);
678        let high_gain: f32 = high_str.trim().parse().unwrap_or(0.0);
679
680        print!("  Overall    Gain (dB) [-24..+24, default 0.0]: ");
681        let _ = stdout().flush();
682        let mut master_str = String::new();
683        let _ = stdin().read_line(&mut master_str);
684        let master_gain: f32 = master_str.trim().parse().unwrap_or(0.0);
685
686        // Visualisasikan pengaturan EQ sebelum pemrosesan
687        println!("\n  \x1b[1;36m--- Preview EQ Settings ---\x1b[0m");
688        println!(
689            "  LOW  SHELF (150 Hz):  \x1b[1;33m{:>+5.1} dB\x1b[0m",
690            low_gain
691        );
692        println!(
693            "  MID PEAK  (1000 Hz):  \x1b[1;33m{:>+5.1} dB\x1b[0m",
694            mid_gain
695        );
696        println!(
697            "  HIGH SHELF (6000 Hz): \x1b[1;33m{:>+5.1} dB\x1b[0m",
698            high_gain
699        );
700        println!(
701            "  MASTER GAIN:          \x1b[1;33m{:>+5.1} dB\x1b[0m",
702            master_gain
703        );
704
705        match WavReader::read_from_file(input_path) {
706            Ok((header, mut channels)) => {
707                let sample_rate = header.sample_rate as f32;
708                let mut chain = ProcessorChain::new();
709                chain.push(EqualizerProcessor::new(
710                    150.0, low_gain, 1000.0, 1.0, mid_gain, 6000.0, high_gain,
711                ));
712                if master_gain.abs() > 1e-4 {
713                    chain.push(GainProcessor::new(master_gain));
714                }
715
716                if let Ok(spec) = AudioSpec::new(sample_rate, channels.len()) {
717                    let _ = chain.prepare(spec);
718                    let mut views: Vec<&mut [f32]> =
719                        channels.iter_mut().map(Vec::as_mut_slice).collect();
720                    if let Ok(mut block) = AudioBlock::new(&mut views) {
721                        let _ = chain.process(&mut block);
722                    }
723                }
724
725                let path_obj = Path::new(input_path);
726                let song_stem = path_obj
727                    .file_stem()
728                    .and_then(|s| s.to_str())
729                    .unwrap_or("audio");
730                let parent = path_obj.parent().unwrap_or_else(|| Path::new("."));
731                let out_path = parent.join(format!("REScale-EQ-{}.wav", song_stem));
732
733                if WavWriter::write_to_file(&out_path, &channels, header.sample_rate, header.format)
734                    .is_ok()
735                {
736                    println!(
737                        "\n  \x1b[1;32m[SUCCESS] EQ saved to: {}\x1b[0m",
738                        out_path.display()
739                    );
740                } else {
741                    println!("\n  \x1b[1;31m[ERROR] Failed to save EQ result file.\x1b[0m");
742                }
743            }
744            Err(e) => {
745                println!("  \x1b[1;31m[ERROR] Failed to read WAV: {}\x1b[0m", e);
746            }
747        }
748
749        self.wait_keypress();
750    }
751
752    // ═══════════════════════════════════════════
753    //  UTILITAS
754    // ═══════════════════════════════════════════
755
756    fn wait_keypress(&self) {
757        print!("\n  \x1b[90mPress Enter to return to Main Menu...\x1b[0m");
758        let _ = stdout().flush();
759        let _ = stdin().read_line(&mut String::new());
760    }
761}
762
763impl Default for TuiMenu {
764    fn default() -> Self {
765        Self::new()
766    }
767}