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 +6 -0
- vocalis/audio/__init__.py +7 -0
- vocalis/audio/chime.py +77 -0
- vocalis/audio/recorder.py +169 -0
- vocalis/audio/vad.py +112 -0
- vocalis/cli.py +496 -0
- vocalis/config.py +157 -0
- vocalis/core.py +246 -0
- vocalis/groq/__init__.py +14 -0
- vocalis/groq/client.py +31 -0
- vocalis/groq/prompts.py +41 -0
- vocalis/groq/refiner.py +96 -0
- vocalis/groq/transcriber.py +62 -0
- vocalis/output/__init__.py +6 -0
- vocalis/output/clipboard.py +105 -0
- vocalis/output/injector.py +86 -0
- vocalis/ui/__init__.py +21 -0
- vocalis/ui/display.py +119 -0
- vocalis-0.1.0.dist-info/METADATA +230 -0
- vocalis-0.1.0.dist-info/RECORD +22 -0
- vocalis-0.1.0.dist-info/WHEEL +4 -0
- vocalis-0.1.0.dist-info/entry_points.txt +3 -0
vocalis/__init__.py
ADDED
|
@@ -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
|