Skip to content

Commit 1cc58f4

Browse files
authored
Fix for speaker identification playback (#1627)
1 parent d1c5646 commit 1cc58f4

9 files changed

Lines changed: 365 additions & 37 deletions

‎buzz/audio_devices.py‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import logging
2+
import threading
3+
import weakref
4+
from contextlib import contextmanager
5+
6+
import sounddevice as sd
7+
8+
9+
# PortAudio reads the list of audio devices once, when it initializes, and keeps
10+
# using that snapshot. A device that appears or disappears later - Bluetooth
11+
# headphones, a dock, or simply a change of the default output device in Windows
12+
# - is invisible to us until PortAudio is re-initialized, which shows up as
13+
# playback going to a device that is no longer there, i.e. silence.
14+
#
15+
# Re-initializing while a stream is open crashes PortAudio, so every stream we
16+
# open is registered here and the refresh is skipped while any of them is alive.
17+
18+
# Weak, so a stream that is dropped without being closed cannot block refreshes forever.
19+
_active_streams = weakref.WeakSet()
20+
_lock = threading.RLock()
21+
22+
23+
@contextmanager
24+
def registered_stream(stream):
25+
"""Mark a sounddevice stream as open, so devices are not refreshed under it."""
26+
with _lock:
27+
_active_streams.add(stream)
28+
try:
29+
yield stream
30+
finally:
31+
unregister_stream(stream)
32+
33+
34+
def register_stream(stream) -> None:
35+
with _lock:
36+
_active_streams.add(stream)
37+
38+
39+
def unregister_stream(stream) -> None:
40+
with _lock:
41+
_active_streams.discard(stream)
42+
43+
44+
def refresh_audio_devices() -> bool:
45+
"""Make PortAudio re-read the system's audio devices.
46+
47+
Returns True when the device list was actually refreshed, False when it was
48+
skipped because a stream is still open or PortAudio refused to restart.
49+
"""
50+
with _lock:
51+
if _active_streams:
52+
logging.debug("Skipping audio device refresh, %s stream(s) open", len(_active_streams))
53+
return False
54+
try:
55+
sd._terminate()
56+
except Exception:
57+
logging.warning("Could not shut down audio devices for a refresh", exc_info=True)
58+
return False
59+
60+
try:
61+
sd._initialize()
62+
except Exception:
63+
# Leaving PortAudio down would break all playback, so try once more.
64+
logging.warning("Could not re-initialize audio devices, retrying", exc_info=True)
65+
try:
66+
sd._initialize()
67+
except Exception:
68+
logging.error("Audio devices are unavailable", exc_info=True)
69+
return False
70+
logging.debug("Refreshed audio devices")
71+
return True

‎buzz/recording.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import sounddevice
66
from PyQt6.QtCore import QObject, pyqtSignal
77

8+
from buzz.audio_devices import register_stream, unregister_stream
9+
810

911
class RecordingAmplitudeListener(QObject):
1012
stream: Optional[sounddevice.InputStream] = None
@@ -32,6 +34,7 @@ def start_recording(self):
3234
channels=1,
3335
callback=self.stream_callback,
3436
)
37+
register_stream(self.stream)
3538
self.stream.start()
3639
self.accumulation_size = int(self.stream.samplerate * self.ACCUMULATION_SECONDS)
3740
except Exception as e:
@@ -43,6 +46,7 @@ def stop_recording(self):
4346
if self.stream is not None:
4447
self.stream.stop()
4548
self.stream.close()
49+
unregister_stream(self.stream)
4650

4751
def stream_callback(self, in_data: np.ndarray, frame_count, time_info, status):
4852
if not self._active:

‎buzz/sounddevice_player.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
import numpy as np
1010
import sounddevice as sd
1111

12+
from buzz.audio_devices import (
13+
refresh_audio_devices,
14+
register_stream,
15+
unregister_stream,
16+
)
1217
from buzz.ffmpeg_utils import find_ffmpeg
1318
from buzz.pip_utils import subprocess_hide_window_kwargs
1419

@@ -93,6 +98,7 @@ def _open_stream(self):
9398
callback=self._callback,
9499
finished_callback=self._on_finished,
95100
)
101+
register_stream(self._stream)
96102
try:
97103
self._latency_frames = int(self._stream.latency * self.samplerate)
98104
except Exception:
@@ -103,6 +109,24 @@ def _on_finished(self):
103109

104110
def play(self):
105111
self._stop_stream()
112+
113+
# Pick up devices that were connected, removed, or made default since
114+
# the last time we played, so we open the output the user is actually
115+
# listening to instead of a stale one.
116+
refresh_audio_devices()
117+
118+
try:
119+
self._start_stream()
120+
except Exception:
121+
# The device can disappear between opening and starting it, e.g.
122+
# headphones dropping off. Re-read the devices and try once more
123+
# against whatever is the default output now.
124+
logging.warning("Could not start audio stream, retrying", exc_info=True)
125+
self._stop_stream()
126+
refresh_audio_devices()
127+
self._start_stream()
128+
129+
def _start_stream(self):
106130
self._open_stream()
107131
self._playing = True
108132
self._stream.start()
@@ -144,6 +168,7 @@ def _stop_stream(self):
144168
self._stream.close()
145169
except Exception:
146170
logging.debug("Error stopping sounddevice stream", exc_info=True)
171+
unregister_stream(self._stream)
147172
self._stream = None
148173

149174
def close(self):

‎buzz/transcriber/recording_transcriber.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
from buzz.transcriber.cuda_device import cuda_works
2121
import sounddevice
2222
from sounddevice import PortAudioError
23+
24+
from buzz.audio_devices import registered_stream
2325
from openai import OpenAI
2426
from PyQt6.QtCore import QObject, pyqtSignal
2527

@@ -102,13 +104,14 @@ def start(self):
102104
)
103105

104106
try:
105-
with self.sounddevice.InputStream(
107+
stream = self.sounddevice.InputStream(
106108
samplerate=self.sample_rate,
107109
device=self.input_device_index,
108110
dtype="float32",
109111
channels=1,
110112
callback=self.stream_callback,
111-
):
113+
)
114+
with stream, registered_stream(stream):
112115
while self.is_running:
113116
if self.queue.size >= self.n_batch_samples:
114117
self.mutex.acquire()

‎buzz/widgets/audio_player.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,12 @@ def update_time_label(self):
290290

291291
def on_audio_outputs_changed(self):
292292
if self._use_sd:
293-
# sounddevice always uses system default; nothing to reconnect
293+
# sounddevice holds on to the device for the life of the stream, so
294+
# a stream that is already running keeps feeding the old output.
295+
# Reopen it to move playback to whatever the default output is now.
296+
if self._sd_player is not None and self._sd_player.is_playing:
297+
self._sd_player.pause()
298+
self._sd_player.play()
294299
return
295300
was_playing = self.media_player.playbackState() == QMediaPlayer.PlaybackState.PlayingState
296301
position = self.media_player.position()

‎buzz/widgets/transcription_viewer/speaker_identification_widget.py‎

Lines changed: 67 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,7 @@
2121

2222
import faster_whisper
2323
import torch
24-
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
25-
from PyQt6.QtCore import Qt, QThread, QObject, pyqtSignal, QUrl, QTimer
24+
from PyQt6.QtCore import Qt, QThread, QObject, pyqtSignal, QTimer
2625
from PyQt6.QtGui import QFont
2726
from PyQt6.QtWidgets import (
2827
QWidget,
@@ -46,12 +45,17 @@
4645
from buzz.db.service.transcription_service import TranscriptionService
4746
from buzz.paths import file_path_as_title
4847
from buzz.settings.settings import Settings
48+
from buzz.sounddevice_player import AudioFilePlayer
4949
from buzz.widgets.line_edit import LineEdit
5050
from buzz.transcriber.transcriber import Segment
5151
from buzz.transcriber.speaker_identifier import build_speaker_segments
5252

5353

5454

55+
# Longest speaker sample played by the "Play sample" button
56+
MAX_PREVIEW_DURATION_MS = 10 * 1000
57+
58+
5559
def process_in_batches(
5660
items,
5761
process_func,
@@ -695,13 +699,35 @@ def _create_save_section(self, layout: QFormLayout) -> None:
695699
layout.addRow(self.save_button)
696700

697701
def _setup_audio_player(self) -> None:
698-
url = QUrl.fromLocalFile(self.transcription.file)
699-
self.player = QMediaPlayer()
700-
self.audio_output = QAudioOutput()
701-
self.player.setAudioOutput(self.audio_output)
702-
self.player.setSource(url)
702+
# Created on first playback: decoding the file takes a moment and the
703+
# dialog is often closed without any sample being played.
704+
self.player: Optional[AudioFilePlayer] = None
703705
self.player_timer = None
704706

707+
def _get_audio_player(self) -> Optional["AudioFilePlayer"]:
708+
"""Returns the sample player, creating it on first use, or None if the file cannot be played."""
709+
if self.player is not None:
710+
return self.player
711+
712+
if not os.path.isfile(self.transcription.file):
713+
logging.warning(
714+
"Speaker identification: cannot play sample, file not found %s",
715+
self.transcription.file,
716+
)
717+
return None
718+
719+
player = AudioFilePlayer(self.transcription.file)
720+
if not player.ready:
721+
player.close()
722+
logging.error(
723+
"Speaker identification: could not open %s for playback",
724+
self.transcription.file,
725+
)
726+
return None
727+
728+
self.player = player
729+
return self.player
730+
705731
def on_identify_button_clicked(self):
706732
self.step_1_button.setEnabled(False)
707733
self.step_1_button.setVisible(False)
@@ -841,24 +867,40 @@ def on_identification_finished(self, result):
841867
self.needs_layout_update = True
842868

843869
def on_speaker_preview(self, speaker_id):
844-
if self.player_timer:
845-
self.player_timer.stop()
870+
self._stop_playback()
846871

847872
speaker_records = [record for record in self.identification_result if record['speaker'] == speaker_id]
848873

849-
if speaker_records:
850-
random_record = random.choice(speaker_records)
874+
if not speaker_records:
875+
return
851876

852-
start_time = random_record['start_time']
853-
end_time = random_record['end_time']
877+
player = self._get_audio_player()
878+
if player is None:
879+
return
854880

855-
self.player.setPosition(int(start_time))
856-
self.player.play()
881+
random_record = random.choice(speaker_records)
857882

858-
self.player_timer = QTimer(self)
859-
self.player_timer.setSingleShot(True)
860-
self.player_timer.timeout.connect(self.player.stop)
861-
self.player_timer.start(min(end_time, 10 * 1000)) # 10 seconds
883+
start_time = int(random_record['start_time'])
884+
end_time = int(random_record['end_time'])
885+
# Stop at the end of the sampled segment, capped so a long monologue
886+
# does not keep playing after the button was clicked.
887+
duration = min(max(end_time - start_time, 0), MAX_PREVIEW_DURATION_MS)
888+
if duration <= 0:
889+
return
890+
891+
player.seek(start_time)
892+
player.play()
893+
894+
self.player_timer = QTimer(self)
895+
self.player_timer.setSingleShot(True)
896+
self.player_timer.timeout.connect(self._stop_playback)
897+
self.player_timer.start(duration)
898+
899+
def _stop_playback(self) -> None:
900+
if self.player_timer:
901+
self.player_timer.stop()
902+
if self.player is not None:
903+
self.player.stop()
862904

863905
def on_save_button_clicked(self):
864906
speaker_names = []
@@ -892,10 +934,7 @@ def on_save_button_clicked(self):
892934
if self.transcriptions_updated_signal:
893935
self.transcriptions_updated_signal.emit(new_transcript_id)
894936

895-
self.player.stop()
896-
897-
if self.player_timer:
898-
self.player_timer.stop()
937+
self._stop_playback()
899938

900939
self.close()
901940

@@ -911,10 +950,11 @@ def changeEvent(self, event):
911950
def closeEvent(self, event):
912951
self.hide()
913952

914-
# Stop media player
915-
self.player.stop()
916-
if self.player_timer:
917-
self.player_timer.stop()
953+
# Stop playback and release the decoded audio
954+
self._stop_playback()
955+
if self.player is not None:
956+
self.player.close()
957+
self.player = None
918958

919959
# Clean up thread if running
920960
self._cleanup_thread()

‎tests/mock_sounddevice.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ def close(self):
157157

158158
def __enter__(self):
159159
self.start()
160+
return self
160161

161162
def __exit__(self, exc_type, exc_val, exc_tb):
162163
self.stop()

0 commit comments

Comments
 (0)