Skip to content

Commit 93998af

Browse files
authored
Merge pull request #48 from pragmatrix/speech-gate
Speech gate
2 parents 1bc2691 + d499752 commit 93998af

9 files changed

Lines changed: 523 additions & 4 deletions

File tree

Cargo.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "context-switch"
3-
version = "1.0.0"
3+
version = "1.0.1"
44
edition = "2024"
55
rust-version = "1.88"
66

@@ -9,6 +9,7 @@ members = [
99
"audio-knife",
1010
"audio-test",
1111
"core",
12+
"filter-test",
1213
"services/aristech",
1314
"services/azure",
1415
"services/google-transcribe",
@@ -114,4 +115,4 @@ openai-api-rs = { path = "external/openai-api-rs" }
114115
rodio = { path = "external/rodio", default-features = false, features = ["symphonia-mp3"] }
115116

116117
rstest = { version = "0.25.0" }
117-
uuid = { version = "1.17.0", features = ["v4"] }
118+
uuid = { version = "1.17.0", features = ["v4"] }

audio-knife/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "audio-knife"
3-
version = "1.3.0"
3+
version = "1.3.1"
44
edition = "2024"
55

66
[profile.dev]

core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ mod duration;
66
mod protocol;
77
mod registry;
88
pub mod service;
9+
pub mod speech_gate;
910

1011
use std::time;
1112

core/src/speech_gate.rs

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
use crate::AudioFrame;
2+
3+
pub fn make_speech_gate_processor(
4+
threshold: f32,
5+
attack_ms: f32,
6+
release_ms: f32,
7+
) -> Box<dyn FnMut(&AudioFrame) -> AudioFrame> {
8+
// soft: (knee_width / threshold)
9+
// 0.01 / 0.0075 (best so far)
10+
// 0.05 / 0.025
11+
// 0.5 / 0.030 (not very good)
12+
// 0.1 / 0.075 (but not too good.)
13+
make_speech_gate_processor_soft_rms(threshold, attack_ms, release_ms, 0.01)
14+
}
15+
16+
// Returns a processing function that can be called for each AudioFrame (mono, 16kHz, i16)
17+
// #[allow(unused)]
18+
// pub fn make_speech_gate_processor_(
19+
// threshold: f32,
20+
// attack_ms: f32,
21+
// release_ms: f32,
22+
// ) -> Box<dyn FnMut(&AudioFrame) -> AudioFrame> {
23+
// let mut node = simple_speech_gate(threshold, 6.0, attack_ms, release_ms);
24+
// let mut sample_rate = None;
25+
26+
// Box::new(move |frame: &AudioFrame| {
27+
// let frame_sample_rate = frame.format.sample_rate as f64;
28+
// match sample_rate {
29+
// None => {
30+
// node.set_sample_rate(frame_sample_rate);
31+
// sample_rate = Some(frame_sample_rate);
32+
// }
33+
// Some(rate) if rate != frame_sample_rate => {
34+
// panic!("Changing frame sample rate is not supported in the speech gate processor");
35+
// }
36+
// Some(_) => {
37+
// // same rate, all good
38+
// }
39+
// }
40+
41+
// let samples_f32: Vec<f32> = frame.samples.iter().map(|&s| s as f32 / 32768.0).collect();
42+
// let processed: Vec<f32> = samples_f32
43+
// .iter()
44+
// .map(|&sample| node.tick(&NumericArray::from([sample]))[0])
45+
// .collect();
46+
// let processed_i16: Vec<i16> = processed
47+
// .iter()
48+
// .map(|&s| (s.clamp(-1.0, 1.0) * 32767.0) as i16)
49+
// .collect();
50+
// AudioFrame {
51+
// format: frame.format,
52+
// samples: processed_i16,
53+
// }
54+
// })
55+
// }
56+
57+
// fn simple_speech_gate(
58+
// threshold: f32,
59+
// softness: f32, // e.g., 6.0 dB for soft knee
60+
// attack_ms: f32,
61+
// release_ms: f32,
62+
// ) -> An<impl AudioNode<Inputs = U1, Outputs = U1>> {
63+
// let envelope_follower = envelope(|x| x * x)
64+
// >> lowpass_hz(10.0, 1.0) // smoother RMS, ~100 ms
65+
// >> map(|x| x[0].sqrt());
66+
67+
// let soft_gate = envelope_follower
68+
// >> map(move |level| {
69+
// let db = 20.0 * level[0].log10().max(-120.0);
70+
// let gain_db = if db < threshold - softness {
71+
// -60.0 // silence
72+
// } else if db > threshold + softness {
73+
// 0.0 // full gain
74+
// } else {
75+
// // Linear ramp over 2 * softness dB
76+
// -60.0 * (1.0 - (db - (threshold - softness)) / (2.0 * softness))
77+
// };
78+
// db_to_gain(gain_db)
79+
// })
80+
// >> afollow(attack_ms / 1000.0, release_ms / 1000.0);
81+
82+
// pass() * soft_gate
83+
// }
84+
85+
// Convert dB to linear gain
86+
// fn db_to_gain(db: f32) -> f32 {
87+
// 10.0_f32.powf(db / 20.0)
88+
// }
89+
90+
// #[allow(unused)]
91+
// fn simple_speech_gate_v1(
92+
// threshold: f32,
93+
// attack_ms: f32,
94+
// release_ms: f32,
95+
// ) -> An<impl AudioNode<Inputs = U1, Outputs = U1>> {
96+
// // Proper RMS envelope follower with 10ms window
97+
// let rms = envelope(|x| x * x) >> lowpass_hz(100.0, 1.0) >> map(|x| x[0].sqrt());
98+
99+
// // Gate control with smoothing using follow
100+
// let gate_control = rms
101+
// >> map(move |level| if level[0] > threshold { 1.0 } else { 0.0 })
102+
// >> afollow(attack_ms / 1000.0, release_ms / 1000.0);
103+
104+
// // Apply gating
105+
// pass() * gate_control
106+
// }
107+
108+
/// Returns a processing function that applies an attack/release envelope-based speech gate (no fundsp), with lazy sample rate initialization and a hard threshold (no knee).
109+
/// Works well with 0.0025 threshold for the examples.
110+
#[allow(unused)]
111+
pub fn make_speech_gate_processor_hard(
112+
threshold: f32, // normalized, 0.0 to 1.0
113+
attack_ms: f32,
114+
release_ms: f32,
115+
) -> Box<dyn FnMut(&AudioFrame) -> AudioFrame> {
116+
let mut envelope = 0.0f32;
117+
let mut sample_rate: Option<f32> = None;
118+
let mut attack_coeff = 0.0f32;
119+
let mut release_coeff = 0.0f32;
120+
Box::new(move |frame: &AudioFrame| {
121+
if sample_rate.is_none() {
122+
let sr = frame.format.sample_rate as f32;
123+
sample_rate = Some(sr);
124+
attack_coeff = (-1.0 / (attack_ms * 0.001 * sr)).exp();
125+
release_coeff = (-1.0 / (release_ms * 0.001 * sr)).exp();
126+
}
127+
let mut samples_i16 = Vec::with_capacity(frame.samples.len());
128+
for &s in frame.samples.iter() {
129+
let sample_f32 = s as f32 / 32768.0;
130+
let energy = sample_f32 * sample_f32;
131+
if energy > envelope {
132+
envelope = attack_coeff * (envelope - energy) + energy;
133+
} else {
134+
envelope = release_coeff * (envelope - energy) + energy;
135+
}
136+
let gain = if envelope >= threshold { 1.0 } else { 0.0 };
137+
samples_i16.push((s as f32 * gain) as i16);
138+
}
139+
AudioFrame {
140+
format: frame.format,
141+
samples: samples_i16,
142+
}
143+
})
144+
}
145+
146+
/// Returns a processing function that applies an attack/release envelope-based speech gate (no
147+
/// fundsp), with lazy sample rate initialization and a soft knee.
148+
///
149+
/// # Parameters
150+
/// - `threshold`: The normalized level (0.0 to 1.0) above which audio passes through.
151+
/// - `attack_ms`: How quickly the gate opens when audio gets louder (milliseconds).
152+
/// - `release_ms`: How quickly the gate closes when audio gets quieter (milliseconds).
153+
/// - `knee_width`: Controls how gradually the gate transitions from closed to open near the
154+
/// threshold. A small knee width makes the gate act like an on/off switch; a larger knee width
155+
/// makes the gate fade in and out more smoothly as the audio approaches the threshold.
156+
///
157+
/// In layman's terms: knee width is the "fade zone" around the threshold where the gate is
158+
/// partially open, making the transition less abrupt.
159+
#[allow(unused)]
160+
pub fn make_speech_gate_processor_soft(
161+
threshold: f32, // normalized, 0.0 to 1.0
162+
attack_ms: f32,
163+
release_ms: f32,
164+
knee_width: f32, // e.g. 0.05 for a soft knee
165+
) -> Box<dyn FnMut(&AudioFrame) -> AudioFrame> {
166+
let mut envelope = 0.0f32;
167+
let mut sample_rate: Option<f32> = None;
168+
let mut attack_coeff = 0.0f32;
169+
let mut release_coeff = 0.0f32;
170+
Box::new(move |frame: &AudioFrame| {
171+
if sample_rate.is_none() {
172+
let sr = frame.format.sample_rate as f32;
173+
sample_rate = Some(sr);
174+
attack_coeff = (-1.0 / (attack_ms * 0.001 * sr)).exp();
175+
release_coeff = (-1.0 / (release_ms * 0.001 * sr)).exp();
176+
}
177+
let mut samples_i16 = Vec::with_capacity(frame.samples.len());
178+
for &s in frame.samples.iter() {
179+
let sample_f32 = s as f32 / 32768.0;
180+
let energy = sample_f32 * sample_f32;
181+
if energy > envelope {
182+
envelope = attack_coeff * (envelope - energy) + energy;
183+
} else {
184+
envelope = release_coeff * (envelope - energy) + energy;
185+
}
186+
let gain = if envelope >= threshold + knee_width {
187+
1.0
188+
} else if envelope <= threshold - knee_width {
189+
0.0
190+
} else {
191+
// Linear ramp in the knee region
192+
0.5 + 0.5 * (envelope - threshold) / knee_width
193+
};
194+
samples_i16.push((s as f32 * gain) as i16);
195+
}
196+
AudioFrame {
197+
format: frame.format,
198+
samples: samples_i16,
199+
}
200+
})
201+
}
202+
203+
/// Returns a processing function that applies an RMS-based speech gate with
204+
/// attack/release envelope for smoother transitions and better echo suppression.
205+
///
206+
/// # Parameters
207+
/// - `threshold`: The normalized level (0.0 to 1.0) above which audio passes through.
208+
/// - `attack_ms`: How quickly the gate opens when audio gets louder (milliseconds).
209+
/// - `release_ms`: How quickly the gate closes when audio gets quieter (milliseconds).
210+
/// - `knee_width`: Controls how gradually the gate transitions from closed to open near the
211+
/// threshold. Recommended value: 0.01-0.05
212+
///
213+
/// This implementation improves on the basic soft knee gate by using RMS energy detection
214+
/// for smoother, more natural speech gating and better echo suppression.
215+
///
216+
/// Claude 3.7
217+
///
218+
/// soft_rms: (knee_width / threshold)
219+
/// 0.01 / 0.0025 (echo example barely audible)
220+
/// Optimization: Take the buffer of the AudioFrame and return it, input is not used anymore.
221+
pub fn make_speech_gate_processor_soft_rms(
222+
threshold: f32, // normalized, 0.0 to 1.0
223+
attack_ms: f32,
224+
release_ms: f32,
225+
knee_width: f32,
226+
) -> Box<dyn FnMut(&AudioFrame) -> AudioFrame + Send + Sync> {
227+
let mut envelope = 0.0f32;
228+
let mut sample_rate: Option<f32> = None;
229+
let mut attack_coeff = 0.0f32;
230+
let mut release_coeff = 0.0f32;
231+
232+
// For RMS calculation
233+
const RMS_WINDOW_SIZE: usize = 16; // Small window for responsive detection
234+
let mut rms_buffer = [0.0f32; RMS_WINDOW_SIZE];
235+
let mut buffer_pos = 0;
236+
237+
Box::new(move |frame: &AudioFrame| {
238+
if sample_rate.is_none() {
239+
let sr = frame.format.sample_rate as f32;
240+
sample_rate = Some(sr);
241+
attack_coeff = (-1.0 / (attack_ms * 0.001 * sr)).exp();
242+
release_coeff = (-1.0 / (release_ms * 0.001 * sr)).exp();
243+
}
244+
245+
// Pre-calculate threshold boundaries
246+
let lower_threshold = threshold - knee_width;
247+
let upper_threshold = threshold + knee_width;
248+
249+
let mut samples_i16 = Vec::with_capacity(frame.samples.len());
250+
for &s in frame.samples.iter() {
251+
let sample_f32 = s as f32 / 32768.0;
252+
253+
// Update RMS calculation with sliding window
254+
rms_buffer[buffer_pos] = sample_f32 * sample_f32;
255+
buffer_pos = (buffer_pos + 1) % RMS_WINDOW_SIZE;
256+
257+
// Calculate RMS energy
258+
let energy = rms_buffer.iter().sum::<f32>() / RMS_WINDOW_SIZE as f32;
259+
260+
// Apply envelope follower with different attack/release
261+
if energy > envelope {
262+
envelope = attack_coeff * (envelope - energy) + energy;
263+
} else {
264+
envelope = release_coeff * (envelope - energy) + energy;
265+
}
266+
267+
// Apply soft knee gate
268+
let gain = if envelope >= upper_threshold {
269+
1.0
270+
} else if envelope <= lower_threshold {
271+
0.0
272+
} else {
273+
// Smoother transition using quadratic curve
274+
let t = (envelope - lower_threshold) / (upper_threshold - lower_threshold);
275+
t * t // Quadratic curve sounds more natural than linear
276+
};
277+
278+
samples_i16.push((sample_f32 * gain * 32767.0) as i16);
279+
}
280+
281+
AudioFrame {
282+
format: frame.format,
283+
samples: samples_i16,
284+
}
285+
})
286+
}

filter-test/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*.wav
2+

filter-test/Cargo.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[package]
2+
name = "filter-test"
3+
version = "0.1.0"
4+
edition = "2024"
5+
6+
[dependencies]
7+
context-switch = { path = ".." }
8+
symphonia = { version = "0.5.3", features = ["mp3", "wav"] }
9+
clap = { version = "4.5.1", features = ["derive"] }
10+
anyhow = "1.0.75"
11+
hound = "3.5.1"
12+
path-absolutize = "3.1.1"
13+
fundsp = "0.16.0"

0 commit comments

Comments
 (0)