TurtleBot3 Burger에서 gTTS 출력 노드는 로봇에서 실행하고 원격 PC PyQt GUI로 음성 토픽 발행하기

1. 목표

실습 환경은 아래와 같습니다.

  1. TurtleBot3 Burger
    실제 USB 스피커가 연결된 장비입니다.
    여기에서 gTTSmpg123을 실행합니다.
  2. 원격 PC
    사용자가 직접 조작하는 GUI 프로그램을 실행합니다.
    여기에서는 음성을 직접 출력하지 않고 /audio/command 토픽만 발행합니다.

즉, 원격 PC의 GUI는 명령만 보내고, TurtleBot3 Burger가 실제 음성을 출력합니다.

2. 시스템 구조

전체 구조는 다음과 같습니다.

원격 PC
  └── robot_audio_gui
      └── audio_gui_publisher.py
              |
              | /audio/command 토픽 발행
              v
TurtleBot3 Burger
  └── robot_audio_output
      └── audio_output_node.py
              |
              | gTTS로 MP3 생성
              | mpg123로 MP3 재생
              v
USB 스피커

3. AudioCommand 메시지

먼저 음성 명령 메시지를 사용합니다.

파일 위치는 다음과 같습니다.

~/turtlebot3_ws/src/robot_audio_interfaces/msg/AudioCommand.msg

내용은 다음과 같습니다.

# Audio command type
uint8 TYPE_TTS=0
uint8 TYPE_EFFECT=1
uint8 TYPE_TTS_AND_EFFECT=2
uint8 TYPE_STOP=3

uint8 type

# TTS text
string text

# Effect sound id
# examples:
# start, waypoint, goal, warning, error
string sound_id

# 0.0 ~ 1.0
float32 volume

# repeat count
uint8 repeat

메시지 필드의 의미는 다음과 같습니다.

필드의미
type명령 종류
textgTTS로 출력할 문장
sound_id출력할 효과음 이름
volume볼륨 값, 0.0 ~ 1.0
repeat반복 횟수

type 값은 다음과 같습니다.

type의미
0TTS 음성만 출력
1효과음만 출력
2효과음 출력 후 TTS 출력
3현재 재생 중인 음성 정지

4. robot_audio_interfaces 패키지 생성

TurtleBot3와 원격 PC 양쪽에 같은 메시지 패키지가 모두 설치된 상태입니다.

메시지가 정상 생성되었는지 확인합니다.

ros2 interface show robot_audio_interfaces/msg/AudioCommand

5. 원격 PC용 PyQt GUI 패키지 생성

이 패키지는 원격 PC에서 실행합니다.

원격 PC에도 ROS 2 Humble과 robot_audio_interfaces 패키지가 있어야 합니다.

cd ~/pyqt_ws/src
ros2 pkg create robot_audio_gui --build-type ament_python --dependencies rclpy robot_audio_interfaces

원격 PC의 GUI는 gTTS를 실행하지 않습니다.
원격 PC의 GUI는 mpg123도 실행하지 않습니다.
원격 PC의 GUI는 USB 스피커도 제어하지 않습니다.

오직 /audio/command 토픽만 발행합니다.

6. 원격 PC용 audio_gui_publisher.py

파일 위치는 다음과 같습니다.

touch ~/pyqt_ws/src/robot_audio_gui/robot_audio_gui/audio_gui_publisher.py

전체 코드는 다음과 같습니다.

#!/usr/bin/env python3

import sys

import rclpy
from rclpy.node import Node

from PyQt5.QtCore import QTimer
from PyQt5.QtWidgets import (
    QApplication,
    QWidget,
    QTextEdit,
    QPushButton,
    QLabel,
    QVBoxLayout,
    QHBoxLayout,
    QComboBox,
    QDoubleSpinBox,
    QSpinBox,
    QMessageBox,
)

from robot_audio_interfaces.msg import AudioCommand


class AudioCommandPublisher(Node):
    def __init__(self):
        super().__init__('audio_gui_publisher_node')

        self.declare_parameter('topic_name', '/audio/command')
        self.topic_name = self.get_parameter('topic_name').value

        self.publisher = self.create_publisher(
            AudioCommand,
            self.topic_name,
            10
        )

        self.get_logger().info(f'Audio GUI publisher started')
        self.get_logger().info(f'Publish topic: {self.topic_name}')

    def publish_command(
        self,
        command_type,
        text='',
        sound_id='',
        volume=1.0,
        repeat=1
    ):
        msg = AudioCommand()
        msg.type = command_type
        msg.text = text
        msg.sound_id = sound_id
        msg.volume = float(volume)
        msg.repeat = int(repeat)

        self.publisher.publish(msg)

        self.get_logger().info(
            f'Published AudioCommand '
            f'type={msg.type}, text="{msg.text}", '
            f'sound_id="{msg.sound_id}", volume={msg.volume}, repeat={msg.repeat}'
        )


class AudioGuiWindow(QWidget):
    def __init__(self, ros_node: AudioCommandPublisher):
        super().__init__()

        self.ros_node = ros_node

        self.setWindowTitle('TurtleBot3 Audio Command GUI')
        self.resize(560, 420)

        self.title_label = QLabel('TurtleBot3 원격 음성 출력 GUI')

        self.text_edit = QTextEdit()
        self.text_edit.setPlaceholderText(
            'TurtleBot3 USB 스피커로 출력할 문장을 입력하세요.'
        )

        self.sound_combo = QComboBox()
        self.sound_combo.addItem('효과음 없음', '')
        self.sound_combo.addItem('start', 'start')
        self.sound_combo.addItem('waypoint', 'waypoint')
        self.sound_combo.addItem('goal', 'goal')
        self.sound_combo.addItem('warning', 'warning')
        self.sound_combo.addItem('error', 'error')

        self.volume_spin = QDoubleSpinBox()
        self.volume_spin.setRange(0.0, 1.0)
        self.volume_spin.setSingleStep(0.1)
        self.volume_spin.setValue(1.0)

        self.repeat_spin = QSpinBox()
        self.repeat_spin.setRange(1, 10)
        self.repeat_spin.setValue(1)

        self.tts_button = QPushButton('TTS 출력')
        self.effect_button = QPushButton('효과음 출력')
        self.tts_effect_button = QPushButton('효과음 + TTS 출력')
        self.stop_button = QPushButton('재생 정지')
        self.clear_button = QPushButton('내용 지우기')

        self.status_label = QLabel('대기 중')

        sound_layout = QHBoxLayout()
        sound_layout.addWidget(QLabel('효과음:'))
        sound_layout.addWidget(self.sound_combo)

        option_layout = QHBoxLayout()
        option_layout.addWidget(QLabel('볼륨:'))
        option_layout.addWidget(self.volume_spin)
        option_layout.addWidget(QLabel('반복:'))
        option_layout.addWidget(self.repeat_spin)

        button_layout_1 = QHBoxLayout()
        button_layout_1.addWidget(self.tts_button)
        button_layout_1.addWidget(self.effect_button)

        button_layout_2 = QHBoxLayout()
        button_layout_2.addWidget(self.tts_effect_button)
        button_layout_2.addWidget(self.stop_button)
        button_layout_2.addWidget(self.clear_button)

        main_layout = QVBoxLayout()
        main_layout.addWidget(self.title_label)
        main_layout.addWidget(self.text_edit)
        main_layout.addLayout(sound_layout)
        main_layout.addLayout(option_layout)
        main_layout.addLayout(button_layout_1)
        main_layout.addLayout(button_layout_2)
        main_layout.addWidget(self.status_label)

        self.setLayout(main_layout)

        self.tts_button.clicked.connect(self.publish_tts)
        self.effect_button.clicked.connect(self.publish_effect)
        self.tts_effect_button.clicked.connect(self.publish_tts_and_effect)
        self.stop_button.clicked.connect(self.publish_stop)
        self.clear_button.clicked.connect(self.clear_text)

        self.spin_timer = QTimer(self)
        self.spin_timer.timeout.connect(self.spin_ros_once)
        self.spin_timer.start(20)

    def spin_ros_once(self):
        rclpy.spin_once(self.ros_node, timeout_sec=0.0)

    def get_text(self):
        return self.text_edit.toPlainText().strip()

    def get_sound_id(self):
        return self.sound_combo.currentData()

    def get_volume(self):
        return self.volume_spin.value()

    def get_repeat(self):
        return self.repeat_spin.value()

    def publish_tts(self):
        text = self.get_text()

        if not text:
            QMessageBox.warning(
                self,
                '입력 오류',
                'TTS로 출력할 문장을 입력하세요.'
            )
            return

        self.ros_node.publish_command(
            command_type=AudioCommand.TYPE_TTS,
            text=text,
            sound_id='',
            volume=self.get_volume(),
            repeat=self.get_repeat()
        )

        self.status_label.setText('TTS 명령 발행 완료')

    def publish_effect(self):
        sound_id = self.get_sound_id()

        if not sound_id:
            QMessageBox.warning(
                self,
                '선택 오류',
                '출력할 효과음을 선택하세요.'
            )
            return

        self.ros_node.publish_command(
            command_type=AudioCommand.TYPE_EFFECT,
            text='',
            sound_id=sound_id,
            volume=self.get_volume(),
            repeat=self.get_repeat()
        )

        self.status_label.setText(f'효과음 명령 발행 완료: {sound_id}')

    def publish_tts_and_effect(self):
        text = self.get_text()
        sound_id = self.get_sound_id()

        if not text:
            QMessageBox.warning(
                self,
                '입력 오류',
                'TTS로 출력할 문장을 입력하세요.'
            )
            return

        if not sound_id:
            QMessageBox.warning(
                self,
                '선택 오류',
                '먼저 출력할 효과음을 선택하세요.'
            )
            return

        self.ros_node.publish_command(
            command_type=AudioCommand.TYPE_TTS_AND_EFFECT,
            text=text,
            sound_id=sound_id,
            volume=self.get_volume(),
            repeat=self.get_repeat()
        )

        self.status_label.setText('효과음 + TTS 명령 발행 완료')

    def publish_stop(self):
        self.ros_node.publish_command(
            command_type=AudioCommand.TYPE_STOP,
            text='',
            sound_id='',
            volume=1.0,
            repeat=1
        )

        self.status_label.setText('재생 정지 명령 발행 완료')

    def clear_text(self):
        self.text_edit.clear()
        self.status_label.setText('내용 삭제 완료')

    def closeEvent(self, event):
        self.spin_timer.stop()
        event.accept()


def main(args=None):
    rclpy.init(args=args)

    ros_node = AudioCommandPublisher()

    app = QApplication(sys.argv)
    window = AudioGuiWindow(ros_node)
    window.show()

    exit_code = app.exec_()

    ros_node.destroy_node()
    rclpy.shutdown()

    sys.exit(exit_code)


if __name__ == '__main__':
    main()

7. 원격 PC GUI 코드 설명

원격 PC GUI에서 중요한 부분은 다음입니다.

self.publisher = self.create_publisher(
    AudioCommand,
    self.topic_name,
    10
)

이 Publisher가 /audio/command 토픽을 발행합니다.

TTS 버튼을 누르면 다음 메시지를 발행합니다.

msg.type = AudioCommand.TYPE_TTS
msg.text = text
msg.sound_id = ''
msg.volume = volume
msg.repeat = repeat

효과음 버튼을 누르면 다음 메시지를 발행합니다.

msg.type = AudioCommand.TYPE_EFFECT
msg.text = ''
msg.sound_id = sound_id
msg.volume = volume
msg.repeat = repeat

효과음과 TTS를 함께 출력하려면 다음 메시지를 발행합니다.

msg.type = AudioCommand.TYPE_TTS_AND_EFFECT
msg.text = text
msg.sound_id = sound_id
msg.volume = volume
msg.repeat = repeat

정지 버튼을 누르면 다음 메시지를 발행합니다.

msg.type = AudioCommand.TYPE_STOP
msg.text = ''
msg.sound_id = ''
msg.volume = 1.0
msg.repeat = 1

즉, 원격 PC GUI는 실제 음성을 만들지 않습니다.
명령 메시지만 만들고 발행합니다.

8. robot_audio_gui/package.xml

<?xml version="1.0"?>
<package format="3">
  <name>robot_audio_gui</name>
  <version>0.0.1</version>
  <description>PyQt GUI publisher for robot audio command topic</description>

  <maintainer email="user@example.com">user</maintainer>
  <license>Apache-2.0</license>

  <exec_depend>rclpy</exec_depend>
  <exec_depend>robot_audio_interfaces</exec_depend>
  <exec_depend>python3-pyqt5</exec_depend>

  <export>
    <build_type>ament_python</build_type>
  </export>
</package>

9. robot_audio_gui/setup.py

from setuptools import setup

package_name = 'robot_audio_gui'

setup(
    name=package_name,
    version='0.0.1',
    packages=[package_name],
    data_files=[
        (
            'share/ament_index/resource_index/packages',
            ['resource/' + package_name]
        ),
        (
            'share/' + package_name,
            ['package.xml']
        ),
    ],
    install_requires=['setuptools'],
    zip_safe=True,
    maintainer='user',
    maintainer_email='user@example.com',
    description='PyQt GUI publisher for robot audio command topic',
    license='Apache-2.0',
    entry_points={
        'console_scripts': [
            'audio_gui_publisher = robot_audio_gui.audio_gui_publisher:main',
        ],
    },
)

10. 원격 PC에서 GUI 패키지 빌드

원격 PC에서 빌드합니다.

cd ~/pyqt_ws
colcon build --packages-select robot_audio_gui
source install/setup.bash

11. 실행

실행 순서는 중요합니다.

먼저 TurtleBot3 Burger에서 음성 출력 노드를 실행합니다.

cd ~/turtlebot3_ws
source install/setup.bash
ros2 run robot_audio_output audio_output_node

그 다음 원격 PC에서 GUI를 실행합니다.

cd ~/turtlebot3_ws
source install/setup.bash
source ~/turtlebot3_ws/install/setup.bash
ros2 run robot_audio_gui audio_gui_publisher

GUI 창이 뜨면 문장을 입력하고 TTS 출력 버튼을 누릅니다.

정상이라면 TurtleBot3 Burger에 연결된 USB 스피커에서 음성이 출력됩니다.

Leave a Comment