feat: add voice pitch shifting

F6/F7 adjust outgoing voice pitch in 1-semitone steps (-12 to +12).
PitchShifter integrates into the capture path at dequeue time so the
PortAudio callback is never blocked.  Status bar shows current pitch
when non-zero.  Config reload treats pitch as a safe change.
This commit is contained in:
Username
2026-02-28 13:55:34 +01:00
parent e8f34b4d80
commit 26695e6e70
4 changed files with 57 additions and 4 deletions

View File

@@ -11,6 +11,7 @@ dependencies = [
"textual>=1.0.0", "textual>=1.0.0",
"pymumble>=1.6", "pymumble>=1.6",
"sounddevice>=0.5.0", "sounddevice>=0.5.0",
"numpy>=1.24.0",
"tomli>=2.0.0;python_version<'3.11'", "tomli>=2.0.0;python_version<'3.11'",
] ]

View File

@@ -118,6 +118,7 @@ class StatusBar(Static):
server_info = reactive("") server_info = reactive("")
output_vol = reactive(100) output_vol = reactive(100)
input_vol = reactive(100) input_vol = reactive(100)
pitch = reactive(0)
@staticmethod @staticmethod
def _vol_bar(pct: int) -> str: def _vol_bar(pct: int) -> str:
@@ -157,9 +158,14 @@ class StatusBar(Static):
f" [dim]out[/]{self._vol_bar(self.output_vol)}" f" [dim]out[/]{self._vol_bar(self.output_vol)}"
f" [dim]in[/]{self._vol_bar(self.input_vol)}" f" [dim]in[/]{self._vol_bar(self.input_vol)}"
) )
if self.pitch != 0:
sign = "+" if self.pitch > 0 else ""
pitch_str = f" [dim]pitch[/]{sign}{self.pitch}"
else:
pitch_str = ""
info = f" [dim]{self.server_info}[/]" if self.server_info else "" info = f" [dim]{self.server_info}[/]" if self.server_info else ""
deaf = f"{deaf_full} " if deaf_full else "" deaf = f"{deaf_full} " if deaf_full else ""
return f" {conn_full} {deaf}{ptt_full}{vol}{info}" return f" {conn_full} {deaf}{ptt_full}{vol}{pitch_str}{info}"
class ChannelTree(Static): class ChannelTree(Static):
@@ -412,6 +418,8 @@ class TuimbleApp(App):
("f2", "cycle_output_volume", "Vol Out"), ("f2", "cycle_output_volume", "Vol Out"),
("f3", "cycle_input_volume", "Vol In"), ("f3", "cycle_input_volume", "Vol In"),
("f5", "reload_config", "Reload"), ("f5", "reload_config", "Reload"),
("f6", "pitch_down", "Pitch-"),
("f7", "pitch_up", "Pitch+"),
("q", "quit", "Quit"), ("q", "quit", "Quit"),
("ctrl+c", "quit", "Quit"), ("ctrl+c", "quit", "Quit"),
] ]
@@ -431,6 +439,7 @@ class TuimbleApp(App):
) )
self._audio.input_gain = acfg.input_gain self._audio.input_gain = acfg.input_gain
self._audio.output_gain = acfg.output_gain self._audio.output_gain = acfg.output_gain
self._audio.pitch = acfg.pitch
self._device_monitor = DeviceMonitor(self._on_device_change) self._device_monitor = DeviceMonitor(self._on_device_change)
self._pending_reload: Config | None = None self._pending_reload: Config | None = None
self._tree_refresh_timer = None self._tree_refresh_timer = None
@@ -469,6 +478,7 @@ class TuimbleApp(App):
status = self.query_one("#status", StatusBar) status = self.query_one("#status", StatusBar)
status.output_vol = int(self._audio.output_gain * 100) status.output_vol = int(self._audio.output_gain * 100)
status.input_vol = int(self._audio.input_gain * 100) status.input_vol = int(self._audio.input_gain * 100)
status.pitch = int(self._audio.pitch)
chatlog = self.query_one("#chatlog", ChatLog) chatlog = self.query_one("#chatlog", ChatLog)
chatlog.write("[dim]tuimble v0.1.0[/dim]") chatlog.write("[dim]tuimble v0.1.0[/dim]")
@@ -687,6 +697,7 @@ class TuimbleApp(App):
) )
self._audio.input_gain = acfg.input_gain self._audio.input_gain = acfg.input_gain
self._audio.output_gain = acfg.output_gain self._audio.output_gain = acfg.output_gain
self._audio.pitch = acfg.pitch
if self._client.connected: if self._client.connected:
self._start_audio() self._start_audio()
@@ -763,6 +774,26 @@ class TuimbleApp(App):
chatlog = self.query_one("#chatlog", ChatLog) chatlog = self.query_one("#chatlog", ChatLog)
chatlog.write(f"[dim]input volume {pct}%[/dim]") chatlog.write(f"[dim]input volume {pct}%[/dim]")
# -- pitch ----------------------------------------------------------------
def action_pitch_down(self) -> None:
"""Decrease voice pitch by 1 semitone."""
self._audio.pitch = max(-12.0, self._audio.pitch - 1.0)
st = int(self._audio.pitch)
status = self.query_one("#status", StatusBar)
status.pitch = st
chatlog = self.query_one("#chatlog", ChatLog)
chatlog.write(f"[dim]pitch {st:+d} semitones[/dim]")
def action_pitch_up(self) -> None:
"""Increase voice pitch by 1 semitone."""
self._audio.pitch = min(12.0, self._audio.pitch + 1.0)
st = int(self._audio.pitch)
status = self.query_one("#status", StatusBar)
status.pitch = st
chatlog = self.query_one("#chatlog", ChatLog)
chatlog.write(f"[dim]pitch {st:+d} semitones[/dim]")
# -- config reload -------------------------------------------------------- # -- config reload --------------------------------------------------------
def _detect_config_changes( def _detect_config_changes(
@@ -782,7 +813,7 @@ class TuimbleApp(App):
safe.append(f"ptt {key}: {old_ptt[key]} -> {new_ptt[key]}") safe.append(f"ptt {key}: {old_ptt[key]} -> {new_ptt[key]}")
# Audio: gains are safe; hardware settings require restart # Audio: gains are safe; hardware settings require restart
safe_audio = {"input_gain", "output_gain"} safe_audio = {"input_gain", "output_gain", "pitch"}
old_aud = dataclasses.asdict(old.audio) old_aud = dataclasses.asdict(old.audio)
new_aud = dataclasses.asdict(new.audio) new_aud = dataclasses.asdict(new.audio)
for key in old_aud: for key in old_aud:
@@ -809,9 +840,11 @@ class TuimbleApp(App):
self._audio.input_gain = new.audio.input_gain self._audio.input_gain = new.audio.input_gain
self._audio.output_gain = new.audio.output_gain self._audio.output_gain = new.audio.output_gain
self._audio.pitch = new.audio.pitch
status = self.query_one("#status", StatusBar) status = self.query_one("#status", StatusBar)
status.input_vol = int(new.audio.input_gain * 100) status.input_vol = int(new.audio.input_gain * 100)
status.output_vol = int(new.audio.output_gain * 100) status.output_vol = int(new.audio.output_gain * 100)
status.pitch = int(new.audio.pitch)
def _apply_restart_changes(self, new: Config) -> None: def _apply_restart_changes(self, new: Config) -> None:
"""Apply changes that require reconnect/audio restart.""" """Apply changes that require reconnect/audio restart."""
@@ -852,6 +885,7 @@ class TuimbleApp(App):
) )
self._audio.input_gain = acfg.input_gain self._audio.input_gain = acfg.input_gain
self._audio.output_gain = acfg.output_gain self._audio.output_gain = acfg.output_gain
self._audio.pitch = acfg.pitch
self._config = new self._config = new
if self._client.connected: if self._client.connected:
self._start_audio() self._start_audio()
@@ -897,6 +931,7 @@ class TuimbleApp(App):
self._apply_safe_changes(new) self._apply_safe_changes(new)
self._config.audio.input_gain = new.audio.input_gain self._config.audio.input_gain = new.audio.input_gain
self._config.audio.output_gain = new.audio.output_gain self._config.audio.output_gain = new.audio.output_gain
self._config.audio.pitch = new.audio.pitch
if safe: if safe:
for change in safe: for change in safe:

View File

@@ -13,6 +13,8 @@ import queue
import threading import threading
from typing import Callable from typing import Callable
from tuimble.modulator import PitchShifter
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
SAMPLE_RATE = 48000 SAMPLE_RATE = 48000
@@ -110,6 +112,7 @@ class AudioPipeline:
self._deafened = False self._deafened = False
self._input_gain = 1.0 self._input_gain = 1.0
self._output_gain = 1.0 self._output_gain = 1.0
self._pitch_shifter = PitchShifter(sample_rate)
def start(self) -> None: def start(self) -> None:
"""Open audio streams.""" """Open audio streams."""
@@ -185,6 +188,14 @@ class AudioPipeline:
def output_gain(self, value: float): def output_gain(self, value: float):
self._output_gain = max(0.0, min(2.0, value)) self._output_gain = max(0.0, min(2.0, value))
@property
def pitch(self) -> float:
return self._pitch_shifter.semitones
@pitch.setter
def pitch(self, value: float):
self._pitch_shifter.semitones = value
def _capture_callback(self, indata, frames, time_info, status) -> None: def _capture_callback(self, indata, frames, time_info, status) -> None:
"""Called by sounddevice when input data is available.""" """Called by sounddevice when input data is available."""
if status: if status:
@@ -219,15 +230,20 @@ class AudioPipeline:
def get_capture_frame(self, timeout: float = 0.0) -> bytes | None: def get_capture_frame(self, timeout: float = 0.0) -> bytes | None:
"""Retrieve next captured PCM frame for transmission. """Retrieve next captured PCM frame for transmission.
Pitch shifting runs here (worker thread) rather than in the
PortAudio callback, which must return within the frame period.
Args: Args:
timeout: Seconds to wait for a frame. 0 returns immediately. timeout: Seconds to wait for a frame. 0 returns immediately.
""" """
try: try:
if timeout > 0: if timeout > 0:
return self._capture_queue.get(timeout=timeout) pcm = self._capture_queue.get(timeout=timeout)
return self._capture_queue.get_nowait() else:
pcm = self._capture_queue.get_nowait()
except queue.Empty: except queue.Empty:
return None return None
return self._pitch_shifter.process(pcm)
def queue_playback(self, pcm_data: bytes) -> None: def queue_playback(self, pcm_data: bytes) -> None:
"""Queue raw PCM data for playback (16-bit, mono, 48kHz).""" """Queue raw PCM data for playback (16-bit, mono, 48kHz)."""

View File

@@ -32,6 +32,7 @@ class AudioConfig:
frame_size: int = 960 # 20ms at 48kHz frame_size: int = 960 # 20ms at 48kHz
input_gain: float = 1.0 input_gain: float = 1.0
output_gain: float = 1.0 output_gain: float = 1.0
pitch: float = 0.0
@dataclass @dataclass