気の向くままに辿るIT/ICT/IoT
IoT・電子工作

PyQt5/Qt Designerで自作スマートスピーカー用操作パネルを作成

ホーム前へ次へ
Raspberry Piって?

PyQt5/Qt Designerで自作スマートスピーカー用操作パネルを作成

PyQt5/Qt Designerで自作スマートスピーカー用操作パネルを作成

2019/11/07

 以前、作って運用しつつもブラッシュアップ中のRaspberry Pi 3 Model B+とJuliusOpen JTalkベースの自作スマートスピーカーがあります。

 主な機能は、

 尚、ラズパイ用ACアダプタを挿したスイッチ付きコンセントでのON/OFFとは別にラズパイ用boot/reboot/shutdown物理ボタン付き。

 音声認識にJuliusを使った自作スマートスピーカーに伝言とメモの機能を実装するにあたり、マイクとスピーカーを専有してしまうOSSやALSAからPulseAudioに移行しました。

 ちなみに便利なのでラズパイだけでなく、PC/Debianにも自作スマートスピーカー機能を搭載しています。

 自ずとモニタ付きとなるPC版スマートスピーカーには、PC及びラズパイ双方のスマートスピーカー機能のデスクトップアプリとしてPyQt5/Qt Designerによる操作パネルも作成しました。

PyQt5/Qt Designerで操作パネルを作成

 今回は、ラズパイにはモニタを搭載していないものの、スマートスピーカーを載せたノートPC(OS:Debian)用、また、このノートからラズパイのスマートスピーカー機能をマウスで遠隔操作すべく、GUIツールキットであるPyQt5/Qt Designerでスマートスピーカー用の操作パネルを作ってみることにしました。

 前回、Gtk+ 3で作ってみたのですが、PyGtkの情報はそこそこあるもデザイナーソフトGladeの情報が少なく、tabウィジェットの作り方に詰まり、他を探してみた次第です。

 が、Qtの方は、Qt Designerの情報は結構ある上、直感的にレイアウトでき、親しみやすさがある一方、PyQt5の(PyQt4/PyQt3含めても)情報が少なく、一長一短...。

 ただ、Designerの情報があれば、PyQt5の情報が少なかったとしてもPython自体の文法で、なんとかなるだろうということでPyGtk/GladeからPyQt5/Qt Designer(Qt5Designer)に乗り換えることにしました。

 ちなみに自身は、Pythonを一通り身につけているわけではないものの、より身近なPerl版は、更新が活発でなかったり、情報が少なかったり、Rubyは門外漢、コンパイルが必要なC/C++よりはインタプリタ...という消去法の結果ながら、機械学習、ディープラーニング、AIとも相性が良さそうという理由もあってPythonバインディングを選択しました。

 ありもののGUIを見ていた限り、ユーザー視点からはGtkの方が好みでQtはちょっと...と思っていたのですが、作ってみるとそんな差は全く感じないルック&フィールに、なんで出来合いのものは、差を感じるのか不思議...。

 今尚活発に開発が進んでいるという点では、ある程度、限られるものの、そもそもGTKやQtだけでなく、GUIツールキットは、クロスプラットフォーム対応のものだけでも結構あるようです。

 尚、PyQt5は、商用利用の場合は、有料、個人利用では、無償で利用が認められているようです。

環境

$ sudo apt update
$ sudo apt -y upgrade
$ sudo apt -y install qttools5-dev-tools qt5-default pyqt5-dev-tools
...
$

 自身はOSにDebian( GNU/Linux)を使っており、pip/pip3やapt/apt-getどちらもいけるようですが、前者でつまづいたので後者で環境を用意することにしました。

 尚、パッケージ名は、直感的とは言い難く、若干試行錯誤した結果、パッケージ[qttools5-dev-tools]があれば、PyQt5自体のコードは書け、これにパッケージ[qt5-default]を追加インストールすると[designer](Qt5Designer用コマンド)も、更に[pyqt5-dev-tools]をインストールすると[pyuic5](Qt Designerで生成される.uiファイルをpythonコードに変換するコマンド)も使えるようになりました。

$ designer &
Qt Designer/Qt Linguist/Qt Assistant

 QtDesigner/Qt5Designerを起動するには、[designer]コマンドを実行します。(Debianではインストールした時点でPATHが通ってました。)

 気づけば、デスクトップのメニューには、Qt Designerの他、Qt Linguist、Qt Assistantなるものも登録されていたので、併せて起動してスクリーンショットを撮ってみました。

 [designer]コマンドの実行でQt Linguist、Qt Assistantが一緒に起動するわけではないのであしからず。

$ pyuic5 test.ui -o test.py
$

 QtDesigner/Qt5Designerで作成したtest.uiファイルを[pyuic5]コマンドでtest.pyに変換するには、こんな感じで実行します。

 この方法だと.pyファイルに反映させたい場合、Qt Designerで編集・変更する度に変換作業を要する[pyuic5]での変換が必要になります。

$ cat test1.py
#!/usr/bin/python3
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt5.QtWidgets import QApplication
 
class ExampleApp(QtWidgets.QMainWindow):
  def __init__(self, parent=None):
    super(ExampleApp, self).__init__(parent)
    self.ui = uic.loadUi("/path/to/test.ui", self)
 
def main():
  app = QApplication(sys.argv)
  form = ExampleApp()
  form.show()
  app.exec_()
 
if __name__ == '__main__':
  main()
$

 一方、PyQt/PyQt5では、[pyuic5]で変換する方法とは別に[uic]モジュールをimportすることで.uiファイルを読み込む方法も用意されています。

 .uiファイルを読み込む方法なら、Qt Designerで保存後にスクリプトを起動させれば、即反映されますし、仮にスクリプトが起動中なら一度終了させ、起動し直せば反映されるのでアジャイル的な作り方をする場合、便利です。

 試作中の自身は、主にこの[uic]モジュールをimportする方法をとっています。

試作

PyQt5/Qt5Designerで作った自作スマートスピーカー用操作パネル

 スマートスピーカーの性質上、時代に逆行している感がなくもないですが、スマートスピーカー機能をデスクトップアプリとしても使うことができると便利なシーンもあるかなと思っての試作です。

 今回は、試作的にコンボボックスを選択することでYouTubeの音楽系プレイリストやICECASTの音楽系のストリーム、音楽タブとしながらも入れてしまったRadikoの各種ラジオ局を再生、プレイリスト再生のYouTube用にスキップボタン、全ての停止操作を網羅した停止ボタンを配置してみました。

 画像では隠れてしまいましたが、これは、コンボボックス(プルダウンボタンの付いた表示ボックス)を開いたところです。

 また、画像のリストは途中で切れてますが、実際にはもっと選択肢があり、全て動作確認済みです。

 何れにせよ、あとでタブを追加して音楽とRadikoは分けようと思います。

 当初、comboboxの値に応じて再生ボタンで...と思ったのですが、どうもできないっぽかったので選択で再生にしました。

 これらのレイアウトは、Qt Designer(designerコマンド)で行ないました。

 尚、自身の場合、ラズパイとノートPC共にスマートスピーカー機能を入れているので、コンボボックスと各ボタンをそれぞれ用意しました。

$ chmod +x /path/to/sp_qt_ctrl_panel.py
$ cat /path/to/sp_qt_ctrl_panel.py
#!/usr/bin/python3
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt5.QtWidgets import QApplication
import sys
import subprocess
 
class ExampleApp(QtWidgets.QMainWindow):
  def __init__(self, parent=None):
    super(ExampleApp, self).__init__(parent)
    self.ui = uic.loadUi("/path/to/tab_test.ui", self)
 
    channel = ["","JPOP","POPS","JAZZ","CLASSIC","BLUES","JWave","InterFM897","TokyoFM","bayfm78","NACK5","FMヨコハマ","TBSラジオ","ニッポン放送","ラジオ日本","文化放送","ラジオNIKKEI第1","ラジオNIKKEI第2","放送大学","東京NHK第1","東京NHKFM"]
    self.ui.local_channel_combo.addItems(channel)
    self.ui.local_channel_combo.activated[str].connect(self.ui.onLocalActivated)
    self.ui.local_youtube_skip_btn.clicked.connect(self.ui.btnLocalYouTubeSkip)
    self.ui.local_stop_btn.clicked.connect(self.ui.btnLocalStop)
 
    self.ui.rpi_channel_combo.addItems(channel)
    self.ui.rpi_channel_combo.activated[str].connect(self.ui.onRpiActivated)
    self.ui.rpi_youtube_skip_btn.clicked.connect(self.ui.btnRpiYouTubeSkip)
    self.ui.rpi_stop_btn.clicked.connect(self.ui.btnRpiStop)
 
    self.ui.show()
 
  def onLocalActivated(self):
    if self.ui.local_channel_combo.currentText() == "JPOP":
      subprocess.call("/path/to/youtube_jpop_playlist.sh &", shell=True)
    elif self.ui.local_channel_combo.currentText() == "POPS":
      subprocess.call("/path/to/youtube_pops_playlist.sh &", shell=True)
    elif self.ui.local_channel_combo.currentText() == "JAZZ":
      subprocess.call("/path/to/icecastjazz.sh &", shell=True)
    elif self.ui.local_channel_combo.currentText() == "CLASSIC":
      subprocess.call("/path/to/icecastclassical.sh &", shell=True)
    elif self.ui.local_channel_combo.currentText() == "BLUES":
      subprocess.call("/path/to/icecastblues.sh &", shell=True)
    elif self.ui.local_channel_combo.currentText() == "JWave":
      subprocess.call("/path/to/radiko.sh -p FMJ &", shell=True)
    elif self.ui.local_channel_combo.currentText() == "InterFM897":
      subprocess.call("/path/to/radiko.sh -p INT &", shell=True)
    elif self.ui.local_channel_combo.currentText() == "TokyoFM":
      subprocess.call("/path/to/radiko.sh -p FMT &", shell=True)
    elif self.ui.local_channel_combo.currentText() == "bayfm78":
      subprocess.call("/path/to/radiko.sh -p BAYFM78 &", shell=True)
    elif self.ui.local_channel_combo.currentText() == "NACK5":
      subprocess.call("/path/to/radiko.sh -p NACK5 &", shell=True)
    elif self.ui.local_channel_combo.currentText() == "FMヨコハマ":
      subprocess.call("/path/to/radiko.sh -p YFM &", shell=True)
    elif self.ui.local_channel_combo.currentText() == "TBSラジオ":
      subprocess.call("/path/to/radiko.sh -p TBS &", shell=True)
    elif self.ui.local_channel_combo.currentText() == "ニッポン放送":
      subprocess.call("/path/to/radiko.sh -p LFR &", shell=True)
    elif self.ui.local_channel_combo.currentText() == "ラジオ日本":
      subprocess.call("/path/to/radiko.sh -p JORF &", shell=True)
    elif self.ui.local_channel_combo.currentText() == "文化放送":
      subprocess.call("/path/to/radiko.sh -p QRR &", shell=True)
    elif self.ui.local_channel_combo.currentText() == "ラジオNIKKEI第1":
      subprocess.call("/path/to/radiko.sh -p RN1 &", shell=True)
    elif self.ui.local_channel_combo.currentText() == "ラジオNIKKEI第2":
      subprocess.call("/path/to/radiko.sh -p RN2 &", shell=True)
    elif self.ui.local_channel_combo.currentText() == "放送大学":
      subprocess.call("/path/to/radiko.sh -p HOUSOU-DAIGAKU &", shell=True)
    elif self.ui.local_channel_combo.currentText() == "東京NHK第1":
      subprocess.call("/path/to/radiko.sh -p JOAK &", shell=True)
    elif self.ui.local_channel_combo.currentText() == "東京NHKFM":
      subprocess.call("/path/to/radiko.sh -p JOAK-FM &", shell=True)
    else:
      pass
 
  def btnLocalYouTubeSkip(self):
    subprocess.call("/path/to/skip_playlist.sh &", shell=True)
 
  def btnLocalStop(self):
    print("\"Stop Music\" button was clicked")
    subprocess.call("/path/to/stop_radio.sh &", shell=True)
 
  def onRpiActivated(self):
    if self.ui.rpi_channel_combo.currentText() == "JPOP":
      subprocess.call("/path/to/youtube_jpop_playlist_raspi.sh &", shell=True)
    elif self.ui.rpi_channel_combo.currentText() == "POPS":
      subprocess.call("/path/to/youtube_pops_playlist_raspi.sh &", shell=True)
    else:
      pass
 
  def btnRpiYouTubeSkip(self):
    subprocess.call("/path/to/skip_playlist_raspi.sh &", shell=True)
 
  def btnRpiStop(self):
    subprocess.call("/path/to/stop_radio_raspi.sh &", shell=True)
 
def main():
  app = QApplication(sys.argv)
  form = ExampleApp()
  form.show()
  app.exec_()
 
if __name__ == '__main__':
  main()
$./path/to/sp_qt_ctrl_panel.py
$

 pythonスクリプトはこんな感じです。

 一応、動作確認も。

 GUIオブジェクトに対するコーディングに必要なものはimportしてあり、uicのimportと[self.ui =]行でQt Designerで作成した.uiファイルを指定してあるので、このpythonスクリプトからは、.uiファイル内のオブジェクトを、self.ui.objectName(objectNameはオブジェクトのプロパティの1つ)で参照できます。

 PythonでGUIを調べるとTkinter/tkinterの情報が当たり前のようにでてきますが、PyQt5では、内部でこれを使っているのか否かはわからないものの、QtCore/QtGui/QtWidgets、QtWidgetsからQApplicationをimportすることで[uic]のインポートと.uiファイルを指定した、もしくは、[pyuic5]コマンドで変換したスクリプトでGUIを扱うことができるようになっています。

$ cat /path/to/julius/julius-kits/dictation-kit-v4.4/mysmartspeaker.list | awk 'BEGIN{ printf("%s", "[" ) } NR>=150 && NR<=175 { printf( "\x22%s\x22,", $1) } END { print "]" }'
["JWAVE","InterFM897","TokyoFM","bayfm78","NACK5","FMヨコハマ","TBSラジオ","ニッポン放送","ラジオ日本","文化放送","ラジオNIKKEI第1","ラジオNIKKEI第1","ラジオNIKKEI第2","ラジオNIKKEI第2","放送大学","東京NHK第1","東京NHK第2","東京NHKFM","調布FM","JAZZ","CLASSIC","BLUES","POPS","洋楽","JPOP","邦楽",]
$

 ちなみにスクリプト中のリストは少ないながら面倒だったので、スマートスピーカーで音声認識させるべく、Juliusで登録してある自前の辞書ファイル(utf-8版)を使ってcatとawkによる、こんな感じのワンライナーで手間を省き、微調整しました。

 日々、つくづく思う(感謝しきりな)ことですが、あらゆる開発環境が揃っていて自由で、何でもやろうと思うことをすぐにできて、セキュアで、異様に動作が遅くなったり、フリーズして強制シャットダウンや再起動せざるを得ない状況に直面することもなく...、UNIX/*BSD/Linuxは、しみじみ良いですね。

 尚、当該スクリプトをデスクトップアイコンから起動したり、メニューに登録する方法、ラズパイのリモート操作などについては、前回のPyGTK/Glade版操作パネルのページにあります。

[2019/11/09]
Radikoと分割した新たな自作スマートスピーカー用操作パネルの音楽タブ

 Radikoタブを追加、音楽タブと分け、ニュースタブ、天気タブも追加、実装しました。

 音楽タブ、Radikoタブ、ニュースタブ、天気タブは、コンボボックスとボタンから成るほぼ同じ構成としました。

 また、音楽タブにおいてスキップボタンが不要なメンバの場合には、当該ボタンを、コンボボックスで空("")以外を選択した場合、停止ボタン押下するまでは、当該コンボボックスを、setEnabled(True)/setEnabled(False)を使って無効にするようにしてみました。

 音楽タブ、Radikoタブ、ニュースタブについては、現在、スクリプトと共に停止ボタン(Push Button)も共有していて、先のコンボの有効・無効化に伴い、これらのコンボも連動するようにしてあります。

 天気タブについては、読み上げだけで計測はしていませんが、数秒〜せいぜい20秒以内と思われ、コンボボックスのみで、そもそもその術を用意していないのですが、停止ボタンは要らないかなとも思いつつも先のコンボの有効・無効化に伴い、これを選択した場合も同様にしてあります。

$ chmod +x /path/to/sp_qt_ctrl_panel_alt.py
$ cat /path/to/sp_qt_ctrl_panel_alt.py
#!/usr/bin/python3
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt5.QtWidgets import QApplication
import subprocess
 
class ExampleApp(QtWidgets.QMainWindow):
  def __init__(self, parent=None):
    super(ExampleApp, self).__init__(parent)
    self.ui = uic.loadUi("/path/to/qt5designer/tab_test.ui", self)
 
    channel = ["","JPOP","POPS","JAZZ","CLASSIC","BLUES"]
    self.ui.local_channel_combo.addItems(channel)
    self.ui.local_channel_combo.activated[str].connect(self.ui.onLocalMusicActivated)
    self.ui.local_youtube_skip_btn.clicked.connect(self.ui.btnLocalYouTubeSkip)
    self.ui.local_stop_btn.clicked.connect(self.ui.btnLocalStop)
 
    self.ui.rpi_channel_combo.addItems(channel)
    self.ui.rpi_channel_combo.activated[str].connect(self.ui.onRpiMusicActivated)
    self.ui.rpi_youtube_skip_btn.clicked.connect(self.ui.btnRpiYouTubeSkip)
    self.ui.rpi_stop_btn.clicked.connect(self.ui.btnRpiStop)
 
    radiko = ["","JWave","InterFM897","TokyoFM","bayfm78","NACK5","FMヨコハマ","TBSラジオ","ニッポン放送","ラジオ日本","文化放送","ラジオNIKKEI第1","ラジオNIKKEI第2","放送大学","東京NHK第1","東京NHKFM"]
    self.ui.local_radiko_combo.addItems(radiko)
    self.ui.local_radiko_combo.activated[str].connect(self.ui.onLocalRadikoActivated)
    self.ui.local_stop_btn_2.clicked.connect(self.ui.btnLocalStop)
 
    self.ui.rpi_radiko_combo.addItems(radiko)
    self.ui.rpi_radiko_combo.activated[str].connect(self.ui.onRpiRadikoActivated)
    self.ui.rpi_stop_btn_2.clicked.connect(self.ui.btnRpiStop)
 
    news = ["","Yahoo! Topics","BBC World News","ABC News"]
    self.ui.local_news_combo.addItems(news)
    self.ui.local_news_combo.activated[str].connect(self.ui.onLocalNewsActivated)
    self.ui.local_stop_btn_3.clicked.connect(self.ui.btnLocalStop)
 
    self.ui.rpi_news_combo.addItems(news)
    self.ui.rpi_news_combo.activated[str].connect(self.ui.onRpiNewsActivated)
    self.ui.rpi_stop_btn_3.clicked.connect(self.ui.btnRpiStop)
 
    weather = ["","今日の天気","今日の気温","明日の天気","明日の気温","明後日の天気"]
    self.ui.local_weather_combo.addItems(weather)
    self.ui.local_weather_combo.activated[str].connect(self.ui.onLocalWeatherActivated)
    self.ui.local_stop_btn_4.clicked.connect(self.ui.btnLocalStop)
 
    self.ui.rpi_weather_combo.addItems(weather)
    self.ui.rpi_weather_combo.activated[str].connect(self.ui.onRpiWeatherActivated)
    self.ui.rpi_stop_btn_4.clicked.connect(self.ui.btnRpiStop)
    self.ui.main_menu_btn.clicked.connect(self.ui.btnMainMenu)
    self.ui.aircon_btn.clicked.connect(self.ui.btnAircon)
    self.ui.tv_btn.clicked.connect(self.ui.btnTV)
 
 
    self.ui.show()
 
  def onLocalMusicActivated(self):
    print("\"local_combo\" was selected")
 
    if not (self.ui.local_channel_combo.currentText() == "JPOP" or self.ui.local_channel_combo.currentText() == "POPS"):
      self.ui.local_youtube_skip_btn.setEnabled(False)
    else:
      self.ui.local_youtube_skip_btn.setEnabled(True)
 
    if self.ui.local_channel_combo.currentText() == "":
      pass
    elif self.ui.local_channel_combo.currentText() == "JPOP":
      subprocess.call("/path/to/youtube_jpop_playlist.sh &", shell=True)
    elif self.ui.local_channel_combo.currentText() == "POPS":
      subprocess.call("/path/to/youtube_pops_playlist.sh &", shell=True)
    elif self.ui.local_channel_combo.currentText() == "JAZZ":
      subprocess.call("/path/to/icecastjazz.sh &", shell=True)
    elif self.ui.local_channel_combo.currentText() == "CLASSIC":
      subprocess.call("/path/to/icecastclassical.sh &", shell=True)
    elif self.ui.local_channel_combo.currentText() == "BLUES":
      subprocess.call("/path/to/icecastblues.sh &", shell=True)
    else:
      pass
 
    if not self.ui.local_channel_combo.currentText() == "":
      self.ui.local_channel_combo.setEnabled(False)
      self.ui.local_radiko_combo.setEnabled(False)
      self.ui.local_news_combo.setEnabled(False)
    else:
      self.ui.local_channel_combo.setEnabled(True)
      self.ui.local_radiko_combo.setEnabled(True)
      self.ui.local_news_combo.setEnabled(True)
 
  def btnLocalYouTubeSkip(self):
    print("\"スキップ\" button was clicked")
    subprocess.call("/path/to/skip_playlist.sh &", shell=True)
 
  def btnLocalStop(self):
    print("\"Stop Music\" button was clicked")
    self.ui.local_channel_combo.setEnabled(True)
    self.ui.local_radiko_combo.setEnabled(True)
    self.ui.local_news_combo.setEnabled(True)
    subprocess.call("/path/to/stop_radio.sh &", shell=True)
 
  def onRpiMusicActivated(self):
    print("\"raspi_combo\" was selected")
 
    if not (self.ui.rpi_channel_combo.currentText() == "JPOP" or self.ui.rpi_channel_combo.currentText() == "POPS"):
      self.ui.rpi_youtube_skip_btn.setEnabled(False)
    else:
      self.ui.rpi_youtube_skip_btn.setEnabled(True)
 
    if self.ui.rpi_channel_combo.currentText() == "":
      pass
    elif self.ui.rpi_channel_combo.currentText() == "JPOP":
      self.ui.rpi_youtube_skip_btn.setEnabled(True)
      subprocess.call("/path/to/youtube_jpop_playlist_raspi.sh &", shell=True)
    elif self.ui.rpi_channel_combo.currentText() == "POPS":
      self.ui.rpi_youtube_skip_btn.setEnabled(True)
      subprocess.call("/path/to/youtube_pops_playlist_raspi.sh &", shell=True)
    elif self.ui.rpi_channel_combo.currentText() == "JAZZ":
      subprocess.call("/path/to/icecastjazz_raspi.sh &", shell=True)
    elif self.ui.rpi_channel_combo.currentText() == "CLASSIC":
      subprocess.call("/path/to/icecastclassical_raspi.sh &", shell=True)
    elif self.ui.rpi_channel_combo.currentText() == "BLUES":
      subprocess.call("/path/to/icecastblues_raspi.sh &", shell=True)
    else:
      pass
 
    if not self.ui.rpi_channel_combo.currentText() == "":
      self.ui.rpi_channel_combo.setEnabled(False)
      self.ui.rpi_radiko_combo.setEnabled(False)
      self.ui.rpi_news_combo.setEnabled(False)
    else:
      self.ui.rpi_channel_combo.setEnabled(True)
      self.ui.rpi_radiko_combo.setEnabled(True)
      self.ui.rpi_news_combo.setEnabled(True)
 
  def btnRpiYouTubeSkip(self):
    print("\"Raspi スキップ\" button was clicked")
    subprocess.call("/path/to/skip_playlist_raspi.sh &", shell=True)
 
  def btnRpiStop(self):
    print("\"Raspi Stop Music\" button was clicked")
    self.ui.rpi_channel_combo.setEnabled(True)
    self.ui.rpi_radiko_combo.setEnabled(True)
    self.ui.rpi_news_combo.setEnabled(True)
    subprocess.call("/path/to/stop_radio_raspi.sh &", shell=True)
 
  def onLocalRadikoActivated(self):
    print("\"local_combo\" was selected")
 
    if self.ui.local_radiko_combo.currentText() == "":
      pass
    elif self.ui.local_radiko_combo.currentText() == "JWave":
      subprocess.call("/path/to/radiko.sh -p FMJ &", shell=True)
    elif self.ui.local_radiko_combo.currentText() == "InterFM897":
      subprocess.call("/path/to/radiko.sh -p INT &", shell=True)
    elif self.ui.local_radiko_combo.currentText() == "TokyoFM":
      subprocess.call("/path/to/radiko.sh -p FMT &", shell=True)
    elif self.ui.local_radiko_combo.currentText() == "bayfm78":
      subprocess.call("/path/to/radiko.sh -p BAYFM78 &", shell=True)
    elif self.ui.local_radiko_combo.currentText() == "NACK5":
      subprocess.call("/path/to/radiko.sh -p NACK5 &", shell=True)
    elif self.ui.local_radiko_combo.currentText() == "FMヨコハマ":
      subprocess.call("/path/to/radiko.sh -p YFM &", shell=True)
    elif self.ui.local_radiko_combo.currentText() == "TBSラジオ":
      subprocess.call("/path/to/radiko.sh -p TBS &", shell=True)
    elif self.ui.local_radiko_combo.currentText() == "ニッポン放送":
      subprocess.call("/path/to/radiko.sh -p LFR &", shell=True)
    elif self.ui.local_radiko_combo.currentText() == "ラジオ日本":
      subprocess.call("/path/to/radiko.sh -p JORF &", shell=True)
    elif self.ui.local_radiko_combo.currentText() == "文化放送":
      subprocess.call("/path/to/radiko.sh -p QRR &", shell=True)
    elif self.ui.local_radiko_combo.currentText() == "ラジオNIKKEI第1":
      subprocess.call("/path/to/radiko.sh -p RN1 &", shell=True)
    elif self.ui.local_radiko_combo.currentText() == "ラジオNIKKEI第2":
      subprocess.call("/path/to/radiko.sh -p RN2 &", shell=True)
    elif self.ui.local_radiko_combo.currentText() == "放送大学":
      subprocess.call("/path/to/radiko.sh -p HOUSOU-DAIGAKU &", shell=True)
    elif self.ui.local_radiko_combo.currentText() == "東京NHK第1":
      subprocess.call("/path/to/radiko.sh -p JOAK &", shell=True)
    elif self.ui.local_radiko_combo.currentText() == "東京NHKFM":
      subprocess.call("/path/to/radiko.sh -p JOAK-FM &", shell=True)
    else:
      pass
 
    if not self.ui.local_radiko_combo.currentText() == "":
      self.ui.local_channel_combo.setEnabled(False)
      self.ui.local_radiko_combo.setEnabled(False)
      self.ui.local_news_combo.setEnabled(False)
    else:
      self.ui.local_channel_combo.setEnabled(True)
      self.ui.local_radiko_combo.setEnabled(True)
      self.ui.local_news_combo.setEnabled(True)
 
  def onRpiRadikoActivated(self):
    print("\"raspi_radiko_combo\" was selected")
 
    if self.ui.rpi_radiko_combo.currentText() == "":
      pass
    elif self.ui.rpi_radiko_combo.currentText() == "JWave":
      subprocess.call("/path/to/radiko_raspi.sh FMJ &", shell=True)
    elif self.ui.rpi_radiko_combo.currentText() == "InterFM897":
      subprocess.call("/path/to/radiko_raspi.sh INT &", shell=True)
    elif self.ui.rpi_radiko_combo.currentText() == "TokyoFM":
      subprocess.call("/path/to/radiko_raspi.sh FMT &", shell=True)
    elif self.ui.rpi_radiko_combo.currentText() == "bayfm78":
      subprocess.call("/path/to/radiko_raspi.sh BAYFM78 &", shell=True)
    elif self.ui.rpi_radiko_combo.currentText() == "NACK5":
      subprocess.call("/path/to/radiko_raspi.sh NACK5 &", shell=True)
    elif self.ui.rpi_radiko_combo.currentText() == "FMヨコハマ":
      subprocess.call("/path/to/radiko_raspi.sh YFM &", shell=True)
    elif self.ui.rpi_radiko_combo.currentText() == "TBSラジオ":
      subprocess.call("/path/to/radiko_raspi.sh TBS &", shell=True)
    elif self.ui.rpi_radiko_combo.currentText() == "ニッポン放送":
      subprocess.call("/path/to/radiko_raspi.sh LFR &", shell=True)
    elif self.ui.rpi_radiko_combo.currentText() == "ラジオ日本":
      subprocess.call("/path/to/radiko_raspi.sh JORF &", shell=True)
    elif self.ui.rpi_radiko_combo.currentText() == "文化放送":
      subprocess.call("/path/to/radiko_raspi.sh QRR &", shell=True)
    elif self.ui.rpi_radiko_combo.currentText() == "ラジオNIKKEI第1":
      subprocess.call("/path/to/radiko_raspi.sh RN1 &", shell=True)
    elif self.ui.rpi_radiko_combo.currentText() == "ラジオNIKKEI第2":
      subprocess.call("/path/to/radiko_raspi.sh RN2 &", shell=True)
    elif self.ui.rpi_radiko_combo.currentText() == "放送大学":
      subprocess.call("/path/to/radiko_raspi.sh HOUSOU-DAIGAKU &", shell=True)
    elif self.ui.rpi_radiko_combo.currentText() == "東京NHK第1":
      subprocess.call("/path/to/radiko_raspi.sh JOAK &", shell=True)
    elif self.ui.rpi_radiko_combo.currentText() == "東京NHKFM":
      subprocess.call("/path/to/radiko_raspi.sh JOAK-FM &", shell=True)
    else:
      pass
 
    if not self.ui.rpi_radiko_combo.currentText() == "":
      self.ui.rpi_channel_combo.setEnabled(False)
      self.ui.rpi_radiko_combo.setEnabled(False)
      self.ui.rpi_news_combo.setEnabled(False)
    else:
      self.ui.rpi_channel_combo.setEnabled(True)
      self.ui.rpi_radiko_combo.setEnabled(True)
      self.ui.rpi_news_combo.setEnabled(True)
 
  def onLocalNewsActivated(self):
    print("\"local_news_combo\" was selected")
 
    if self.ui.local_news_combo.currentText() == "":
      pass
    elif self.ui.local_news_combo.currentText() == "Yahoo! Topics":
      subprocess.call("/path/to/get_yahoo_news.py &", shell=True)
    elif self.ui.local_news_combo.currentText() == "BBC World News":
      subprocess.call("mplayer http://bbcwssc.ic.llnwd.net/stream/bbcwssc_mp1_ws-eieuk &", shell=True)
    elif self.ui.local_news_combo.currentText() == "ABC News":
      subprocess.call("mplayer -playlist http://abc.net.au/res/streaming/audio/aac/news_radio.pls &", shell=True)
 
    if not self.ui.local_news_combo.currentText() == "":
      self.ui.local_channel_combo.setEnabled(False)
      self.ui.local_radiko_combo.setEnabled(False)
      self.ui.local_news_combo.setEnabled(False)
    else:
      self.ui.local_channel_combo.setEnabled(True)
      self.ui.local_radiko_combo.setEnabled(True)
      self.ui.local_news_combo.setEnabled(True)
 
  def onRpiNewsActivated(self):
    print("\"raspi_news_combo\" was selected")
 
    if self.ui.rpi_news_combo.currentText() == "":
      pass
    elif self.ui.rpi_news_combo.currentText() == "Yahoo! Topics":
      subprocess.call("/path/to/get_yahoo_news_raspi.sh &", shell=True)
    elif self.ui.rpi_news_combo.currentText() == "BBC World News":
      subprocess.call("/path/to/bbc_news_radio_raspi.sh &", shell=True)
    elif self.ui.rpi_news_combo.currentText() == "ABC News":
      subprocess.call("/path/to/bbc_news_radio_raspi.sh &", shell=True)
 
    if not self.ui.rpi_news_combo.currentText() == "":
      self.ui.rpi_channel_combo.setEnabled(False)
      self.ui.rpi_radiko_combo.setEnabled(False)
      self.ui.rpi_news_combo.setEnabled(False)
    else:
      self.ui.rpi_channel_combo.setEnabled(True)
      self.ui.rpi_radiko_combo.setEnabled(True)
      self.ui.rpi_news_combo.setEnabled(True)
 
  def onLocalWeatherActivated(self):
    print("\"local_weather_combo\" was selected")
 
    if self.ui.local_weather_combo.currentText() == "":
      pass
    elif self.ui.local_weather_combo.currentText() == "今日の天気":
      subprocess.call("/path/to/today_weather.py &", shell=True)
    elif self.ui.local_weather_combo.currentText() == "今日の気温":
      subprocess.call("/path/to/today_temperature.py &", shell=True)
    elif self.ui.local_weather_combo.currentText() == "明日の天気":
      subprocess.call("/path/to/tomorrow_weather.py &", shell=True)
    elif self.ui.local_weather_combo.currentText() == "明日の気温":
      subprocess.call("/path/to/tomorrow_temperature.py &", shell=True)
    elif self.ui.local_weather_combo.currentText() == "明後日の天気":
      subprocess.call("/path/to/day_after_tomorrow_weather.py &", shell=True)
 
  def onRpiWeatherActivated(self):
    print("\"raspi_weather_combo\" was selected")
 
    if self.ui.rpi_weather_combo.currentText() == "":
      pass
    elif self.ui.rpi_weather_combo.currentText() == "今日の天気":
      subprocess.call("/path/to/get_yahoo_weather_raspi.sh &", shell=True)
    elif self.ui.rpi_weather_combo.currentText() == "今日の気温":
      subprocess.call("/path/to/bbc_weather_radio_raspi.sh &", shell=True)
    elif self.ui.rpi_weather_combo.currentText() == "明日の天気":
      subprocess.call("/path/to/bbc_weather_radio_raspi.sh &", shell=True)
    elif self.ui.rpi_weather_combo.currentText() == "明日の気温":
      subprocess.call("/path/to/bbc_weather_radio_raspi.sh &", shell=True)
    elif self.ui.rpi_weather_combo.currentText() == "明後日の天気":
      subprocess.call("/path/to/bbc_weather_radio_raspi.sh &", shell=True)
 
  def btnMainMenu(self):
    print("\"メインメニュー\" button was clicked")
    subprocess.call("/path/to/home_elec_panel.sh MAIN &", shell=True)
 
  def btnAircon(self):
    print("\"エアコン\" button was clicked")
    subprocess.call("/path/to/home_elec_panel.sh AIRCON1 &", shell=True)
 
  def btnTV(self):
    print("\"テレビ\" button was clicked")
    subprocess.call("/path/to/home_elec_panel.sh TV1 &", shell=True)
 
def main():
  app = QApplication(sys.argv)
  form = ExampleApp()
  form.show()
  app.exec_()
 
if __name__ == '__main__':
  main()
$

 音楽タブは、任意に選んだYouTubeプレイリストとICECASTのストリーム局、手持ちの古いCDをリッピングしてラズパイサーバに上げてジャンル分けしてあるプレイリスト。

 Radikoタブは、NHK第一とNHK FM含む各種放送局。

 ニュースタブは、Yahoo!ピックアップ(主要ニュース?)のOpen JTalkによる読み上げとABC NEWS、BBC WorldNewsのストリーム配信。

 天気タブは、livedoor天気情報をWebスクレイピングで取得、Open JTalkで読み上げすることにしています。

自作スマートスピーカー用GUI操作パネルからブラウザ版家電操作パネルを起動

 家電タブには、ボタン(Push Button)のみを配置しました。

 WiFi搭載マイコンESP8266/WiFi・Bluetooth搭載マイコンESP32は、常時電源ONさせているわけではありません。

 そこで疎通確認の上、ESPモジュールにアップロードした操作パネル用HTMLファイル(mDNSにより例えばトップページはdomain.local)にアクセスさせることに。

 より具体的には、[firefox -private espctrl_panel.local]などとしたshellスクリプトを書き、.uiファイルからこれを呼ぶことでFirefoxブラウザ版スマートホーム操作パネルを表示するようにしてみました。

 スマートスピーカーとは離れますが、同様に天気タブにボタンを追加して天気予報のサイトを表示、また、交通タブでも作って電車の運行状況や車の渋滞情報のサイトを表示するボタンを配置するのもよいかもしれないですね。

 GUI操作でブラウザを表示するなら、SNSやGMail、クラウドなんかに遷移する用のタブやボタンを作ってみるのもありかもしれませんね。

 アラームタブに変更するかもしれないタイマータブには、5分刻みで5〜60分までを選択可能なコンボとタイマー解除ボタンを配置しました。

 ちなみにJulius辞書をあまり膨らませたくないこともあり、音声操作では5〜30分まで指定できるようにしていますが、スクリプト自体は引数1つで分指定、2つで時分指定など汎用的に作ってあるのでGUIパネル上からなら、5分刻みにする必要すらなく、選択肢は、いくらでも追加可能です。

 未実装の内、追加予定の伝言タブや音声メモタブは、録音再生取り消しボタンを配置するかとか、共にアラーム設定内容や伝言メッセージの内容を表示させるか否かは思案中です。

 日付時刻については、操作パネルを使うくらいなら読み上げはしないまでもPCのアプリとして既に立派なものがありますし、旧暦干支については、必要な気がしない...よく考えると伝言音声メモも含め、GUIパネルには実装しない可能性大...。

 これら、各機能の詳細については、冒頭や後段のリンクにあります。

自作スマートスピーカー用タイマー操作パネルに追加した電源パネルと電源OFFボタン
[2020/03/21]

 ラズパイ/Julius/Open JTalk自作スマートスピーカーの電源OFF用パネル及びボタンをQt Designerで追加しました。

$ cat /path/to/sp_qt_ctrl_panel_alt.py
#!/usr/bin/python3
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt5.QtWidgets import QApplication
import subprocess
 
class ExampleApp(QtWidgets.QMainWindow):
  def __init__(self, parent=None):
    super(ExampleApp, self).__init__(parent)
    self.ui = uic.loadUi("/path/to/qt5designer/tab_test.ui", self)
 
 
...
 
    self.ui.rpipowerbtn.clicked.connect(self.ui.btnRpiPowerOff)
 
 
    self.ui.show()
 
...
 
  def btnRpiPowerOff(self):
    print("\"電源OFF\" was selected")
    subprocess.call("/path/to/script/poweroff_raspi.sh &", shell=True)
...
def main():
  app = QApplication(sys.argv)
  form = ExampleApp()
  form.show()
  app.exec_()
 
if __name__ == '__main__':
  main()
$

 ついては、PyQt5スクリプトの太字部分を追加しました。

 rpipowerbtnは、Qt Designerで作った.uiファイル上の[objectName]プロパティの値、btnRpiPowerOffは、任意の関数名で、今回の場合、[click]時に実行されることになります。

 /path/to/script/poweroff_raspi.shは、ラズパイスピーカーをシャットダウンさせるためのスクリプトです。

 尚、自身の場合、現時点でラズパイ用モニタを設置していないので、この自作スマートスピーカー機能も搭載しているメインPC上から実行できるようにしました。

[2020/03/28]
従前の自作スマートスピーカー用タイマー操作パネル

 ラズベリーパイ 3 B+自作スマートスピーカー用デスクトップ操作パネルのタイマーパネルの更新に伴い、タイマー・アラーム設定を機能変更しました。

 具体的には、5分刻みの60分タイマーを24時間、時・分指定のアラームに更新。

24時間サイクルの時間指定アラームとした自作スマートスピーカー用タイマー操作パネル

 リンク先にあるタイマースクリプトは、元々、引数1つで分刻みで何分でも、引数2つで24時間、時・分指定のアラームに対応していたのでパネル側のみの更新です。

 ついては、Qt Designerで元となる.uiファイルのオブジェクト・レイアウト変更、PyQt5スクリプトの変更を実施。

$ cat /path/to/sp_qt_ctrl_panel_alt.py
...
 
  timer = ["","5分","10分","15分","20分","25分","30分","35分","40分","45分","50分","55分","60分"]
  self.ui.local_timer_combo.addItems(timer)
  self.ui.local_timer_combo.activated[str].connect(self.ui.onLocalTimerActivated
  self.ui.local_timer_stop_btn.clicked.connect(self.ui.btnLocalTimerStop)
...
 
    self.ui.show()
 
...
 
 
 def onLocalTimerActivated(self):
  print("\"local_timer_combo\" was selected")
 
  if self.ui.local_timer_combo.currentText() == "":
    pass
  elif self.ui.local_timer_combo.currentText() == "5分":
    subprocess.call("/path/to/script/timer.pl 5 &", shell=True)
  elif self.ui.local_timer_combo.currentText() == "10分":
    subprocess.call("/path/to/script/timer.pl 10 &", shell=True)
  elif self.ui.local_timer_combo.currentText() == "15分":
    subprocess.call("/path/to/script/timer.pl 15 &", shell=True)
  elif self.ui.local_timer_combo.currentText() == "20分":
    subprocess.call("/path/to/script/timer.pl 20 &", shell=True)
  elif self.ui.local_timer_combo.currentText() == "25分":
    subprocess.call("/path/to/script/timer.pl 25 &", shell=True)
  elif self.ui.local_timer_combo.currentText() == "30分":
    subprocess.call("/path/to/script/timer.pl 30 &", shell=True)
  elif self.ui.local_timer_combo.currentText() == "35分":
    subprocess.call("/path/to/script/timer.pl 35 &", shell=True)
  elif self.ui.local_timer_combo.currentText() == "40分":
    subprocess.call("/path/to/script/timer.pl 40 &", shell=True)
  elif self.ui.local_timer_combo.currentText() == "45分":
    subprocess.call("/path/to/script/timer.pl 45 &", shell=True)
  elif self.ui.local_timer_combo.currentText() == "50分":
    subprocess.call("/path/to/script/timer.pl 50 &", shell=True)
  elif self.ui.local_timer_combo.currentText() == "60分":
    subprocess.call("/path/to/script/timer.pl 60 &", shell=True)
 
...
$

 変更前。

 ちなみに自身の場合、自作スマートスピーカー機能を載せたメインPCとラズパイスピーカーがあるので実際には、2つ分あります。

 呼んでいるスクリプト例がPerlになっていますが、もちろん、Pythonで実装しても構いません。

$ cat /path/to/sp_qt_ctrl_panel_alt.py
...
 
    self.ui.local_timer_set_btn.clicked.connect(self.ui.onLocalTimerActivated)
 
 
    self.ui.show()
 
...
 
 
 def onLocalTimerActivated(self):
  print("\"local_timer_set_btn\" was selected")
 
  if self.ui.spinBoxLocalHour.value() == 0 and self.ui.spinBoxLocalMinute.value() == 0:
   pass
  else:
   hour = self.ui.spinBoxLocalHour.value()
   minute = self.ui.spinBoxLocalMinute.value()
   args = str(hour) + " " + str(minute)
   subprocess.Popen("/path/to/script/timer.pl " + args, shell=True)
 
...
$

 変更後。

 スクリプトへの2つの引数の渡し方でちょっと悩みましたが、こんな風にすることでいけました。

 尚、これまで末尾に&をつけていましたが、この場合、スクリプトコール時におけるバックグラウンド起動の方法がにわかには思いつかなかったので、これまで使っていたsubprocess.callではなく、subprocess.Popenを使うことにしました(前者だとその後のパネル操作ができなくなるため)。

[2021/09/20]
自作スマートスピーカー用タイマー操作パネルにカメラボタン追加

 Linuxパソコン上のJulius+Open JTalkスマートスピーカーにカメラ映像リアルタイム表示機能を追加しました。

 [カメラ]ボタン押下でラズパイ+Webカメラ+ZoneMinderによるネットワークカメラ/IPカメラとしてブラウザが起動、カメラ映像が表示され、Chromiumを閉じることを以てカメラ映像終了としています。

 単にブラウザを起動してURL入力欄にZoneMinderカメラサーバへのURLを入れて表示させても、これをデスクトップアイコンとしておいて、それをクリックしても良いんですけどね。

 スマホではそうしてますが、まぁ、音声操作もできるということで。

[2021/09/23]
自作スマートスピーカー用タイマー操作パネルにミーティングタブ及び内線や友人、会議ボタン等追加

 Julius+Open JTalkスマートスピーカーを入れたLinuxパソコン上に自作スマートスピーカーからビデオ会議や内線通話...etc.を開始する機能を追加しました。

 尤も当該機能については、自作スマートスピーカー機能の有無に関わらず、モニター付きであれば、ラズパイでもパソコンでも使えますが。

 [内線]ボタン押下で同一ネットワーク内における自端末及び対象端末のJitsi Meetによる同一会議IDを含むURLを指定したブラウザが起動します。

 各端末において初回なら名前を指定して[Join Meeting]/[ミーティングに参加]ボタンをクリックすることで内線や友人との雑談、会議等々ができる状態になります。

 同一ネットワークにあること前提の内線においては、sshを使うことで開始だけでなく、自端末及び対象端末の終了(pkill ブラウザ)もできるようになります。

 Jitsi Meetでは、ビデオチャット、ボイスチャット、テキストチャットなどができます。

 スマホやタブレットについては、同一ネットワーク内にあったとしてもJitsi Meetの場合、専用アプリを使うことになっているのでアプリから会議IDを入力して参加することになります。

 このパネルからでもスマホやタブレットで直接にしてもブラウザにURLを入れる恰好で参加しようとした場合、このリンクをクリックしてアプリを使ってねといった旨の画面からアプリを起動することはできますが。

[2023/06/03]
自作スマートスピーカー用タイマー操作パネルにカメラボタン追加

 リモコン付き空気清浄機からスマホ専用アプリによるスマート空気清浄機に買い替え、必須となったという会員登録・ログイン・クラウドを回避しつつ、ブラウザ操作パネルと操作スクリプトを自作してみました。

パソコンでも使える自作ブラウザ操作パネル/ダイキン加湿ストリーマ空気清浄機MCK70Y用

 結果、このJulius/Open JTalk用操作パネルからも専用アプリではなく、自作操作パネルにIPアドレスやmDNSでアクセスできるようになりました。

 また、Julius/Open JTalkスマートスピーカー用スクリプトから自作スクリプトをファイル名+引数で操作コマンドを送ることができるのでAmazon AlexaやGoogle Homeでなく、Julius/Open JTalkスマートスピーカー専用機からも、同機能を入れた2台のパソコンからも音声操作もできるようになりました。

ホーム前へ次へ