vocalis 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
vocalis/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Vocalis: Wispr Flow-inspired AI voice dictation CLI."""
2
+
3
+ from vocalis.cli import cli, main
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["cli", "main", "__version__"]
@@ -0,0 +1,7 @@
1
+ """Audio recording, processing, and sound effects module."""
2
+
3
+ from vocalis.audio.chime import ChimePlayer
4
+ from vocalis.audio.recorder import AudioRecorder
5
+ from vocalis.audio.vad import SpeechState, VADDetector
6
+
7
+ __all__ = ["AudioRecorder", "ChimePlayer", "SpeechState", "VADDetector"]
vocalis/audio/chime.py ADDED
@@ -0,0 +1,77 @@
1
+ """Non-blocking audio cues and earcons for Vocalis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import shutil
7
+ import subprocess
8
+ import threading
9
+ from typing import Optional
10
+
11
+ import numpy as np
12
+ from scipy.io import wavfile
13
+
14
+
15
+ class ChimePlayer:
16
+ """Generates and plays lightweight audio chimes for voice state transitions."""
17
+
18
+ def __init__(self, enabled: bool = True) -> None:
19
+ self.enabled = enabled
20
+ self._start_wav: Optional[bytes] = None
21
+ self._done_wav: Optional[bytes] = None
22
+ if self.enabled:
23
+ self._prepare_chimes()
24
+
25
+ def _generate_tone(self, freq: float, duration: float, volume: float = 0.12) -> bytes:
26
+ """Synthesize a soft sine wave chime with smooth cosine envelope."""
27
+ sample_rate = 16000
28
+ num_samples = int(sample_rate * duration)
29
+ t = np.linspace(0, duration, num_samples, endpoint=False)
30
+
31
+ # Smooth bell envelope (raised cosine) to eliminate clicks
32
+ envelope = np.sin(np.pi * t / duration)
33
+ signal = (envelope * volume * np.sin(2 * np.pi * freq * t) * 32767.0).astype(np.int16)
34
+
35
+ buf = io.BytesIO()
36
+ wavfile.write(buf, sample_rate, signal)
37
+ return buf.getvalue()
38
+
39
+ def _prepare_chimes(self) -> None:
40
+ """Pre-generate in-memory WAV bytes so playback has zero computational latency."""
41
+ try:
42
+ # Start chime: Pleasant high A5 (880 Hz, 35ms)
43
+ self._start_wav = self._generate_tone(880.0, 0.035, volume=0.10)
44
+ # Done chime: Bright C6 (1046.5 Hz, 45ms)
45
+ self._done_wav = self._generate_tone(1046.5, 0.045, volume=0.10)
46
+ except Exception:
47
+ self.enabled = False
48
+
49
+ def _play_raw(self, wav_bytes: Optional[bytes]) -> None:
50
+ """Play WAV audio in a subprocess without blocking the main thread."""
51
+ if not self.enabled or not wav_bytes:
52
+ return
53
+
54
+ def worker():
55
+ for cmd in (["aplay", "-q"], ["pw-play", "-"]):
56
+ if shutil.which(cmd[0]):
57
+ try:
58
+ proc = subprocess.Popen(
59
+ cmd,
60
+ stdin=subprocess.PIPE,
61
+ stdout=subprocess.DEVNULL,
62
+ stderr=subprocess.DEVNULL,
63
+ )
64
+ proc.communicate(input=wav_bytes, timeout=0.6)
65
+ return
66
+ except Exception:
67
+ continue
68
+
69
+ threading.Thread(target=worker, daemon=True).start()
70
+
71
+ def play_start(self) -> None:
72
+ """Play cue when speech starts."""
73
+ self._play_raw(self._start_wav)
74
+
75
+ def play_done(self) -> None:
76
+ """Play cue when processing completes and text is typed."""
77
+ self._play_raw(self._done_wav)
@@ -0,0 +1,169 @@
1
+ """Audio recording using native system sound interfaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import collections
6
+ import io
7
+ import sys
8
+ import threading
9
+ from typing import Callable, Optional
10
+
11
+ import numpy as np
12
+ from scipy.io import wavfile
13
+
14
+ # Guard against soundcard's sys.argv inspection in unusual environments
15
+ if not hasattr(sys, "argv") or len(sys.argv) < 2:
16
+ if not hasattr(sys, "argv"):
17
+ sys.argv = ["vocalis"]
18
+ elif len(sys.argv) == 1 and sys.argv[0] == "-c":
19
+ sys.argv.append("vocalis")
20
+
21
+ import soundcard as sc
22
+
23
+ from vocalis.audio.vad import SpeechState, VADDetector
24
+
25
+
26
+ class AudioRecorder:
27
+ """Manages microphone input capture and voice activity listening."""
28
+
29
+ def __init__(
30
+ self,
31
+ sample_rate: int = 16000,
32
+ device_name: Optional[str] = None,
33
+ block_size: int = 1600, # 100ms at 16kHz
34
+ silence_duration: float = 0.9,
35
+ energy_threshold: float = 0.012,
36
+ ) -> None:
37
+ self.sample_rate = sample_rate
38
+ self.device_name = device_name
39
+ self.block_size = block_size
40
+ self.silence_duration = silence_duration
41
+ self.energy_threshold = energy_threshold
42
+
43
+ @staticmethod
44
+ def list_devices() -> list[str]:
45
+ """List all available microphone device names."""
46
+ try:
47
+ mics = sc.all_microphones()
48
+ return [m.name for m in mics]
49
+ except Exception:
50
+ return []
51
+
52
+ def get_microphone(self) -> sc.Microphone:
53
+ """Get the configured or default microphone."""
54
+ if self.device_name:
55
+ for mic in sc.all_microphones():
56
+ if self.device_name.lower() in mic.name.lower():
57
+ return mic
58
+ return sc.default_microphone()
59
+
60
+ @staticmethod
61
+ def to_mono(audio_chunk: np.ndarray) -> np.ndarray:
62
+ """Convert multi-channel audio to mono 1D array."""
63
+ if audio_chunk.ndim > 1:
64
+ if audio_chunk.shape[1] > 1:
65
+ return np.mean(audio_chunk, axis=1)
66
+ return audio_chunk[:, 0]
67
+ return audio_chunk
68
+
69
+ @classmethod
70
+ def to_wav_bytes(cls, audio_data: np.ndarray, sample_rate: int = 16000) -> bytes:
71
+ """Convert float32/int16 numpy audio to in-memory WAV file bytes."""
72
+ audio_mono = cls.to_mono(audio_data)
73
+
74
+ # Normalize and convert float32 to 16-bit PCM for universal speech API compatibility
75
+ if audio_mono.dtype != np.int16:
76
+ # Clip between -1.0 and 1.0, scale to int16 range
77
+ clipped = np.clip(audio_mono, -1.0, 1.0)
78
+ pcm16 = (clipped * 32767.0).astype(np.int16)
79
+ else:
80
+ pcm16 = audio_mono
81
+
82
+ buf = io.BytesIO()
83
+ wavfile.write(buf, sample_rate, pcm16)
84
+ return buf.getvalue()
85
+
86
+ def record_utterance(
87
+ self,
88
+ on_chunk: Optional[Callable[[SpeechState, float, float], None]] = None,
89
+ on_speech_started: Optional[Callable[[], None]] = None,
90
+ stop_event: Optional[threading.Event] = None,
91
+ manual_mode: bool = False,
92
+ ) -> tuple[np.ndarray, float, bool]:
93
+ """Record until speech ends (VAD) or stop_event is set.
94
+
95
+ Args:
96
+ on_chunk: Callback(state, rms, elapsed_sec) called every 100ms.
97
+ on_speech_started: Callback called once when voice activity begins.
98
+ stop_event: Threading event to externally signal stop.
99
+ manual_mode: If True, only stop_event or timeout terminates recording.
100
+
101
+ Returns:
102
+ Tuple of (recorded_mono_numpy_array, duration_seconds, speech_started)
103
+ """
104
+ mic = self.get_microphone()
105
+ vad = VADDetector(
106
+ sample_rate=self.sample_rate,
107
+ energy_threshold=self.energy_threshold,
108
+ silence_duration=self.silence_duration,
109
+ )
110
+
111
+ # Pre-roll ring buffer: store last ~400ms before speech detected
112
+ # so initial consonants are preserved perfectly
113
+ preroll_count = 4
114
+ preroll_buffer: collections.deque[np.ndarray] = collections.deque(maxlen=preroll_count)
115
+
116
+ recorded_chunks: list[np.ndarray] = []
117
+ speech_started = False
118
+ start_time: float | None = None
119
+
120
+ with mic.recorder(samplerate=self.sample_rate, channels=1, blocksize=self.block_size) as rec:
121
+ import time
122
+
123
+ start_time = time.time()
124
+ while True:
125
+ if stop_event and stop_event.is_set():
126
+ break
127
+
128
+ # Read 100ms audio chunk
129
+ raw_chunk = rec.record(numframes=self.block_size)
130
+ mono_chunk = self.to_mono(raw_chunk)
131
+
132
+ if manual_mode:
133
+ if not speech_started:
134
+ speech_started = True
135
+ if on_speech_started:
136
+ on_speech_started()
137
+ recorded_chunks.append(mono_chunk)
138
+ rms = vad.calculate_rms(mono_chunk)
139
+ elapsed = time.time() - start_time
140
+ if on_chunk:
141
+ on_chunk(SpeechState.SPEAKING, rms, elapsed)
142
+ continue
143
+
144
+ state, rms = vad.process_chunk(mono_chunk)
145
+ elapsed = time.time() - start_time
146
+
147
+ if state in (SpeechState.SPEAKING, SpeechState.PAUSED):
148
+ if not speech_started:
149
+ speech_started = True
150
+ if on_speech_started:
151
+ on_speech_started()
152
+ # Include pre-roll chunks
153
+ recorded_chunks.extend(list(preroll_buffer))
154
+ recorded_chunks.append(mono_chunk)
155
+ elif not speech_started:
156
+ preroll_buffer.append(mono_chunk)
157
+
158
+ if on_chunk:
159
+ on_chunk(state, rms, elapsed)
160
+
161
+ if state in (SpeechState.COMPLETED, SpeechState.TIMEOUT):
162
+ break
163
+
164
+ if not recorded_chunks:
165
+ return np.array([], dtype=np.float32), 0.0, speech_started
166
+
167
+ full_audio = np.concatenate(recorded_chunks, axis=0)
168
+ duration = len(full_audio) / self.sample_rate
169
+ return full_audio, duration, speech_started
vocalis/audio/vad.py ADDED
@@ -0,0 +1,112 @@
1
+ """Voice Activity Detection and energy analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ import time
7
+ import numpy as np
8
+
9
+
10
+ class SpeechState(str, enum.Enum):
11
+ """Current state of voice activity."""
12
+ IDLE = "idle" # Waiting for initial speech
13
+ SPEAKING = "speaking" # Speech in progress
14
+ PAUSED = "paused" # Speech occurred, currently silent (counting down timeout)
15
+ COMPLETED = "completed" # Silence duration reached after speech
16
+ TIMEOUT = "timeout" # Max duration reached
17
+
18
+
19
+ class VADDetector:
20
+ """Energy-based voice activity detector with adaptive noise floor and silence hangover."""
21
+
22
+ def __init__(
23
+ self,
24
+ sample_rate: int = 16000,
25
+ energy_threshold: float = 0.012,
26
+ silence_duration: float = 0.9,
27
+ min_record_seconds: float = 0.4,
28
+ max_record_seconds: float = 90.0,
29
+ ) -> None:
30
+ self.sample_rate = sample_rate
31
+ self.base_threshold = energy_threshold
32
+ self.silence_duration = silence_duration
33
+ self.min_record_seconds = min_record_seconds
34
+ self.max_record_seconds = max_record_seconds
35
+
36
+ self.state = SpeechState.IDLE
37
+ self.noise_floor = 0.005
38
+ self.speech_start_time: float | None = None
39
+ self.silence_start_time: float | None = None
40
+ self.total_speech_frames: int = 0
41
+ self.calibration_chunks: int = 0
42
+
43
+ @staticmethod
44
+ def calculate_rms(audio_chunk: np.ndarray) -> float:
45
+ """Calculate Root Mean Square (RMS) energy of an audio chunk."""
46
+ if audio_chunk.size == 0:
47
+ return 0.0
48
+ # If stereo, flatten or take mean across channels
49
+ if audio_chunk.ndim > 1:
50
+ audio_chunk = np.mean(audio_chunk, axis=1)
51
+ return float(np.sqrt(np.mean(audio_chunk.astype(np.float32) ** 2)))
52
+
53
+ def get_effective_threshold(self) -> float:
54
+ """Dynamic threshold based on base setting and tracked noise floor."""
55
+ return max(self.base_threshold, self.noise_floor * 2.5)
56
+
57
+ def process_chunk(self, audio_chunk: np.ndarray) -> tuple[SpeechState, float]:
58
+ """Process an incoming audio chunk (e.g. 100ms) and return (new_state, rms)."""
59
+ rms = self.calculate_rms(audio_chunk)
60
+ now = time.time()
61
+
62
+ # Initial calibration of ambient noise floor (first 4 chunks)
63
+ if self.calibration_chunks < 4:
64
+ self.noise_floor = (self.noise_floor * self.calibration_chunks + rms) / (self.calibration_chunks + 1)
65
+ self.calibration_chunks += 1
66
+ return SpeechState.IDLE, rms
67
+
68
+ effective_threshold = self.get_effective_threshold()
69
+ is_speech = rms >= effective_threshold
70
+
71
+ # Adapt ambient noise floor slowly if not speaking
72
+ if not is_speech and self.state == SpeechState.IDLE:
73
+ self.noise_floor = 0.95 * self.noise_floor + 0.05 * rms
74
+
75
+ if is_speech:
76
+ if self.state in (SpeechState.IDLE, SpeechState.PAUSED):
77
+ if self.speech_start_time is None:
78
+ self.speech_start_time = now
79
+ self.silence_start_time = None
80
+ self.state = SpeechState.SPEAKING
81
+ self.total_speech_frames += len(audio_chunk)
82
+
83
+ else:
84
+ # Silence detected
85
+ if self.state == SpeechState.SPEAKING:
86
+ self.state = SpeechState.PAUSED
87
+ self.silence_start_time = now
88
+
89
+ elif self.state == SpeechState.PAUSED and self.silence_start_time is not None:
90
+ elapsed_silence = now - self.silence_start_time
91
+ speech_duration = (self.total_speech_frames / self.sample_rate)
92
+
93
+ # Check if we have spoken enough and silence exceeded threshold
94
+ if elapsed_silence >= self.silence_duration:
95
+ if speech_duration >= self.min_record_seconds:
96
+ self.state = SpeechState.COMPLETED
97
+ else:
98
+ # Spurious noise, reset
99
+ self.reset()
100
+
101
+ # Check max recording duration
102
+ if self.speech_start_time and (now - self.speech_start_time) >= self.max_record_seconds:
103
+ self.state = SpeechState.TIMEOUT
104
+
105
+ return self.state, rms
106
+
107
+ def reset(self) -> None:
108
+ """Reset the detector state for next utterance."""
109
+ self.state = SpeechState.IDLE
110
+ self.speech_start_time = None
111
+ self.silence_start_time = None
112
+ self.total_speech_frames = 0