forked from gstreamer101/AnnoySpeaker
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.py
More file actions
1001 lines (850 loc) · 39.6 KB
/
Copy pathmain.py
File metadata and controls
1001 lines (850 loc) · 39.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 dlgus8648
"""AnnoySpeaker GUI — PySide6 frontend over pluggable TTS engines.
Layout follows the Windows Balabolka style at a high level: toolbar with
play/stop/export, an engine selector combobox, a voice selector combobox,
rate/pitch/volume sliders, a large text edit, and a status bar.
The engine combobox is backed by the ENGINES registry — selecting an entry
swaps the GStreamer sink element used for playback and the export tool used
for m4a save. Currently AVSpeechSynthesizer (macttssink) is the only engine;
the registry is structured so additional macOS TTS APIs wrapped as GStreamer
sink plugins can be added with a single entry.
The voice combobox is filled per-engine from `voice_list_tool --list-voices`
(for AVSpeech, the kb-tts-export binary). On macOS each voice is bound to a
language, so the list is sorted by language; the selected voice identifier is
passed to both playback (the sink's `voice` property) and export (`--voice`).
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from PySide6.QtCore import QObject, QProcess, Qt, QTimer, Signal
from PySide6.QtGui import QAction, QColor, QKeySequence, QTextCharFormat, QTextCursor
from PySide6.QtWidgets import (
QApplication,
QComboBox,
QFileDialog,
QHBoxLayout,
QLabel,
QMainWindow,
QPlainTextEdit,
QProgressDialog,
QSlider,
QStatusBar,
QStyle,
QTextEdit,
QToolBar,
QVBoxLayout,
QWidget,
)
# 순수 로직(텍스트 처리·음성 필터)은 백엔드/UI 의존이 없는 모듈로 분리해
# 헤드리스 CI에서 단위 테스트한다(이슈 #16). 여기선 그걸 가져다 쓴다.
from textproc import (
TAIL_PADDING,
build_spoken_text,
map_spoken_range,
preprocess_for_speech,
ui_speed_to_rate,
)
from voices import parse_voice_list, pick_default_voice
def _is_frozen_bundle() -> bool:
"""PyInstaller .app 번들 환경인지 여부."""
return getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS")
if _is_frozen_bundle():
# .app 번들: PyInstaller가 추가 리소스/바이너리를 _MEIPASS 아래에 풀어둠
# --add-binary "src:dest" 의 dest는 폴더라서 binary는 dest 안에 들어감
_BUNDLE_ROOT = Path(sys._MEIPASS) # type: ignore[attr-defined]
PLUGIN_DIR = _BUNDLE_ROOT / "plugin"
EXPORT_TOOL = _BUNDLE_ROOT / "tools" / "kb-tts-export" / "kb-tts-export"
else:
# 개발 모드 (python main.py)
PROJECT_ROOT = Path(__file__).resolve().parent.parent
PLUGIN_DIR = PROJECT_ROOT / "plugin" / "builddir"
EXPORT_TOOL = PROJECT_ROOT / "tools" / "kb-tts-export" / "kb-tts-export"
# GStreamer Framework는 시스템 의존 (번들 안 함). 공식 .pkg 필요.
GSTREAMER_FRAMEWORK = Path("/Library/Frameworks/GStreamer.framework/Versions/1.0")
def _setup_gstreamer_env() -> None:
"""gi/Gst import 전에 framework의 typelib·dylib·plugin 경로를 환경에 주입.
공식 framework가 번들한 PyGObject는 python3.9용이라 우리 3.12 venv에선
별도 설치한 pygobject(3.50.0 고정)를 쓰되, typelib/dylib은 framework
것을 가리켜야 한다. 셸 환경변수나 re-exec 없이 os.environ 설정만으로
macOS에서 로드됨(Phase 0/4에서 실측). 자세한 내막은 dev-log 14 참고.
"""
fw = GSTREAMER_FRAMEWORK
os.environ.setdefault("GI_TYPELIB_PATH", str(fw / "lib" / "girepository-1.0"))
dyld = str(fw / "lib")
existing = os.environ.get("DYLD_FALLBACK_LIBRARY_PATH", "")
if dyld not in existing.split(":"):
os.environ["DYLD_FALLBACK_LIBRARY_PATH"] = dyld + (f":{existing}" if existing else "")
# 플러그인 경로: 우리 macttssink(PLUGIN_DIR) + 시스템 framework의 코어
# 플러그인(filesrc, capsfilter 등). .app에선 GST_PLUGIN_PATH를 명시하면
# 기본 시스템 경로 스캔이 빠질 수 있어 framework 경로를 함께 지정한다.
os.environ["GST_PLUGIN_PATH"] = os.pathsep.join(
[str(PLUGIN_DIR), str(fw / "lib" / "gstreamer-1.0")]
)
scanner = fw / "libexec" / "gstreamer-1.0" / "gst-plugin-scanner"
if scanner.exists():
os.environ["GST_PLUGIN_SCANNER"] = str(scanner)
_setup_gstreamer_env()
# .app(frozen) 한정: macOS dyld는 DYLD_* 환경변수를 "프로세스 시작 시점"에만
# 읽는다. 번들 gi(_gi.so)의 @rpath 의존성(libgirepository 등)을 시스템
# framework로 폴백시키려면 그 환경이 시작 때 있어야 한다. os.environ 설정만으론
# 이미 시작된 dyld에 안 먹히므로, 환경을 세팅한 뒤 한 번 재실행(execv)해 새
# 프로세스가 시작 시점에 이 환경을 갖게 한다. (dev 모드는 framework가 기본
# 경로라 불필요.)
if _is_frozen_bundle() and os.environ.get("ANNOYSPEAKER_REEXEC") != "1":
os.environ["ANNOYSPEAKER_REEXEC"] = "1"
os.execv(sys.executable, [sys.executable, *sys.argv[1:]])
try:
# 동적 import: PyInstaller의 gi 자동 훅이 우리 pygobject(3.50)+공식
# framework 조합과 비호환이라 .app 빌드 시 크래시(Gst.init(None) 등)한다.
# 모듈명을 변수로 줘서 정적 분석에 안 걸리게 우회하고, gi는 spec에서 수동
# 수집한다. 런타임 typelib/dylib은 _setup_gstreamer_env()가 가리키는 시스템
# framework에서 로드된다.
import importlib
_gi_mod = "gi"
gi = importlib.import_module(_gi_mod)
gi.require_version("Gst", "1.0")
Gst = importlib.import_module(_gi_mod + ".repository.Gst")
Gst.init([])
GST_AVAILABLE = True
GST_IMPORT_ERROR = ""
except Exception as gst_err: # framework 미설치/연결 실패 등 — 재생 시 안내
Gst = None
GST_AVAILABLE = False
GST_IMPORT_ERROR = str(gst_err)
# ---- TTS 엔진 레지스트리 ---------------------------------------------------
#
# Balabolka가 SAPI4 / SAPI5 / MS Speech Platform을 갈아끼우듯, AnnoySpeaker도
# "엔진"을 콤보박스로 고를 수 있게 한다. 한 엔진 = (재생용 GStreamer sink
# 엘리먼트) + (m4a export 도구) 한 쌍. 콤보박스에서 엔진을 바꾸면 재생
# 파이프라인의 sink 엘리먼트와 export 도구가 통째로 교체된다.
#
# 지금은 macOS AVSpeechSynthesizer(macttssink) 하나뿐이지만, 다른 macOS TTS
# API(예: NSSpeechSynthesizer)를 GStreamer sink 플러그인 + export 경로로
# 감싸 이 리스트에 dict 하나 추가하면 콤보박스에 자동으로 나타난다.
#
# 확장 포인트: 엔진마다 지원하는 속성(rate/pitch/volume)이나 그 단위가
# 다를 수 있다. 현재는 모든 엔진이 macttssink와 동일한 rate/pitch/volume
# float 속성을 받는다고 가정한다. 엔진별 속성 매핑이 필요해지면 Engine에
# 필드를 추가한다.
@dataclass(frozen=True)
class Engine:
"""선택 가능한 TTS 엔진 하나.
id: 내부 식별자
display_name: 콤보박스에 보일 이름
sink_element: 재생 파이프라인에서 쓸 GStreamer sink 엘리먼트 이름
export_tool: m4a export 실행 파일 경로 (None = export 미지원)
voice_list_tool: 설치된 음성 목록을 출력하는 도구 경로
(`<tool> --list-voices` → "id\\tname\\tlang\\tquality" 줄들).
None = 보이스 열거 미지원 (드롭다운에 "시스템 기본"만).
"""
id: str
display_name: str
sink_element: str
export_tool: Path | None
voice_list_tool: Path | None
ENGINES: list[Engine] = [
Engine(
id="avspeech",
display_name="macOS AVSpeechSynthesizer",
sink_element="macttssink",
export_tool=EXPORT_TOOL,
# AVSpeech 음성 열거는 export 도구의 --list-voices를 재사용
# (같은 AVFoundation 바이너리라 .app에 이미 번들됨).
voice_list_tool=EXPORT_TOOL,
),
# 다음 엔진 예시 (구현되면 주석 해제):
# Engine(
# id="nsspeech",
# display_name="macOS NSSpeechSynthesizer (클래식 보이스)",
# sink_element="macnsttssink", # 별도 GStreamer 플러그인 필요
# export_tool=NS_EXPORT_TOOL, # 별도 export 도구 필요
# voice_list_tool=NS_EXPORT_TOOL,
# ),
]
class LabeledSlider(QWidget):
"""라벨 + 가로 슬라이더. 값이 바뀌면 라벨에 현재 값 표시.
내부 값은 정수(min..max). 표시 포맷은 formatter로 커스터마이즈
(기본은 "v%"). plugin에 넘길 float 매핑은 호출자가 따로 계산.
"""
def __init__(
self,
title: str,
lo: int,
hi: int,
default: int,
formatter=None,
) -> None:
super().__init__()
self._title = title
self._formatter = formatter or (lambda v: f"{v}%")
self.label = QLabel()
self.label.setStyleSheet("color: #444;")
self.slider = QSlider(Qt.Orientation.Horizontal)
self.slider.setMinimum(lo)
self.slider.setMaximum(hi)
self.slider.setValue(default)
self.slider.valueChanged.connect(self._on_value_changed)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(2)
layout.addWidget(self.label)
layout.addWidget(self.slider)
self._on_value_changed(default)
def _on_value_changed(self, v: int) -> None:
self.label.setText(f"{self._title}: {self._formatter(v)}")
def value(self) -> int:
return self.slider.value()
class GstPlayer(QObject):
"""인프로세스 GStreamer 재생 컨트롤러 (PyGObject).
파이프라인(filesrc ! capsfilter ! macttssink)을 GUI 프로세스 안에서 직접
구동한다. 그래야 (a) macttssink가 버스에 올리는 단어 범위 메시지를
QTimer 폴링으로 받아 실시간 하이라이트에 쓰고, (b) 일시정지/재개/정지를
엘리먼트에 직접 보낼 수 있다 — gst-launch 서브프로세스로는 불가능. 자세한
설계 배경은 dev-log 14 참고.
버스는 GLib 메인루프 대신 QTimer로 폴링한다(Qt 이벤트 루프와 자연스럽게
통합). 메시지는 스트리밍/메인 스레드에서 post되지만 폴링·시그널 방출은
모두 GUI 스레드라 UI 접근이 안전하다.
"""
wordSpoken = Signal(int, int) # 읽기 문자열의 단어 범위 (start, end)
finished = Signal()
failed = Signal(str)
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self._pipeline = None
self._sink = None
self._timer = QTimer(self)
self._timer.setInterval(20)
self._timer.timeout.connect(self._poll_bus)
def is_active(self) -> bool:
return self._pipeline is not None
def play(
self,
text_path: str,
sink_element: str,
rate: float,
pitch: float,
volume: float,
voice: str | None,
) -> None:
self.stop()
pipeline = Gst.Pipeline.new("annoyspeaker")
src = Gst.ElementFactory.make("filesrc", "src")
capsf = Gst.ElementFactory.make("capsfilter", "caps")
sink = Gst.ElementFactory.make(sink_element, "tts")
if not (pipeline and src and capsf and sink):
self.failed.emit(f"GStreamer 요소 생성 실패 ('{sink_element}' 플러그인 확인)")
return
src.set_property("location", text_path)
src.set_property("blocksize", 104857600) # 100MB - 어떤 길이든 한 buffer로
capsf.set_property("caps", Gst.Caps.from_string("text/x-raw,format=utf8"))
sink.set_property("rate", rate)
sink.set_property("pitch", pitch)
sink.set_property("volume", volume)
if voice:
sink.set_property("voice", voice)
for el in (src, capsf, sink):
pipeline.add(el)
src.link(capsf)
capsf.link(sink)
self._pipeline = pipeline
self._sink = sink
pipeline.set_state(Gst.State.PLAYING)
self._timer.start()
def pause(self) -> None:
if self._sink is not None:
self._sink.emit("pause")
def resume(self) -> None:
if self._sink is not None:
self._sink.emit("resume")
def stop(self) -> None:
self._teardown()
def _teardown(self) -> None:
self._timer.stop()
if self._pipeline is not None:
# unlock()이 블록된 render()를 깨워 즉시 NULL로 내려간다 (데드락 없음).
self._pipeline.set_state(Gst.State.NULL)
self._pipeline = None
self._sink = None
def _poll_bus(self) -> None:
if self._pipeline is None:
return
bus = self._pipeline.get_bus()
types = Gst.MessageType.ELEMENT | Gst.MessageType.ERROR | Gst.MessageType.EOS
while True:
msg = bus.timed_pop_filtered(0, types)
if msg is None:
break
if msg.type == Gst.MessageType.ELEMENT:
s = msg.get_structure()
if s is None:
continue
name = s.get_name()
if name == "annoyspeaker-word":
self.wordSpoken.emit(s.get_value("start"), s.get_value("end"))
elif name == "annoyspeaker-done":
self._teardown()
self.finished.emit()
return
elif msg.type == Gst.MessageType.EOS:
self._teardown()
self.finished.emit()
return
elif msg.type == Gst.MessageType.ERROR:
err, _dbg = msg.parse_error()
self._teardown()
self.failed.emit(str(err))
return
class MainWindow(QMainWindow):
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("AnnoySpeaker")
self.resize(760, 560)
self._export_process: QProcess | None = None
self._export_dialog: QProgressDialog | None = None
self._export_buf: str = "" # stderr 줄바꿈 경계 버퍼
self._export_canceled: bool = False
self._tmp_text_path: str | None = None
self._current_engine: Engine = ENGINES[0]
self._has_voices: bool = False
self._omap: list[int] = [] # 읽기 문자열 → 원본 위치 매핑 (현재 재생)
self._hl_start: int | None = None # 누적 하이라이트 시작 위치
self._playback_state = "idle" # idle / playing / paused
# 인프로세스 재생 컨트롤러 (PyGObject). framework 미연결이면 None.
self._player = GstPlayer(self) if GST_AVAILABLE else None
if self._player is not None:
self._player.wordSpoken.connect(self._on_word)
self._player.finished.connect(self._on_play_finished)
self._player.failed.connect(self._on_play_failed)
self._build_toolbar()
self._build_central()
self._build_statusbar()
self._update_char_count()
self._on_engine_changed(0)
# ---- UI construction ---------------------------------------------------
def _build_toolbar(self) -> None:
toolbar = QToolBar("Main")
toolbar.setMovable(False)
toolbar.setIconSize(toolbar.iconSize() * 1.1)
self.addToolBar(toolbar)
style = self.style()
self.play_action = QAction(
style.standardIcon(QStyle.StandardPixmap.SP_MediaPlay),
"재생",
self,
)
self.play_action.setShortcut(QKeySequence("Ctrl+Return"))
self.play_action.setToolTip("재생: 처음부터 / 일시정지 중이면 이어재생 (Cmd+Enter)")
self.play_action.triggered.connect(self._on_play)
toolbar.addAction(self.play_action)
# 일시정지 (재생 ▶ 와 정지 ■ 사이). 일시정지 전용 — 아이콘 안 바뀌고,
# 일시정지하면 비활성화된다(재개는 ▶ 재생 버튼이 담당).
self.pause_action = QAction(
style.standardIcon(QStyle.StandardPixmap.SP_MediaPause), "일시정지", self
)
self.pause_action.setShortcut(QKeySequence("Ctrl+P"))
self.pause_action.setToolTip("일시정지 (Cmd+P)")
self.pause_action.triggered.connect(self._do_pause)
self.pause_action.setEnabled(False)
toolbar.addAction(self.pause_action)
self.stop_action = QAction(
style.standardIcon(QStyle.StandardPixmap.SP_MediaStop),
"정지",
self,
)
self.stop_action.setShortcut(QKeySequence("Ctrl+."))
self.stop_action.setToolTip("재생 중지 (Cmd+.)")
self.stop_action.triggered.connect(self._on_stop)
self.stop_action.setEnabled(False)
toolbar.addAction(self.stop_action)
toolbar.addSeparator()
self.export_action = QAction(
style.standardIcon(QStyle.StandardPixmap.SP_DialogSaveButton),
"내보내기",
self,
)
self.export_action.setShortcut(QKeySequence("Ctrl+S"))
self.export_action.setToolTip(
"현재 텍스트와 슬라이더 설정대로 .m4a 음성 파일로 저장 (Cmd+S)"
)
self.export_action.triggered.connect(self._on_export)
toolbar.addAction(self.export_action)
def _build_central(self) -> None:
central = QWidget()
layout = QVBoxLayout(central)
layout.setContentsMargins(8, 8, 8, 8)
layout.setSpacing(6)
# 엔진 선택 row: "엔진:" 라벨 + 콤보박스 (Balabolka의 엔진 탭에 해당)
engine_row = QWidget()
engine_row_layout = QHBoxLayout(engine_row)
engine_row_layout.setContentsMargins(4, 2, 4, 2)
engine_row_layout.setSpacing(8)
engine_caption = QLabel("엔진:")
engine_caption.setStyleSheet("color: #555;")
self.engine_combo = QComboBox()
for engine in ENGINES:
self.engine_combo.addItem(engine.display_name, engine.id)
self.engine_combo.setToolTip("음성 합성에 사용할 TTS 엔진 선택")
self.engine_combo.currentIndexChanged.connect(self._on_engine_changed)
engine_row_layout.addWidget(engine_caption)
engine_row_layout.addWidget(self.engine_combo, stretch=1)
layout.addWidget(engine_row)
# 음성 선택 row: "음성:" 라벨 + 콤보박스 (Balabolka의 voice 드롭다운에 해당)
# 음성은 언어에 묶여 있다 — 한국어 텍스트엔 한국어 음성(Yuna), 영어엔
# 영어 음성(Samantha 등). 선택 엔진이 바뀌면 목록을 다시 채운다.
voice_row = QWidget()
voice_row_layout = QHBoxLayout(voice_row)
voice_row_layout.setContentsMargins(4, 2, 4, 2)
voice_row_layout.setSpacing(8)
voice_caption = QLabel("음성:")
voice_caption.setStyleSheet("color: #555;")
self.voice_combo = QComboBox()
self.voice_combo.setToolTip(
"읽을 음성 선택. 텍스트 언어에 맞는 음성을 고르세요 (예: 한국어 → Yuna)."
)
voice_row_layout.addWidget(voice_caption)
voice_row_layout.addWidget(self.voice_combo, stretch=1)
layout.addWidget(voice_row)
# 슬라이더 row: 속도 / 음높이 / 볼륨
# 내부 값(정수) → 표시 → plugin float 매핑:
# 속도 0..20 → "0.0x"..."2.0x" → rate v/20 (0.00..1.00)
# 음높이 50..200 → "50%"..."200%" → pitch v/100 (0.50..2.00)
# 볼륨 0..100 → "0%"..."100%" → volume v/100 (0.00..1.00)
sliders_row = QWidget()
sliders_layout = QHBoxLayout(sliders_row)
sliders_layout.setContentsMargins(4, 4, 4, 4)
sliders_layout.setSpacing(16)
self.rate_slider = LabeledSlider("속도", 0, 20, 10, formatter=lambda v: f"{v / 10:.1f}x")
self.pitch_slider = LabeledSlider("음높이", 50, 200, 100)
self.volume_slider = LabeledSlider("볼륨", 0, 100, 100)
sliders_layout.addWidget(self.rate_slider)
sliders_layout.addWidget(self.pitch_slider)
sliders_layout.addWidget(self.volume_slider)
layout.addWidget(sliders_row)
self.text_edit = QPlainTextEdit()
self.text_edit.setPlaceholderText("여기에 읽을 텍스트를 붙여넣으세요…")
self.text_edit.textChanged.connect(self._on_text_changed)
layout.addWidget(self.text_edit, stretch=1)
self.setCentralWidget(central)
def _build_statusbar(self) -> None:
bar = QStatusBar()
self.setStatusBar(bar)
self.status_label = QLabel("준비")
self.engine_status = QLabel("macttssink")
self.engine_status.setStyleSheet("color: #777;")
self.char_label = QLabel("글자: 0")
bar.addWidget(self.status_label, stretch=1)
bar.addPermanentWidget(self.engine_status)
bar.addPermanentWidget(QLabel("│"))
bar.addPermanentWidget(self.char_label)
# ---- Actions -----------------------------------------------------------
def _update_char_count(self) -> None:
n = len(self.text_edit.toPlainText())
self.char_label.setText(f"글자: {n}")
def _on_text_changed(self) -> None:
self._update_char_count()
# 재생/일시정지 중 사용자가 텍스트를 편집하면 문서 위치가 밀려 현재 재생의
# 위치 매핑(omap)이 어긋나 하이라이트 싱크가 깨지고, 편집한 글자는 지금
# 재생엔 포함도 안 된다. 그래서 편집은 "정지"와 동일하게 처리한다 —
# 멈추고 하이라이트를 지운 뒤, 새 내용은 ▶로 처음부터 듣게 한다.
if self._playback_state in ("playing", "paused"):
self._on_stop()
def _on_engine_changed(self, index: int) -> None:
"""콤보박스에서 엔진을 바꾸면 현재 엔진을 교체하고 UI를 갱신.
재생 파이프라인의 sink 엘리먼트와 export 도구가 이 엔진을 따라간다.
"""
if not (0 <= index < len(ENGINES)):
return
self._current_engine = ENGINES[index]
# 상태바에 현재 엔진의 sink 엘리먼트 이름 표시
self.engine_status.setText(self._current_engine.sink_element)
# export 도구가 없는(또는 빌드 안 된) 엔진이면 내보내기 비활성화
tool = self._current_engine.export_tool
export_ok = tool is not None and tool.exists()
self.export_action.setEnabled(export_ok)
self.export_action.setToolTip(
"현재 텍스트와 슬라이더 설정대로 .m4a 음성 파일로 저장 (Cmd+S)"
if export_ok
else "이 엔진은 m4a 내보내기를 지원하지 않습니다."
)
# 엔진별 음성 목록 갱신
self._populate_voices()
def _populate_voices(self) -> None:
"""선택 엔진의 설치 음성으로 음성 콤보박스를 채운다.
`voice_list_tool --list-voices` 출력(id\\tname\\tlang\\tquality)을
파싱하고, PREFERRED_VOICE_NAMES(자연 음성 화이트리스트)로 걸러
언어→이름 순으로 넣는다. 화이트리스트 음성이 하나도 없으면(도구
실패 또는 미설치) "시스템 기본"으로 폴백한다.
"""
self.voice_combo.clear()
# 도구 실행(subprocess)만 여기서 하고, 파싱·필터·정렬·기본선택은 순수
# 모듈(voices)에 위임 — 그쪽이 단위 테스트 대상이다.
tool = self._current_engine.voice_list_tool
voices: list[tuple[str, str, str, str]] = [] # (lang, name, identifier, quality)
if tool is not None and tool.exists():
try:
result = subprocess.run(
[str(tool), "--list-voices"],
capture_output=True,
text=True,
timeout=5,
)
voices = parse_voice_list(result.stdout)
except (OSError, subprocess.SubprocessError):
pass # 실패해도 아래 폴백으로 계속 동작
for lang, name, ident, _quality in voices:
# name이 이미 "(Premium)"/"(Enhanced)"를 포함하므로 그대로 표시
self.voice_combo.addItem(f"{name} · {lang}", ident)
best_ident = pick_default_voice(voices)
# 음성이 하나도 없으면 시스템 기본으로 폴백
self._has_voices = self.voice_combo.count() > 0
if not self._has_voices:
self.voice_combo.addItem("시스템 기본", None)
self.voice_combo.setEnabled(self._has_voices)
if best_ident is not None:
idx = self.voice_combo.findData(best_ident)
if idx >= 0:
self.voice_combo.setCurrentIndex(idx)
def _selected_voice(self) -> str | None:
"""현재 선택된 음성 identifier. "시스템 기본"이면 None."""
return self.voice_combo.currentData()
def _slider_values(self) -> tuple[float, float, float]:
"""슬라이더 정수값을 plugin이 받는 float로 매핑.
속도는 AVSpeech의 비선형성 때문에 ui_speed_to_rate()로 압축.
"""
ui_x = self.rate_slider.value() / 10.0 # 0.0..2.0 (사용자 표시)
rate = ui_speed_to_rate(ui_x) # 0.00..0.70 (압축됨)
pitch = self.pitch_slider.value() / 100.0 # 0.50..2.00
volume = self.volume_slider.value() / 100.0 # 0.00..1.00
return rate, pitch, volume
def _on_play(self) -> None:
"""재생 버튼: 일시정지 중이면 이어재생, 아니면 처음부터 재생."""
if self._playback_state == "paused":
self._do_resume()
else:
self._start_playback()
def _start_playback(self) -> None:
if self._player is None:
self._fail(f"GStreamer를 불러올 수 없습니다 (framework 확인). {GST_IMPORT_ERROR}")
return
if self._player.is_active():
self._player.stop() # 만약을 위한 정리
# 읽기 문자열 + 원본 위치 매핑. spoken은 preprocess+패딩과 바이트 동일이라
# 오디오는 그대로고, omap으로 단어 범위를 원본에 되돌려 하이라이트한다.
spoken, omap = build_spoken_text(self.text_edit.toPlainText())
if not spoken:
self.status_label.setText("입력된 텍스트가 없습니다.")
return
if not PLUGIN_DIR.exists():
self._fail(f"플러그인 빌드 폴더를 찾을 수 없음: {PLUGIN_DIR}")
return
# 긴 텍스트도 한 buffer로 처리되도록 임시 파일 + filesrc 사용.
try:
tmp_fd, self._tmp_text_path = tempfile.mkstemp(suffix=".txt", prefix="kb-tts-")
with os.fdopen(tmp_fd, "wb") as f:
f.write(spoken.encode("utf-8"))
except OSError as e:
self._fail(f"임시 파일 생성 실패: {e}")
return
self._omap = omap
self._clear_highlight()
rate, pitch, volume = self._slider_values()
self._player.play(
self._tmp_text_path,
self._current_engine.sink_element,
rate,
pitch,
volume,
self._selected_voice(),
)
self._apply_state("playing")
ui_x = self.rate_slider.value() / 10.0
self.status_label.setText(
f"재생 중… (속도 {ui_x:.1f}x · rate {rate:.2f}, "
f"음높이 {int(pitch * 100)}%, 볼륨 {int(volume * 100)}%)"
)
# ---- 하이라이트 ---------------------------------------------------------
def _on_word(self, start: int, end: int) -> None:
"""macttssink가 보낸 단어 범위(읽기 문자열 기준)를 원본 위치로 되돌려
누적 하이라이트. (범위는 UTF-16 코드유닛; BMP=한/영은 코드포인트와 1:1.)"""
mapped = map_spoken_range(self._omap, start, end)
if mapped is None:
return
lo, hi = mapped
if self._hl_start is None:
self._hl_start = lo
self._apply_highlight(self._hl_start, hi)
def _apply_highlight(self, lo: int, hi: int) -> None:
sel = QTextEdit.ExtraSelection()
fmt = QTextCharFormat()
fmt.setBackground(QColor("#2d6cdf"))
fmt.setForeground(QColor("white"))
cursor = QTextCursor(self.text_edit.document())
cursor.setPosition(lo)
cursor.setPosition(hi, QTextCursor.MoveMode.KeepAnchor)
sel.cursor = cursor
sel.format = fmt
self.text_edit.setExtraSelections([sel])
def _clear_highlight(self) -> None:
self._hl_start = None
self.text_edit.setExtraSelections([])
def _on_export(self) -> None:
if self._export_process is not None:
return # 이미 내보내는 중
export_tool = self._current_engine.export_tool
if export_tool is None:
self._fail(
f"'{self._current_engine.display_name}' 엔진은 m4a 내보내기를 지원하지 않습니다."
)
return
if not export_tool.exists():
self._fail("export 도구가 없습니다. tools/kb-tts-export/ 에서 'make' 실행 필요.")
return
text = preprocess_for_speech(self.text_edit.toPlainText())
if not text:
self.status_label.setText("입력된 텍스트가 없습니다.")
return
# 라이브 재생과 동일한 한국어 안전 패딩 적용 (무음 공백)
text = text + TAIL_PADDING
# 파일 경로 받기
path, _ = QFileDialog.getSaveFileName(
self,
"음성 파일로 저장",
"untitled.m4a",
"Audio (*.m4a)",
)
if not path:
return # 사용자 취소
if not path.lower().endswith(".m4a"):
path += ".m4a"
rate, pitch, volume = self._slider_values()
args = [
"--out",
path,
"--rate",
f"{rate:.2f}",
"--pitch",
f"{pitch:.2f}",
"--volume",
f"{volume:.2f}",
]
voice = self._selected_voice()
if voice:
args += ["--voice", voice]
proc = QProcess(self)
proc.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
proc.finished.connect(
lambda code, status, p=path: self._on_export_finished(p, code, status)
)
proc.errorOccurred.connect(self._on_error)
proc.readyReadStandardOutput.connect(self._on_export_output)
self._export_buf = ""
self._export_canceled = False
self._export_process = proc
proc.start(str(export_tool), args)
if not proc.waitForStarted(3000):
self._fail("kb-tts-export 시작 실패")
self._export_process = None
return
proc.write(text.encode("utf-8"))
proc.closeWriteChannel()
# 변환이 끝날 때까지 텍스트 입력을 막고 진행률을 모달로 표시
self.export_action.setEnabled(False)
self.engine_combo.setEnabled(False)
self.voice_combo.setEnabled(False)
self.text_edit.setReadOnly(True)
# 총 청크 수를 아직 모르므로 0~0(바쁨 표시)로 시작 → "Encoding N" 수신 시 확정
dlg = QProgressDialog("내보내기 준비 중…", "취소", 0, 0, self)
dlg.setWindowTitle("음성 파일로 저장")
dlg.setWindowModality(Qt.WindowModality.WindowModal)
dlg.setMinimumDuration(0)
dlg.setAutoClose(False)
dlg.setAutoReset(False)
dlg.canceled.connect(self._on_export_cancel)
self._export_dialog = dlg
dlg.show()
self.status_label.setText(f"내보내는 중… → {path}")
def _on_export_output(self) -> None:
"""kb-tts-export의 stderr(병합됨)를 줄 단위로 읽어 진행률 갱신."""
proc = self._export_process
if proc is None:
return
self._export_buf += bytes(proc.readAllStandardOutput()).decode("utf-8", "replace")
*lines, self._export_buf = self._export_buf.split("\n")
for line in lines:
self._parse_export_line(line)
def _parse_export_line(self, line: str) -> None:
dlg = self._export_dialog
if dlg is None:
return
m = re.search(r"Encoding (\d+) chunk", line)
if m:
total = int(m.group(1))
dlg.setRange(0, total)
dlg.setValue(0)
dlg.setLabelText(f"음성 합성 중… 0/{total}")
return
m = re.search(r"\.\.\. (\d+)/(\d+) chunks", line)
if m:
done, total = int(m.group(1)), int(m.group(2))
if total:
dlg.setRange(0, total)
dlg.setValue(done)
pct = round(done * 100 / total)
dlg.setLabelText(f"음성 합성 중… {done}/{total} ({pct}%)")
return
if "Converting WAV" in line:
# 마지막 m4a 인코딩 단계 — 길이를 알 수 없어 바쁨 표시로 전환
dlg.setRange(0, 0)
dlg.setLabelText("오디오 인코딩 중… (m4a)")
def _on_export_cancel(self) -> None:
self._export_canceled = True
proc = self._export_process
if proc is not None:
proc.kill() # finished 콜백에서 미완성 파일 정리
def _on_export_finished(
self, path: str, exit_code: int, exit_status: QProcess.ExitStatus
) -> None:
self._export_process = None
self.export_action.setEnabled(True)
self.engine_combo.setEnabled(True)
self.voice_combo.setEnabled(self._has_voices)
self.text_edit.setReadOnly(False)
# 취소 여부를 먼저 확정한 뒤 다이얼로그를 닫는다. close()가 canceled를
# 재발생시켜 정상 완료를 취소로 오인(→ 파일 삭제)하지 않도록 시그널을
# 끊고 플래그도 로컬에 캡처한다.
canceled = self._export_canceled
self._export_canceled = False
if self._export_dialog is not None:
dlg = self._export_dialog
self._export_dialog = None
dlg.canceled.disconnect(self._on_export_cancel)
dlg.close()
if canceled:
# 사용자가 취소 → 미완성 산출물(.m4a, 임시 .wav) 정리
for p in (path, path + ".tmp.wav"):
try:
os.remove(p)
except OSError:
pass
self.status_label.setText("내보내기 취소됨")
return
if exit_status == QProcess.ExitStatus.CrashExit:
self.status_label.setText("내보내기 중단됨")
elif exit_code != 0:
self.status_label.setText(f"내보내기 실패 (exit {exit_code})")
else:
self.status_label.setText(f"저장됨: {path}")
def _on_stop(self) -> None:
if self._player is not None:
self._player.stop() # unlock()이 즉시 NULL로 내려 매달림 없음
self._cleanup_tmp_text()
self._clear_highlight()
self._apply_state("idle")
self.status_label.setText("정지됨")
def _on_play_finished(self) -> None:
# 자연 완료: 하이라이트는 남겨둠(완독 표시), 다음 재생 시 초기화.
self._cleanup_tmp_text()
self._apply_state("idle")
self.status_label.setText("준비")
def _on_play_failed(self, message: str) -> None:
self._cleanup_tmp_text()
self._clear_highlight()
self._fail(message)
# ---- 일시정지 / 재개 ----------------------------------------------------
def _do_pause(self) -> None:
"""일시정지 버튼. 일시정지하면 이 버튼은 비활성화되고, 재개는 ▶가 맡는다."""
if self._player is not None:
self._player.pause()
self._apply_state("paused")
self.status_label.setText("일시정지됨")
def _do_resume(self) -> None:
"""▶가 paused 상태에서 호출 → 멈춘 위치부터 이어재생."""
if self._player is not None:
self._player.resume()
self._apply_state("playing")
self.status_label.setText("재생 중…")
def _cleanup_tmp_text(self) -> None:
if self._tmp_text_path:
try:
os.unlink(self._tmp_text_path)
except OSError:
pass
self._tmp_text_path = None
def _apply_state(self, state: str) -> None:
"""재생 상태(idle/playing/paused)에 맞춰 버튼·콤보박스 활성화를 설정.
- idle: ▶ on, ‖ off, ■ off (처음부터 재생 가능)
- playing: ▶ off, ‖ on, ■ on
- paused: ▶ on, ‖ off, ■ on (▶는 이어재생, ‖는 비활성화)
"""
self._playback_state = state
playing = state == "playing"
active = state in ("playing", "paused")
self.play_action.setEnabled(not playing)
self.pause_action.setEnabled(playing)
self.stop_action.setEnabled(active)
self.engine_combo.setEnabled(not active)
self.voice_combo.setEnabled(self._has_voices and not active)
def _on_error(self, err: QProcess.ProcessError) -> None:
# 내보내기(QProcess) 에러 경로
if self._export_canceled:
return # 취소로 인한 kill — finished 콜백이 정리/상태표시 담당
self._fail(f"프로세스 에러: {err}")
def _fail(self, message: str) -> None:
self._apply_state("idle")
self.status_label.setText(message)
def _selftest() -> int:
"""GStreamer 연결 자가진단. `AnnoySpeaker --selftest`로 호출.
번들(.app)에서도 gi가 로드되고 macttssink 요소가 만들어지는지 확인 —
"두 개의 glib" 같은 패키징 문제를 조기에 잡는다. 정상 0, 실패 1.
"""
if not GST_AVAILABLE:
print(f"SELFTEST FAIL: GStreamer 사용 불가 — {GST_IMPORT_ERROR}")
return 1
print(f"SELFTEST: GStreamer {Gst.version_string()}")
el = Gst.ElementFactory.make(ENGINES[0].sink_element, "t")
if el is None:
print(f"SELFTEST FAIL: '{ENGINES[0].sink_element}' 요소 생성 실패 (플러그인 경로 확인)")
return 1
print(f"SELFTEST: {ENGINES[0].sink_element} 인스턴스화 OK (rate={el.get_property('rate')})")
# 실제 재생까지 — 요소 생성만으론 "두 glib" 같은 런타임 충돌(무음)을 못 잡는다.
# 짧은 텍스트를 끝까지 합성해 단어 이벤트/완료가 오는지 본다.
app = QApplication([])
player = GstPlayer()
state = {"words": 0, "done": False, "fail": None}
player.wordSpoken.connect(lambda s, e: state.__setitem__("words", state["words"] + 1))
player.finished.connect(lambda: (state.__setitem__("done", True), app.quit()))
player.failed.connect(lambda m: (state.__setitem__("fail", m), app.quit()))
fd, path = tempfile.mkstemp(suffix=".txt", prefix="selftest-")
with os.fdopen(fd, "wb") as f:
f.write("자가진단 문장 하나 둘 셋".encode())
player.play(path, ENGINES[0].sink_element, 0.5, 1.0, 1.0, None)
QTimer.singleShot(12000, app.quit)
app.exec()
try:
os.unlink(path)
except OSError:
pass
if state["fail"]:
print(f"SELFTEST SPEAK FAIL: {state['fail']}")
return 1
if not state["done"] or state["words"] == 0:
print(f"SELFTEST SPEAK FAIL: 재생 미완료 (단어 {state['words']}개, done={state['done']})")
return 1
print(f"SELFTEST SPEAK OK: 단어 {state['words']}개 수신 + 완료 (소리 정상일 가능성 높음)")
return 0
def main() -> int:
if "--selftest" in sys.argv:
return _selftest()
app = QApplication(sys.argv)
app.setApplicationName("AnnoySpeaker")
window = MainWindow()
window.show()
return app.exec()
if __name__ == "__main__":