diff --git a/apps/cli/src/selftest/playback.rs b/apps/cli/src/selftest/playback.rs index 2e6929ea290..24d17bbdb4b 100644 --- a/apps/cli/src/selftest/playback.rs +++ b/apps/cli/src/selftest/playback.rs @@ -906,6 +906,7 @@ mod fixture { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }], transitions: Vec::new(), diff --git a/apps/desktop-gpui/locales/de.toml b/apps/desktop-gpui/locales/de.toml index 20701865b6e..9d89dcf305e 100644 --- a/apps/desktop-gpui/locales/de.toml +++ b/apps/desktop-gpui/locales/de.toml @@ -2440,6 +2440,38 @@ mono_left = "Mono L" mono_right = "Mono R" stereo = "Stereo" +[editor.tabs.audio.voice_eq] +apply_all = "Auf alle Clips anwenden" +band_high = "Höhen" +band_high_mid = "Obere Mitten" +band_low = "Tiefen" +band_low_cut = "Low-Cut" +band_low_mid = "Untere Mitten" +declick = "De-Click" +declick_description = "Repariert Schmatzgeräusche und Knistern und lässt den Rest Ihrer Stimme unverändert." +declick_light = "Leicht" +declick_off = "Aus" +declick_strong = "Stark" +description = "Formen Sie den Klang Ihres Mikrofons. Was Sie hier hören, wird auch exportiert." +frequency = "Frequenz" +gain = "Verstärkung" +graph_hint = "Ziehen Sie einen Punkt, um den Klang zu formen. Scrollen Sie über einem Punkt, um ihn breiter oder schmaler zu machen, und doppelklicken Sie, um ihn zurückzusetzen." +low_cut_description = "Entfernt Rumpeln und Griffgeräusche unterhalb der Grenzfrequenz." +off = "Aus" +preset_bright = "Hell" +preset_broadcast = "Rundfunk" +preset_clear = "Klar" +preset_custom = "Eigene" +preset_natural = "Natürlich" +preset_warm = "Warm" +q = "Q" +scope_all = "Alle Clips werden bearbeitet" +scope_all_hint = "Wählen Sie einen Clip in der Zeitleiste aus, um ihn einzeln anzupassen." +scope_mixed = "Die Clips haben unterschiedliche Einstellungen. Eine Änderung hier gilt für alle." +scope_selected = "Ausgewählter Clip wird bearbeitet" +scope_selected_many = "Ausgewählte Clips werden bearbeitet" +title = "Stimm-EQ" + [editor.tabs.camera] advanced_shadow = "Erweiterte Schatteneinstellungen" background = "Hintergrund" @@ -2714,6 +2746,7 @@ manual_zoom = "Manueller Zoom" muted = "Stumm" numbered_clip = "Clip {number}" sensitive = "Vertraulich" +voice_eq = "Stimm-EQ" [editor.timeline.track] audio = "Audio" @@ -2800,6 +2833,7 @@ transition_none = "Keiner" transition_crossfade = "Überblendung" transition_fade = "Schwarzblende" transition_seconds = "{seconds} s" +voice_eq = "Stimm-EQ" [editor.window.clip_menu.merge_blocker] edge = "Auf dieser Seite ist kein Clip" diff --git a/apps/desktop-gpui/locales/en.toml b/apps/desktop-gpui/locales/en.toml index ff6f72e44b1..64dc278e328 100644 --- a/apps/desktop-gpui/locales/en.toml +++ b/apps/desktop-gpui/locales/en.toml @@ -2442,6 +2442,38 @@ mono_left = "Mono L" mono_right = "Mono R" stereo = "Stereo" +[editor.tabs.audio.voice_eq] +apply_all = "Apply to all clips" +band_high = "High" +band_high_mid = "High-mid" +band_low = "Low" +band_low_cut = "Low cut" +band_low_mid = "Low-mid" +declick = "De-click" +declick_description = "Repairs mouth clicks and crackle and leaves the rest of your voice untouched." +declick_light = "Light" +declick_off = "Off" +declick_strong = "Strong" +description = "Shape your microphone's tone. What you hear here is what exports." +frequency = "Frequency" +gain = "Gain" +graph_hint = "Drag a point to shape the sound. Scroll over a point to widen or narrow it, and double-click to reset it." +low_cut_description = "Removes rumble and handling noise below the cutoff." +off = "Off" +preset_bright = "Bright" +preset_broadcast = "Broadcast" +preset_clear = "Clear" +preset_custom = "Custom" +preset_natural = "Natural" +preset_warm = "Warm" +q = { text = "Q", context = "Bandwidth of an EQ band. Keep the letter Q, the standard audio term." } +scope_all = "Editing all clips" +scope_all_hint = "Select a clip in the timeline to tune it on its own." +scope_mixed = "Clips have different settings. A change here applies to all of them." +scope_selected = "Editing the selected clip" +scope_selected_many = "Editing the selected clips" +title = "Voice EQ" + [editor.tabs.camera] advanced_shadow = "Advanced shadow settings" background = "Background" @@ -2716,6 +2748,7 @@ manual_zoom = "Manual Zoom" muted = "Muted" numbered_clip = { text = "Clip {number}", context = "{number} is the recording clip number, starting at 1." } sensitive = { text = "Sensitive", context = "Mask type that blurs a private area." } +voice_eq = "Voice EQ" [editor.timeline.track] audio = "Audio" @@ -2802,6 +2835,7 @@ transition_none = { text = "None", context = "Transition chip: no transition bef transition_crossfade = { text = "Crossfade", context = "Transition chip: the previous clip dissolves into this one." } transition_fade = { text = "Fade", context = "Transition chip: fade through black into this clip." } transition_seconds = { text = "{seconds}s", context = "Transition length chip; {seconds} is a number such as 0.5." } +voice_eq = "Voice EQ" [editor.window.clip_menu.merge_blocker] edge = { text = "No clip on that side", context = "Grey hint next to a disabled Merge with next/previous clip row." } diff --git a/apps/desktop-gpui/locales/es.toml b/apps/desktop-gpui/locales/es.toml index 3156d6cb740..fda1abbfec9 100644 --- a/apps/desktop-gpui/locales/es.toml +++ b/apps/desktop-gpui/locales/es.toml @@ -2440,6 +2440,38 @@ mono_left = "Mono I" mono_right = "Mono D" stereo = "Estéreo" +[editor.tabs.audio.voice_eq] +apply_all = "Aplicar a todos los clips" +band_high = "Agudos" +band_high_mid = "Medios agudos" +band_low = "Graves" +band_low_cut = "Corte de graves" +band_low_mid = "Medios graves" +declick = "Anticlics" +declick_description = "Repara los chasquidos de boca y los crujidos sin tocar el resto de tu voz." +declick_light = "Suave" +declick_off = "Desactivado" +declick_strong = "Fuerte" +description = "Moldea el sonido de tu micrófono. Lo que oyes aquí es lo que se exporta." +frequency = "Frecuencia" +gain = "Ganancia" +graph_hint = "Arrastra un punto para moldear el sonido. Desplázate sobre un punto para ensancharlo o estrecharlo, y haz doble clic para restablecerlo." +low_cut_description = "Elimina el retumbe y los ruidos de manipulación por debajo del corte." +off = "Desactivado" +preset_bright = "Brillante" +preset_broadcast = "Radio" +preset_clear = "Claro" +preset_custom = "Personalizado" +preset_natural = "Natural" +preset_warm = "Cálido" +q = "Q" +scope_all = "Editando todos los clips" +scope_all_hint = "Selecciona un clip en la línea de tiempo para ajustarlo por separado." +scope_mixed = "Los clips tienen ajustes distintos. Un cambio aquí se aplica a todos." +scope_selected = "Editando el clip seleccionado" +scope_selected_many = "Editando los clips seleccionados" +title = "EQ de voz" + [editor.tabs.camera] advanced_shadow = "Ajustes avanzados de sombra" background = "Fondo" @@ -2714,6 +2746,7 @@ manual_zoom = "Zoom manual" muted = "Silenciado" numbered_clip = "Clip {number}" sensitive = "Sensible" +voice_eq = "EQ de voz" [editor.timeline.track] audio = "Audio" @@ -2800,6 +2833,7 @@ transition_none = "Ninguna" transition_crossfade = "Fundido encadenado" transition_fade = "Fundido" transition_seconds = "{seconds} s" +voice_eq = "EQ de voz" [editor.window.clip_menu.merge_blocker] edge = "No hay ningún clip a ese lado" diff --git a/apps/desktop-gpui/locales/fr.toml b/apps/desktop-gpui/locales/fr.toml index 16385c4743d..c1dd4625c77 100644 --- a/apps/desktop-gpui/locales/fr.toml +++ b/apps/desktop-gpui/locales/fr.toml @@ -2440,6 +2440,38 @@ mono_left = "Mono G" mono_right = "Mono D" stereo = "Stéréo" +[editor.tabs.audio.voice_eq] +apply_all = "Appliquer à tous les clips" +band_high = "Aigus" +band_high_mid = "Haut-médiums" +band_low = "Graves" +band_low_cut = "Coupe-bas" +band_low_mid = "Bas-médiums" +declick = "Anti-clics" +declick_description = "Répare les clics de bouche et les crépitements sans toucher au reste de votre voix." +declick_light = "Léger" +declick_off = "Désactivé" +declick_strong = "Fort" +description = "Façonnez le son de votre micro. Ce que vous entendez ici est ce qui sera exporté." +frequency = "Fréquence" +gain = "Gain" +graph_hint = "Faites glisser un point pour façonner le son. Faites défiler sur un point pour l'élargir ou le resserrer, et double-cliquez pour le réinitialiser." +low_cut_description = "Supprime le grondement et les bruits de manipulation sous la fréquence de coupure." +off = "Désactivé" +preset_bright = "Brillant" +preset_broadcast = "Radio" +preset_clear = "Clair" +preset_custom = "Personnalisé" +preset_natural = "Naturel" +preset_warm = "Chaud" +q = "Q" +scope_all = "Modification de tous les clips" +scope_all_hint = "Sélectionnez un clip dans la timeline pour le régler séparément." +scope_mixed = "Les clips ont des réglages différents. Une modification ici s'applique à tous." +scope_selected = "Modification du clip sélectionné" +scope_selected_many = "Modification des clips sélectionnés" +title = "EQ vocal" + [editor.tabs.camera] advanced_shadow = "Réglages avancés de l'ombre" background = "Arrière-plan" @@ -2714,6 +2746,7 @@ manual_zoom = "Zoom manuel" muted = "Muet" numbered_clip = "Clip {number}" sensitive = "Sensible" +voice_eq = "EQ vocal" [editor.timeline.track] audio = "Audio" @@ -2800,6 +2833,7 @@ transition_none = "Aucune" transition_crossfade = "Fondu enchaîné" transition_fade = "Fondu" transition_seconds = "{seconds} s" +voice_eq = "EQ vocal" [editor.window.clip_menu.merge_blocker] edge = "Aucun clip de ce côté" diff --git a/apps/desktop-gpui/locales/hi.toml b/apps/desktop-gpui/locales/hi.toml index b8d7d9f0015..b618a4b582b 100644 --- a/apps/desktop-gpui/locales/hi.toml +++ b/apps/desktop-gpui/locales/hi.toml @@ -2440,6 +2440,38 @@ mono_left = "मोनो L" mono_right = "मोनो R" stereo = "स्टीरियो" +[editor.tabs.audio.voice_eq] +apply_all = "सभी क्लिप पर लागू करें" +band_high = "हाई" +band_high_mid = "हाई-मिड" +band_low = "लो" +band_low_cut = "लो कट" +band_low_mid = "लो-मिड" +declick = "डी-क्लिक" +declick_description = "मुँह की क्लिक और चटचटाहट को ठीक करता है और आपकी बाकी आवाज़ को नहीं छूता।" +declick_light = "हल्का" +declick_off = "बंद" +declick_strong = "तेज़" +description = "अपने माइक्रोफ़ोन की आवाज़ को आकार दें। यहाँ जो सुनाई देता है, वही एक्सपोर्ट होता है।" +frequency = "फ़्रीक्वेंसी" +gain = "गेन" +graph_hint = "आवाज़ को आकार देने के लिए किसी पॉइंट को खींचें। पॉइंट पर स्क्रॉल करके उसे चौड़ा या संकरा करें, और रीसेट करने के लिए डबल-क्लिक करें।" +low_cut_description = "कटऑफ़ से नीचे की गड़गड़ाहट और हैंडलिंग शोर हटाता है।" +off = "बंद" +preset_bright = "ब्राइट" +preset_broadcast = "ब्रॉडकास्ट" +preset_clear = "क्लियर" +preset_custom = "कस्टम" +preset_natural = "नैचुरल" +preset_warm = "वॉर्म" +q = "Q" +scope_all = "सभी क्लिप संपादित हो रहे हैं" +scope_all_hint = "किसी क्लिप को अलग से ट्यून करने के लिए टाइमलाइन में उसे चुनें।" +scope_mixed = "क्लिप की सेटिंग्स अलग-अलग हैं। यहाँ किया गया बदलाव सभी पर लागू होगा।" +scope_selected = "चुना गया क्लिप संपादित हो रहा है" +scope_selected_many = "चुने गए क्लिप संपादित हो रहे हैं" +title = "वॉइस EQ" + [editor.tabs.camera] advanced_shadow = "छाया की एडवांस्ड सेटिंग्स" background = "बैकग्राउंड" @@ -2714,6 +2746,7 @@ manual_zoom = "मैनुअल ज़ूम" muted = "म्यूट" numbered_clip = "क्लिप {number}" sensitive = "संवेदनशील" +voice_eq = "वॉइस EQ" [editor.timeline.track] audio = "ऑडियो" @@ -2800,6 +2833,7 @@ transition_none = "कोई नहीं" transition_crossfade = "क्रॉसफ़ेड" transition_fade = "फ़ेड" transition_seconds = "{seconds} सेकंड" +voice_eq = "वॉइस EQ" [editor.window.clip_menu.merge_blocker] edge = "उस तरफ़ कोई क्लिप नहीं है" diff --git a/apps/desktop-gpui/locales/ja.toml b/apps/desktop-gpui/locales/ja.toml index c04e300ea12..041d8bfd6bf 100644 --- a/apps/desktop-gpui/locales/ja.toml +++ b/apps/desktop-gpui/locales/ja.toml @@ -2440,6 +2440,38 @@ mono_left = "モノラル(左)" mono_right = "モノラル(右)" stereo = "ステレオ" +[editor.tabs.audio.voice_eq] +apply_all = "すべてのクリップに適用" +band_high = "高域" +band_high_mid = "中高域" +band_low = "低域" +band_low_cut = "ローカット" +band_low_mid = "中低域" +declick = "デクリック" +declick_description = "口元のクリック音やパチパチというノイズを修復し、声のほかの部分はそのまま残します。" +declick_light = "弱" +declick_off = "オフ" +declick_strong = "強" +description = "マイクの音色を整えます。ここで聞こえる音がそのまま書き出されます。" +frequency = "周波数" +gain = "ゲイン" +graph_hint = "ポイントをドラッグして音を整えます。ポイントの上でスクロールすると幅が変わり、ダブルクリックでリセットされます。" +low_cut_description = "カットオフより下のランブルやハンドリングノイズを取り除きます。" +off = "オフ" +preset_bright = "ブライト" +preset_broadcast = "ブロードキャスト" +preset_clear = "クリア" +preset_custom = "カスタム" +preset_natural = "ナチュラル" +preset_warm = "ウォーム" +q = "Q" +scope_all = "すべてのクリップを編集中" +scope_all_hint = "タイムラインでクリップを選択すると、そのクリップだけを調整できます。" +scope_mixed = "クリップごとに設定が異なります。ここでの変更はすべてのクリップに適用されます。" +scope_selected = "選択したクリップを編集中" +scope_selected_many = "選択した複数のクリップを編集中" +title = "ボイスEQ" + [editor.tabs.camera] advanced_shadow = "影の詳細設定" background = "背景" @@ -2714,6 +2746,7 @@ manual_zoom = "手動ズーム" muted = "ミュート" numbered_clip = "クリップ{number}" sensitive = "機密" +voice_eq = "ボイスEQ" [editor.timeline.track] audio = "音声" @@ -2800,6 +2833,7 @@ transition_none = "なし" transition_crossfade = "クロスフェード" transition_fade = "フェード" transition_seconds = "{seconds}秒" +voice_eq = "ボイスEQ" [editor.window.clip_menu.merge_blocker] edge = "その側にクリップがありません" diff --git a/apps/desktop-gpui/locales/zh-CN.toml b/apps/desktop-gpui/locales/zh-CN.toml index 0d3ddef7591..ce480f82833 100644 --- a/apps/desktop-gpui/locales/zh-CN.toml +++ b/apps/desktop-gpui/locales/zh-CN.toml @@ -2440,6 +2440,38 @@ mono_left = "单声道 L" mono_right = "单声道 R" stereo = "立体声" +[editor.tabs.audio.voice_eq] +apply_all = "应用到所有片段" +band_high = "高频" +band_high_mid = "中高频" +band_low = "低频" +band_low_cut = "低切" +band_low_mid = "中低频" +declick = "去咔嗒声" +declick_description = "修复口腔咔嗒声和噼啪声,不改变你声音的其他部分。" +declick_light = "轻度" +declick_off = "关闭" +declick_strong = "强力" +description = "调整麦克风的音色。你在这里听到的就是导出的效果。" +frequency = "频率" +gain = "增益" +graph_hint = "拖动控制点来调整声音。在控制点上滚动可改变宽度,双击可重置。" +low_cut_description = "去除截止频率以下的隆隆声和操作噪音。" +off = "关闭" +preset_bright = "明亮" +preset_broadcast = "广播" +preset_clear = "清晰" +preset_custom = "自定义" +preset_natural = "自然" +preset_warm = "温暖" +q = "Q" +scope_all = "正在编辑所有片段" +scope_all_hint = "在时间线中选择一个片段即可单独调整。" +scope_mixed = "各片段的设置不同。在此处的更改会应用到所有片段。" +scope_selected = "正在编辑所选片段" +scope_selected_many = "正在编辑所选的多个片段" +title = "人声 EQ" + [editor.tabs.camera] advanced_shadow = "高级阴影设置" background = "背景" @@ -2714,6 +2746,7 @@ manual_zoom = "手动缩放" muted = "已静音" numbered_clip = "片段 {number}" sensitive = "敏感内容" +voice_eq = "人声 EQ" [editor.timeline.track] audio = "音频" @@ -2800,6 +2833,7 @@ transition_none = "无" transition_crossfade = "交叉淡化" transition_fade = "淡入淡出" transition_seconds = "{seconds} 秒" +voice_eq = "人声 EQ" [editor.window.clip_menu.merge_blocker] edge = "该侧没有片段" diff --git a/apps/desktop-gpui/src/editor_clips.rs b/apps/desktop-gpui/src/editor_clips.rs index 872d3592965..75dc274a5f3 100644 --- a/apps/desktop-gpui/src/editor_clips.rs +++ b/apps/desktop-gpui/src/editor_clips.rs @@ -2111,6 +2111,7 @@ impl PreparedMp4Import { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }); add_clip_configs(&mut config, index, std::slice::from_ref(&self.segment)); @@ -2595,6 +2596,7 @@ fn full_timeline_for_segments( name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }) }) @@ -3193,6 +3195,7 @@ fn full_timeline_for_source_segments( name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }) }) @@ -3283,6 +3286,7 @@ fn source_timeline_segments_for_import( name: None, speed_audio_mode: segment.speed_audio_mode, hide_cursor: segment.hide_cursor, + voice_eq: segment.voice_eq, volume: segment.volume, }); } @@ -3515,6 +3519,7 @@ pub(crate) fn append_cap_project_to_editor( name: None, speed_audio_mode: source_segment.speed_audio_mode, hide_cursor: source_segment.hide_cursor, + voice_eq: source_segment.voice_eq, volume: source_segment.volume, }); } @@ -3952,6 +3957,7 @@ mod tests { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, } } diff --git a/apps/desktop-gpui/src/editor_edits.rs b/apps/desktop-gpui/src/editor_edits.rs index 13631e4537f..f79cc035c5f 100644 --- a/apps/desktop-gpui/src/editor_edits.rs +++ b/apps/desktop-gpui/src/editor_edits.rs @@ -29,7 +29,8 @@ use cap_project::{ AudioTrackSegment, Camera3DProperties, Camera3DSegment, CaptionTrackSegment, ClipSpeedAudioMode, CursorClickEvent, GlideDirection, KeyboardTrackSegment, MaskKind, MaskSegment, OverlayTrack, ProjectConfiguration, SceneMode, SceneSegment, TextSegment, - TimelineConfiguration, TimelineSegment, XY, ZoomMode, ZoomSegment, mask_effect_contract, + TimelineConfiguration, TimelineSegment, VoiceEq, XY, ZoomMode, ZoomSegment, + mask_effect_contract, }; use crate::editor_timeline::{self, Segment, TrackKind}; @@ -1945,6 +1946,7 @@ pub fn ensure_timeline(project: &mut ProjectConfiguration, clip_display_duration name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }) .collect(), @@ -2687,6 +2689,24 @@ pub fn set_clip_volume(timeline: &mut TimelineConfiguration, index: usize, volum true } +pub fn set_clip_voice_eq( + timeline: &mut TimelineConfiguration, + indices: &[usize], + voice_eq: Option, +) -> bool { + let voice_eq = voice_eq.map(|voice_eq| voice_eq.sanitized()); + let mut changed = false; + for index in indices { + if let Some(segment) = timeline.segments.get_mut(*index) + && segment.voice_eq != voice_eq + { + segment.voice_eq = voice_eq; + changed = true; + } + } + changed +} + pub fn set_clip_muted(timeline: &mut TimelineConfiguration, index: usize, muted: bool) -> bool { let Some(segment) = timeline.segments.get_mut(index) else { return false; @@ -2845,6 +2865,7 @@ pub fn merge_clip_segments( left.speed_audio_mode = settings.speed_audio_mode; left.volume = settings.volume; left.hide_cursor = settings.hide_cursor; + left.voice_eq = settings.voice_eq; // `transitionsAfterClipMerge`: the merged boundary's transition is gone, // everything past it shifts down one. @@ -3217,6 +3238,7 @@ mod tests { timescale: 1., muted: false, cursor_hidden: false, + voice_eq: false, volume: 1., recording_clip: 0, holds: Arc::from(&[][..]), diff --git a/apps/desktop-gpui/src/editor_sidebar.rs b/apps/desktop-gpui/src/editor_sidebar.rs index 52febe73953..7e0d933eb26 100644 --- a/apps/desktop-gpui/src/editor_sidebar.rs +++ b/apps/desktop-gpui/src/editor_sidebar.rs @@ -719,6 +719,7 @@ pub enum SliderKey { Caption(CaptionSlider), Keyboard(KeyboardSlider), Panel(PanelSlider, usize), + VoiceEq(crate::editor_voice_eq::VoiceEqSlider), } /// Which colour the open `NSColorPanel` is editing. @@ -817,6 +818,7 @@ pub struct SidebarState { pads: std::cell::RefCell>, /// The live pad drag, and whether it is holding a `history.pause()`. pub pad_drag: Option, + pub voice_eq: crate::editor_voice_eq::VoiceEqView, /// `ColorCorrectionSection`'s `adjustOpen` signal -- one per instance, so /// the Background tab's and the Camera tab's open independently. @@ -923,6 +925,7 @@ impl SidebarState { tracks: std::cell::RefCell::new(HashMap::new()), pads: std::cell::RefCell::new(HashMap::new()), pad_drag: None, + voice_eq: crate::editor_voice_eq::VoiceEqView::default(), grade_adjust: [CollapsibleState::new(false), CollapsibleState::new(false)], camera_shadow_open: CollapsibleState::new(false), cursor_physics_open: CollapsibleState::new(!config.cursor.raw), @@ -1046,7 +1049,12 @@ impl EditorWindow { /// The tab rail's click: clear any selection, then switch tab and put the /// scroll body back to the top (`:632-650`). - fn select_sidebar_tab(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + pub(crate) fn select_sidebar_tab( + &mut self, + index: usize, + window: &mut Window, + cx: &mut Context, + ) { let Some(tab) = SidebarTab::ALL.get(index).copied() else { return; }; @@ -1284,6 +1292,7 @@ impl EditorWindow { SliderKey::Caption(slider) => slider.limits(), SliderKey::Keyboard(slider) => slider.limits(), SliderKey::Panel(slider, index) => self.panel_slider_limits(slider, index), + SliderKey::VoiceEq(slider) => slider.limits(), } } @@ -1313,6 +1322,7 @@ impl EditorWindow { SliderKey::Caption(slider) => slider.read(&project), SliderKey::Keyboard(slider) => slider.read(&project), SliderKey::Panel(slider, index) => self.panel_slider_value(slider, index), + SliderKey::VoiceEq(slider) => self.voice_eq_slider_value(slider), } } @@ -1346,6 +1356,7 @@ impl EditorWindow { SliderKey::Panel(slider, index) => { self.apply_panel_slider(slider, index, value, window, cx) } + SliderKey::VoiceEq(slider) => self.apply_voice_eq_slider(slider, value, window, cx), } } @@ -3940,6 +3951,9 @@ pub(crate) fn format_slider_value(value: f32, unit: &str) -> String { "pct" => format!("{:.1}%", value * 100.), // The ripple duration's `${v.toFixed(2)}s`. "secs" => format!("{value:.2}s"), + "log2hz" => crate::editor_voice_eq::format_hz(value.exp2()), + "sdb" => crate::editor_voice_eq::format_gain(value), + "q" => crate::i18n::decimal(format!("{value:.2}")), unit => format!("{value:.1}{unit}"), } } diff --git a/apps/desktop-gpui/src/editor_tabs.rs b/apps/desktop-gpui/src/editor_tabs.rs index 3a49d8e5c14..5d3e424ff4b 100644 --- a/apps/desktop-gpui/src/editor_tabs.rs +++ b/apps/desktop-gpui/src/editor_tabs.rs @@ -1836,6 +1836,7 @@ impl EditorWindow { ), ) .children(has_microphone.then(|| self.render_studio_sound_card(cx))) + .children(has_microphone.then(|| self.render_voice_eq_card(cx))) .children(has_microphone.then(|| { self.slider_field_disabled( tr::editor::tabs::audio::mic_volume(), diff --git a/apps/desktop-gpui/src/editor_timeline.rs b/apps/desktop-gpui/src/editor_timeline.rs index b4a22b8b16c..bfe39bf5e62 100644 --- a/apps/desktop-gpui/src/editor_timeline.rs +++ b/apps/desktop-gpui/src/editor_timeline.rs @@ -1207,6 +1207,7 @@ pub enum SegmentDetail { muted: bool, /// `hideCursor`: the cursor (and its ripples) fade out for this clip. cursor_hidden: bool, + voice_eq: bool, volume: f64, recording_clip: u32, /// Held (paused) windows inside this clip's on-screen box, in output @@ -1741,6 +1742,7 @@ fn clip_rows( timescale: segment.timescale, muted: clip_is_muted(segment) || segment.volume() == 0.0, cursor_hidden: segment.hides_cursor(), + voice_eq: segment.active_voice_eq().is_some(), volume: segment.volume(), recording_clip: segment.recording_clip, holds: inner_holds, @@ -3547,6 +3549,7 @@ fn label_body( source_duration, muted, cursor_hidden, + voice_eq, .. }, LabelTier::Full, @@ -3568,6 +3571,14 @@ fn label_body( tr::editor::timeline::segment::cursor_hidden(), )) }) + .when(*voice_eq, |this| { + this.child(clip_badge( + theme, + color, + "icons/sliders-horizontal.svg", + tr::editor::timeline::segment::voice_eq(), + )) + }) .child(label_secondary(theme, color).child(format_clip_time(*source_duration))) .into_any_element(), ( @@ -3575,6 +3586,7 @@ fn label_body( source_duration, muted, cursor_hidden, + voice_eq, .. }, LabelTier::Compact, @@ -3598,6 +3610,15 @@ fn label_body( .text_color(seg_label(theme, color)), ) }) + .when(*voice_eq, |this| { + this.child( + svg() + .path("icons/sliders-horizontal.svg") + .size(px(12.)) + .flex_none() + .text_color(seg_label(theme, color)), + ) + }) .child(label_primary(theme, color).child(format_clip_time(*source_duration))) .into_any_element(), (SegmentDetail::Clip { muted: true, .. }, LabelTier::Glyph) => label_glyph( diff --git a/apps/desktop-gpui/src/editor_voice_eq.rs b/apps/desktop-gpui/src/editor_voice_eq.rs new file mode 100644 index 00000000000..ac6c73fab4c --- /dev/null +++ b/apps/desktop-gpui/src/editor_voice_eq.rs @@ -0,0 +1,1542 @@ +//! Each edit writes the whole [`VoiceEq`] to every target clip, so the curve +//! drawn here is always exactly what each of them plays and exports. + +use cap_project::{ + ProjectConfiguration, TimelineSegment, VoiceDeclick, VoiceEq, VoiceEqBandKind, VoiceEqPreset, +}; +use gpui::{ + AnyElement, Bounds, Context, CursorStyle, FontWeight, Hsla, InteractiveElement, IntoElement, + MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement, PathBuilder, PathStyle, Pixels, + Point, ScrollWheelEvent, SharedString, StatefulInteractiveElement, Styled, Window, canvas, div, + linear_color_stop, linear_gradient, point, prelude::FluentBuilder, px, svg, +}; +use lyon_tessellation::{LineCap, LineJoin, StrokeOptions}; + +use crate::{ + editor_edits::set_clip_voice_eq, + editor_sidebar::SliderKey, + editor_timeline::TrackKind, + editor_window::EditorWindow, + i18n::{self, tr}, + theme::Theme, + ui, +}; + +const MIN_HZ: f32 = 20.; +const MAX_HZ: f32 = 20_000.; +const PLOT_DB: f32 = 15.; +const GRAPH_HEIGHT: f32 = 168.; +const LABEL_STRIP: f32 = 18.; +const HANDLE: f32 = 14.; +const HIT_RADIUS: f32 = 18.; +const CURVE_POINTS: usize = 192; +const MAJOR_HZ: [f32; 3] = [100., 1_000., 10_000.]; +const MINOR_HZ: [f32; 6] = [50., 200., 500., 2_000., 5_000., 20.]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum VoiceEqHandle { + HighPass, + Band(VoiceEqBandKind), +} + +impl VoiceEqHandle { + pub const ALL: [Self; 5] = [ + Self::HighPass, + Self::Band(VoiceEqBandKind::Low), + Self::Band(VoiceEqBandKind::LowMid), + Self::Band(VoiceEqBandKind::HighMid), + Self::Band(VoiceEqBandKind::High), + ]; + + fn label(self) -> &'static str { + match self { + Self::HighPass => tr::editor::tabs::audio::voice_eq::band_low_cut(), + Self::Band(VoiceEqBandKind::Low) => tr::editor::tabs::audio::voice_eq::band_low(), + Self::Band(VoiceEqBandKind::LowMid) => { + tr::editor::tabs::audio::voice_eq::band_low_mid() + } + Self::Band(VoiceEqBandKind::HighMid) => { + tr::editor::tabs::audio::voice_eq::band_high_mid() + } + Self::Band(VoiceEqBandKind::High) => tr::editor::tabs::audio::voice_eq::band_high(), + } + } + + fn color(self) -> Hsla { + let (hue, saturation, lightness) = match self { + Self::HighPass => (350., 0.72, 0.60), + Self::Band(VoiceEqBandKind::Low) => (26., 0.86, 0.56), + Self::Band(VoiceEqBandKind::LowMid) => (44., 0.90, 0.50), + Self::Band(VoiceEqBandKind::HighMid) => (164., 0.62, 0.42), + Self::Band(VoiceEqBandKind::High) => (214., 0.84, 0.58), + }; + gpui::hsla(hue / 360., saturation, lightness, 1.) + } + + fn key(self) -> &'static str { + match self { + Self::HighPass => "low-cut", + Self::Band(VoiceEqBandKind::Low) => "low", + Self::Band(VoiceEqBandKind::LowMid) => "low-mid", + Self::Band(VoiceEqBandKind::HighMid) => "high-mid", + Self::Band(VoiceEqBandKind::High) => "high", + } + } + + fn hz_range(self) -> (f32, f32) { + match self { + Self::HighPass => VoiceEq::HIGH_PASS_RANGE, + Self::Band(kind) => kind.hz_range(), + } + } + + fn hz(self, voice_eq: &VoiceEq) -> f32 { + match self { + Self::HighPass => voice_eq.high_pass_hz, + Self::Band(kind) => voice_eq.band(kind).hz, + } + } +} + +/// Frequencies move on a log2 scale so a slider spends as much travel on +/// 100-200 Hz as on 5-10 kHz. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum VoiceEqSlider { + HighPassHz, + Hz(VoiceEqBandKind), + Gain(VoiceEqBandKind), + Q(VoiceEqBandKind), +} + +impl VoiceEqSlider { + pub fn limits(self) -> (f32, f32, f32) { + match self { + Self::HighPassHz => { + let (min, max) = VoiceEq::HIGH_PASS_RANGE; + (min.log2(), max.log2(), 0.01) + } + Self::Hz(kind) => { + let (min, max) = kind.hz_range(); + (min.log2(), max.log2(), 0.01) + } + Self::Gain(_) => (VoiceEq::GAIN_RANGE.0, VoiceEq::GAIN_RANGE.1, 0.1), + Self::Q(_) => (VoiceEq::Q_RANGE.0, VoiceEq::Q_RANGE.1, 0.05), + } + } + + pub fn unit(self) -> &'static str { + match self { + Self::HighPassHz | Self::Hz(_) => "log2hz", + Self::Gain(_) => "sdb", + Self::Q(_) => "q", + } + } + + pub fn read(self, voice_eq: &VoiceEq) -> f32 { + match self { + Self::HighPassHz => voice_eq.high_pass_hz.log2(), + Self::Hz(kind) => voice_eq.band(kind).hz.log2(), + Self::Gain(kind) => voice_eq.band(kind).gain_db, + Self::Q(kind) => voice_eq.band(kind).q, + } + } + + pub fn write(self, voice_eq: &mut VoiceEq, value: f32) { + match self { + Self::HighPassHz => { + voice_eq.high_pass = true; + voice_eq.high_pass_hz = value.exp2(); + } + Self::Hz(kind) => voice_eq.band_mut(kind).hz = value.exp2(), + Self::Gain(kind) => voice_eq.band_mut(kind).gain_db = value, + Self::Q(kind) => voice_eq.band_mut(kind).q = value, + } + } +} + +pub struct VoiceEqView { + pub selected: VoiceEqHandle, + pub hover: Option, + pub drag: Option<(VoiceEqHandle, Bounds)>, + plot: ui::SliderTrack, +} + +impl Default for VoiceEqView { + fn default() -> Self { + Self { + selected: VoiceEqHandle::Band(VoiceEqBandKind::HighMid), + hover: None, + drag: None, + plot: ui::SliderTrack::default(), + } + } +} + +pub fn hz_to_fraction(hz: f32) -> f32 { + ((hz.max(MIN_HZ) / MIN_HZ).log10() / (MAX_HZ / MIN_HZ).log10()).clamp(0., 1.) +} + +pub fn fraction_to_hz(fraction: f32) -> f32 { + MIN_HZ * (MAX_HZ / MIN_HZ).powf(fraction.clamp(0., 1.)) +} + +pub fn db_to_fraction(db: f32) -> f32 { + (0.5 - db / (2. * PLOT_DB)).clamp(0., 1.) +} + +pub fn fraction_to_db(fraction: f32) -> f32 { + (0.5 - fraction) * 2. * PLOT_DB +} + +pub fn format_hz(hz: f32) -> String { + if hz.round() >= 1_000. { + let khz = (hz / 100.).round() / 10.; + if khz.fract() == 0. { + format!("{khz:.0} kHz") + } else { + i18n::decimal(format!("{khz:.1} kHz")) + } + } else { + format!("{:.0} Hz", hz.round()) + } +} + +pub fn format_gain(db: f32) -> String { + let db = (db * 10.).round() / 10.; + if db == 0. { + i18n::decimal("0.0 dB") + } else { + i18n::decimal(format!( + "{}{:.1} dB", + if db > 0. { "+" } else { "\u{2212}" }, + db.abs() + )) + } +} + +fn preset_label(preset: VoiceEqPreset) -> &'static str { + match preset { + VoiceEqPreset::Natural => tr::editor::tabs::audio::voice_eq::preset_natural(), + VoiceEqPreset::Clear => tr::editor::tabs::audio::voice_eq::preset_clear(), + VoiceEqPreset::Warm => tr::editor::tabs::audio::voice_eq::preset_warm(), + VoiceEqPreset::Bright => tr::editor::tabs::audio::voice_eq::preset_bright(), + VoiceEqPreset::Broadcast => tr::editor::tabs::audio::voice_eq::preset_broadcast(), + VoiceEqPreset::Custom => tr::editor::tabs::audio::voice_eq::preset_custom(), + } +} + +pub fn voice_eq_summary(segment: &TimelineSegment) -> String { + let Some(voice_eq) = segment.voice_eq else { + return tr::editor::tabs::audio::voice_eq::off().to_string(); + }; + let tone = voice_eq + .shapes_tone() + .then(|| preset_label(voice_eq.preset)); + let declick = + (voice_eq.declick != VoiceDeclick::Off).then(tr::editor::tabs::audio::voice_eq::declick); + match (tone, declick) { + (Some(tone), Some(declick)) => format!("{tone} \u{b7} {declick}"), + (Some(tone), None) => tone.to_string(), + (None, Some(declick)) => declick.to_string(), + (None, None) => tr::editor::tabs::audio::voice_eq::off().to_string(), + } +} + +fn response(voice_eq: &VoiceEq, filters: &[usize]) -> cap_audio::ToneResponse { + let all = cap_editor::voice_eq_filters(voice_eq); + let mut chosen = [None; 5]; + for index in filters { + chosen[*index] = all[*index]; + } + cap_audio::VoiceToneSettings { + filters: chosen, + declick: None, + } + .response() +} + +fn handle_filters(handle: VoiceEqHandle) -> &'static [usize] { + match handle { + VoiceEqHandle::HighPass => &[0], + VoiceEqHandle::Band(VoiceEqBandKind::Low) => &[1], + VoiceEqHandle::Band(VoiceEqBandKind::LowMid) => &[2], + VoiceEqHandle::Band(VoiceEqBandKind::HighMid) => &[3], + VoiceEqHandle::Band(VoiceEqBandKind::High) => &[4], + } +} + +const ALL_FILTERS: [usize; 5] = [0, 1, 2, 3, 4]; + +pub fn handle_position(voice_eq: &VoiceEq, handle: VoiceEqHandle) -> (f32, f32) { + let x = hz_to_fraction(handle.hz(voice_eq)); + let db = match handle { + VoiceEqHandle::HighPass if voice_eq.high_pass => { + response(voice_eq, &ALL_FILTERS).db(voice_eq.high_pass_hz) + } + VoiceEqHandle::HighPass => 0., + VoiceEqHandle::Band(kind) => voice_eq.band(kind).gain_db, + }; + (x, db_to_fraction(db)) +} + +pub fn hit_test( + voice_eq: &VoiceEq, + bounds: Bounds, + position: Point, + selected: VoiceEqHandle, +) -> Option { + let width = f32::from(bounds.size.width); + let height = f32::from(bounds.size.height); + if width <= 0. || height <= 0. { + return None; + } + let local_x = f32::from(position.x - bounds.origin.x); + let local_y = f32::from(position.y - bounds.origin.y); + VoiceEqHandle::ALL + .into_iter() + .map(|handle| { + let (x, y) = handle_position(voice_eq, handle); + let distance = (x * width - local_x).hypot(y * height - local_y); + let bias = if handle == selected { -0.5 } else { 0. }; + (handle, distance + bias) + }) + .filter(|(_, distance)| *distance <= HIT_RADIUS) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(handle, _)| handle) +} + +pub fn drag_to( + voice_eq: &mut VoiceEq, + handle: VoiceEqHandle, + bounds: Bounds, + position: Point, +) -> bool { + let Some((x, y)) = ui::pad_position(position, bounds) else { + return false; + }; + let (min, max) = handle.hz_range(); + let hz = fraction_to_hz(x as f32).clamp(min, max); + let hz = if hz >= 1_000. { + (hz / 10.).round() * 10. + } else { + hz.round() + }; + let gain = (fraction_to_db(y as f32).clamp(VoiceEq::GAIN_RANGE.0, VoiceEq::GAIN_RANGE.1) * 10.) + .round() + / 10.; + let before = *voice_eq; + match handle { + VoiceEqHandle::HighPass => { + voice_eq.high_pass = true; + voice_eq.high_pass_hz = hz; + } + VoiceEqHandle::Band(kind) => { + let band = voice_eq.band_mut(kind); + band.hz = hz; + band.gain_db = gain; + } + } + *voice_eq != before +} + +fn segment_count(project: &ProjectConfiguration) -> usize { + project + .timeline + .as_ref() + .map_or(0, |timeline| timeline.segments.len()) +} + +impl EditorWindow { + pub(crate) fn voice_eq_targets(&self) -> Vec { + let count = segment_count(&self.project); + let mut selected = self + .selection + .as_ref() + .filter(|selection| selection.track == TrackKind::Clip) + .map(|selection| { + selection + .indices + .iter() + .copied() + .filter(|index| *index < count) + .collect::>() + }) + .unwrap_or_default(); + selected.sort_unstable(); + selected.dedup(); + if selected.is_empty() { + (0..count).collect() + } else { + selected + } + } + + fn voice_eq_selection_scoped(&self) -> bool { + self.voice_eq_targets().len() < segment_count(&self.project) + } + + pub(crate) fn shown_voice_eq(&self) -> Option { + let timeline = self.project.timeline.as_ref()?; + let first = *self.voice_eq_targets().first()?; + timeline.segments.get(first)?.voice_eq + } + + fn voice_eq_mixed(&self) -> bool { + let Some(timeline) = self.project.timeline.as_ref() else { + return false; + }; + let mut settings = self + .voice_eq_targets() + .into_iter() + .filter_map(|index| timeline.segments.get(index)) + .map(|segment| segment.voice_eq); + let Some(first) = settings.next() else { + return false; + }; + settings.any(|other| other != first) + } + + pub(crate) fn edit_voice_eq( + &mut self, + reason: &'static str, + window: &mut Window, + cx: &mut Context, + change: impl FnOnce(&mut VoiceEq), + ) { + let targets = self.voice_eq_targets(); + let mut next = self.shown_voice_eq().unwrap_or_default(); + change(&mut next); + self.edit_project(reason, window, cx, move |project| { + project + .timeline + .as_mut() + .is_some_and(|timeline| set_clip_voice_eq(timeline, &targets, Some(next))) + }); + } + + fn edit_voice_eq_tone( + &mut self, + window: &mut Window, + cx: &mut Context, + change: impl FnOnce(&mut VoiceEq), + ) { + self.edit_voice_eq("voice-eq", window, cx, |voice_eq| { + change(voice_eq); + voice_eq.enabled = true; + voice_eq.preset = VoiceEqPreset::Custom; + }); + } + + pub(crate) fn apply_voice_eq_slider( + &mut self, + slider: VoiceEqSlider, + value: f32, + window: &mut Window, + cx: &mut Context, + ) { + self.edit_voice_eq_tone(window, cx, |voice_eq| slider.write(voice_eq, value)); + } + + pub(crate) fn voice_eq_slider_value(&self, slider: VoiceEqSlider) -> f32 { + slider.read(&self.shown_voice_eq().unwrap_or_default().sanitized()) + } + + fn apply_voice_eq_to_all(&mut self, window: &mut Window, cx: &mut Context) { + let shown = self.shown_voice_eq(); + let count = segment_count(&self.project); + self.edit_project("voice-eq-all", window, cx, move |project| { + project.timeline.as_mut().is_some_and(|timeline| { + set_clip_voice_eq(timeline, &(0..count).collect::>(), shown) + }) + }); + } + + pub(crate) fn voice_eq_dragging(&self) -> bool { + self.sidebar.voice_eq.drag.is_some() + } + + fn voice_eq_mouse_down( + &mut self, + event: &MouseDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + let Some(bounds) = self.sidebar.voice_eq.plot.get() else { + return; + }; + let voice_eq = self.shown_voice_eq().unwrap_or_default().sanitized(); + let Some(handle) = hit_test( + &voice_eq, + bounds, + event.position, + self.sidebar.voice_eq.selected, + ) else { + return; + }; + cx.stop_propagation(); + self.sidebar.voice_eq.selected = handle; + if event.click_count >= 2 { + self.edit_voice_eq_tone(window, cx, |voice_eq| match handle { + VoiceEqHandle::HighPass => voice_eq.high_pass = !voice_eq.high_pass, + VoiceEqHandle::Band(kind) => voice_eq.band_mut(kind).gain_db = 0., + }); + return; + } + self.history.pause(); + self.sidebar.voice_eq.drag = Some((handle, bounds)); + cx.notify(); + } + + pub(crate) fn voice_eq_mouse_move( + &mut self, + position: Point, + window: &mut Window, + cx: &mut Context, + ) { + let Some((handle, bounds)) = self.sidebar.voice_eq.drag else { + return; + }; + let mut next = self.shown_voice_eq().unwrap_or_default().sanitized(); + if !drag_to(&mut next, handle, bounds, position) { + return; + } + self.edit_voice_eq_tone(window, cx, move |voice_eq| *voice_eq = next); + } + + pub(crate) fn voice_eq_mouse_up(&mut self, cx: &mut Context) { + if self.sidebar.voice_eq.drag.take().is_some() { + let config = self.project.clone(); + self.history.resume(&config); + cx.notify(); + } + } + + fn voice_eq_hover_at(&mut self, position: Point, cx: &mut Context) { + let hover = self.sidebar.voice_eq.plot.get().and_then(|bounds| { + let voice_eq = self.shown_voice_eq().unwrap_or_default().sanitized(); + hit_test(&voice_eq, bounds, position, self.sidebar.voice_eq.selected) + }); + if hover != self.sidebar.voice_eq.hover { + self.sidebar.voice_eq.hover = hover; + cx.notify(); + } + } + + fn voice_eq_scroll( + &mut self, + event: &ScrollWheelEvent, + window: &mut Window, + cx: &mut Context, + ) { + let Some(VoiceEqHandle::Band(kind)) = self.sidebar.voice_eq.hover else { + return; + }; + if kind.is_shelf() { + return; + } + let delta = f32::from(event.delta.pixel_delta(window.line_height()).y); + if delta == 0. { + return; + } + cx.stop_propagation(); + self.sidebar.voice_eq.selected = VoiceEqHandle::Band(kind); + self.edit_voice_eq_tone(window, cx, |voice_eq| { + let band = voice_eq.band_mut(kind); + let next = band.q * (1.0 + delta / 400.).clamp(0.8, 1.25); + band.q = (next.clamp(VoiceEq::Q_RANGE.0, VoiceEq::Q_RANGE.1) * 100.).round() / 100.; + }); + } + + pub(crate) fn render_voice_eq_card(&self, cx: &mut Context) -> AnyElement { + let theme = self.theme; + let shown = self.shown_voice_eq(); + let voice_eq = shown.unwrap_or_default().sanitized(); + let enabled = shown.is_some_and(|voice_eq| voice_eq.enabled); + let accent = Hsla::from(theme.editor.accent); + let line = Hsla::from(theme.editor.line); + let clip_count = segment_count(&self.project); + + div() + .id("voice-eq-card") + .flex() + .flex_col() + .rounded(px(12.)) + .bg(Hsla::from(theme.editor.card_2)) + .p(px(14.)) + .child( + div() + .flex() + .flex_row() + .items_center() + .gap(px(10.)) + .child( + div() + .flex() + .flex_shrink_0() + .items_center() + .justify_center() + .size(px(30.)) + .rounded(px(9.)) + .bg(if enabled { + Theme::with_alpha(theme.editor.accent, 0.12) + } else { + Hsla::from(theme.editor.ctl) + }) + .child( + svg() + .path("icons/sliders-horizontal.svg") + .size(px(16.)) + .text_color(if enabled { + accent + } else { + Hsla::from(theme.editor.text_2) + }), + ), + ) + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_w_0() + .gap(px(2.)) + .child( + div() + .text_size(px(13.)) + .font_weight(FontWeight::MEDIUM) + .text_color(Hsla::from(theme.editor.text_1)) + .child(tr::editor::tabs::audio::voice_eq::title()), + ) + .child( + div() + .text_size(px(12.)) + .line_height(px(16.)) + .text_color(Hsla::from(theme.editor.text_3)) + .child(tr::editor::tabs::audio::voice_eq::description()), + ), + ) + .child( + ui::Toggle::plain(&theme, "voice-eq-enabled", enabled).on_click( + cx.listener(move |this, _, window, cx| { + this.edit_voice_eq("voice-eq-enabled", window, cx, |voice_eq| { + voice_eq.enabled = !enabled; + }); + }), + ), + ), + ) + .when(clip_count > 1, |card| { + card.child(self.render_voice_eq_scope(cx)) + }) + .when(shown.is_some(), |card| { + card.child(self.render_voice_eq_presets(&voice_eq, enabled, cx)) + .child(self.render_voice_eq_graph(&voice_eq, enabled, cx)) + .child( + div() + .mt(px(6.)) + .text_size(px(11.)) + .line_height(px(14.)) + .text_color(Hsla::from(theme.editor.text_3)) + .child(tr::editor::tabs::audio::voice_eq::graph_hint()), + ) + .child(self.render_voice_eq_bands(&voice_eq, cx)) + }) + .child( + div() + .mt(px(14.)) + .pt(px(12.)) + .border_t_1() + .border_color(line) + .child(self.render_voice_eq_declick(shown, cx)), + ) + .into_any_element() + } + + fn render_voice_eq_scope(&self, cx: &mut Context) -> AnyElement { + let theme = self.theme; + let scoped = self.voice_eq_selection_scoped(); + let many = self.voice_eq_targets().len() > 1; + let (title, hint) = if scoped { + ( + if many { + tr::editor::tabs::audio::voice_eq::scope_selected_many() + } else { + tr::editor::tabs::audio::voice_eq::scope_selected() + }, + None, + ) + } else if self.voice_eq_mixed() { + ( + tr::editor::tabs::audio::voice_eq::scope_all(), + Some(tr::editor::tabs::audio::voice_eq::scope_mixed()), + ) + } else { + ( + tr::editor::tabs::audio::voice_eq::scope_all(), + Some(tr::editor::tabs::audio::voice_eq::scope_all_hint()), + ) + }; + div() + .mt(px(12.)) + .px(px(10.)) + .py(px(8.)) + .rounded(px(8.)) + .bg(Hsla::from(theme.editor.ctl)) + .flex() + .flex_col() + .gap(px(2.)) + .child( + div() + .flex() + .flex_row() + .items_center() + .justify_between() + .gap(px(8.)) + .child( + div() + .flex() + .flex_row() + .items_center() + .gap(px(6.)) + .min_w_0() + .child(div().size(px(6.)).flex_none().rounded_full().bg(if scoped { + Hsla::from(theme.editor.accent) + } else { + Hsla::from(theme.editor.text_3) + })) + .child( + div() + .text_size(px(12.)) + .font_weight(FontWeight::MEDIUM) + .text_color(Hsla::from(theme.editor.text_1)) + .child(title), + ), + ) + .when(scoped, |row| { + row.child( + div() + .id("voice-eq-apply-all") + .flex_none() + .px(px(8.)) + .py(px(3.)) + .rounded(px(6.)) + .cursor_pointer() + .text_size(px(11.5)) + .font_weight(FontWeight::MEDIUM) + .text_color(Hsla::from(theme.editor.accent)) + .hover(|this| this.bg(Hsla::from(theme.editor.ctl_hover))) + .child(tr::editor::tabs::audio::voice_eq::apply_all()) + .on_click(cx.listener(|this, _, window, cx| { + this.apply_voice_eq_to_all(window, cx); + })), + ) + }), + ) + .children(hint.map(|hint| { + div() + .pl(px(12.)) + .text_size(px(11.)) + .line_height(px(14.)) + .text_color(Hsla::from(theme.editor.text_3)) + .child(hint) + })) + .into_any_element() + } + + fn render_voice_eq_presets( + &self, + voice_eq: &VoiceEq, + enabled: bool, + cx: &mut Context, + ) -> AnyElement { + let theme = self.theme; + let current = voice_eq.preset; + let chips = VoiceEqPreset::ALL.into_iter().map(|preset| { + let selected = preset == current && enabled; + div() + .id(SharedString::from(format!("voice-eq-preset-{preset:?}"))) + .h(px(26.)) + .px(px(10.)) + .flex() + .items_center() + .rounded(px(7.)) + .text_size(px(12.)) + .font_weight(if selected { + FontWeight::MEDIUM + } else { + FontWeight::NORMAL + }) + .bg(if selected { + Hsla::from(theme.editor.card) + } else { + gpui::transparent_black() + }) + .when(selected, |chip| chip.shadow_sm()) + .text_color(Hsla::from(if selected { + theme.editor.text_1 + } else { + theme.editor.text_2 + })) + .cursor_pointer() + .hover(|this| this.text_color(Hsla::from(theme.editor.text_1))) + .on_click(cx.listener(move |this, _, window, cx| { + this.edit_voice_eq("voice-eq-preset", window, cx, |voice_eq| { + *voice_eq = voice_eq.with_preset(preset); + voice_eq.enabled = true; + }); + })) + .child(preset_label(preset)) + }); + div() + .mt(px(12.)) + .flex() + .flex_row() + .flex_wrap() + .gap(px(2.)) + .p(px(2.)) + .rounded(px(9.)) + .bg(Hsla::from(theme.editor.ctl)) + .children(chips) + .into_any_element() + } + + fn render_voice_eq_graph( + &self, + voice_eq: &VoiceEq, + enabled: bool, + cx: &mut Context, + ) -> AnyElement { + let theme = self.theme; + let view = &self.sidebar.voice_eq; + let plot_cell = view.plot.clone(); + let selected = view.selected; + let active = view.drag.map(|(handle, _)| handle).or(view.hover); + let accent = Hsla::from(theme.editor.accent); + let curve_color = if enabled { + accent + } else { + Hsla::from(theme.editor.text_3) + }; + let grid = Theme::with_alpha(theme.editor.text_3, 0.16); + let zero = Theme::with_alpha(theme.editor.text_3, 0.38); + let fill = Theme::with_alpha(theme.editor.accent, if enabled { 0.16 } else { 0.0 }); + let band_fill = Hsla { + a: if enabled { 0.16 } else { 0.08 }, + ..selected.color() + }; + let total_response = response(voice_eq, &ALL_FILTERS); + let handle_response = response(voice_eq, handle_filters(selected)); + let curve = (0..CURVE_POINTS) + .map(|index| { + let fraction = index as f32 / (CURVE_POINTS - 1) as f32; + let hz = fraction_to_hz(fraction); + ( + fraction, + db_to_fraction(total_response.db(hz)), + db_to_fraction(handle_response.db(hz)), + ) + }) + .collect::>(); + + let handles = VoiceEqHandle::ALL.into_iter().map(|handle| { + let (x, y) = handle_position(voice_eq, handle); + let is_selected = handle == selected; + let is_active = active == Some(handle); + let size = if is_selected || is_active { + HANDLE + 2. + } else { + HANDLE + }; + let color = handle.color(); + let off = handle == VoiceEqHandle::HighPass && !voice_eq.high_pass; + div() + .absolute() + .left(gpui::relative(x)) + .top(gpui::relative(y)) + .ml(px(-13.)) + .mt(px(-13.)) + .size(px(26.)) + .flex() + .items_center() + .justify_center() + .rounded_full() + .when(is_selected || is_active, |ring| { + ring.bg(Hsla { + a: if enabled { 0.2 } else { 0.12 }, + ..color + }) + }) + .child( + div() + .size(px(size)) + .rounded_full() + .border_2() + .border_color(if off { + Hsla { a: 0.9, ..color } + } else { + Hsla::from(theme.editor.card) + }) + .bg(if off { + Hsla::from(theme.editor.card) + } else if enabled { + color + } else { + Hsla { a: 0.55, ..color } + }) + .shadow_sm(), + ) + }); + + let readout = active.map(|handle| { + let (x, y) = handle_position(voice_eq, handle); + let text = match handle { + VoiceEqHandle::HighPass if voice_eq.high_pass => { + format!( + "{} \u{b7} {}", + handle.label(), + format_hz(voice_eq.high_pass_hz) + ) + } + VoiceEqHandle::HighPass => format!( + "{} \u{b7} {}", + handle.label(), + tr::editor::tabs::audio::voice_eq::off() + ), + VoiceEqHandle::Band(kind) => { + let band = voice_eq.band(kind); + format!( + "{} \u{b7} {} \u{b7} {}", + handle.label(), + format_hz(band.hz), + format_gain(band.gain_db) + ) + } + }; + let above = y > 0.32; + let leftward = x > 0.5; + div() + .absolute() + .top(gpui::relative(y)) + .mt(px(if above { -38. } else { 16. })) + .when(leftward, |pill| { + pill.right(gpui::relative(1. - x)).mr(px(-12.)) + }) + .when(!leftward, |pill| pill.left(gpui::relative(x)).ml(px(-12.))) + .flex() + .child( + div() + .px(px(8.)) + .py(px(4.)) + .rounded(px(6.)) + .bg(Hsla::from(theme.editor.text_1)) + .text_color(Hsla::from(theme.editor.card)) + .text_size(px(11.)) + .font_weight(FontWeight::MEDIUM) + .whitespace_nowrap() + .child(text), + ) + }); + + let labels = MAJOR_HZ.into_iter().map(|hz| { + div() + .absolute() + .bottom(px(3.)) + .left(gpui::relative(hz_to_fraction(hz))) + .ml(px(-20.)) + .w(px(40.)) + .flex() + .justify_center() + .text_size(px(10.)) + .text_color(Hsla::from(theme.editor.text_3)) + .child(if hz >= 1_000. { + format!("{}k", hz / 1_000.) + } else { + format!("{hz}") + }) + }); + let db_labels = [(12., "+12"), (-12., "\u{2212}12")] + .into_iter() + .map(|(db, text)| { + div() + .absolute() + .left(px(6.)) + .top(gpui::relative(db_to_fraction(db))) + .mt(px(-7.)) + .text_size(px(9.5)) + .text_color(Theme::with_alpha(theme.editor.text_3, 0.8)) + .child(text) + }); + + let plot = div() + .id("voice-eq-plot") + .absolute() + .top_0() + .left_0() + .right_0() + .bottom(px(LABEL_STRIP)) + .cursor(if self.sidebar.voice_eq.drag.is_some() { + CursorStyle::ClosedHand + } else if self.sidebar.voice_eq.hover.is_some() { + CursorStyle::OpenHand + } else { + CursorStyle::Arrow + }) + .child( + canvas( + move |bounds, _, _| { + plot_cell.set(Some(bounds)); + bounds + }, + move |_, bounds, window, _| { + paint_plot( + window, + bounds, + &curve, + PlotColors { + grid, + zero, + fill, + band_fill, + curve: curve_color, + }, + ); + }, + ) + .absolute() + .inset_0(), + ) + .children(db_labels) + .children(handles) + .children(readout) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, event: &MouseDownEvent, window, cx| { + this.voice_eq_mouse_down(event, window, cx); + }), + ) + .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _, cx| { + if !this.voice_eq_dragging() { + this.voice_eq_hover_at(event.position, cx); + } + })) + .on_hover(cx.listener(|this, hovered: &bool, _, cx| { + if !*hovered && this.sidebar.voice_eq.hover.take().is_some() { + cx.notify(); + } + })) + .on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, window, cx| { + this.voice_eq_scroll(event, window, cx); + })); + + div() + .mt(px(10.)) + .relative() + .w_full() + .h(px(GRAPH_HEIGHT)) + .rounded(px(10.)) + .border_1() + .border_color(Hsla::from(theme.editor.line)) + .bg(Hsla::from(theme.editor.card)) + .overflow_hidden() + .child(plot) + .children(labels) + .into_any_element() + } + + fn render_voice_eq_bands(&self, voice_eq: &VoiceEq, cx: &mut Context) -> AnyElement { + let theme = self.theme; + let selected = self.sidebar.voice_eq.selected; + let chips = VoiceEqHandle::ALL + .into_iter() + .map(|handle| { + let is_selected = handle == selected; + div() + .id(SharedString::from(format!( + "voice-eq-band-{}", + handle.key() + ))) + .h(px(26.)) + .px(px(8.)) + .flex() + .flex_row() + .items_center() + .gap(px(6.)) + .rounded(px(7.)) + .cursor_pointer() + .text_size(px(11.5)) + .bg(if is_selected { + Hsla::from(theme.editor.card) + } else { + gpui::transparent_black() + }) + .when(is_selected, |chip| chip.shadow_sm()) + .text_color(Hsla::from(if is_selected { + theme.editor.text_1 + } else { + theme.editor.text_2 + })) + .hover(|this| this.text_color(Hsla::from(theme.editor.text_1))) + .child( + div() + .size(px(7.)) + .flex_none() + .rounded_full() + .bg(handle.color()), + ) + .child(handle.label()) + .on_click(cx.listener(move |this, _, _, cx| { + this.sidebar.voice_eq.selected = handle; + cx.notify(); + })) + }) + .collect::>(); + + let controls: Vec = match selected { + VoiceEqHandle::HighPass => { + let on = voice_eq.high_pass; + vec![ + div() + .flex() + .flex_row() + .items_center() + .justify_between() + .gap(px(10.)) + .child( + div() + .flex_1() + .min_w_0() + .text_size(px(12.)) + .line_height(px(16.)) + .text_color(Hsla::from(theme.editor.text_2)) + .child(tr::editor::tabs::audio::voice_eq::low_cut_description()), + ) + .child(ui::Toggle::plain(&theme, "voice-eq-low-cut", on).on_click( + cx.listener(move |this, _, window, cx| { + this.edit_voice_eq_tone(window, cx, |voice_eq| { + voice_eq.high_pass = !on; + }); + }), + )) + .into_any_element(), + self.slider_field_disabled( + tr::editor::tabs::audio::voice_eq::frequency(), + SliderKey::VoiceEq(VoiceEqSlider::HighPassHz), + VoiceEqSlider::HighPassHz.unit(), + !on, + cx, + ) + .into_any_element(), + ] + } + VoiceEqHandle::Band(kind) => { + let mut rows = vec![ + self.slider_field( + tr::editor::tabs::audio::voice_eq::frequency(), + SliderKey::VoiceEq(VoiceEqSlider::Hz(kind)), + VoiceEqSlider::Hz(kind).unit(), + cx, + ) + .into_any_element(), + self.slider_field( + tr::editor::tabs::audio::voice_eq::gain(), + SliderKey::VoiceEq(VoiceEqSlider::Gain(kind)), + VoiceEqSlider::Gain(kind).unit(), + cx, + ) + .into_any_element(), + ]; + if !kind.is_shelf() { + rows.push( + self.slider_field( + tr::editor::tabs::audio::voice_eq::q(), + SliderKey::VoiceEq(VoiceEqSlider::Q(kind)), + VoiceEqSlider::Q(kind).unit(), + cx, + ) + .into_any_element(), + ); + } + rows + } + }; + + div() + .mt(px(12.)) + .flex() + .flex_col() + .gap(px(10.)) + .child( + div() + .flex() + .flex_row() + .flex_wrap() + .gap(px(2.)) + .p(px(2.)) + .rounded(px(9.)) + .bg(Hsla::from(theme.editor.ctl)) + .children(chips), + ) + .child(div().flex().flex_col().gap(px(2.)).children(controls)) + .into_any_element() + } + + fn render_voice_eq_declick( + &self, + shown: Option, + cx: &mut Context, + ) -> AnyElement { + let theme = self.theme; + let current = shown.map_or(VoiceDeclick::Off, |voice_eq| voice_eq.declick); + let options = [ + ( + VoiceDeclick::Off, + tr::editor::tabs::audio::voice_eq::declick_off(), + ), + ( + VoiceDeclick::Light, + tr::editor::tabs::audio::voice_eq::declick_light(), + ), + ( + VoiceDeclick::Strong, + tr::editor::tabs::audio::voice_eq::declick_strong(), + ), + ]; + div() + .flex() + .flex_col() + .gap(px(8.)) + .child( + div() + .flex() + .flex_row() + .items_center() + .justify_between() + .gap(px(10.)) + .child( + div() + .text_size(px(13.)) + .font_weight(FontWeight::MEDIUM) + .text_color(Hsla::from(theme.editor.text_1)) + .child(tr::editor::tabs::audio::voice_eq::declick()), + ) + .child( + div().w(px(200.)).child( + ui::SegmentedControl::editor( + &theme, + "voice-eq-declick", + options + .iter() + .map(|(value, label)| { + ui::SegmentOption::new(*label, current == *value) + }) + .collect(), + ) + .stretch() + .on_select(cx.listener( + move |this, index: &usize, window, cx| { + let Some(&(declick, _)) = options.get(*index) else { + return; + }; + if declick == current { + return; + } + let fresh = this.shown_voice_eq().is_none(); + this.edit_voice_eq( + "voice-eq-declick", + window, + cx, + |voice_eq| { + if fresh { + voice_eq.enabled = false; + } + voice_eq.declick = declick; + }, + ); + }, + )), + ), + ), + ) + .child( + div() + .text_size(px(12.)) + .line_height(px(16.)) + .text_color(Hsla::from(theme.editor.text_3)) + .child(tr::editor::tabs::audio::voice_eq::declick_description()), + ) + .into_any_element() + } +} + +#[derive(Clone, Copy)] +struct PlotColors { + grid: Hsla, + zero: Hsla, + fill: Hsla, + band_fill: Hsla, + curve: Hsla, +} + +fn paint_plot( + window: &mut Window, + bounds: Bounds, + curve: &[(f32, f32, f32)], + colors: PlotColors, +) { + let width = f32::from(bounds.size.width); + let height = f32::from(bounds.size.height); + if width <= 0. || height <= 0. || curve.len() < 2 { + return; + } + let at = |x: f32, y: f32| { + point( + bounds.origin.x + px(x * width), + bounds.origin.y + px(y * height), + ) + }; + + let mut minor = PathBuilder::stroke(px(1.)); + for hz in MINOR_HZ.into_iter().chain(MAJOR_HZ) { + let x = hz_to_fraction(hz); + minor.move_to(at(x, 0.)); + minor.line_to(at(x, 1.)); + } + for db in [-12., -6., 6., 12.] { + let y = db_to_fraction(db); + minor.move_to(at(0., y)); + minor.line_to(at(1., y)); + } + if let Ok(path) = minor.build() { + window.paint_path(path, colors.grid); + } + let mut zero = PathBuilder::stroke(px(1.)); + zero.move_to(at(0., db_to_fraction(0.))); + zero.line_to(at(1., db_to_fraction(0.))); + if let Ok(path) = zero.build() { + window.paint_path(path, colors.zero); + } + + let baseline = db_to_fraction(0.); + if colors.band_fill.a > 0. { + let mut band = PathBuilder::fill(); + band.move_to(at(0., baseline)); + for (x, _, y) in curve { + band.line_to(at(*x, *y)); + } + band.line_to(at(1., baseline)); + band.close(); + if let Ok(path) = band.build() { + window.paint_path(path, colors.band_fill); + } + } + if colors.fill.a > 0. { + let mut under = PathBuilder::fill(); + under.move_to(at(0., 1.)); + for (x, y, _) in curve { + under.line_to(at(*x, *y)); + } + under.line_to(at(1., 1.)); + under.close(); + if let Ok(path) = under.build() { + window.paint_path( + path, + linear_gradient( + 180., + linear_color_stop(colors.fill, 0.), + linear_color_stop( + Hsla { + a: 0., + ..colors.fill + }, + 1., + ), + ), + ); + } + } + + let mut stroke = PathBuilder::stroke(px(2.)).with_style(PathStyle::Stroke( + StrokeOptions::default() + .with_line_width(2.) + .with_line_cap(LineCap::Round) + .with_line_join(LineJoin::Round), + )); + stroke.move_to(at(curve[0].0, curve[0].1)); + for (x, y, _) in &curve[1..] { + stroke.line_to(at(*x, *y)); + } + if let Ok(path) = stroke.build() { + window.paint_path(path, colors.curve); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::size; + + fn plot() -> Bounds { + Bounds { + origin: point(px(40.), px(300.)), + size: size(px(356.), px(GRAPH_HEIGHT - LABEL_STRIP)), + } + } + + fn at(fraction: (f32, f32)) -> Point { + let bounds = plot(); + point( + bounds.origin.x + bounds.size.width * fraction.0, + bounds.origin.y + bounds.size.height * fraction.1, + ) + } + + #[test] + fn plot_axes_round_trip_and_clamp() { + for hz in [20., 80., 1_000., 3_500., 20_000.] { + assert!((fraction_to_hz(hz_to_fraction(hz)) - hz).abs() / hz < 1e-4); + } + assert_eq!(hz_to_fraction(5.), 0.); + assert_eq!(hz_to_fraction(40_000.), 1.); + for db in [-12., -3.5, 0., 6., 12.] { + assert!((fraction_to_db(db_to_fraction(db)) - db).abs() < 1e-4); + } + assert_eq!(db_to_fraction(0.), 0.5); + } + + #[test] + fn readouts_are_compact() { + assert_eq!(format_hz(80.), "80 Hz"); + assert_eq!(format_hz(999.6), "1 kHz"); + assert_eq!(format_hz(3_500.), "3.5 kHz"); + assert_eq!(format_hz(10_000.), "10 kHz"); + assert_eq!(format_gain(1.5), "+1.5 dB"); + assert_eq!(format_gain(-3.04), "\u{2212}3.0 dB"); + assert_eq!(format_gain(-0.04), "0.0 dB"); + } + + #[test] + fn dragging_a_point_moves_it_under_the_pointer_within_its_band() { + let bounds = plot(); + let mut voice_eq = VoiceEq::default(); + let handle = VoiceEqHandle::Band(VoiceEqBandKind::HighMid); + assert!(drag_to( + &mut voice_eq, + handle, + bounds, + at((hz_to_fraction(4_000.), db_to_fraction(6.))) + )); + assert_eq!(voice_eq.high_mid.hz, 4_000.); + assert_eq!(voice_eq.high_mid.gain_db, 6.); + let landed = handle_position(&voice_eq, handle); + assert!((landed.0 - hz_to_fraction(4_000.)).abs() < 1e-4); + + drag_to(&mut voice_eq, handle, bounds, at((0., 0.))); + assert_eq!(voice_eq.high_mid.hz, VoiceEqBandKind::HighMid.hz_range().0); + assert_eq!(voice_eq.high_mid.gain_db, VoiceEq::GAIN_RANGE.1); + + let before = voice_eq.high_pass_hz; + voice_eq.high_pass = false; + drag_to( + &mut voice_eq, + VoiceEqHandle::HighPass, + bounds, + at((hz_to_fraction(150.), 0.95)), + ); + assert!(voice_eq.high_pass); + assert_eq!(voice_eq.high_pass_hz, 150.); + assert_ne!(before, 150.); + assert_eq!(voice_eq.sanitized(), voice_eq); + } + + #[test] + fn hit_testing_finds_the_nearest_point_and_ignores_empty_space() { + let bounds = plot(); + let voice_eq = VoiceEq::default(); + for handle in VoiceEqHandle::ALL { + let position = at(handle_position(&voice_eq, handle)); + assert_eq!( + hit_test(&voice_eq, bounds, position, VoiceEqHandle::HighPass), + Some(handle), + "{handle:?}" + ); + } + assert_eq!( + hit_test(&voice_eq, bounds, at((0.5, 0.02)), VoiceEqHandle::HighPass), + None + ); + let empty = Bounds::default(); + assert_eq!( + hit_test( + &voice_eq, + empty, + point(px(0.), px(0.)), + VoiceEqHandle::HighPass + ), + None + ); + } + + #[test] + fn band_sliders_round_trip_through_the_project_model() { + let mut voice_eq = VoiceEq::default(); + for kind in VoiceEqBandKind::ALL { + let sliders = [ + VoiceEqSlider::Hz(kind), + VoiceEqSlider::Gain(kind), + VoiceEqSlider::Q(kind), + ]; + for slider in sliders { + let (min, max, _) = slider.limits(); + for value in [min, (min + max) / 2., max] { + slider.write(&mut voice_eq, value); + assert!((slider.read(&voice_eq.sanitized()) - value).abs() < 1e-3); + } + } + } + voice_eq.high_pass = false; + VoiceEqSlider::HighPassHz.write(&mut voice_eq, 100f32.log2()); + assert!(voice_eq.high_pass); + assert!((voice_eq.high_pass_hz - 100.).abs() < 1e-3); + } + + #[test] + fn the_reported_curve_is_the_rendered_one() { + let voice_eq = VoiceEq::from_preset(VoiceEqPreset::Broadcast); + let settings = cap_audio::VoiceToneSettings { + filters: cap_editor::voice_eq_filters(&voice_eq), + declick: None, + }; + for hz in [30., 110., 380., 2_800., 9_000.] { + assert_eq!( + response(&voice_eq, &ALL_FILTERS).db(hz), + settings.response_db(hz) + ); + } + let (_, y) = handle_position(&voice_eq, VoiceEqHandle::Band(VoiceEqBandKind::LowMid)); + assert_eq!(y, db_to_fraction(-3.5)); + } + + #[test] + fn clip_menu_summary_names_the_preset_and_de_click() { + let mut segment = TimelineSegment::default(); + assert_eq!(voice_eq_summary(&segment), "Off"); + segment.voice_eq = Some(VoiceEq::from_preset(VoiceEqPreset::Clear)); + assert_eq!(voice_eq_summary(&segment), "Clear"); + segment.voice_eq = Some(VoiceEq { + declick: VoiceDeclick::Light, + ..VoiceEq::from_preset(VoiceEqPreset::Warm) + }); + assert_eq!(voice_eq_summary(&segment), "Warm \u{b7} De-click"); + segment.voice_eq = Some(VoiceEq { + enabled: false, + declick: VoiceDeclick::Strong, + ..VoiceEq::default() + }); + assert_eq!(voice_eq_summary(&segment), "De-click"); + segment.voice_eq = Some(VoiceEq { + enabled: false, + ..VoiceEq::default() + }); + assert_eq!(voice_eq_summary(&segment), "Off"); + } +} diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs index 64edb658fe8..c495d4f97f4 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -459,6 +459,7 @@ pub fn preflight(path: &std::path::Path) -> Result { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }) .collect(), @@ -8366,6 +8367,19 @@ impl EditorWindow { self.note_edit("clip-volume", Some(TrackKind::Clip)); } + fn open_clip_voice_eq(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + self.clip_speed = None; + if self.sidebar.tab != crate::editor_sidebar::SidebarTab::Audio + && let Some(position) = crate::editor_sidebar::SidebarTab::ALL + .iter() + .position(|tab| *tab == crate::editor_sidebar::SidebarTab::Audio) + { + self.select_sidebar_tab(position, window, cx); + } + self.set_selection(Some(Selection::single(TrackKind::Clip, index)), cx); + cx.notify(); + } + fn set_clip_muted( &mut self, index: usize, @@ -8579,6 +8593,9 @@ impl EditorWindow { let muted = edits::clip_is_muted(segment); let audio_mode = segment.speed_audio_mode.unwrap_or_default(); let cursor_hidden = segment.hides_cursor(); + let voice_eq_active = segment.active_voice_eq().is_some(); + let voice_eq_summary = crate::editor_voice_eq::voice_eq_summary(segment); + let has_microphone = self.summary().is_some_and(|summary| summary.has_microphone); let normal_speed = (timescale - 1.0).abs() < f64::EPSILON; let title = match self.timeline.clips.get(index).map(|clip| &clip.detail) { Some(timeline::SegmentDetail::Clip { name, .. }) => name.clone(), @@ -8784,6 +8801,64 @@ impl EditorWindow { )) })) .into_any_element() + }) + .when(has_microphone, |section| { + section.child( + div() + .id("clip-menu-voice-eq") + .h(px(40.)) + .w_full() + .flex() + .items_center() + .gap(px(10.)) + .rounded(px(8.)) + .px(px(8.)) + .cursor_pointer() + .hover(|this| this.bg(Hsla::from(theme.editor.ctl_hover))) + .child( + svg() + .path("icons/sliders-horizontal.svg") + .size(px(14.)) + .flex_none() + .text_color(Hsla::from(if voice_eq_active { + theme.editor.accent + } else { + theme.editor.text_2 + })), + ) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .child( + div() + .text_size(px(12.5)) + .text_color(Hsla::from(theme.editor.text_1)) + .child(tr::editor::window::clip_menu::voice_eq()), + ) + .child( + div() + .text_size(px(10.5)) + .text_color(Hsla::from(theme.editor.text_3)) + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .child(voice_eq_summary), + ), + ) + .child( + svg() + .path("icons/chevron-right.svg") + .size(px(12.)) + .flex_none() + .text_color(Hsla::from(theme.editor.text_3)), + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_clip_voice_eq(index, window, cx); + })), + ) }), ) .child( @@ -8816,7 +8891,11 @@ impl EditorWindow { let duration = transition.map_or(DEFAULT_CLIP_TRANSITION_DURATION, |current| current.duration); let kinds = [ - ("None", tr::editor::window::clip_menu::transition_none(), None), + ( + "None", + tr::editor::window::clip_menu::transition_none(), + None, + ), ( "Crossfade", tr::editor::window::clip_menu::transition_crossfade(), @@ -8837,24 +8916,26 @@ impl EditorWindow { &theme, tr::editor::window::clip_menu::transition(), )) - .child(chip_row().children(kinds.into_iter().map(|(id, label, kind)| { - let selected = transition.map(|current| current.kind) == kind; - chip( - SharedString::from(format!("clip-transition-{id}")), - selected, - label.to_string(), - ) - .on_click(cx.listener( - move |this, _, window, cx| { - this.set_clip_transition( - index, - kind.map(|kind| (kind, duration)), - window, - cx, - ); - }, - )) - }))) + .child( + chip_row().children(kinds.into_iter().map(|(id, label, kind)| { + let selected = transition.map(|current| current.kind) == kind; + chip( + SharedString::from(format!("clip-transition-{id}")), + selected, + label.to_string(), + ) + .on_click(cx.listener( + move |this, _, window, cx| { + this.set_clip_transition( + index, + kind.map(|kind| (kind, duration)), + window, + cx, + ); + }, + )) + })), + ) .when_some(transition, |section, current| { section.child( chip_row().children( @@ -11429,9 +11510,10 @@ impl EditorWindow { } })) .child( - div().min_w_0().truncate().child( - self.auto_zoom_prompt(), - ), + div() + .min_w_0() + .truncate() + .child(self.auto_zoom_prompt()), ) .child( ui::Button::plain( @@ -12263,6 +12345,18 @@ impl Render for EditorWindow { // {mousemove, mouseup})` is exactly what the pad's own press // installs (`ConfigSidebar.tsx:6264-6271`), and its release is // what closes the pad's undo bracket. + .children(self.voice_eq_dragging().then(|| { + ui::Slider::drag_layer( + "voice-eq-drag", + cx.listener(|this, event: &MouseMoveEvent, window, cx| { + this.voice_eq_mouse_move(event.position, window, cx); + }), + cx.listener(|this, _: &MouseUpEvent, _window, cx| { + this.voice_eq_mouse_up(cx); + }), + ) + .cursor(gpui::CursorStyle::ClosedHand) + })) .children(self.pad_dragging().then(|| { ui::Slider::drag_layer( "sidebar-pad-drag", @@ -13439,6 +13533,7 @@ mod tests { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }], transitions: Vec::new(), @@ -13494,6 +13589,7 @@ mod tests { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }, cap_project::TimelineSegment { @@ -13504,6 +13600,7 @@ mod tests { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }, ], diff --git a/apps/desktop-gpui/src/i18n/editor_shell_surface_tests.rs b/apps/desktop-gpui/src/i18n/editor_shell_surface_tests.rs index 7ba482c4968..7f139f35fd4 100644 --- a/apps/desktop-gpui/src/i18n/editor_shell_surface_tests.rs +++ b/apps/desktop-gpui/src/i18n/editor_shell_surface_tests.rs @@ -1170,10 +1170,90 @@ fn editor_shell_surfaces_match_the_literals_they_replaced() { tr::editor::window::clip_menu::merge_blocker::speed(), "Match the clip speeds first", ), - (tr::editor::tabs::font::system_sans_serif(), "System Sans-Serif"), + ( + tr::editor::tabs::font::system_sans_serif(), + "System Sans-Serif", + ), (tr::editor::tabs::font::system_serif(), "System Serif"), - (tr::editor::tabs::font::system_monospace(), "System Monospace"), + ( + tr::editor::tabs::font::system_monospace(), + "System Monospace", + ), (tr::ui::selection::select_all(), "Select all"), + ( + tr::editor::tabs::audio::voice_eq::apply_all(), + "Apply to all clips", + ), + (tr::editor::tabs::audio::voice_eq::band_high(), "High"), + ( + tr::editor::tabs::audio::voice_eq::band_high_mid(), + "High-mid", + ), + (tr::editor::tabs::audio::voice_eq::band_low(), "Low"), + (tr::editor::tabs::audio::voice_eq::band_low_cut(), "Low cut"), + (tr::editor::tabs::audio::voice_eq::band_low_mid(), "Low-mid"), + (tr::editor::tabs::audio::voice_eq::declick(), "De-click"), + ( + tr::editor::tabs::audio::voice_eq::declick_description(), + "Repairs mouth clicks and crackle and leaves the rest of your voice untouched.", + ), + (tr::editor::tabs::audio::voice_eq::declick_light(), "Light"), + (tr::editor::tabs::audio::voice_eq::declick_off(), "Off"), + ( + tr::editor::tabs::audio::voice_eq::declick_strong(), + "Strong", + ), + ( + tr::editor::tabs::audio::voice_eq::description(), + "Shape your microphone's tone. What you hear here is what exports.", + ), + (tr::editor::tabs::audio::voice_eq::frequency(), "Frequency"), + (tr::editor::tabs::audio::voice_eq::gain(), "Gain"), + ( + tr::editor::tabs::audio::voice_eq::graph_hint(), + "Drag a point to shape the sound. Scroll over a point to widen or narrow it, and double-click to reset it.", + ), + ( + tr::editor::tabs::audio::voice_eq::low_cut_description(), + "Removes rumble and handling noise below the cutoff.", + ), + (tr::editor::tabs::audio::voice_eq::off(), "Off"), + (tr::editor::tabs::audio::voice_eq::preset_bright(), "Bright"), + ( + tr::editor::tabs::audio::voice_eq::preset_broadcast(), + "Broadcast", + ), + (tr::editor::tabs::audio::voice_eq::preset_clear(), "Clear"), + (tr::editor::tabs::audio::voice_eq::preset_custom(), "Custom"), + ( + tr::editor::tabs::audio::voice_eq::preset_natural(), + "Natural", + ), + (tr::editor::tabs::audio::voice_eq::preset_warm(), "Warm"), + (tr::editor::tabs::audio::voice_eq::q(), "Q"), + ( + tr::editor::tabs::audio::voice_eq::scope_all(), + "Editing all clips", + ), + ( + tr::editor::tabs::audio::voice_eq::scope_all_hint(), + "Select a clip in the timeline to tune it on its own.", + ), + ( + tr::editor::tabs::audio::voice_eq::scope_mixed(), + "Clips have different settings. A change here applies to all of them.", + ), + ( + tr::editor::tabs::audio::voice_eq::scope_selected(), + "Editing the selected clip", + ), + ( + tr::editor::tabs::audio::voice_eq::scope_selected_many(), + "Editing the selected clips", + ), + (tr::editor::tabs::audio::voice_eq::title(), "Voice EQ"), + (tr::editor::timeline::segment::voice_eq(), "Voice EQ"), + (tr::editor::window::clip_menu::voice_eq(), "Voice EQ"), ]; for (translated, literal) in pairs { assert_eq!(translated, literal); diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 0819040393f..cc7379af854 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -39,6 +39,7 @@ mod editor_panels; mod editor_preparing; mod editor_sidebar; mod editor_tabs; +mod editor_voice_eq; mod editor_timeline; mod editor_window; mod feeds; diff --git a/apps/desktop-gpui/src/transcription.rs b/apps/desktop-gpui/src/transcription.rs index 5a4eba1d34d..e44a0ccb42d 100644 --- a/apps/desktop-gpui/src/transcription.rs +++ b/apps/desktop-gpui/src/transcription.rs @@ -2002,6 +2002,7 @@ pub fn apply_caption_result( name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }], transitions: Vec::new(), diff --git a/apps/web/content/docs/recording/studio-mode.mdx b/apps/web/content/docs/recording/studio-mode.mdx index 9e14a4ddff7..f361f5a9c99 100644 --- a/apps/web/content/docs/recording/studio-mode.mdx +++ b/apps/web/content/docs/recording/studio-mode.mdx @@ -121,6 +121,19 @@ Audio tracks are separate from the captured microphone and system audio. Add mul - Adjust a segment's volume and fade-in or fade-out. - Separately mute the capture or adjust its microphone and system-audio volumes. Stereo microphone recordings can also select stereo, left-channel mono, or right-channel mono handling. +## Shape your microphone sound + +**Voice EQ** adjusts the tone of the microphone, clip by clip: + +1. Open the **Audio** tab and turn on **Voice EQ**, or choose **Voice EQ** from a clip's menu on the timeline. +2. Start from a preset: Natural, Clear, Warm, Bright, or Broadcast. +3. Drag the points on the curve, or pick a band and set its frequency, gain, and Q exactly. The low cut removes rumble below its cutoff. Scroll over a point to widen or narrow it, and double-click a point to reset it. +4. Select clips on the timeline to tune them on their own, or use **Apply to all clips**. + +**De-click** repairs mouth clicks and crackle. Light only touches obvious clicks, and Strong also catches quieter crackle. + +Voice EQ and de-click only change the microphone track, and they work alongside Studio Sound's noise reduction. They play back live while you edit and export exactly as you hear them, with audio and video staying in sync. Turning Voice EQ off keeps your settings, so you can compare before and after. EQ can't repair audio that clipped while recording. + ## Style the canvas and screen Studio Mode can reframe the raw capture without changing the source recording: diff --git a/crates/audio/src/lib.rs b/crates/audio/src/lib.rs index ef0e71c45e3..7028b4778f8 100644 --- a/crates/audio/src/lib.rs +++ b/crates/audio/src/lib.rs @@ -8,6 +8,7 @@ mod sync_analysis; mod transcription_timing; mod voice; mod voice_level; +mod voice_tone; pub use audio_data::*; pub use calibration_store::*; @@ -19,6 +20,7 @@ pub use sync_analysis::*; pub use transcription_timing::*; pub use voice::*; pub use voice_level::*; +pub use voice_tone::*; pub trait FromSampleBytes: cpal::SizedSample + std::fmt::Debug + Send + 'static { const BYTE_SIZE: usize; diff --git a/crates/audio/src/voice.rs b/crates/audio/src/voice.rs index 049002b162a..7e358a89e07 100644 --- a/crates/audio/src/voice.rs +++ b/crates/audio/src/voice.rs @@ -193,10 +193,10 @@ impl VoiceEnhancer { } pub struct VoiceAudio { - samples: Vec, - channels: u16, - start: usize, - total_samples: usize, + pub(crate) samples: Vec, + pub(crate) channels: u16, + pub(crate) start: usize, + pub(crate) total_samples: usize, } impl VoiceAudio { diff --git a/crates/audio/src/voice_tone.rs b/crates/audio/src/voice_tone.rs new file mode 100644 index 00000000000..8c19772ad51 --- /dev/null +++ b/crates/audio/src/voice_tone.rs @@ -0,0 +1,1239 @@ +use crate::{AudioSampleSource, VoiceAudio}; +use std::{collections::VecDeque, f64::consts::PI, ops::Range, sync::LazyLock}; + +const SAMPLE_RATE: f64 = 48_000.0; +const BLOCK_FRAMES: usize = 24_000; +const EQ_PREROLL_FRAMES: usize = 9_600; +const AR_ORDER: usize = 16; +const SUB_BLOCK: usize = 512; +const DETECTION_MARGIN: usize = 512; +const DEPENDENCY_MARGIN: usize = 3 * SUB_BLOCK + AR_ORDER; +const MERGE_DISTANCE: usize = 2 * AR_ORDER; +const GAP_PADDING: usize = 2; +const DETECTION_FLOOR: f64 = 3e-4; +const CACHED_BLOCKS: usize = 3; +const BUTTERWORTH_Q: [f64; 2] = [0.541_196_100_146_197, 1.306_562_964_876_376_5]; +const MAX_SECTIONS: usize = 10; + +pub const VOICE_TONE_LOOKBEHIND_SAMPLES: usize = + BLOCK_FRAMES + EQ_PREROLL_FRAMES + DETECTION_MARGIN + DEPENDENCY_MARGIN; +pub const VOICE_TONE_LOOKAHEAD_SAMPLES: usize = BLOCK_FRAMES + DETECTION_MARGIN + DEPENDENCY_MARGIN; + +#[derive(Clone, Copy, Debug)] +pub enum ToneFilter { + HighPass { hz: f32 }, + LowShelf { hz: f32, gain_db: f32 }, + Bell { hz: f32, gain_db: f32, q: f32 }, + HighShelf { hz: f32, gain_db: f32 }, +} + +impl ToneFilter { + fn bits(self) -> (u8, u32, u32, u32) { + match self { + Self::HighPass { hz } => (0, hz.to_bits(), 0, 0), + Self::LowShelf { hz, gain_db } => (1, hz.to_bits(), gain_db.to_bits(), 0), + Self::Bell { hz, gain_db, q } => (2, hz.to_bits(), gain_db.to_bits(), q.to_bits()), + Self::HighShelf { hz, gain_db } => (3, hz.to_bits(), gain_db.to_bits(), 0), + } + } + + fn sections(self) -> [Option; 2] { + match self { + Self::HighPass { hz } => { + BUTTERWORTH_Q.map(|q| finite(hz).and_then(|hz| Biquad::high_pass(hz, q))) + } + Self::LowShelf { hz, gain_db } => [ + finite(hz) + .zip(audible_gain(gain_db)) + .and_then(|(hz, gain)| Biquad::shelf(hz, gain, false)), + None, + ], + Self::HighShelf { hz, gain_db } => [ + finite(hz) + .zip(audible_gain(gain_db)) + .and_then(|(hz, gain)| Biquad::shelf(hz, gain, true)), + None, + ], + Self::Bell { hz, gain_db, q } => [ + finite(hz) + .zip(audible_gain(gain_db)) + .zip(finite(q)) + .and_then(|((hz, gain), q)| Biquad::bell(hz, gain, q)), + None, + ], + } + } +} + +impl PartialEq for ToneFilter { + fn eq(&self, other: &Self) -> bool { + self.bits() == other.bits() + } +} + +impl Eq for ToneFilter {} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DeclickStrength { + Light, + Strong, +} + +impl DeclickStrength { + fn threshold(self) -> f64 { + match self { + Self::Light => 3.5, + Self::Strong => 2.5, + } + } + + fn max_gap(self) -> usize { + match self { + Self::Light => 48, + Self::Strong => 128, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct VoiceToneSettings { + pub filters: [Option; 5], + pub declick: Option, +} + +impl VoiceToneSettings { + pub fn is_active(&self) -> bool { + self.declick.is_some() || !self.sections().is_empty() + } + + fn sections(&self) -> Vec { + self.filters + .iter() + .flatten() + .flat_map(|filter| filter.sections()) + .flatten() + .collect() + } + + pub fn response_db(&self, hz: f32) -> f32 { + self.response().db(hz) + } + + pub fn response(&self) -> ToneResponse { + ToneResponse(self.sections()) + } +} + +pub struct ToneResponse(Vec); + +impl ToneResponse { + pub fn db(&self, hz: f32) -> f32 { + let omega = 2.0 * PI * f64::from(hz).clamp(1.0, SAMPLE_RATE / 2.0) / SAMPLE_RATE; + self.0 + .iter() + .map(|section| 10.0 * section.magnitude_squared(omega).max(1e-12).log10()) + .sum::() as f32 + } +} + +fn finite(value: f32) -> Option { + value.is_finite().then_some(f64::from(value)) +} + +fn audible_gain(gain_db: f32) -> Option { + finite(gain_db) + .filter(|gain| gain.abs() >= 0.01) + .map(|gain| gain.clamp(-24.0, 24.0)) +} + +#[derive(Clone, Copy, Debug)] +struct Biquad { + b0: f64, + b1: f64, + b2: f64, + a1: f64, + a2: f64, +} + +impl Biquad { + fn omega(hz: f64) -> (f64, f64) { + let omega = 2.0 * PI * hz.clamp(10.0, SAMPLE_RATE * 0.45) / SAMPLE_RATE; + (omega.cos(), omega.sin()) + } + + fn normalized(b: [f64; 3], a: [f64; 3]) -> Option { + let section = Self { + b0: b[0] / a[0], + b1: b[1] / a[0], + b2: b[2] / a[0], + a1: a[1] / a[0], + a2: a[2] / a[0], + }; + [section.b0, section.b1, section.b2, section.a1, section.a2] + .iter() + .all(|value| value.is_finite()) + .then_some(section) + } + + fn high_pass(hz: f64, q: f64) -> Option { + let (cos, sin) = Self::omega(hz); + let alpha = sin / (2.0 * q); + Self::normalized( + [(1.0 + cos) / 2.0, -(1.0 + cos), (1.0 + cos) / 2.0], + [1.0 + alpha, -2.0 * cos, 1.0 - alpha], + ) + } + + fn shelf(hz: f64, gain_db: f64, high: bool) -> Option { + let (cos, sin) = Self::omega(hz); + let a = 10f64.powf(gain_db / 40.0); + let root = 2.0 * a.sqrt() * sin / std::f64::consts::SQRT_2; + let sign = if high { -1.0 } else { 1.0 }; + let plus = a + 1.0; + let minus = a - 1.0; + Self::normalized( + [ + a * (plus - sign * minus * cos + root), + sign * 2.0 * a * (minus - sign * plus * cos), + a * (plus - sign * minus * cos - root), + ], + [ + plus + sign * minus * cos + root, + -sign * 2.0 * (minus + sign * plus * cos), + plus + sign * minus * cos - root, + ], + ) + } + + fn bell(hz: f64, gain_db: f64, q: f64) -> Option { + let (cos, sin) = Self::omega(hz); + let a = 10f64.powf(gain_db / 40.0); + let alpha = sin / (2.0 * q.clamp(0.1, 20.0)); + Self::normalized( + [1.0 + alpha * a, -2.0 * cos, 1.0 - alpha * a], + [1.0 + alpha / a, -2.0 * cos, 1.0 - alpha / a], + ) + } + + fn magnitude_squared(&self, omega: f64) -> f64 { + let (cos, cos2) = (omega.cos(), (2.0 * omega).cos()); + let numerator = self.b0 * self.b0 + + self.b1 * self.b1 + + self.b2 * self.b2 + + 2.0 * (self.b0 * self.b1 + self.b1 * self.b2) * cos + + 2.0 * self.b0 * self.b2 * cos2; + let denominator = 1.0 + + self.a1 * self.a1 + + self.a2 * self.a2 + + 2.0 * (self.a1 + self.a1 * self.a2) * cos + + 2.0 * self.a2 * cos2; + numerator / denominator + } + + /// Runs every section on each sample before moving to the next, so the + /// sections' feedback chains overlap instead of each section making its + /// own serial pass. Every section still sees the same inputs in the same + /// order, so the output is bit-identical to filtering one section at a + /// time. + fn process_cascade(sections: &[Biquad], samples: &mut [f64]) { + let mut states = [[0.0f64; 4]; MAX_SECTIONS]; + let states = &mut states[..sections.len()]; + for sample in samples { + let mut x0 = *sample; + for (section, [x1, x2, y1, y2]) in sections.iter().zip(states.iter_mut()) { + let y0 = section.b0 * x0 + section.b1 * *x1 + section.b2 * *x2 + - section.a1 * *y1 + - section.a2 * *y2; + (*x2, *x1, *y2, *y1) = (*x1, x0, *y1, y0); + x0 = y0; + } + *sample = x0; + } + } +} + +/// Renders a microphone track through the voice EQ and de-click. Every output +/// block is computed from a fresh filter state primed over a fixed pre-roll +/// on an absolute sample grid, so a sample's value never depends on where +/// playback, a seek or an export started. +pub struct VoiceTone { + settings: VoiceToneSettings, + sections: Vec, + channels: usize, + blocks: VecDeque<(usize, usize, Vec)>, +} + +impl VoiceTone { + pub fn new(channels: u16, settings: VoiceToneSettings) -> Self { + Self { + sections: settings.sections(), + settings, + channels: usize::from(channels.max(1)), + blocks: VecDeque::with_capacity(CACHED_BLOCKS), + } + } + + pub fn settings(&self) -> VoiceToneSettings { + self.settings + } + + fn region_start(&self, block_start: usize) -> usize { + if self.sections.is_empty() { + block_start + } else { + block_start.saturating_sub(EQ_PREROLL_FRAMES) + } + } + + fn context(&self) -> usize { + if self.settings.declick.is_some() { + DETECTION_MARGIN + DEPENDENCY_MARGIN + } else { + 0 + } + } + + pub fn source_range(&self, start: usize, count: usize) -> Range { + if count == 0 { + return start..start; + } + let first_block = start / BLOCK_FRAMES * BLOCK_FRAMES; + let last_block_end = (start + count - 1) / BLOCK_FRAMES * BLOCK_FRAMES + BLOCK_FRAMES; + self.region_start(first_block) + .saturating_sub(self.context())..last_block_end.saturating_add(self.context()) + } + + pub fn render( + &mut self, + source: &T, + start: usize, + count: usize, + ) -> VoiceAudio { + let total = source.sample_count(); + let end = start.saturating_add(count).min(total); + let mut samples = Vec::with_capacity(end.saturating_sub(start) * self.channels); + let mut position = start; + while position < end { + let block_start = position / BLOCK_FRAMES * BLOCK_FRAMES; + let block_end = (block_start + BLOCK_FRAMES).min(total); + let index = self.block(source, block_start, block_end); + let until = end.min(block_end); + let data = &self.blocks[index].2; + samples.extend_from_slice( + &data[(position - block_start) * self.channels + ..(until - block_start) * self.channels], + ); + position = until; + } + VoiceAudio { + samples, + channels: self.channels as u16, + start, + total_samples: total, + } + } + + fn block( + &mut self, + source: &T, + block_start: usize, + block_end: usize, + ) -> usize { + if let Some(index) = self + .blocks + .iter() + .position(|(start, end, _)| *start == block_start && *end == block_end) + { + return index; + } + let data = self.compute_block(source, block_start, block_end); + if self.blocks.len() == CACHED_BLOCKS { + self.blocks.pop_front(); + } + self.blocks.push_back((block_start, block_end, data)); + self.blocks.len() - 1 + } + + fn compute_block( + &self, + source: &T, + block_start: usize, + block_end: usize, + ) -> Vec { + let total = source.sample_count(); + let channels = self.channels; + let region_start = self.region_start(block_start); + let read_start = region_start.saturating_sub(self.context()); + let read_end = block_end.saturating_add(self.context()).min(total); + let mut output = vec![0.0; (block_end - block_start) * channels]; + let mut input = Vec::with_capacity(read_end - read_start); + let mut region = Vec::with_capacity(block_end - region_start); + for channel in 0..channels { + input.clear(); + input.extend((read_start..read_end).map(|frame| { + source + .sample(frame * channels + channel) + .copied() + .filter(|value| value.is_finite()) + .map_or(0.0, f64::from) + })); + region.clear(); + region.extend_from_slice(&input[region_start - read_start..block_end - read_start]); + if let Some(strength) = self.settings.declick { + let signal = Signal { + samples: &input, + offset: read_start, + total, + }; + declick(&signal, region_start, &mut region, strength); + } + Biquad::process_cascade(&self.sections, &mut region); + for (frame, value) in region[block_start - region_start..].iter().enumerate() { + output[frame * channels + channel] = *value as f32; + } + } + output + } +} + +struct Signal<'a> { + samples: &'a [f64], + offset: usize, + total: usize, +} + +impl Signal<'_> { + fn at(&self, frame: isize) -> f64 { + usize::try_from(frame) + .ok() + .filter(|frame| *frame < self.total) + .and_then(|frame| frame.checked_sub(self.offset)) + .and_then(|index| self.samples.get(index)) + .copied() + .unwrap_or(0.0) + } +} + +type Coefficients = [f64; AR_ORDER + 1]; + +const IDENTITY: Coefficients = { + let mut coefficients = [0.0; AR_ORDER + 1]; + coefficients[0] = 1.0; + coefficients +}; + +const WINDOW: usize = 2 * SUB_BLOCK; + +static HANN: LazyLock<[f64; WINDOW]> = LazyLock::new(|| { + std::array::from_fn(|index| 0.5 - 0.5 * (2.0 * PI * (index as f64 + 0.5) / WINDOW as f64).cos()) +}); + +fn sub_block_coefficients(signal: &Signal, block: usize) -> Coefficients { + let start = (block * SUB_BLOCK) as isize - (SUB_BLOCK / 2) as isize; + let mut window = [0.0; WINDOW]; + for (index, (value, hann)) in window.iter_mut().zip(HANN.iter()).enumerate() { + *value = signal.at(start + index as isize) * hann; + } + let mut correlation = [0.0; AR_ORDER + 1]; + for (lag, value) in correlation.iter_mut().enumerate() { + *value = window[lag..].iter().zip(&window).map(|(a, b)| a * b).sum(); + } + levinson(&correlation).unwrap_or(IDENTITY) +} + +fn levinson(correlation: &[f64; AR_ORDER + 1]) -> Option { + if correlation[0] <= 1e-12 || !correlation[0].is_finite() { + return None; + } + let mut coefficients = IDENTITY; + let mut error = correlation[0] * (1.0 + 1e-5); + for order in 1..=AR_ORDER { + let accumulated = correlation[order] + + (1..order) + .map(|index| coefficients[index] * correlation[order - index]) + .sum::(); + let reflection = -accumulated / error; + let previous = coefficients; + for index in 1..order { + coefficients[index] = previous[index] + reflection * previous[order - index]; + } + coefficients[order] = reflection; + error *= 1.0 - reflection * reflection; + if !reflection.is_finite() || error <= 0.0 { + return None; + } + } + Some(coefficients) +} + +fn percentile_98(values: &mut [f64]) -> f64 { + if values.is_empty() { + return 0.0; + } + let rank = (values.len() * 98 / 100).min(values.len() - 1); + *values.select_nth_unstable_by(rank, f64::total_cmp).1 +} + +/// Detection measures the forward and backward prediction error of a local AR +/// model against the 98th percentile of that error nearby, so glottal pulses in +/// voiced speech set the bar instead of tripping it. Everything is evaluated on +/// a fixed sub-block grid with margins wider than any gap, so two overlapping +/// regions agree on every sample they share. +fn declick( + signal: &Signal, + region_start: usize, + region: &mut [f64], + strength: DeclickStrength, +) -> usize { + let region_end = region_start + region.len(); + let detect_start = region_start.saturating_sub(DETECTION_MARGIN); + let detect_end = (region_end + DETECTION_MARGIN).min(signal.total); + if detect_end <= detect_start { + return 0; + } + let first_block = detect_start / SUB_BLOCK; + let last_block = (detect_end - 1) / SUB_BLOCK; + let residual_first = first_block.saturating_sub(1); + let residual_end = last_block + 2; + let coefficients = (residual_first..residual_end) + .map(|block| sub_block_coefficients(signal, block)) + .collect::>(); + let base = residual_first * SUB_BLOCK; + let length = (residual_end - residual_first) * SUB_BLOCK; + let padded = (0..length + 2 * AR_ORDER) + .map(|index| signal.at((base + index) as isize - AR_ORDER as isize)) + .collect::>(); + let mut forward = vec![0.0; length]; + let mut backward = vec![0.0; length]; + for index in 0..length { + let a = &coefficients[index / SUB_BLOCK]; + let center = index + AR_ORDER; + let (mut ahead, mut behind) = (0.0, 0.0); + for (lag, coefficient) in a.iter().enumerate() { + behind += coefficient * padded[center - lag]; + ahead += coefficient * padded[center + lag]; + } + forward[index] = behind.abs(); + backward[index] = ahead.abs(); + } + let threshold = strength.threshold(); + let mut scratch = Vec::with_capacity(3 * SUB_BLOCK); + let thresholds = (first_block..=last_block) + .map(|block| { + let from = block.saturating_sub(1) * SUB_BLOCK - base; + let to = (block + 2) * SUB_BLOCK - base; + let mut level = |values: &[f64]| { + scratch.clear(); + scratch.extend_from_slice(&values[from..to]); + percentile_98(&mut scratch) * threshold + }; + (level(&forward), level(&backward)) + }) + .collect::>(); + let outliers = |frame: usize| { + let index = frame - base; + let (forward_limit, backward_limit) = thresholds[frame / SUB_BLOCK - first_block]; + ( + forward[index] > forward_limit && forward[index] > DETECTION_FLOOR, + backward[index] > backward_limit && backward[index] > DETECTION_FLOOR, + ) + }; + + let mut repaired = 0; + let mut frame = detect_start; + while frame < detect_end { + let (ahead, behind) = outliers(frame); + if !ahead && !behind { + frame += 1; + continue; + } + let first = frame; + let mut last = frame; + let mut onset = None; + let mut release = None; + let mut confirmed = false; + let mut cursor = frame; + while cursor < detect_end && cursor <= last + MERGE_DISTANCE { + let (ahead, behind) = outliers(cursor); + if ahead || behind { + last = cursor; + } + if ahead { + onset.get_or_insert(cursor); + } + if behind { + release = Some(cursor); + } + confirmed |= ahead && behind; + cursor += 1; + } + frame = last + 1; + let (Some(onset), Some(release)) = (onset, release) else { + continue; + }; + let touches_edge = first == detect_start || last + 1 >= detect_end; + let gap_start = onset.min(release).saturating_sub(GAP_PADDING); + let gap_end = (onset.max(release) + GAP_PADDING + 1).min(signal.total); + if !confirmed + || touches_edge + || gap_end - gap_start > strength.max_gap() + || gap_start < AR_ORDER + || gap_end + AR_ORDER > signal.total + || gap_end <= region_start + || gap_start >= region_end + { + continue; + } + let a = &coefficients[((gap_start + gap_end) / 2) / SUB_BLOCK - residual_first]; + let values = interpolate(signal, a, gap_start, gap_end); + for (offset, value) in values.into_iter().enumerate() { + let frame = gap_start + offset; + if (region_start..region_end).contains(&frame) { + region[frame - region_start] = value; + } + } + repaired += 1; + } + repaired +} + +/// Least-squares AR interpolation of `start..end`: the missing samples that +/// minimise the model's prediction error across the gap and `AR_ORDER` +/// samples past it, with a straight line as the fallback. +fn interpolate(signal: &Signal, a: &Coefficients, start: usize, end: usize) -> Vec { + let gap = end - start; + let known = |frame: usize| { + if (start..end).contains(&frame) { + 0.0 + } else { + signal.at(frame as isize) + } + }; + let autocorrelation = (0..=AR_ORDER) + .map(|lag| { + (0..=AR_ORDER - lag) + .map(|index| a[index] * a[index + lag]) + .sum::() + }) + .collect::>(); + let excitation = (start..end + AR_ORDER) + .map(|frame| { + (0..=AR_ORDER) + .filter(|lag| *lag <= frame) + .map(|lag| a[lag] * known(frame - lag)) + .sum::() + }) + .collect::>(); + let mut matrix = vec![0.0; gap * gap]; + let mut rhs = vec![0.0; gap]; + for row in 0..gap { + for column in 0..gap { + let lag = row.abs_diff(column); + if lag <= AR_ORDER { + matrix[row * gap + column] = autocorrelation[lag]; + } + } + matrix[row * gap + row] += autocorrelation[0] * 1e-9; + rhs[row] = -(0..=AR_ORDER) + .map(|lag| a[lag] * excitation[row + lag]) + .sum::(); + } + solve_cholesky(&mut matrix, &mut rhs, gap) + .filter(|values| values.iter().all(|value| value.is_finite())) + .unwrap_or_else(|| { + let before = signal.at(start as isize - 1); + let after = signal.at(end as isize); + (0..gap) + .map(|index| before + (after - before) * (index + 1) as f64 / (gap + 1) as f64) + .collect() + }) +} + +fn solve_cholesky(matrix: &mut [f64], rhs: &mut [f64], size: usize) -> Option> { + for column in 0..size { + let mut diagonal = matrix[column * size + column]; + for index in 0..column { + diagonal -= matrix[column * size + index] * matrix[column * size + index]; + } + if diagonal <= 0.0 || !diagonal.is_finite() { + return None; + } + let diagonal = diagonal.sqrt(); + matrix[column * size + column] = diagonal; + for row in column + 1..size { + let mut value = matrix[row * size + column]; + for index in 0..column { + value -= matrix[row * size + index] * matrix[column * size + index]; + } + matrix[row * size + column] = value / diagonal; + } + } + for row in 0..size { + let mut value = rhs[row]; + for index in 0..row { + value -= matrix[row * size + index] * rhs[index]; + } + rhs[row] = value / matrix[row * size + row]; + } + for row in (0..size).rev() { + let mut value = rhs[row]; + for index in row + 1..size { + value -= matrix[index * size + row] * rhs[index]; + } + rhs[row] = value / matrix[row * size + row]; + } + Some(rhs.to_vec()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + + struct Samples { + data: Vec, + channels: u16, + } + + impl AudioSampleSource for Samples { + fn channels(&self) -> u16 { + self.channels + } + fn sample_count(&self) -> usize { + self.data.len() / usize::from(self.channels) + } + fn sample(&self, index: usize) -> Option<&f32> { + self.data.get(index) + } + } + + struct Probe<'a> { + inner: &'a Samples, + window: Range, + reads: Cell<(usize, usize)>, + } + + impl AudioSampleSource for Probe<'_> { + fn channels(&self) -> u16 { + self.inner.channels + } + fn sample_count(&self) -> usize { + self.inner.sample_count() + } + fn sample(&self, index: usize) -> Option<&f32> { + let frame = index / usize::from(self.inner.channels); + let (low, high) = self.reads.get(); + self.reads.set((low.min(frame), high.max(frame + 1))); + if self.window.contains(&frame) { + self.inner.sample(index) + } else { + None + } + } + } + + struct Random(u64); + + impl Random { + fn next(&mut self) -> f64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + (self.0 >> 11) as f64 / (1u64 << 53) as f64 + } + fn noise(&mut self) -> f64 { + self.next() * 2.0 - 1.0 + } + } + + /// Formant-filtered glottal pulses with a drifting pitch, syllable + /// envelopes, breath noise and fricative bursts: the residual peaks of + /// real voiced speech that a click detector must not mistake for clicks. + fn voice(seconds: f64, seed: u64) -> Vec { + let frames = (seconds * SAMPLE_RATE) as usize; + let mut random = Random(seed); + let formants = [(700.0, 90.0), (1_220.0, 110.0), (2_600.0, 160.0)].map( + |(hz, bandwidth): (f64, f64)| { + let radius = (-PI * bandwidth / SAMPLE_RATE).exp(); + let theta = 2.0 * PI * hz / SAMPLE_RATE; + (2.0 * radius * theta.cos(), -radius * radius, [0.0f64; 2]) + }, + ); + let mut formants = formants; + let mut phase = 0.0; + let mut fricative = [0.0f64; 2]; + let mut output = Vec::with_capacity(frames); + for frame in 0..frames { + let t = frame as f64 / SAMPLE_RATE; + let pitch = 125.0 + 35.0 * (t * 1.7).sin() + 10.0 * (t * 5.3).sin(); + phase += pitch / SAMPLE_RATE; + let pulse = if phase >= 1.0 { + phase -= 1.0; + 1.0 + } else { + 0.0 + }; + let syllable = (t * 3.1 * PI).sin().max(0.0).powf(0.6); + let mut sample = pulse * syllable + random.noise() * 0.002; + for (a1, a2, state) in &mut formants { + let y = sample + *a1 * state[0] + *a2 * state[1]; + state[1] = state[0]; + state[0] = y; + sample = y * 0.08; + } + let hiss = random.noise(); + let high = hiss - 1.6 * fricative[0] + 0.64 * fricative[1]; + fricative = [hiss, fricative[0]]; + let fricative_gate = if (t % 1.3) > 1.05 { 0.05 } else { 0.0 }; + output.push((sample * 3.0 + high * fricative_gate) as f32); + } + let peak = output + .iter() + .fold(0.0f32, |peak, value| peak.max(value.abs())); + output.iter().map(|value| value / peak * 0.3).collect() + } + + fn render_all(settings: VoiceToneSettings, source: &Samples) -> Vec { + VoiceTone::new(source.channels, settings) + .render(source, 0, source.sample_count()) + .samples + } + + fn eq_only() -> VoiceToneSettings { + VoiceToneSettings { + filters: [ + Some(ToneFilter::HighPass { hz: 90.0 }), + Some(ToneFilter::LowShelf { + hz: 150.0, + gain_db: -3.0, + }), + Some(ToneFilter::Bell { + hz: 350.0, + gain_db: -4.0, + q: 1.2, + }), + Some(ToneFilter::Bell { + hz: 3_500.0, + gain_db: 5.0, + q: 0.9, + }), + Some(ToneFilter::HighShelf { + hz: 9_000.0, + gain_db: 3.0, + }), + ], + declick: None, + } + } + + fn assert_same(actual: &[f32], expected: &[f32], context: &str) { + assert_eq!(actual.len(), expected.len(), "{context}"); + if let Some(index) = actual + .iter() + .zip(expected) + .position(|(a, b)| a.to_bits() != b.to_bits()) + { + panic!( + "{context}: first difference at {index}: {} vs {}", + actual[index], expected[index] + ); + } + } + + fn rms(samples: &[f32]) -> f64 { + (samples.iter().map(|v| f64::from(*v).powi(2)).sum::() / samples.len() as f64).sqrt() + } + + #[test] + fn flat_settings_do_nothing_and_report_a_flat_curve() { + let flat = VoiceToneSettings { + filters: [ + None, + Some(ToneFilter::LowShelf { + hz: 120.0, + gain_db: 0.0, + }), + Some(ToneFilter::Bell { + hz: 1_000.0, + gain_db: 0.004, + q: 1.0, + }), + Some(ToneFilter::HighShelf { + hz: 8_000.0, + gain_db: f32::NAN, + }), + Some(ToneFilter::HighPass { hz: f32::INFINITY }), + ], + declick: None, + }; + assert!(!flat.is_active()); + assert!(!VoiceToneSettings::default().is_active()); + for hz in [20.0, 1_000.0, 20_000.0] { + assert_eq!(flat.response_db(hz), 0.0); + } + } + + #[test] + fn measured_gain_matches_the_reported_response() { + let cases = [ + ToneFilter::HighPass { hz: 80.0 }, + ToneFilter::LowShelf { + hz: 200.0, + gain_db: 6.0, + }, + ToneFilter::Bell { + hz: 1_000.0, + gain_db: -9.0, + q: 2.0, + }, + ToneFilter::HighShelf { + hz: 6_000.0, + gain_db: -6.0, + }, + ]; + for filter in cases { + let settings = VoiceToneSettings { + filters: [Some(filter), None, None, None, None], + declick: None, + }; + for hz in [30.0, 80.0, 200.0, 1_000.0, 3_000.0, 6_000.0, 12_000.0] { + let frames = 96_000; + let data = (0..frames) + .map(|frame| (0.25 * (2.0 * PI * hz * frame as f64 / SAMPLE_RATE).sin()) as f32) + .collect::>(); + let source = Samples { data, channels: 1 }; + let output = render_all(settings, &source); + let measured = + 20.0 * (rms(&output[48_000..]) / rms(&source.data[48_000..])).log10(); + let expected = f64::from(settings.response_db(hz as f32)); + assert!( + (measured - expected).abs() < 0.05, + "{filter:?} at {hz} Hz: measured {measured:.3} dB, expected {expected:.3} dB" + ); + } + } + let high_pass = VoiceToneSettings { + filters: [ + Some(ToneFilter::HighPass { hz: 80.0 }), + None, + None, + None, + None, + ], + declick: None, + }; + assert!(high_pass.response_db(20.0) < -45.0); + assert!(high_pass.response_db(1_000.0).abs() < 0.01); + let bell = VoiceToneSettings { + filters: [ + Some(ToneFilter::Bell { + hz: 2_000.0, + gain_db: 6.0, + q: 1.0, + }), + None, + None, + None, + None, + ], + declick: None, + }; + assert!((bell.response_db(2_000.0) - 6.0).abs() < 0.01); + } + + #[test] + fn chunked_and_seeking_renders_match_one_whole_render() { + let settings = [ + eq_only(), + VoiceToneSettings { + filters: [None; 5], + declick: Some(DeclickStrength::Strong), + }, + VoiceToneSettings { + declick: Some(DeclickStrength::Light), + ..eq_only() + }, + ]; + for channels in [1u16, 2] { + let mono = voice(3.3, 7); + let data = mono + .iter() + .enumerate() + .flat_map(|(index, value)| { + let other = if index.is_multiple_of(997) { + 0.4 + } else { + *value * 0.7 + }; + [*value, other].into_iter().take(usize::from(channels)) + }) + .collect::>(); + let source = Samples { data, channels }; + let total = source.sample_count(); + for settings in settings { + let expected = render_all(settings, &source); + assert_eq!(expected.len(), source.data.len()); + let mut tone = VoiceTone::new(channels, settings); + let mut random = Random(99); + for _ in 0..60 { + let start = (random.next() * total as f64) as usize; + let count = 1 + (random.next() * 30_000.0) as usize; + let audio = tone.render(&source, start, count); + let end = (start + count).min(total); + assert_same( + &audio.samples, + &expected[start * usize::from(channels)..end * usize::from(channels)], + &format!("{settings:?} {channels}ch {start}+{count}"), + ); + } + let mut cursor = 0; + let mut streamed = Vec::new(); + for count in [1, 479, 4_096, 23_999, 1, 24_001, 7].into_iter().cycle() { + if cursor == total { + break; + } + streamed.extend(tone.render(&source, cursor, count).samples); + cursor = (cursor + count).min(total); + } + assert_same( + &streamed, + &expected, + &format!("{settings:?} {channels}ch stream"), + ); + } + } + } + + #[test] + fn renders_read_only_inside_their_source_range() { + let settings = VoiceToneSettings { + declick: Some(DeclickStrength::Strong), + ..eq_only() + }; + let source = Samples { + data: voice(4.0, 3), + channels: 1, + }; + let total = source.sample_count(); + let expected = render_all(settings, &source); + let mut random = Random(5); + for _ in 0..40 { + let start = (random.next() * total as f64) as usize; + let count = 1 + (random.next() * 8_192.0) as usize; + let tone = VoiceTone::new(1, settings); + let range = tone.source_range(start, count); + assert!(start - range.start.min(start) <= VOICE_TONE_LOOKBEHIND_SAMPLES); + assert!(range.end - (start + count) <= VOICE_TONE_LOOKAHEAD_SAMPLES); + let probe = Probe { + inner: &source, + window: range.clone(), + reads: Cell::new((usize::MAX, 0)), + }; + let audio = VoiceTone::new(1, settings).render(&probe, start, count); + let (low, high) = probe.reads.get(); + assert!(low >= range.start && high <= range.end, "{start}+{count}"); + let end = (start + count).min(total); + assert_eq!(audio.samples, &expected[start..end]); + } + let eq = VoiceTone::new(1, eq_only()); + assert_eq!(eq.source_range(50_000, 10), 38_400..72_000); + assert_eq!(eq.source_range(50_000, 0), 50_000..50_000); + } + + fn with_clicks(clean: &[f32], seed: u64, per_second: f64) -> (Vec, Vec) { + let mut random = Random(seed); + let mut data = clean.to_vec(); + let mut positions = Vec::new(); + let mut frame = 4_000; + while frame + 200 < data.len() { + frame += (SAMPLE_RATE / per_second * (0.5 + random.next())) as usize; + if frame + 200 >= data.len() { + break; + } + let width = 1 + (random.next() * 6.0) as usize; + let amplitude = + (0.08 + random.next() * 0.5) * if random.next() > 0.5 { 1.0 } else { -1.0 }; + for offset in 0..width { + let shape = 1.0 - offset as f64 / (width as f64 + 1.0); + data[frame + offset] += (amplitude * shape) as f32; + } + positions.push(frame); + } + (data, positions) + } + + fn error_db(signal: &[f32], reference: &[f32]) -> f64 { + let error = signal + .iter() + .zip(reference) + .map(|(a, b)| f64::from(a - b).powi(2)) + .sum::(); + let power = reference.iter().map(|v| f64::from(*v).powi(2)).sum::(); + 10.0 * (error / power).log10() + } + + #[test] + fn declick_removes_clicks_and_crackle_from_voice() { + let clean = voice(12.0, 11); + for (strength, rate, minimum_gain_db) in [ + (DeclickStrength::Light, 4.0, 15.0), + (DeclickStrength::Strong, 4.0, 20.0), + (DeclickStrength::Strong, 40.0, 15.0), + ] { + let (noisy, positions) = with_clicks(&clean, 21, rate); + let source = Samples { + data: noisy.clone(), + channels: 1, + }; + let output = render_all( + VoiceToneSettings { + filters: [None; 5], + declick: Some(strength), + }, + &source, + ); + let before = error_db(&noisy, &clean); + let after = error_db(&output, &clean); + let repaired = positions + .iter() + .filter(|frame| { + let range = **frame..**frame + 8; + error_db(&output[range.clone()], &clean[range.clone()]) + < error_db(&noisy[range.clone()], &clean[range]) - 10.0 + }) + .count(); + eprintln!( + "{strength:?} {rate}/s: error {before:.1} dB -> {after:.1} dB, {repaired}/{} clicks repaired", + positions.len() + ); + assert!(before - after >= minimum_gain_db); + assert!(repaired * 100 >= positions.len() * 90); + } + } + + #[test] + fn declick_leaves_clean_voice_alone() { + for seed in [1, 2, 3] { + let clean = voice(10.0, seed); + let source = Samples { + data: clean.clone(), + channels: 1, + }; + for strength in [DeclickStrength::Light, DeclickStrength::Strong] { + let output = render_all( + VoiceToneSettings { + filters: [None; 5], + declick: Some(strength), + }, + &source, + ); + let changed = output.iter().zip(&clean).filter(|(a, b)| a != b).count(); + eprintln!("seed {seed} {strength:?}: {changed} samples changed"); + if strength == DeclickStrength::Light { + assert_eq!(changed, 0); + } else { + assert!(changed < clean.len() / 2_000); + assert!(error_db(&output, &clean) < -45.0); + } + } + } + } + + #[test] + fn silence_short_tracks_and_non_finite_samples_stay_safe() { + let settings = VoiceToneSettings { + declick: Some(DeclickStrength::Strong), + ..eq_only() + }; + for frames in [0, 1, 17, 480, 23_999, 24_001] { + let source = Samples { + data: vec![0.0; frames], + channels: 1, + }; + assert_eq!(render_all(settings, &source), vec![0.0; frames]); + } + let mut data = voice(1.0, 4); + data[100] = f32::NAN; + data[20_000] = f32::INFINITY; + data[30_000] = f32::NEG_INFINITY; + let output = render_all(settings, &Samples { data, channels: 1 }); + assert!( + output + .iter() + .all(|value| value.is_finite() && value.abs() < 2.0) + ); + let mut impulse = vec![0.0; 48_000]; + impulse[20_000] = 0.9; + let output = render_all( + VoiceToneSettings { + filters: [None; 5], + declick: Some(DeclickStrength::Strong), + }, + &Samples { + data: impulse, + channels: 1, + }, + ); + assert!(output.iter().all(|value| value.abs() < 1e-6)); + } + + #[test] + #[ignore = "requires CAP_VOICE_TONE_TEST_AUDIO pointing to local microphone recordings"] + fn declick_rarely_touches_real_voice() { + ffmpeg::init().unwrap(); + let paths = std::env::var("CAP_VOICE_TONE_TEST_AUDIO").unwrap(); + for path in paths.split(';') { + let audio = crate::AudioData::from_file(path).unwrap(); + let channels = audio.channels(); + let source = Samples { + data: audio.samples().to_vec(), + channels, + }; + let minutes = source.sample_count() as f64 / SAMPLE_RATE / 60.0; + for strength in [DeclickStrength::Light, DeclickStrength::Strong] { + let output = render_all( + VoiceToneSettings { + filters: [None; 5], + declick: Some(strength), + }, + &source, + ); + let changed = output + .iter() + .zip(&source.data) + .filter(|(a, b)| a != b) + .count(); + eprintln!( + "{strength:?} {minutes:.1} min: {changed} samples changed ({:.1} per minute), error {:.1} dB", + changed as f64 / minutes, + error_db(&output, &source.data) + ); + } + } + } + + #[test] + #[ignore = "timing probe"] + fn tone_render_speed() { + let source = Samples { + data: voice(60.0, 9), + channels: 1, + }; + for settings in [ + eq_only(), + VoiceToneSettings { + declick: Some(DeclickStrength::Strong), + ..eq_only() + }, + ] { + let started = std::time::Instant::now(); + let output = render_all(settings, &source); + eprintln!( + "{:?}: {:.1} ms per audio second", + settings.declick, + started.elapsed().as_secs_f64() * 1_000.0 / 60.0 + ); + assert_eq!(output.len(), source.data.len()); + } + } +} diff --git a/crates/editor/examples/editor-playback-benchmark.rs b/crates/editor/examples/editor-playback-benchmark.rs index 0af9f92b602..d4374d04c68 100644 --- a/crates/editor/examples/editor-playback-benchmark.rs +++ b/crates/editor/examples/editor-playback-benchmark.rs @@ -361,6 +361,7 @@ async fn load_recording( name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }] } @@ -382,6 +383,7 @@ async fn load_recording( name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }) }) diff --git a/crates/editor/examples/playback-pipeline-benchmark.rs b/crates/editor/examples/playback-pipeline-benchmark.rs index 6b4e23dd869..093c522ee9f 100644 --- a/crates/editor/examples/playback-pipeline-benchmark.rs +++ b/crates/editor/examples/playback-pipeline-benchmark.rs @@ -277,6 +277,7 @@ async fn load_recording( name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }] } @@ -301,6 +302,7 @@ async fn load_recording( name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }) }) diff --git a/crates/editor/src/audio.rs b/crates/editor/src/audio.rs index f793e41d67c..824a0c99f21 100644 --- a/crates/editor/src/audio.rs +++ b/crates/editor/src/audio.rs @@ -1,14 +1,15 @@ use crate::export_audio::{EXPORT_AUDIO_BLOCK_SAMPLES, ExportAudioError, ExportAudioSources}; use cap_audio::{ - AudioData, AudioRendererTrack, DecodedAudio, FromSampleBytes, StereoMode, VoiceAudio, - VoiceEnhancer, VoiceSource, cast_bytes_to_f32_slice, cast_f32_slice_to_bytes, - high_quality_resampler_options, + AudioData, AudioRendererTrack, DeclickStrength, DecodedAudio, FromSampleBytes, StereoMode, + ToneFilter, VoiceAudio, VoiceEnhancer, VoiceSource, VoiceTone, VoiceToneSettings, + cast_bytes_to_f32_slice, cast_f32_slice_to_bytes, high_quality_resampler_options, }; use cap_media::MediaError; use cap_media_info::AudioInfo; use cap_project::{ AudioConfiguration, ClipOffsets, ClipSpeedAudioMode, ClipTransitionType, ProjectConfiguration, - TimelineConfiguration, TimelineFrameMapping, TimelineSource, VoiceIsolation, + TimelineConfiguration, TimelineFrameMapping, TimelineSegment, TimelineSource, VoiceDeclick, + VoiceIsolation, }; use ffmpeg::{ ChannelLayout, filter, format as avformat, frame::Audio as FFAudio, software::resampling, @@ -158,6 +159,7 @@ struct SpeedAudioProcessorKey { mic_stereo_mode: u8, mic_offset_bits: u32, system_offset_bits: u32, + voice_tone: Option, } struct SpeedAudioProcessorSlot { @@ -185,6 +187,12 @@ struct SpeedAudioProcessor { voice_enhancement: VoiceEnhancementCache, } +#[derive(Clone, Copy)] +struct ClipAudio<'a> { + data: &'a [AudioSegment], + voice_tone: Option, +} + impl AudioRenderer { pub const SAMPLE_FORMAT: avformat::Sample = AudioData::SAMPLE_FORMAT; pub const SAMPLE_RATE: u32 = AudioData::SAMPLE_RATE; @@ -303,6 +311,7 @@ impl AudioRenderer { let rendered = sources.render( project, self.cursor.clip_index, + None, self.cursor.samples + written, count, &mut output[..count * 2], @@ -366,6 +375,7 @@ impl AudioRenderer { sources.render( project, source.segment.recording_clip, + segment_voice_tone(source.segment), self.playhead_to_samples(source.source_time) + span_offset, count, output, @@ -390,6 +400,7 @@ impl AudioRenderer { sources.render( project, outgoing.segment.recording_clip, + segment_voice_tone(outgoing.segment), self.playhead_to_samples(outgoing.source_time) + span_offset, count, outgoing_buffer, @@ -400,6 +411,7 @@ impl AudioRenderer { sources.render( project, incoming.segment.recording_clip, + segment_voice_tone(incoming.segment), self.playhead_to_samples(incoming.source_time) + span_offset, count, incoming_buffer, @@ -685,7 +697,7 @@ impl AudioRenderer { out_offset: usize, out: &mut [f32], ) -> usize { - self.render_chunk_at_cursor(project, self.cursor, samples, out_offset, out) + self.render_chunk_at_cursor(project, self.cursor, None, samples, out_offset, out) } fn render_segment_chunk( @@ -701,7 +713,8 @@ impl AudioRenderer { return 0; } let cursor = source_cursor(source, self.playhead_to_samples(source.source_time)); - self.render_chunk_at_cursor(project, cursor, samples, out_offset, out) + let voice_tone = segment_voice_tone(source.segment); + self.render_chunk_at_cursor(project, cursor, voice_tone, samples, out_offset, out) } else { self.render_speed_audio_chunk(project, source, samples, out_offset, out) }; @@ -756,6 +769,7 @@ impl AudioRenderer { mic_stereo_mode: project_stereo_mode_key(&project.audio.mic_stereo_mode), mic_offset_bits: offsets.mic.to_bits(), system_offset_bits: offsets.system_audio.to_bits(), + voice_tone: segment_voice_tone(source.segment), }; let requested_source_sample = source.source_time * Self::SAMPLE_RATE as f64; self.speed_audio_use_counter = self.speed_audio_use_counter.saturating_add(1); @@ -799,7 +813,10 @@ impl AudioRenderer { let result = match &mut slot.state { SpeedAudioProcessorState::Ready(processor) => processor.render( - &self.data, + ClipAudio { + data: &self.data, + voice_tone: key.voice_tone, + }, project, requested_source_sample, samples, @@ -827,12 +844,16 @@ impl AudioRenderer { &mut self, project: &ProjectConfiguration, cursor: AudioRendererCursor, + voice_tone: Option, samples: usize, out_offset: usize, out: &mut [f32], ) -> usize { render_audio_data_chunk( - &self.data, + ClipAudio { + data: &self.data, + voice_tone, + }, project, cursor, samples, @@ -860,8 +881,58 @@ fn source_cursor(source: TimelineSource<'_>, samples: usize) -> AudioRendererCur } } +pub fn voice_eq_filters(voice_eq: &cap_project::VoiceEq) -> [Option; 5] { + let voice_eq = voice_eq.sanitized(); + let (low, low_mid, high_mid, high) = ( + voice_eq.low, + voice_eq.low_mid, + voice_eq.high_mid, + voice_eq.high, + ); + [ + voice_eq.high_pass.then_some(ToneFilter::HighPass { + hz: voice_eq.high_pass_hz, + }), + Some(ToneFilter::LowShelf { + hz: low.hz, + gain_db: low.gain_db, + }), + Some(ToneFilter::Bell { + hz: low_mid.hz, + gain_db: low_mid.gain_db, + q: low_mid.q, + }), + Some(ToneFilter::Bell { + hz: high_mid.hz, + gain_db: high_mid.gain_db, + q: high_mid.q, + }), + Some(ToneFilter::HighShelf { + hz: high.hz, + gain_db: high.gain_db, + }), + ] +} + +pub(crate) fn segment_voice_tone(segment: &TimelineSegment) -> Option { + let voice_eq = segment.active_voice_eq()?; + let settings = VoiceToneSettings { + filters: if voice_eq.shapes_tone() { + voice_eq_filters(&voice_eq) + } else { + [None; 5] + }, + declick: match voice_eq.declick { + VoiceDeclick::Off => None, + VoiceDeclick::Light => Some(DeclickStrength::Light), + VoiceDeclick::Strong => Some(DeclickStrength::Strong), + }, + }; + settings.is_active().then_some(settings) +} + fn render_audio_data_chunk( - data: &[AudioSegment], + clip: ClipAudio<'_>, project: &ProjectConfiguration, cursor: AudioRendererCursor, samples: usize, @@ -869,6 +940,7 @@ fn render_audio_data_chunk( out: &mut [f32], enhancement: &mut VoiceEnhancementCache, ) -> usize { + let ClipAudio { data, voice_tone } = clip; let Some(segment) = data.get(cursor.clip_index as usize) else { return 0; }; @@ -919,7 +991,10 @@ fn render_audio_data_chunk( }) .collect::>(); - if !project.audio.improve || project.audio.mute { + let isolation = + (project.audio.improve && !project.audio.mute).then_some(project.audio.isolation); + let voice_tone = voice_tone.filter(|_| !project.audio.mute); + if isolation.is_none() && voice_tone.is_none() { return cap_audio::render_audio(&track_datas, cursor.samples, samples, out_offset, out); } let clip_key = cursor.clip_index as usize; @@ -944,13 +1019,14 @@ fn render_audio_data_chunk( if start >= end || start >= data.data.sample_count() { return None; } - Some(enhancement.render( + enhancement.render( (clip_key, index), data.data, start, end.saturating_sub(start), - project.audio.isolation, - )) + isolation, + voice_tone, + ) }) .collect::>(); let sources = track_datas @@ -981,9 +1057,12 @@ fn render_audio_data_chunk( samples } +type VoiceEnhancementKey = (usize, usize, VoiceIsolation, Option); + #[derive(Default)] struct VoiceEnhancementCache { - slots: Vec<((usize, usize, VoiceIsolation), VoiceEnhancer)>, + slots: Vec<(VoiceEnhancementKey, VoiceEnhancer)>, + tones: Vec<((usize, usize), VoiceTone)>, } impl VoiceEnhancementCache { @@ -993,14 +1072,21 @@ impl VoiceEnhancementCache { source: &DecodedAudio, start: usize, count: usize, - isolation: VoiceIsolation, - ) -> VoiceAudio { - let key = (key.0, key.1, isolation); + isolation: Option, + voice_tone: Option, + ) -> Option { + let Some(isolation) = isolation else { + return voice_tone.map(|voice_tone| { + self.tone(key, source.channels(), voice_tone) + .render(source, start, count) + }); + }; + let key = (key.0, key.1, isolation, voice_tone); let index = self .slots .iter() .position(|(candidate, state)| *candidate == key && state.is_contiguous(start)); - let slot = if let Some(index) = index { + let mut slot = if let Some(index) = index { self.slots.remove(index) } else { ( @@ -1015,12 +1101,41 @@ impl VoiceEnhancementCache { if self.slots.len() == 4 { self.slots.remove(0); } + let audio = match voice_tone { + Some(voice_tone) => { + let range = slot.1.source_range(start, count); + let toned = self + .tone((key.0, key.1), source.channels(), voice_tone) + .render(source, range.start, range.len()); + slot.1.render(&toned, start, count) + } + None => slot.1.render(source, start, count), + }; self.slots.push(slot); - self.slots - .last_mut() - .unwrap() - .1 - .render(source, start, count) + Some(audio) + } + + fn tone( + &mut self, + key: (usize, usize), + channels: u16, + settings: VoiceToneSettings, + ) -> &mut VoiceTone { + let index = match self + .tones + .iter() + .position(|(candidate, tone)| *candidate == key && tone.settings() == settings) + { + Some(index) => index, + None => { + if self.tones.len() == 4 { + self.tones.remove(0); + } + self.tones.push((key, VoiceTone::new(channels, settings))); + self.tones.len() - 1 + } + }; + &mut self.tones[index].1 } } @@ -1113,7 +1228,7 @@ impl SpeedAudioProcessor { fn render( &mut self, - data: &[AudioSegment], + clip: ClipAudio<'_>, project: &ProjectConfiguration, requested_source_sample: f64, samples: usize, @@ -1123,7 +1238,7 @@ impl SpeedAudioProcessor { let target_samples = samples.saturating_add(self.discard_output_samples); while self.pending.len() / 2 < target_samples { if self.input_samples < self.segment_end_samples { - self.feed_input(data, project)?; + self.feed_input(clip, project)?; } else if !self.flushed { let mut source = self.graph.get("in").ok_or(ffmpeg::Error::InvalidData)?; source.source().flush()?; @@ -1152,7 +1267,7 @@ impl SpeedAudioProcessor { fn feed_input( &mut self, - data: &[AudioSegment], + clip: ClipAudio<'_>, project: &ProjectConfiguration, ) -> Result<(), ffmpeg::Error> { let samples = SPEED_AUDIO_INPUT_BLOCK_SAMPLES @@ -1160,7 +1275,7 @@ impl SpeedAudioProcessor { self.input_data.resize(samples * 2, 0.0); self.input_data.fill(0.0); render_audio_data_chunk( - data, + clip, project, AudioRendererCursor { clip_index: self.recording_clip, @@ -3446,6 +3561,7 @@ mod tests { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }, TimelineSegment { @@ -3456,6 +3572,7 @@ mod tests { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }, TimelineSegment { @@ -3466,6 +3583,7 @@ mod tests { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }, TimelineSegment { @@ -3476,6 +3594,7 @@ mod tests { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }, TimelineSegment { @@ -3486,6 +3605,7 @@ mod tests { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }, TimelineSegment { @@ -3496,6 +3616,7 @@ mod tests { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }, ], @@ -3954,6 +4075,7 @@ mod tests { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, } } @@ -4941,7 +5063,14 @@ mod tests { type FixtureEdit = fn(&mut ProgressiveFixture); - fn stateful_edits() -> [(&'static str, FixtureEdit); 5] { + fn voice_eq(preset: cap_project::VoiceEqPreset, declick: VoiceDeclick) -> cap_project::VoiceEq { + cap_project::VoiceEq { + declick, + ..cap_project::VoiceEq::from_preset(preset) + } + } + + fn stateful_edits() -> [(&'static str, FixtureEdit); 6] { [ ("studio sound", |f| f.project.audio.improve = true), ("speed", |f| f.timeline().segments[1].timescale = 1.5), @@ -4963,6 +5092,13 @@ mod tests { ("segment start on a sample tie", |f| { f.timeline().segments[1].start = 3.5 + 0.5 / AudioData::SAMPLE_RATE as f64 }), + ("studio sound with voice eq", |f| { + f.project.audio.improve = true; + f.timeline().segments[1].voice_eq = Some(voice_eq( + cap_project::VoiceEqPreset::Warm, + VoiceDeclick::Strong, + )); + }), ] } @@ -5184,6 +5320,69 @@ mod tests { assert!(shifts.iter().all(|count| *count > 0), "{shifts:?}"); } + #[test] + fn voice_eq_renders_replay_from_any_start() { + let _reuse = CACHE_RELEASE.read().unwrap_or_else(PoisonError::into_inner); + let mut fixture = ProgressiveFixture::new(); + fixture.timeline().segments[0].voice_eq = Some(voice_eq( + cap_project::VoiceEqPreset::Broadcast, + VoiceDeclick::Strong, + )); + fixture.timeline().segments[1].voice_eq = Some(cap_project::VoiceEq { + enabled: false, + ..voice_eq(cap_project::VoiceEqPreset::Natural, VoiceDeclick::Light) + }); + fixture.timeline().segments[2].voice_eq = Some(voice_eq( + cap_project::VoiceEqPreset::Bright, + VoiceDeclick::Off, + )); + let output_info = stereo_48k(); + let cache = PlaybackAudioCache::default(); + let first = fixture.cached(&cache, output_info, 1.0); + first.wait_until_fully_rendered(); + let timeline = progressive_timeline(&first); + drop(first); + for start in [ + 0.0, + 0.4, + 2.7, + 3.0 + 1.0 / 3.0, + 5.0 + 0.4 / 48_000.0, + 6.123_456_789, + 7.4, + ] { + let context = format!("voice eq start {start}"); + let mut replay = fixture.cached(&cache, output_info, start); + assert!( + Arc::ptr_eq(&timeline, &progressive_timeline(&replay)), + "{context}: must replay the cached render" + ); + replay.set_playhead(start); + assert!(replay.wait_until_ready(std::time::Duration::from_secs(1))); + assert_bit_identical( + &read_everything(&mut replay), + &fixture.reference(output_info, start), + &context, + ); + } + let mut plain = ProgressiveFixture::new(); + let flat = plain.reference(output_info, 0.0); + plain.timeline().segments[0].voice_eq = Some(cap_project::VoiceEq { + enabled: false, + ..Default::default() + }); + assert_bit_identical( + &plain.reference(output_info, 0.0), + &flat, + "bypassed voice eq", + ); + assert_ne!( + fixture.reference(output_info, 0.0), + flat, + "an active voice eq must change the audio" + ); + } + #[test] fn stateful_audio_and_resampled_output_never_replay() { let _reuse = CACHE_RELEASE.read().unwrap_or_else(PoisonError::into_inner); @@ -5255,7 +5454,7 @@ mod tests { #[test] fn every_audio_change_renders_fresh_and_visual_changes_reuse() { let _reuse = CACHE_RELEASE.read().unwrap_or_else(PoisonError::into_inner); - let audio_edits: [(&str, FixtureEdit); 11] = [ + let audio_edits: [(&str, FixtureEdit); 13] = [ ("mic volume", |f| f.project.audio.mic_volume_db = -6.0), ("system volume", |f| f.project.audio.system_volume_db = -6.0), ("mute", |f| f.project.audio.mute = true), @@ -5266,6 +5465,18 @@ mod tests { ("clip volume", |f| { f.timeline().segments[0].volume = Some(0.25) }), + ("voice eq", |f| { + f.timeline().segments[0].voice_eq = Some(voice_eq( + cap_project::VoiceEqPreset::Clear, + VoiceDeclick::Off, + )) + }), + ("declick", |f| { + f.timeline().segments[2].voice_eq = Some(cap_project::VoiceEq { + enabled: false, + ..voice_eq(cap_project::VoiceEqPreset::Natural, VoiceDeclick::Light) + }) + }), ("muted speed", |f| { let segment = &mut f.timeline().segments[1]; segment.timescale = 1.5; @@ -5330,6 +5541,7 @@ mod tests { assert_bit_identical(&after_audio, &fixture.reference(output_info, 0.0), name); assert!( name == "split" + || name == "declick" || before_audio.len() != after_audio.len() || before_audio .iter() diff --git a/crates/editor/src/editor_instance.rs b/crates/editor/src/editor_instance.rs index c3c44bec553..8e82aa06327 100644 --- a/crates/editor/src/editor_instance.rs +++ b/crates/editor/src/editor_instance.rs @@ -427,6 +427,7 @@ impl EditorInstance { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }], _ => { @@ -462,6 +463,7 @@ impl EditorInstance { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }) }) diff --git a/crates/editor/src/export_audio.rs b/crates/editor/src/export_audio.rs index 8f8e5cc33f4..aa05242a8ea 100644 --- a/crates/editor/src/export_audio.rs +++ b/crates/editor/src/export_audio.rs @@ -1,8 +1,9 @@ use crate::{AudioRenderer, SegmentMedia}; use cap_audio::{ AudioData, AudioRendererTrack, AudioSampleSource, AudioStream, AudioStreamError, ChunkRead, - StereoMode, VOICE_PREROLL_SAMPLES, VOICE_PROFILE_SAMPLES, VOICE_WINDOW_PADDING_SAMPLES, - VoiceAudio, VoiceEnhancer, VoiceProfile, VoiceSource, + StereoMode, VOICE_PREROLL_SAMPLES, VOICE_PROFILE_SAMPLES, VOICE_TONE_LOOKAHEAD_SAMPLES, + VOICE_TONE_LOOKBEHIND_SAMPLES, VOICE_WINDOW_PADDING_SAMPLES, VoiceAudio, VoiceEnhancer, + VoiceProfile, VoiceSource, VoiceTone, VoiceToneSettings, }; use cap_project::{ClipOffsets, ProjectConfiguration, RecordingMeta, StudioRecordingMeta}; use std::{ @@ -18,8 +19,11 @@ use std::{ pub(crate) const EXPORT_AUDIO_BLOCK_SAMPLES: usize = 4_096; const MAX_PARALLEL_AUDIO_SOURCES: usize = 4; -const MAX_ENHANCED_WINDOW_SAMPLES: usize = - EXPORT_AUDIO_BLOCK_SAMPLES + VOICE_PREROLL_SAMPLES + VOICE_WINDOW_PADDING_SAMPLES; +const MAX_ENHANCED_WINDOW_SAMPLES: usize = EXPORT_AUDIO_BLOCK_SAMPLES + + VOICE_PREROLL_SAMPLES + + VOICE_WINDOW_PADDING_SAMPLES + + VOICE_TONE_LOOKBEHIND_SAMPLES + + VOICE_TONE_LOOKAHEAD_SAMPLES; const STUDIO_SOUND_HISTORY_SAMPLES: usize = VOICE_PREROLL_SAMPLES + VOICE_WINDOW_PADDING_SAMPLES; #[derive(Clone, Debug)] @@ -467,6 +471,7 @@ impl ExportAudioSources { &mut self, project: &ProjectConfiguration, clip_index: u32, + voice_tone: Option, cursor: usize, samples: usize, output: &mut [f32], @@ -481,56 +486,35 @@ impl ExportAudioSources { .find(|clip| clip.index == clip_index) .map(|clip| clip.offsets) .unwrap_or_default(); + let clip_has_tone = project.timeline.as_ref().is_some_and(|timeline| { + timeline.segments.iter().any(|segment| { + segment.recording_clip == clip_index + && crate::audio::segment_voice_tone(segment).is_some() + }) + }); for track in tracks.iter_mut() { let start = cursor as i128 + track.offset(&offsets) as i128; let end = start + samples as i128; - let improve = track.mic - && project.audio.improve + let processable = track.mic && !project.audio.mute && project.audio.mic_volume_db > -30.0 && (1..=2).contains(&track.source.channels()); - if improve { - let start = - usize::try_from(start.max(0)).map_err(|_| ExportAudioError::InvalidWindow)?; - let end = - usize::try_from(end.max(0)).map_err(|_| ExportAudioError::InvalidWindow)?; - if track.eof.is_some_and(|eof| start >= eof) { - track.enhanced = None; - continue; - } - if track.isolation != project.audio.isolation { - track.enhancer = None; - track.isolation = project.audio.isolation; - } - let profile = *track.voice_profile.get_or_insert_with(|| { - AudioData::from_file_range(&track.path, 0, VOICE_PROFILE_SAMPLES) - .map(|audio| VoiceProfile::analyze(&audio)) - .unwrap_or_else(|error| { - tracing::warn!(%error, "Could not estimate export voice level"); - VoiceProfile::default() - }) - }); - let mut enhancer = track.enhancer.take().unwrap_or_else(|| { - VoiceEnhancer::with_settings( - track.source.channels(), - project.audio.isolation.strength(), - profile, - ) - }); - let range = enhancer.source_range(start, end.saturating_sub(start)); - track.prepare( - range.start as i128, - range.end as i128, - STUDIO_SOUND_HISTORY_SAMPLES, - )?; - track.enhanced = - Some(enhancer.render(&track.view(), start, end.saturating_sub(start))); - track.enhancer = Some(enhancer); + let improve = processable && project.audio.improve; + let voice_tone = voice_tone.filter(|_| processable); + let history = if clip_has_tone && track.mic { + VOICE_TONE_LOOKBEHIND_SAMPLES } else { - track.prepare(start, end, 0)?; - track.enhanced = None; + 0 + }; + if track.tone.as_ref().map(VoiceTone::settings) != voice_tone { + track.tone = + voice_tone.map(|settings| VoiceTone::new(track.source.channels(), settings)); track.enhancer = None; } + let mut tone = track.tone.take(); + let enhanced = track.render_voice(project, improve, tone.as_mut(), start, end, history); + track.tone = tone; + track.enhanced = enhanced?; } let views = tracks.iter().map(|track| track.view()).collect::>(); let sources = tracks @@ -604,6 +588,7 @@ struct ExportAudioTrack { enhanced: Option, voice_profile: Option, isolation: cap_project::VoiceIsolation, + tone: Option, } impl ExportAudioTrack { @@ -635,9 +620,83 @@ impl ExportAudioTrack { enhanced: None, voice_profile: None, isolation: cap_project::VoiceIsolation::default(), + tone: None, }) } + /// Voice EQ and Studio Sound run in the order and with the windows the + /// playback renderer uses, so preview and export produce the same samples. + fn render_voice( + &mut self, + project: &ProjectConfiguration, + improve: bool, + tone: Option<&mut VoiceTone>, + start: i128, + end: i128, + history: usize, + ) -> Result, ExportAudioError> { + if !improve && tone.is_none() { + self.prepare(start, end, history)?; + self.enhancer = None; + return Ok(None); + } + let start = usize::try_from(start.max(0)).map_err(|_| ExportAudioError::InvalidWindow)?; + let end = usize::try_from(end.max(0)).map_err(|_| ExportAudioError::InvalidWindow)?; + if self.eof.is_some_and(|eof| start >= eof) { + return Ok(None); + } + let count = end.saturating_sub(start); + match tone { + Some(tone) if !improve => { + self.enhancer = None; + let window = tone.source_range(start, count); + self.prepare(window.start as i128, window.end as i128, history)?; + Ok(Some(tone.render(&self.view(), start, count))) + } + mut tone => { + if self.isolation != project.audio.isolation { + self.enhancer = None; + self.isolation = project.audio.isolation; + } + let path = &self.path; + let profile = *self.voice_profile.get_or_insert_with(|| { + AudioData::from_file_range(path, 0, VOICE_PROFILE_SAMPLES) + .map(|audio| VoiceProfile::analyze(&audio)) + .unwrap_or_else(|error| { + tracing::warn!(%error, "Could not estimate export voice level"); + VoiceProfile::default() + }) + }); + let mut enhancer = self.enhancer.take().unwrap_or_else(|| { + VoiceEnhancer::with_settings( + self.source.channels(), + project.audio.isolation.strength(), + profile, + ) + }); + let range = enhancer.source_range(start, count); + let window = tone.as_ref().map_or(range.clone(), |tone| { + tone.source_range(range.start, range.len()) + }); + self.prepare( + window.start as i128, + window.end as i128, + STUDIO_SOUND_HISTORY_SAMPLES + history, + )?; + let view = self.view(); + let audio = match tone.as_mut() { + Some(tone) => { + let toned = tone.render(&view, range.start, range.len()); + enhancer.render(&toned, start, count) + } + None => enhancer.render(&view, start, count), + }; + self.enhancer = Some(enhancer); + Ok(Some(audio)) + } + } + } + fn offset(&self, offsets: &ClipOffsets) -> isize { let offset = if self.mic { offsets.mic @@ -1100,6 +1159,7 @@ mod tests { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, } } @@ -1436,6 +1496,7 @@ mod tests { .render( &project, clip as u32, + None, cursor, EXPORT_AUDIO_BLOCK_SAMPLES, &mut output, @@ -1660,6 +1721,222 @@ mod tests { std::fs::write(path, bytes).unwrap(); } + fn write_clicky_voice_wav(path: &Path, frames: usize, channels: u16, seed: u64) { + write_voice_like_wav(path, frames, channels, seed); + let mut bytes = std::fs::read(path).unwrap(); + let channels = usize::from(channels); + let mut frame = 2_000 + seed as usize * 37; + while frame + 8 < frames { + for width in 0..1 + frame % 5 { + let index = 44 + ((frame + width) * channels) * 2; + let value = if frame.is_multiple_of(2) { + 24_000i16 + } else { + -24_000 + }; + bytes[index..index + 2].copy_from_slice(&value.to_le_bytes()); + } + frame += 2_400 + (frame * 7_919) % 4_800; + } + std::fs::write(path, bytes).unwrap(); + } + + #[test] + fn voice_eq_matches_preview_across_cuts_clips_and_studio_sound() { + use cap_project::{VoiceDeclick, VoiceEq, VoiceEqPreset}; + ffmpeg::init().unwrap(); + let directory = tempfile::tempdir().unwrap(); + let paths = + ["mic.wav", "system.wav", "incoming.wav"].map(|name| directory.path().join(name)); + for (((path, frames), channels), seed) in paths + .iter() + .zip([168_013, 96_007, 150_013]) + .zip([1, 2, 2]) + .zip([5, 9, 13]) + { + write_clicky_voice_wav(path, frames, channels, seed); + } + let data = paths + .iter() + .map(|path| Arc::new(AudioData::from_file(path).unwrap())) + .collect::>(); + let eq = |preset, declick| VoiceEq { + declick, + ..VoiceEq::from_preset(preset) + }; + let declick_only = VoiceEq { + enabled: false, + ..eq(VoiceEqPreset::Natural, VoiceDeclick::Strong) + }; + for variant in 0..10 { + let mut project = project(); + project.audio.improve = variant % 2 == 1; + let timeline = project.timeline.as_mut().unwrap(); + timeline.segments = vec![ + segment(0, 0.001_01, 0.72), + segment(0, 0.73, 0.8), + segment(0, 0.800_02, 0.92), + segment(0, 1.0, 1.63), + segment(1, 0.35, 1.4), + segment(1, 1.5, 2.2), + segment(0, 1.8, 2.5), + segment(0, 2.500_02, 3.05), + ]; + match variant / 2 { + 0 => { + for segment in &mut timeline.segments { + segment.voice_eq = Some(eq(VoiceEqPreset::Clear, VoiceDeclick::Off)); + } + } + 1 => { + let settings = [ + Some(eq(VoiceEqPreset::Clear, VoiceDeclick::Off)), + None, + Some(eq(VoiceEqPreset::Warm, VoiceDeclick::Strong)), + Some(declick_only), + Some(eq(VoiceEqPreset::Bright, VoiceDeclick::Light)), + None, + Some(eq(VoiceEqPreset::Broadcast, VoiceDeclick::Light)), + Some(eq(VoiceEqPreset::Broadcast, VoiceDeclick::Light)), + ]; + for (segment, voice_eq) in timeline.segments.iter_mut().zip(settings) { + segment.voice_eq = voice_eq; + } + } + 2 => { + timeline.segments = vec![segment(0, 0.2, 2.9), segment(1, 0.4, 2.5)]; + timeline.segments[0].voice_eq = + Some(eq(VoiceEqPreset::Natural, VoiceDeclick::Strong)); + timeline.segments[1].voice_eq = + Some(eq(VoiceEqPreset::Warm, VoiceDeclick::Off)); + timeline.transitions.push(ClipTransition { + segment_index: 1, + kind: ClipTransitionType::CrossFade, + duration: 0.431_234_567, + }); + } + 3 => { + for segment in &mut timeline.segments { + segment.voice_eq = Some(declick_only); + } + timeline.segments[5].voice_eq = None; + project.clips[0].offsets.mic = 0.137_125; + project.clips[1].offsets.mic = -0.117_125; + project.audio.mic_stereo_mode = cap_project::StereoMode::MonoR; + project.audio.mic_volume_db = 4.0; + } + _ => { + for segment in &mut timeline.segments { + segment.voice_eq = Some(eq(VoiceEqPreset::Bright, VoiceDeclick::Strong)); + } + project.audio.mute = variant == 8; + project.audio.mic_volume_db = if variant == 9 { -30.0 } else { 0.0 }; + } + } + for request in [997, 4_096, 48_001] { + let context = format!("variant {variant}, request {request}"); + let (mut reference, mut candidate) = fixture(&paths, &data); + reference.set_playhead(0.0, &project); + loop { + let expected = reference.render_frame_raw(request, &project); + let mut actual = Vec::new(); + let written = candidate + .render_chunks(request, &project, |offset, samples| { + assert_eq!(offset * 2, actual.len()); + assert!(samples.len() <= EXPORT_AUDIO_BLOCK_SAMPLES * 2); + actual.extend_from_slice(samples); + Ok(()) + }) + .unwrap_or_else(|error| panic!("{context}: {error}")); + assert_eq!( + written, + expected.as_ref().map(|(count, _)| *count), + "{context}" + ); + let expected = expected.as_ref().map_or(&[][..], |(_, data)| data); + assert_eq!(actual.len(), expected.len(), "{context}"); + if let Some(index) = actual + .iter() + .zip(expected) + .position(|(a, b)| a.to_bits() != b.to_bits()) + { + panic!( + "{context}: sample {index} differs: {} vs {}", + actual[index], expected[index] + ); + } + for track in candidate.sources.tracks.iter().flatten() { + let limit = MAX_ENHANCED_WINDOW_SAMPLES + + STUDIO_SOUND_HISTORY_SAMPLES + + VOICE_TONE_LOOKBEHIND_SAMPLES; + assert!( + track.samples.len() <= limit * track.source.channels() as usize, + "{context}: source window grew to {}", + track.samples.len() + ); + } + if written.is_none() { + break; + } + } + candidate.validate_to_end().unwrap(); + } + } + } + + #[test] + fn voice_eq_changes_only_the_microphone() { + use cap_project::{VoiceDeclick, VoiceEq, VoiceEqPreset}; + ffmpeg::init().unwrap(); + let directory = tempfile::tempdir().unwrap(); + let paths = + ["mic.wav", "system.wav", "incoming.wav"].map(|name| directory.path().join(name)); + for (((path, frames), channels), seed) in paths + .iter() + .zip([96_013, 96_007, 96_013]) + .zip([1, 2, 2]) + .zip([5, 9, 13]) + { + write_clicky_voice_wav(path, frames, channels, seed); + } + let data = paths + .iter() + .map(|path| Arc::new(AudioData::from_file(path).unwrap())) + .collect::>(); + let render = |project: &ProjectConfiguration| { + let (mut reference, _) = fixture(&paths, &data); + reference.set_playhead(0.0, project); + let mut output = Vec::new(); + while let Some((_, samples)) = reference.render_frame_raw(4_096, project) { + output.extend(samples); + } + output + }; + let mut silent_mic = project(); + silent_mic.audio.mic_volume_db = f32::NEG_INFINITY; + let system_only = render(&silent_mic); + for segment in &mut silent_mic.timeline.as_mut().unwrap().segments { + segment.voice_eq = Some(VoiceEq { + declick: VoiceDeclick::Strong, + ..VoiceEq::from_preset(VoiceEqPreset::Broadcast) + }); + } + assert_eq!(render(&silent_mic), system_only); + let mut plain = project(); + let original = render(&plain); + for segment in &mut plain.timeline.as_mut().unwrap().segments { + segment.voice_eq = Some(VoiceEq { + enabled: false, + ..VoiceEq::default() + }); + } + assert_eq!(render(&plain), original); + for segment in &mut plain.timeline.as_mut().unwrap().segments { + segment.voice_eq = Some(VoiceEq::from_preset(VoiceEqPreset::Bright)); + } + assert_ne!(render(&plain), original); + } + #[test] fn studio_sound_random_cuts_never_reject_the_export_window() { ffmpeg::init().unwrap(); diff --git a/crates/editor/src/lib.rs b/crates/editor/src/lib.rs index afe97ba7b1f..4e7dfce3ce0 100644 --- a/crates/editor/src/lib.rs +++ b/crates/editor/src/lib.rs @@ -14,7 +14,9 @@ mod segments; mod telemetry; mod thumbnail; -pub use audio::{AudioRenderer, AudioSegment, MusicTracks, release_playback_audio_caches}; +pub use audio::{ + AudioRenderer, AudioSegment, MusicTracks, release_playback_audio_caches, voice_eq_filters, +}; #[cfg(feature = "test-hooks")] pub use audio_output::OfflineAudioSink; pub use audio_output::{ diff --git a/crates/editor/src/playback.rs b/crates/editor/src/playback.rs index 1e4d2efd150..116fc72419b 100644 --- a/crates/editor/src/playback.rs +++ b/crates/editor/src/playback.rs @@ -64,6 +64,7 @@ fn clip_audio_changed(previous: &ProjectConfiguration, next: &ProjectConfigurati segment.timescale, segment.speed_audio_mode, segment.volume(), + segment.active_voice_eq(), offsets.mic, offsets.system_audio, ) @@ -2328,6 +2329,19 @@ mod tests { next.timeline.as_mut().unwrap().segments[0].name = Some("Renamed".into()); next.timeline.as_mut().unwrap().segments[0].volume = Some(1.0); assert!(!clip_audio_changed(&project, &next)); + next.timeline.as_mut().unwrap().segments[0].voice_eq = Some(cap_project::VoiceEq { + enabled: false, + ..Default::default() + }); + assert!(!clip_audio_changed(&project, &next)); + let mut voice_eq = cap_project::VoiceEq::default(); + next.timeline.as_mut().unwrap().segments[0].voice_eq = Some(voice_eq); + assert!(clip_audio_changed(&project, &next)); + let equalized = next.clone(); + voice_eq.high_mid.gain_db += 1.0; + next.timeline.as_mut().unwrap().segments[0].voice_eq = Some(voice_eq); + assert!(clip_audio_changed(&equalized, &next)); + next.timeline.as_mut().unwrap().segments[0].voice_eq = None; next.timeline.as_mut().unwrap().segments[0].volume = Some(0.5); assert!(clip_audio_changed(&project, &next)); next.timeline.as_mut().unwrap().segments[0].volume = None; diff --git a/crates/editor/src/preparing_audio/mixer.rs b/crates/editor/src/preparing_audio/mixer.rs index 9a309333db2..14ca2aeeb3c 100644 --- a/crates/editor/src/preparing_audio/mixer.rs +++ b/crates/editor/src/preparing_audio/mixer.rs @@ -75,6 +75,7 @@ impl PreparingAudioSources { || segment.timescale != 1.0 || segment.speed_audio_mode.is_some() || segment.volume() != 1.0 + || segment.active_voice_eq().is_some() || !segment.end.is_finite() || segment.end <= 0.0 { diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 6c5dc34694f..627b8fafd8e 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -313,6 +313,7 @@ pub fn synthesize_default_timeline( name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }) }) diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index bb0a0491097..4fd1d15ffab 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -675,6 +675,220 @@ impl Default for AudioConfiguration { } } +#[derive(Type, Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum VoiceEqPreset { + #[default] + Natural, + Clear, + Warm, + Bright, + Broadcast, + Custom, +} + +impl VoiceEqPreset { + pub const ALL: [Self; 5] = [ + Self::Natural, + Self::Clear, + Self::Warm, + Self::Bright, + Self::Broadcast, + ]; +} + +#[derive(Type, Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum VoiceDeclick { + #[default] + Off, + Light, + Strong, +} + +#[derive(Type, Serialize, Deserialize, Clone, Copy, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct VoiceEqBand { + pub hz: f32, + pub gain_db: f32, + #[serde(default = "VoiceEqBand::default_q")] + pub q: f32, +} + +impl VoiceEqBand { + const fn new(hz: f32, gain_db: f32, q: f32) -> Self { + Self { hz, gain_db, q } + } + + fn default_q() -> f32 { + 1.0 + } + + fn sanitized(self, kind: VoiceEqBandKind, fallback: Self) -> Self { + Self { + hz: clamp_finite(self.hz, kind.hz_range(), fallback.hz), + gain_db: clamp_finite(self.gain_db, VoiceEq::GAIN_RANGE, fallback.gain_db), + q: clamp_finite(self.q, VoiceEq::Q_RANGE, fallback.q), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum VoiceEqBandKind { + Low, + LowMid, + HighMid, + High, +} + +impl VoiceEqBandKind { + pub const ALL: [Self; 4] = [Self::Low, Self::LowMid, Self::HighMid, Self::High]; + + pub fn hz_range(self) -> (f32, f32) { + match self { + Self::Low => (40.0, 600.0), + Self::LowMid => (100.0, 2_000.0), + Self::HighMid => (600.0, 10_000.0), + Self::High => (2_000.0, 16_000.0), + } + } + + pub fn is_shelf(self) -> bool { + matches!(self, Self::Low | Self::High) + } +} + +/// `enabled` bypasses the EQ without losing it; de-click runs on its own +/// switch. +#[derive(Type, Serialize, Deserialize, Clone, Copy, Debug, PartialEq)] +#[serde(rename_all = "camelCase", default)] +pub struct VoiceEq { + pub enabled: bool, + pub preset: VoiceEqPreset, + pub high_pass: bool, + pub high_pass_hz: f32, + pub low: VoiceEqBand, + pub low_mid: VoiceEqBand, + pub high_mid: VoiceEqBand, + pub high: VoiceEqBand, + pub declick: VoiceDeclick, +} + +impl Default for VoiceEq { + fn default() -> Self { + Self::from_preset(VoiceEqPreset::Natural) + } +} + +impl VoiceEq { + pub const GAIN_RANGE: (f32, f32) = (-12.0, 12.0); + pub const Q_RANGE: (f32, f32) = (0.3, 4.0); + pub const HIGH_PASS_RANGE: (f32, f32) = (20.0, 300.0); + + pub fn from_preset(preset: VoiceEqPreset) -> Self { + let (high_pass_hz, low, low_mid, high_mid, high) = match preset { + VoiceEqPreset::Natural | VoiceEqPreset::Custom => ( + 80.0, + VoiceEqBand::new(150.0, 0.0, 0.7), + VoiceEqBand::new(300.0, -2.0, 1.0), + VoiceEqBand::new(3_500.0, 1.5, 0.8), + VoiceEqBand::new(10_000.0, 0.0, 0.7), + ), + VoiceEqPreset::Clear => ( + 90.0, + VoiceEqBand::new(150.0, -1.5, 0.7), + VoiceEqBand::new(320.0, -3.0, 1.2), + VoiceEqBand::new(3_500.0, 3.5, 0.9), + VoiceEqBand::new(10_000.0, 2.0, 0.7), + ), + VoiceEqPreset::Warm => ( + 70.0, + VoiceEqBand::new(160.0, 3.0, 0.7), + VoiceEqBand::new(400.0, -1.0, 1.0), + VoiceEqBand::new(3_000.0, 1.0, 0.8), + VoiceEqBand::new(9_000.0, -2.0, 0.7), + ), + VoiceEqPreset::Bright => ( + 100.0, + VoiceEqBand::new(180.0, -2.0, 0.7), + VoiceEqBand::new(350.0, -2.0, 1.0), + VoiceEqBand::new(4_500.0, 3.0, 0.8), + VoiceEqBand::new(10_000.0, 4.5, 0.7), + ), + VoiceEqPreset::Broadcast => ( + 80.0, + VoiceEqBand::new(110.0, 3.5, 0.7), + VoiceEqBand::new(380.0, -3.5, 1.4), + VoiceEqBand::new(2_800.0, 3.0, 1.0), + VoiceEqBand::new(9_000.0, 2.5, 0.7), + ), + }; + Self { + enabled: true, + preset, + high_pass: true, + high_pass_hz, + low, + low_mid, + high_mid, + high, + declick: VoiceDeclick::Off, + } + } + + pub fn with_preset(self, preset: VoiceEqPreset) -> Self { + Self { + enabled: self.enabled, + declick: self.declick, + ..Self::from_preset(preset) + } + } + + pub fn band(&self, kind: VoiceEqBandKind) -> VoiceEqBand { + match kind { + VoiceEqBandKind::Low => self.low, + VoiceEqBandKind::LowMid => self.low_mid, + VoiceEqBandKind::HighMid => self.high_mid, + VoiceEqBandKind::High => self.high, + } + } + + pub fn band_mut(&mut self, kind: VoiceEqBandKind) -> &mut VoiceEqBand { + match kind { + VoiceEqBandKind::Low => &mut self.low, + VoiceEqBandKind::LowMid => &mut self.low_mid, + VoiceEqBandKind::HighMid => &mut self.high_mid, + VoiceEqBandKind::High => &mut self.high, + } + } + + pub fn sanitized(&self) -> Self { + let fallback = Self::from_preset(self.preset); + let mut clean = *self; + clean.high_pass_hz = clamp_finite( + self.high_pass_hz, + Self::HIGH_PASS_RANGE, + fallback.high_pass_hz, + ); + for kind in VoiceEqBandKind::ALL { + *clean.band_mut(kind) = self.band(kind).sanitized(kind, fallback.band(kind)); + } + clean + } + + pub fn shapes_tone(&self) -> bool { + self.enabled + && (self.high_pass + || VoiceEqBandKind::ALL + .iter() + .any(|kind| self.sanitized().band(*kind).gain_db.abs() >= 0.01)) + } + + pub fn is_active(&self) -> bool { + self.shapes_tone() || self.declick != VoiceDeclick::Off + } +} + #[derive(Type, Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum CursorType { @@ -944,6 +1158,8 @@ pub struct TimelineSegment { pub volume: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub hide_cursor: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub voice_eq: Option, } #[derive(Type, Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -998,6 +1214,12 @@ impl TimelineSegment { self.hide_cursor.unwrap_or(false) } + pub fn active_voice_eq(&self) -> Option { + self.voice_eq + .filter(VoiceEq::is_active) + .map(|voice_eq| voice_eq.sanitized()) + } + pub fn volume(&self) -> f64 { self.volume .filter(|volume| volume.is_finite()) @@ -3320,6 +3542,7 @@ mod tests { speed_audio_mode: None, volume: None, hide_cursor: None, + voice_eq: None, }, TimelineSegment { recording_clip: 1, @@ -3330,6 +3553,7 @@ mod tests { speed_audio_mode: None, volume: None, hide_cursor: None, + voice_eq: None, }, ], transitions, @@ -4410,6 +4634,7 @@ mod tests { speed_audio_mode: None, volume: None, hide_cursor: hide_cursor.then_some(true), + voice_eq: None, } } diff --git a/crates/recording/src/recovery.rs b/crates/recording/src/recovery.rs index 4aac5550a44..09aa8a918a8 100644 --- a/crates/recording/src/recovery.rs +++ b/crates/recording/src/recovery.rs @@ -2786,6 +2786,7 @@ impl RecoveryManager { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }) }) diff --git a/crates/recording/src/studio_recording.rs b/crates/recording/src/studio_recording.rs index c868f574c63..69a0857d87c 100644 --- a/crates/recording/src/studio_recording.rs +++ b/crates/recording/src/studio_recording.rs @@ -2892,6 +2892,7 @@ async fn stop_recording( name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }) }) diff --git a/crates/recording/src/track_heal.rs b/crates/recording/src/track_heal.rs index 638ce019a14..9f5a41909a8 100644 --- a/crates/recording/src/track_heal.rs +++ b/crates/recording/src/track_heal.rs @@ -1215,6 +1215,7 @@ mod tests { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }], transitions: Vec::new(), @@ -1273,6 +1274,7 @@ mod tests { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }], transitions: Vec::new(), @@ -1351,6 +1353,7 @@ mod tests { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }, TimelineSegment { @@ -1361,6 +1364,7 @@ mod tests { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }, ], diff --git a/crates/rendering/src/zoom_spring.rs b/crates/rendering/src/zoom_spring.rs index f8df92f8ac5..76933da62d3 100644 --- a/crates/rendering/src/zoom_spring.rs +++ b/crates/rendering/src/zoom_spring.rs @@ -1876,6 +1876,7 @@ mod tests { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }, TimelineSegment { @@ -1886,6 +1887,7 @@ mod tests { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }, ]; @@ -2344,6 +2346,7 @@ mod tests { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }]; let cursor = CursorEvents { @@ -2721,6 +2724,7 @@ mod tests { speed_audio_mode: None, volume: None, hide_cursor: None, + voice_eq: None, }); } project @@ -3069,6 +3073,7 @@ mod tests { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }], transitions: vec![], @@ -3130,6 +3135,7 @@ mod tests { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }, TimelineSegment { @@ -3140,6 +3146,7 @@ mod tests { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }, ], diff --git a/crates/verify/src/fixture.rs b/crates/verify/src/fixture.rs index 8c5da234274..773b58352ec 100644 --- a/crates/verify/src/fixture.rs +++ b/crates/verify/src/fixture.rs @@ -312,6 +312,7 @@ pub fn write_project_files( name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }); clips.push(ClipConfiguration { diff --git a/crates/verify/tests/common/mod.rs b/crates/verify/tests/common/mod.rs index 85ca09d7a66..e24abae2ed4 100644 --- a/crates/verify/tests/common/mod.rs +++ b/crates/verify/tests/common/mod.rs @@ -98,6 +98,7 @@ pub fn set_cuts(config: &mut ProjectConfiguration, cuts: &[(f64, f64, f64)]) { name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }) .collect(); @@ -116,6 +117,7 @@ pub fn set_clip_cuts(config: &mut ProjectConfiguration, cuts: &[(u32, f64, f64, name: None, speed_audio_mode: None, hide_cursor: None, + voice_eq: None, volume: None, }) .collect(); diff --git a/crates/verify/tests/edits/mod.rs b/crates/verify/tests/edits/mod.rs index a7011c4bea4..f891c778f28 100644 --- a/crates/verify/tests/edits/mod.rs +++ b/crates/verify/tests/edits/mod.rs @@ -303,6 +303,7 @@ pub fn base_config(durations: &[f64]) -> ProjectConfiguration { speed_audio_mode: None, volume: None, hide_cursor: None, + voice_eq: None, }) .collect(), transitions: Vec::new(),