ZWO ASI + Acuter + YOLO ハイブリッド自動追尾システム 開発記録

1. プロジェクト概要

本プロジェクトは、ZWO ASIカメラ、Astromechanics EFレンズコントローラー、および Acuter Traverse マウントを統合制御し、AIを用いた高度な自動追尾と自動録画を行うシステムである。 遠方の微小なターゲットをOpenCVの動体検知で捕捉し、接近した際にYOLOv8の物体認識へシームレスに移行する「ハイブリッド・トラッキング」を実現している。

2. システム構成(3ファイル構成)

複雑化を避け、保守性を高めるために以下の3ファイルに分割している。

  • device_controllers.py: ハードウェア制御層(Acuterマウントのシリアル通信、EFレンズのフォーカス/絞り制御)。
  • camera_yolo.py: 画像処理層(ZWO ASIからのフレーム取得、YOLOv8による推論、OpenCVによる動体検知、動画保存ワーカー)。
  • main.py: UIおよび統合制御層(PyQt6によるGUI、PID制御(P制御)によるトラッキングアルゴリズム、描画オーバーレイ)。

3. 開発の経緯と主要なトラブルシューティング

開発過程において、以下の技術的課題を解決しシステムを安定化させた。

  • 名前空間の衝突解決 (v2.33)
    • [課題] 標準ライブラリの hardware とファイル名が衝突しインポートエラーが発生。
    • [解決] ファイル名を device_controllers.py に変更して分離。
  • マウントのオーバーシュート・行き過ぎ問題の解決 (v2.34 – v2.35)
    • [課題] 追尾時にターゲットが中心(デッドバンド)に入ってもマウントが止まらず行き過ぎる。
    • [解決] 毎秒20回のコマンド送信によるシリアルバッファの渋滞が原因と判明。通信頻度を毎秒10回に落とし、不要な読み返し(:J)を削除。また、ブレーキ時にはバッファを強制破棄し、Acuterの瞬時停止コマンド(:L)を叩き込む設計に変更。
  • Auto Engage(自動検知)と録画の細切れ問題 (v2.36)
    • [課題] 対象を少しでも見失うと別ターゲットと認識され、動画が細切れになる。対象外をクリックしてもAuto Engageが即座に再ロックしてしまう。
    • [解決] ロスト猶予期間を大幅に延長(最大150フレーム/約5秒)。距離制限を撤廃し、画面内に同じラベルが再出現した場合は同一ターゲットとしてリカバリーする処理を追加。空クリック時はAuto Engageをオフにして強制キャンセルするよう仕様変更。
  • ハイブリッド・トラッキングの実装 (v2.38)
    • [課題] YOLOが認識できない遠方の小さなターゲットを追尾できない。
    • [解決] OpenCVの輪郭抽出を用いた動体検知(Motion Detect)を追加。動体を追尾中にYOLOが同じ座標で物体を認識した場合、YOLOのトラッキング(ID保持)に自動でアップグレードする連携機構を実装。
  • Slewモード(手動/追尾回転)の動作不能問題 (v2.39)
    • [課題] 矢印キーや追尾時にマウントが回転しない。
    • [解決] Sky-Watcher(Acuter)の通信プロトコル仕様に基づき、Slewモードでも必須となる実行コマンド(:J)を正しく再実装。また、逆回転を指示された際はモーター保護のため必ず停止コマンド(:L)を挟む処理を追加。

/Users/mars/acuter/main.py

# CodeName: ZWO_ASI_Acuter_EF_YOLOv8_ContinuousTrack_AutoEngage_UI_Clean
# Version: 2.39.0

import sys
import os
import time
import cv2
import numpy as np
import zwoasi as asi

from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QImage, QPixmap
from PyQt6.QtWidgets import (
    QApplication, QCheckBox, QFormLayout, QGroupBox, QHBoxLayout, QLabel,
    QMainWindow, QScrollArea, QSpinBox, QDoubleSpinBox, QStatusBar, 
    QVBoxLayout, QWidget, QLineEdit, QPushButton, QMessageBox, QComboBox, 
    QGridLayout, QTabWidget
)

from device_controllers import AcuterController, AstromechanicsEFController, PositionWorker, MoveWorker
from camera_yolo import download_model_files, YOLO_MODEL_FILE, VideoLabel, CaptureThread, ControlRow, VideoSaveWorker

class MainWindow(QMainWindow):
    def __init__(self, sdk_path):
        super().__init__()
        self.setWindowTitle('ZWO ASI Camera + EF Lens + Acuter + YOLOv8 Complete (v2.39.0)')
        self.resize(1300, 900)

        asi.init(sdk_path)
        if asi.get_num_cameras() == 0:
            raise RuntimeError('ZWOカメラが見つかりません')

        self.camera = asi.Camera(0)
        self.camera.set_image_type(asi.ASI_IMG_RGB24)
        
        cam_prop = self.camera.get_camera_property()
        supported_bins = cam_prop.get('SupportedBins', [1])
        self.current_bins = 2 if 2 in supported_bins else 1
        current_w = cam_prop['MaxWidth'] // self.current_bins
        current_h = cam_prop['MaxHeight'] // self.current_bins
        
        self.camera.set_roi(start_x=0, start_y=0, width=current_w, height=current_h, bins=self.current_bins)
        self.current_start_x = 0
        self.current_start_y = 0

        self.target_id = None
        self.last_target_pos = None
        self.last_target_name = None
        self.current_detections = []
        self.last_frame_size = (current_w, current_h)
        self.latest_frame = None
        self.last_motion_box = None

        self.pixel_size = 0.0038
        self.next_track_time = 0.0
        self.lost_counter = 0
        
        self.is_recording_event = False
        self.video_buffer = []
        self.home_deg = {1: None, 2: None}
        
        self.debug_info_dxdy = "Target diff: N/A"
        self.debug_info_azalt = "Cmd: N/A"
        self.debug_info_status = "Status: Idle"
        
        self.calib_state = 0
        self.calib_timer = QTimer(self)
        self.calib_timer.timeout.connect(self.calib_step)
        self.calib_pts_prev = None
        self.calib_gray_prev = None

        self.acuter = AcuterController()
        self.acuter_poll_timer = QTimer(self)
        self.acuter_poll_timer.timeout.connect(self.poll_acuter_position)

        self.ef_controller = AstromechanicsEFController()
        self.capture_thread = None

        self._build_ui()
        self._update_ef_ui_state(False)

        self._frame_count = 0
        self._start_capture()
        QTimer.singleShot(500, self.auto_connect_devices)

    def auto_connect_devices(self):
        port_ef = self.ef_port_edit.text().strip()
        if port_ef:
            self.ef_controller.port = port_ef
            self.statusBar().showMessage("EFレンズ自動接続試行中...")
            try:
                if self.ef_controller.connect():
                    self._update_ef_ui_state(True)
                    self.btn_ef_connect.setText("接続済")
                    self.btn_ef_connect.setStyleSheet("background-color: #28a745; color: white;")
                    self.on_ef_refresh_position()
            except Exception:
                self.ef_controller.disconnect()

        port_acuter = self.acuter_port_input.text().strip()
        if port_acuter:
            self.statusBar().showMessage("Acuterマウント自動接続試行中...")
            try:
                if self.acuter.connect(port_acuter):
                    self.btn_acuter_connect.setText("切断")
                    self.btn_acuter_connect.setStyleSheet("background-color: #28a745; color: white;")
                    self.lbl_acuter_status.setText("接続済み (GoTo/Slew有効)")
                    self.lbl_acuter_status.setStyleSheet("color: #00FF00; font-weight: bold;")
                    self._set_acuter_controls_state(True)
                    self.acuter_poll_timer.start(500)
            except Exception:
                self.statusBar().showMessage("自動接続に失敗しました。後で手動接続してください。")

    def _start_capture(self):
        if self.capture_thread and self.capture_thread.isRunning():
            self.capture_thread.stop()
        
        self.capture_thread = CaptureThread(self.camera, YOLO_MODEL_FILE)
        self.capture_thread.target_classes = self.get_current_target_classes()
        self.capture_thread.detection_enabled = self.cb_detection.isChecked()
        self.capture_thread.motion_enabled = self.cb_motion.isChecked()
        self.capture_thread.frame_ready.connect(self._on_frame)
        self.capture_thread.error.connect(lambda msg: self.statusBar().showMessage(f"取得エラー: {msg}"))
        self.capture_thread.start()

    def _build_ui(self):
        central = QWidget()
        self.setCentralWidget(central)
        root = QHBoxLayout(central)
        root.setContentsMargins(15, 15, 15, 15)
        root.setSpacing(15)

        # ========== 左ペイン ==========
        left_widget = QWidget()
        left_layout = QVBoxLayout(left_widget)
        left_layout.setContentsMargins(0, 0, 0, 0)
        left_layout.setSpacing(15)

        self.preview_label = VideoLabel('starting...')
        self.preview_label.setMinimumSize(640, 480)
        self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.preview_label.setStyleSheet("background-color: #000000; color: #FFFFFF; border-radius: 8px;")
        self.preview_label.clicked.connect(self._on_preview_clicked)
        self.preview_label.roi_selected.connect(self._on_roi_dragged)
        left_layout.addWidget(self.preview_label, stretch=1)

        self.ef_group = QGroupBox("Canon EF Lens (Astromechanics)")
        ef_layout = QVBoxLayout(self.ef_group)
        
        ef_conn_layout = QHBoxLayout()
        self.ef_port_edit = QLineEdit("/dev/tty.usbserial-AK06UIRD")
        ef_conn_layout.addWidget(QLabel("ポート:"))
        ef_conn_layout.addWidget(self.ef_port_edit, stretch=1)
        self.btn_ef_connect = QPushButton("接続")
        self.btn_ef_connect.clicked.connect(self.on_ef_connect)
        self.btn_ef_disconnect = QPushButton("切断")
        self.btn_ef_disconnect.clicked.connect(self.on_ef_disconnect)
        ef_conn_layout.addWidget(self.btn_ef_connect)
        ef_conn_layout.addWidget(self.btn_ef_disconnect)
        ef_layout.addLayout(ef_conn_layout)

        ef_pos_move_row = QHBoxLayout()
        self.lbl_ef_position = QLabel("—")
        self.lbl_ef_position.setStyleSheet("color: white; background-color: #333; padding: 4px; border-radius: 4px;")
        self.btn_ef_refresh = QPushButton("更新")
        self.btn_ef_refresh.clicked.connect(self.on_ef_refresh_position)
        self.spin_ef_target = QSpinBox()
        self.spin_ef_target.setRange(0, 32767)
        self.spin_ef_target.setValue(5000)
        self.spin_ef_target.setSingleStep(100)
        self.btn_ef_goto = QPushButton("移動")
        self.btn_ef_goto.clicked.connect(self.on_ef_goto)
        ef_pos_move_row.addWidget(QLabel("現在位置:"))
        ef_pos_move_row.addWidget(self.lbl_ef_position)
        ef_pos_move_row.addWidget(self.btn_ef_refresh)
        ef_pos_move_row.addSpacing(20)
        ef_pos_move_row.addWidget(QLabel("目標位置:"))
        ef_pos_move_row.addWidget(self.spin_ef_target)
        ef_pos_move_row.addWidget(self.btn_ef_goto)
        ef_layout.addLayout(ef_pos_move_row)

        ef_rel_ap_row = QHBoxLayout()
        self.spin_ef_rel = QSpinBox()
        self.spin_ef_rel.setRange(1, 5000)
        self.spin_ef_rel.setValue(100)
        self.spin_ef_rel.setSingleStep(50)
        self.btn_ef_in = QPushButton("← IN (−)")
        self.btn_ef_in.clicked.connect(lambda: self.on_ef_relative(-1))
        self.btn_ef_out = QPushButton("OUT (+) →")
        self.btn_ef_out.clicked.connect(lambda: self.on_ef_relative(+1))
        self.spin_ef_aperture = QSpinBox()
        self.spin_ef_aperture.setRange(0, 30)
        self.spin_ef_aperture.setValue(0)
        self.btn_ef_aperture = QPushButton("設定")
        self.btn_ef_aperture.clicked.connect(self.on_ef_set_aperture)
        ef_rel_ap_row.addWidget(QLabel("相対:"))
        ef_rel_ap_row.addWidget(self.spin_ef_rel)
        ef_rel_ap_row.addWidget(self.btn_ef_in)
        ef_rel_ap_row.addWidget(self.btn_ef_out)
        ef_rel_ap_row.addSpacing(20)
        ef_rel_ap_row.addWidget(QLabel("絞り(0=開放):"))
        ef_rel_ap_row.addWidget(self.spin_ef_aperture)
        ef_rel_ap_row.addWidget(self.btn_ef_aperture)
        ef_rel_ap_row.addStretch()
        ef_layout.addLayout(ef_rel_ap_row)
        left_layout.addWidget(self.ef_group)
        root.addWidget(left_widget, stretch=3)

        # ========== 右ペイン ==========
        right_scroll = QScrollArea()
        right_scroll.setWidgetResizable(True)
        right_scroll.setMinimumWidth(430)
        right_scroll.setFrameShape(QScrollArea.Shape.NoFrame)

        right_panel = QWidget()
        right_layout = QVBoxLayout(right_panel)
        right_layout.setContentsMargins(0, 0, 10, 0)
        
        self.ai_group = QGroupBox("AI Object Tracking")
        ai_layout = QVBoxLayout(self.ai_group)
        self.cb_detection = QCheckBox("YOLOv8 トラッキングを有効にする (ID保持)")
        self.cb_detection.setChecked(False)
        self.cb_detection.toggled.connect(self.on_detection_toggled)
        ai_layout.addWidget(self.cb_detection)

        self.cb_motion = QCheckBox("動体検知 (Motion Detect) [YOLO認識前の小目標用]")
        self.cb_motion.setChecked(True)
        self.cb_motion.setStyleSheet("color: #ffcc00; font-weight: bold;")
        self.cb_motion.toggled.connect(self.on_motion_toggled)
        ai_layout.addWidget(self.cb_motion)

        info_label = QLabel("※クリックで「対象ロックオン」、ドラッグで「ROI切り出し」")
        info_label.setStyleSheet("color: #aaaaaa; font-size: 11px;")
        ai_layout.addWidget(info_label)

        targets_layout = QHBoxLayout()
        self.cb_person = QCheckBox("人")
        self.cb_bicycle = QCheckBox("自転車")
        self.cb_car = QCheckBox("車")
        self.cb_airplane = QCheckBox("航空機")
        self.cb_bird = QCheckBox("鳥")
        for cb in [self.cb_person, self.cb_bicycle, self.cb_car, self.cb_airplane, self.cb_bird]:
            cb.setChecked(True)
            cb.toggled.connect(self.update_target_classes)
            targets_layout.addWidget(cb)
        ai_layout.addLayout(targets_layout)
        
        self.cb_auto_engage = QCheckBox("選択対象の自動検知&録画 (Auto Engage)")
        self.cb_auto_engage.setChecked(False)
        self.cb_auto_engage.setStyleSheet("color: #5c9eff; font-weight: bold;")
        ai_layout.addWidget(self.cb_auto_engage)
        right_layout.addWidget(self.ai_group)

        self.acuter_group = QGroupBox("Acuter Traverse Control")
        self.acuter_layout = QVBoxLayout(self.acuter_group)

        acuter_conn_layout = QHBoxLayout()
        self.acuter_port_input = QLineEdit("/dev/cu.usbmodem4E94509B34001")
        self.btn_acuter_connect = QPushButton("接続")
        self.btn_acuter_connect.clicked.connect(self.toggle_acuter_connection)
        acuter_conn_layout.addWidget(self.acuter_port_input)
        acuter_conn_layout.addWidget(self.btn_acuter_connect)
        self.acuter_layout.addLayout(acuter_conn_layout)

        self.lbl_acuter_status = QLabel("未接続")
        self.lbl_acuter_status.setStyleSheet("color: red; font-weight: bold;")
        self.acuter_layout.addWidget(self.lbl_acuter_status)
        
        self.btn_auto_track = QPushButton("Auto Tracking (OFF / 待機)")
        self.btn_auto_track.setCheckable(True)
        self.btn_auto_track.setFixedHeight(60)
        self.btn_auto_track.setStyleSheet("font-size: 18px; font-weight: bold; background-color: #555555; color: white;")
        self.btn_auto_track.toggled.connect(self.on_auto_track_toggled)
        self.acuter_layout.addWidget(self.btn_auto_track)

        pos_layout = QHBoxLayout()
        self.lbl_acuter_az = QLabel("Az : --.- °")
        self.lbl_acuter_az.setStyleSheet("font-family: 'Menlo', 'Consolas', 'Courier New'; font-size: 16px; font-weight: bold; color: white; background-color: #333; padding: 4px; border-radius: 4px;")
        self.lbl_acuter_alt = QLabel("Alt: --.- °")
        self.lbl_acuter_alt.setStyleSheet("font-family: 'Menlo', 'Consolas', 'Courier New'; font-size: 16px; font-weight: bold; color: white; background-color: #333; padding: 4px; border-radius: 4px;")
        pos_layout.addWidget(self.lbl_acuter_az)
        pos_layout.addWidget(self.lbl_acuter_alt)
        self.acuter_layout.addLayout(pos_layout)

        speed_form = QFormLayout()
        self.acuter_speed_combo = QComboBox()
        self.acuter_speed_combo.addItems(["1.0", "5.0", "10.0", "15.0"])
        self.acuter_speed_combo.setCurrentText("15.0")
        speed_form.addRow("最大回転速度(度/秒):", self.acuter_speed_combo)
        self.acuter_layout.addLayout(speed_form)

        goto_layout = QHBoxLayout()
        self.acuter_az_input = QLineEdit("10.0")
        self.acuter_az_input.setMaximumWidth(50)
        self.btn_az_goto = QPushButton("Az GoTo")
        self.btn_az_goto.setEnabled(False)
        self.btn_az_goto.clicked.connect(lambda: self.acuter.start_move(1, float(self.acuter_az_input.text()), exact_goto=True))
        self.acuter_alt_input = QLineEdit("10.0")
        self.acuter_alt_input.setMaximumWidth(50)
        self.btn_alt_goto = QPushButton("Alt GoTo")
        self.btn_alt_goto.setEnabled(False)
        self.btn_alt_goto.clicked.connect(lambda: self.acuter.start_move(2, float(self.acuter_alt_input.text()), exact_goto=True))
        goto_layout.addWidget(QLabel("Az:"))
        goto_layout.addWidget(self.acuter_az_input)
        goto_layout.addWidget(QLabel("°"))
        goto_layout.addWidget(self.btn_az_goto)
        goto_layout.addSpacing(15)
        goto_layout.addWidget(QLabel("Alt:"))
        goto_layout.addWidget(self.acuter_alt_input)
        goto_layout.addWidget(QLabel("°"))
        goto_layout.addWidget(self.btn_alt_goto)
        goto_layout.addStretch()
        self.acuter_layout.addLayout(goto_layout)

        dpad_layout = QGridLayout()
        self.btn_up = QPushButton("▲")
        self.btn_left = QPushButton("◀")
        self.btn_stop_center = QPushButton("■ STOP")
        self.btn_stop_center.setStyleSheet("background-color: #d9534f; color: white; font-weight: bold;")
        self.btn_right = QPushButton("▶")
        self.btn_down = QPushButton("▼")

        huge_angle = 1000.0 
        self.btn_up.pressed.connect(lambda: self.acuter.start_move(2, -huge_angle, speed_deg_sec=float(self.acuter_speed_combo.currentText())))
        self.btn_down.pressed.connect(lambda: self.acuter.start_move(2, huge_angle, speed_deg_sec=float(self.acuter_speed_combo.currentText())))
        self.btn_left.pressed.connect(lambda: self.acuter.start_move(1, -huge_angle, speed_deg_sec=float(self.acuter_speed_combo.currentText())))
        self.btn_right.pressed.connect(lambda: self.acuter.start_move(1, huge_angle, speed_deg_sec=float(self.acuter_speed_combo.currentText())))
        
        self.btn_up.released.connect(lambda: self.acuter.stop_axis(2))
        self.btn_down.released.connect(lambda: self.acuter.stop_axis(2))
        self.btn_left.released.connect(lambda: self.acuter.stop_axis(1))
        self.btn_right.released.connect(lambda: self.acuter.stop_axis(1))
        self.btn_stop_center.clicked.connect(self.acuter.emergency_stop)

        dpad_layout.addWidget(self.btn_up, 0, 1)
        dpad_layout.addWidget(self.btn_left, 1, 0)
        dpad_layout.addWidget(self.btn_stop_center, 1, 1)
        dpad_layout.addWidget(self.btn_right, 1, 2)
        dpad_layout.addWidget(self.btn_down, 2, 1)
        self.acuter_layout.addLayout(dpad_layout)
        right_layout.addWidget(self.acuter_group)

        # 3. ZWO ASI カメラ設定
        asi_group = QGroupBox('ZWO ASI & Tracking Settings')
        asi_layout = QVBoxLayout(asi_group)

        self.cam_tabs = QTabWidget()
        self.tab_main = QWidget()
        self.tab_adv = QWidget()
        form_main = QFormLayout(self.tab_main)
        form_adv = QFormLayout(self.tab_adv)

        # ★ Mainタブにはキャリブレーションボタンのみを配置
        self.btn_auto_calib = QPushButton("Run Auto Calibration")
        self.btn_auto_calib.clicked.connect(self.start_auto_calib)
        self.btn_auto_calib.setStyleSheet("background-color: #28a745; color: white; font-weight: bold; font-size: 16px; padding: 12px;")
        form_main.addRow(self.btn_auto_calib)

        # ★ その他の設定はすべてAdvancedタブへ移動
        track_calib_group = QGroupBox("Tracking Settings & Axis Mapping")
        track_calib_layout = QFormLayout(track_calib_group)

        self.focal_spin = QDoubleSpinBox()
        self.focal_spin.setRange(1.0, 2000.0)
        self.focal_spin.setValue(55.0) # 初期値を55.0に変更
        self.kp_spin = QDoubleSpinBox()
        self.kp_spin.setRange(0.01, 5.0)
        self.kp_spin.setSingleStep(0.1)
        self.kp_spin.setValue(0.8)
        self.deadband_spin = QSpinBox()
        self.deadband_spin.setRange(5, 200)
        self.deadband_spin.setValue(40)

        self.combo_x_axis = QComboBox()
        self.combo_x_axis.addItems(["Az (Axis 1)", "Alt (Axis 2)"])
        self.combo_x_axis.setCurrentIndex(0) 
        self.combo_y_axis = QComboBox()
        self.combo_y_axis.addItems(["Alt (Axis 2)", "Az (Axis 1)"])
        self.combo_y_axis.setCurrentIndex(0)
        self.cb_inv_x = QCheckBox("Invert X-Axis (Reverse)")
        self.cb_inv_y = QCheckBox("Invert Y-Axis (Reverse)")
        self.cb_inv_y.setChecked(True)

        test_move_widget = QWidget()
        test_move_grid = QGridLayout(test_move_widget)
        self.btn_test_alt_p = QPushButton("Alt +1.0°")
        self.btn_test_alt_m = QPushButton("Alt -1.0°")
        self.btn_test_az_m = QPushButton("Az -1.0°")
        self.btn_test_az_p = QPushButton("Az +1.0°")
        self.btn_test_alt_p.clicked.connect(lambda: self.acuter.start_move(2, 1.0, exact_goto=True, speed_deg_sec=2.0))
        self.btn_test_alt_m.clicked.connect(lambda: self.acuter.start_move(2, -1.0, exact_goto=True, speed_deg_sec=2.0))
        self.btn_test_az_m.clicked.connect(lambda: self.acuter.start_move(1, -1.0, exact_goto=True, speed_deg_sec=2.0))
        self.btn_test_az_p.clicked.connect(lambda: self.acuter.start_move(1, 1.0, exact_goto=True, speed_deg_sec=2.0))
        
        self.calib_buttons = [self.btn_test_alt_p, self.btn_test_alt_m, self.btn_test_az_m, self.btn_test_az_p, self.btn_auto_calib]
        for btn in self.calib_buttons: btn.setEnabled(False)

        test_move_grid.addWidget(self.btn_test_alt_p, 0, 1)
        test_move_grid.addWidget(self.btn_test_az_m, 1, 0)
        test_move_grid.addWidget(self.btn_test_alt_m, 1, 1)
        test_move_grid.addWidget(self.btn_test_az_p, 1, 2)

        track_calib_layout.addRow("Focal Length (mm):", self.focal_spin)
        track_calib_layout.addRow("Tracking Gain (Kp):", self.kp_spin)
        track_calib_layout.addRow("Deadband (px):", self.deadband_spin)
        track_calib_layout.addRow("Camera X-Axis:", self.combo_x_axis)
        track_calib_layout.addRow("", self.cb_inv_x)
        track_calib_layout.addRow("Camera Y-Axis:", self.combo_y_axis)
        track_calib_layout.addRow("", self.cb_inv_y)
        track_calib_layout.addRow("Test Move:", test_move_widget)
        form_adv.addRow(track_calib_group)

        cam_prop = self.camera.get_camera_property()
        bin_group = QGroupBox("Sensor Binning")
        bin_layout = QHBoxLayout(bin_group)
        self.bin_combo = QComboBox()
        for b in cam_prop['SupportedBins']:
            if b != 0: self.bin_combo.addItem(f"Bin {b}x{b}", b)
        self.bin_combo.setCurrentText(f"Bin {self.current_bins}x{self.current_bins}")
        self.btn_apply_bin = QPushButton("Apply Binning")
        self.btn_apply_bin.clicked.connect(self.on_apply_binning)
        bin_layout.addWidget(QLabel("Binning:"))
        bin_layout.addWidget(self.bin_combo)
        bin_layout.addWidget(self.btn_apply_bin)

        roi_group = QGroupBox("Sensor ROI (Resolution)")
        roi_layout = QGridLayout(roi_group)
        
        current_w = cam_prop['MaxWidth'] // self.current_bins
        current_h = cam_prop['MaxHeight'] // self.current_bins
        
        self.roi_w_spin = QSpinBox()
        self.roi_w_spin.setRange(8, current_w)
        self.roi_w_spin.setSingleStep(8)
        self.roi_w_spin.setValue(current_w)
        self.roi_h_spin = QSpinBox()
        self.roi_h_spin.setRange(2, current_h)
        self.roi_h_spin.setSingleStep(2)
        self.roi_h_spin.setValue(current_h)
        self.btn_apply_roi = QPushButton("Apply ROI (Center)")
        self.btn_apply_roi.clicked.connect(self.on_apply_roi_center)
        self.btn_reset_roi = QPushButton("Reset to Full Frame")
        self.btn_reset_roi.clicked.connect(self.on_reset_roi)
        self.btn_reset_roi.setStyleSheet("background-color: #555555;")
        
        roi_layout.addWidget(QLabel("Width:"), 0, 0)
        roi_layout.addWidget(self.roi_w_spin, 0, 1)
        roi_layout.addWidget(QLabel("Height:"), 1, 0)
        roi_layout.addWidget(self.roi_h_spin, 1, 1)
        roi_layout.addWidget(self.btn_apply_roi, 2, 0, 1, 2)
        roi_layout.addWidget(self.btn_reset_roi, 3, 0, 1, 2)
        
        form_adv.addRow(roi_group)
        form_adv.addRow(bin_group)

        self.rows = []
        for name, caps in sorted(self.camera.get_controls().items()):
            row = ControlRow(self.camera, caps, lambda: self.statusBar().showMessage('ASIパラメータ更新', 1000))
            self.rows.append(row)
            # ★ カメラ設定も全て Advanced へ移動
            form_adv.addRow(name, row)

        self.cam_tabs.addTab(self.tab_main, "Main Controls")
        self.cam_tabs.addTab(self.tab_adv, "Advanced")
        asi_layout.addWidget(self.cam_tabs)
        
        right_layout.addWidget(asi_group)
        right_layout.addStretch()

        right_scroll.setWidget(right_panel)
        root.addWidget(right_scroll, stretch=2)
        self.setStatusBar(QStatusBar())

    def _update_ef_ui_state(self, connected: bool):
        self.btn_ef_connect.setEnabled(not connected)
        self.btn_ef_disconnect.setEnabled(connected)
        self.btn_ef_goto.setEnabled(connected)
        self.btn_ef_in.setEnabled(connected)
        self.btn_ef_out.setEnabled(connected)

    def on_ef_connect(self):
        try:
            self.ef_controller.port = self.ef_port_edit.text()
            if self.ef_controller.connect():
                self._update_ef_ui_state(True)
                self.on_ef_refresh_position()
        except: pass

    def on_ef_disconnect(self):
        self.ef_controller.disconnect()
        self._update_ef_ui_state(False)

    def on_ef_refresh_position(self):
        if not self.ef_controller.is_connected: return
        pos = self.ef_controller.get_position()
        if pos is not None:
            self.lbl_ef_position.setText(str(pos))
            self.spin_ef_target.setValue(pos)

    def on_ef_goto(self):
        if self.ef_controller.is_connected:
            self.ef_controller.move_absolute(self.spin_ef_target.value())
            QTimer.singleShot(1000, self.on_ef_refresh_position)

    def on_ef_relative(self, direction):
        if self.ef_controller.is_connected:
            target = self.spin_ef_target.value() + (self.spin_ef_rel.value() * direction)
            self.ef_controller.move_absolute(target)
            QTimer.singleShot(1000, self.on_ef_refresh_position)

    def on_ef_set_aperture(self):
        if self.ef_controller.is_connected:
            self.ef_controller.set_aperture(self.spin_ef_aperture.value())

    def toggle_acuter_connection(self):
        if self.acuter.is_connected:
            self.acuter.disconnect()
            self.btn_acuter_connect.setText("接続")
            self._set_acuter_controls_state(False)
        else:
            if self.acuter.connect(self.acuter_port_input.text()):
                self.btn_acuter_connect.setText("切断")
                self._set_acuter_controls_state(True)
                self.acuter_poll_timer.start(500)

    def _set_acuter_controls_state(self, state):
        self.btn_az_goto.setEnabled(state)
        self.btn_alt_goto.setEnabled(state)
        self.btn_up.setEnabled(state)
        self.btn_down.setEnabled(state)
        self.btn_left.setEnabled(state)
        self.btn_right.setEnabled(state)
        self.btn_stop_center.setEnabled(state)
        for btn in self.calib_buttons: btn.setEnabled(state)

    def poll_acuter_position(self):
        self.acuter.poll_position()
        d1, d2 = self.acuter.current_deg.get(1), self.acuter.current_deg.get(2)
        if d1 is not None: self.lbl_acuter_az.setText(f"Az : {d1:+8.3f} °")
        if d2 is not None: self.lbl_acuter_alt.setText(f"Alt: {d2:+8.3f} °")
        
        if self.capture_thread:
            self.capture_thread.camera_is_moving = self.acuter.axis_moving.get(1, False) or self.acuter.axis_moving.get(2, False)

    def on_auto_track_toggled(self, checked):
        if checked:
            self.btn_auto_track.setText("Tracking (ON)")
            self.btn_auto_track.setStyleSheet("background-color: #d9534f; color: white; font-size: 18px; font-weight: bold;")
        else:
            self.btn_auto_track.setText("Tracking (OFF)")
            self.btn_auto_track.setStyleSheet("background-color: #555; color: white; font-size: 18px; font-weight: bold;")
            self.acuter.emergency_stop()
            if getattr(self, 'is_recording_event', False):
                self.stop_event_record_and_return()

    def on_motion_toggled(self, checked):
        if self.capture_thread:
            self.capture_thread.motion_enabled = checked
            if not checked:
                self.capture_thread.cv2_tracker = None

    def start_event_record_and_track(self):
        self.home_deg = {1: self.acuter.current_deg.get(1), 2: self.acuter.current_deg.get(2)}
        self.is_recording_event = True
        self.video_buffer = []
        if not self.btn_auto_track.isChecked(): self.btn_auto_track.setChecked(True)

    def stop_event_record_and_return(self):
        self.is_recording_event = False
        if self.video_buffer:
            save_dir = "/Users/mars/acuter/videos"
            os.makedirs(save_dir, exist_ok=True)
            filename = os.path.join(save_dir, time.strftime("track_%Y%m%d_%H%M%S.mp4"))
            
            self.statusBar().showMessage(f"動画を保存中... {filename}", 5000)
            self.video_saver = VideoSaveWorker(self.video_buffer, filename, 30.0)
            self.video_saver.finished_ok.connect(lambda f: self.statusBar().showMessage(f"保存完了: {f}", 5000))
            self.video_saver.error.connect(lambda err: self.statusBar().showMessage(f"保存エラー: {err}", 5000))
            self.video_saver.start()
            self.video_buffer = []
            
        for axis in (1, 2):
            h_deg = self.home_deg.get(axis)
            c_deg = self.acuter.current_deg.get(axis)
            if h_deg is not None and c_deg is not None:
                diff = h_deg - c_deg
                if diff > 180: diff -= 360
                elif diff < -180: diff += 360
                if abs(diff) > 0.05:
                    self.acuter.start_move(axis, diff, exact_goto=True, speed_deg_sec=15.0)

    def get_current_target_classes(self):
        targets = []
        if self.cb_person.isChecked(): targets.append(0)
        if self.cb_bicycle.isChecked(): targets.append(1)
        if self.cb_car.isChecked(): targets.append(2)
        if self.cb_airplane.isChecked(): targets.append(4)
        if self.cb_bird.isChecked(): targets.append(14)
        return targets

    def update_target_classes(self):
        if self.capture_thread:
            self.capture_thread.target_classes = self.get_current_target_classes()

    def on_detection_toggled(self, checked):
        if self.capture_thread:
            self.capture_thread.detection_enabled = checked
            if not checked:
                self.target_id = None
                self.btn_auto_track.setChecked(False)

    def _on_preview_clicked(self, lx, ly):
        scale = min(self.preview_label.width() / self.last_frame_size[0], self.preview_label.height() / self.last_frame_size[1])
        ox = (self.preview_label.width() - self.last_frame_size[0] * scale) / 2
        oy = (self.preview_label.height() - self.last_frame_size[1] * scale) / 2
        fx, fy = (lx - ox) / scale, (ly - oy) / scale

        clicked_id = None
        for d in self.current_detections:
            if d[0] <= fx <= d[2] and d[1] <= fy <= d[3]:
                clicked_id = d[6]
                break
        
        if clicked_id is not None:
            self.target_id = clicked_id
            if self.capture_thread: self.capture_thread.yolo_target_locked = True
            self.lost_counter = 0  
            if not getattr(self, 'is_recording_event', False):
                self.start_event_record_and_track()
            if not self.btn_auto_track.isChecked():
                self.btn_auto_track.setChecked(True)
            return

        if getattr(self, 'last_motion_box', None) is not None:
            mx, my, mw, mh = self.last_motion_box
            if mx <= fx <= mx+mw and my <= fy <= my+mh:
                self.target_id = None
                if self.capture_thread: self.capture_thread.yolo_target_locked = False
                if not getattr(self, 'is_recording_event', False):
                    self.start_event_record_and_track()
                if not self.btn_auto_track.isChecked():
                    self.btn_auto_track.setChecked(True)
                return

        self.target_id = None
        if self.capture_thread: self.capture_thread.yolo_target_locked = False
        self.cb_auto_engage.setChecked(False)
        self.btn_auto_track.setChecked(False)
        self.acuter.emergency_stop()

    def _on_roi_dragged(self, lx, ly, lw, lh): pass 

    def on_reset_roi(self):
        cam_prop = self.camera.get_camera_property()
        max_w = cam_prop['MaxWidth'] // self.current_bins
        max_h = cam_prop['MaxHeight'] // self.current_bins
        self.camera.set_roi(start_x=0, start_y=0, width=max_w, height=max_h, bins=self.current_bins)
        self._start_capture()
        
    def on_apply_binning(self):
        b = self.bin_combo.currentData()
        self.camera.set_roi(bins=b)
        self.current_bins = b
        self._start_capture()
        
    def on_apply_roi_center(self):
        w, h = (self.roi_w_spin.value() // 8) * 8, (self.roi_h_spin.value() // 2) * 2
        cam_prop = self.camera.get_camera_property()
        sx = ((cam_prop['MaxWidth'] // self.current_bins - w) // 2 // 4) * 4
        sy = ((cam_prop['MaxHeight'] // self.current_bins - h) // 2 // 2) * 2
        self.camera.set_roi(start_x=sx, start_y=sy, width=w, height=h, bins=self.current_bins)
        self._start_capture()

    def _on_frame(self, frame: np.ndarray, detections: list, motion_box: object = None):
        self._frame_count += 1
        self.last_motion_box = motion_box
        h, w = frame.shape[:2]
        self.last_frame_size = (w, h)
        self.current_detections = detections
        self.latest_frame = frame.copy()
        
        draw_frame = frame.copy()
        cx, cy = w // 2, h // 2
        
        db_px = self.deadband_spin.value()
        cv2.circle(draw_frame, (cx, cy), db_px, (255, 255, 255), 1, cv2.LINE_AA)
        cv2.line(draw_frame, (cx-20, cy), (cx+20, cy), (255,255,255), 1)
        cv2.line(draw_frame, (cx, cy-20), (cx, cy+20), (255,255,255), 1)

        motion_cx, motion_cy = None, None
        if motion_box is not None:
            mx, my, mw, mh = [int(v) for v in motion_box]
            motion_cx, motion_cy = mx + mw//2, my + mh//2
            cv2.rectangle(draw_frame, (mx, my), (mx+mw, my+mh), (0, 165, 255), 2)
            cv2.putText(draw_frame, "MOTION", (mx, max(my-10, 10)), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 165, 255), 2)

        if self.target_id is None and motion_box is not None and detections:
            for d in detections:
                dcx, dcy = (d[0]+d[2])//2, (d[1]+d[3])//2
                if np.hypot(dcx - motion_cx, dcy - motion_cy) < 100:
                    self.target_id = d[6]
                    self.last_target_name = d[5]
                    if self.capture_thread: self.capture_thread.yolo_target_locked = True
                    self.statusBar().showMessage(f"Upgraded to YOLO Target: ID {self.target_id}", 3000)
                    break

        if self.cb_auto_engage.isChecked() and self.target_id is None and detections:
            best_det = max(detections, key=lambda d: d[4])
            if best_det[6] is not None:
                self.target_id = best_det[6]
                self.last_target_name = best_det[5]
                self.last_target_pos = ((best_det[0]+best_det[2])//2, (best_det[1]+best_det[3])//2)
                self.lost_counter = 0
                if self.capture_thread: self.capture_thread.yolo_target_locked = True
                self.start_event_record_and_track()

        if self.cb_auto_engage.isChecked() and self.target_id is None and motion_box is not None:
            if not getattr(self, 'is_recording_event', False):
                self.start_event_record_and_track()

        is_tracking_active = False
        tcx, tcy = None, None
        tracking_color = (0, 255, 0)

        if self.target_id is not None:
            target_found = False
            for d in detections:
                if self.target_id == d[6]:
                    target_found = True
                    self.last_target_pos = ((d[0]+d[2])//2, (d[1]+d[3])//2)
                    break
            
            if not target_found and self.last_target_pos:
                best_d = None
                min_dist = float('inf')
                for d in detections:
                    if d[5] == self.last_target_name:
                        dist = np.hypot((((d[0]+d[2])//2) - self.last_target_pos[0]), (((d[1]+d[3])//2) - self.last_target_pos[1]))
                        if dist < min_dist:
                            min_dist = dist
                            best_d = d
                if best_d is not None:
                    self.target_id = best_d[6]
                    target_found = True
                    self.lost_counter = 0

            if target_found:
                tcx, tcy = self.last_target_pos
                is_tracking_active = True
                tracking_color = (0, 0, 255)
                self.lost_counter = 0
            else:
                self.lost_counter += 1
                if self.last_target_pos:
                    gx, gy = self.last_target_pos
                    cv2.circle(draw_frame, (gx, gy), 15, (0,165,255), 2)
                    cv2.putText(draw_frame, "SEARCHING...", (gx+20, gy), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0,165,255), 2)
                
                if self.lost_counter == 1:
                    self.acuter.emergency_stop()
                    self.debug_info_status = "Status: Target Lost (Braking)"
                
                if self.lost_counter > 150:
                    self.debug_info_status = "Status: Target completely LOST! (Aborted)"
                    self.acuter.emergency_stop()
                    self.btn_auto_track.setChecked(False)
                    self.target_id = None
                    if self.capture_thread: self.capture_thread.yolo_target_locked = False
                    self.last_target_pos = None
                    self.lost_counter = 0

        elif motion_box is not None:
            tcx, tcy = motion_cx, motion_cy
            is_tracking_active = True
            tracking_color = (0, 165, 255)

        for d in detections:
            tid = d[6]
            is_target = (self.target_id is not None and tid == self.target_id)
            color = (0,0,255) if is_target else (0,255,0)
            thickness = 4 if is_target else 2
            
            cv2.rectangle(draw_frame, (d[0], d[1]), (d[2], d[3]), color, thickness)
            
            id_str = f"ID:{tid} " if tid is not None else ""
            label_text = f"{id_str}{d[5]} {d[4]*100:.1f}%"
            if is_target:
                label_text = "[LOCKED] " + label_text
            
            cv2.putText(draw_frame, label_text, (d[0], max(d[1] - 10, 10)), cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2)

        if is_tracking_active and tcx is not None:
            cv2.line(draw_frame, (int(tcx) - 15, int(tcy)), (int(tcx) + 15, int(tcy)), tracking_color, 2)
            cv2.line(draw_frame, (int(tcx), int(tcy) - 15), (int(tcx), int(tcy) + 15), tracking_color, 2)
            cv2.line(draw_frame, (int(tcx), int(tcy)), (cx, cy), (0, 255, 255), 1)
            
            if self.btn_auto_track.isChecked() and self.calib_state == 0:
                self._update_tracking(tcx, tcy, w, h)

        if getattr(self, 'is_recording_event', False):
            cv2.circle(draw_frame, (40, 40), 12, (0, 0, 255), -1)
            cv2.putText(draw_frame, "REC", (60, 48), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 0, 255), 3)
            self.video_buffer.append(draw_frame.copy())

        cv2.putText(draw_frame, getattr(self, 'debug_info_dxdy', ''), (10, 90), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 255), 2)
        cv2.putText(draw_frame, getattr(self, 'debug_info_azalt', ''), (10, 120), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 255), 2)
        cv2.putText(draw_frame, getattr(self, 'debug_info_status', ''), (10, 150), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 255), 2)

        qimg = QImage(draw_frame.data, w, h, 3*w, QImage.Format.Format_BGR888)
        self.preview_label.setPixmap(QPixmap.fromImage(qimg).scaled(self.preview_label.size(), Qt.AspectRatioMode.KeepAspectRatio))

    def _update_tracking(self, tcx, tcy, w, h):
        if not self.acuter.is_connected or time.time() < self.next_track_time: return
        self.next_track_time = time.time() + 0.1 
        
        dx, dy = tcx - w/2.0, tcy - h/2.0
        db = self.deadband_spin.value()
        
        if abs(dx) < db and abs(dy) < db:
            if self.acuter.axis_moving.get(1) or self.acuter.axis_moving.get(2):
                self.acuter.emergency_stop()
            self.debug_info_status = f"Status: Target Centered (<{db}px)"
            return
            
        deg_per_px = (self.pixel_size * self.current_bins / self.focal_spin.value()) * (180.0 / np.pi)
        vx = dx * deg_per_px * self.kp_spin.value() * (-1.0 if self.cb_inv_x.isChecked() else 1.0)
        vy = dy * deg_per_px * self.kp_spin.value() * (-1.0 if self.cb_inv_y.isChecked() else 1.0)

        az_deg = vx if self.combo_x_axis.currentIndex() == 0 else vy
        alt_deg = vx if self.combo_x_axis.currentIndex() == 1 else vy

        spd = float(self.acuter_speed_combo.currentText())
        db_deg = db * deg_per_px * self.kp_spin.value()

        self.debug_info_dxdy = f"Target diff: dx={dx:.1f}px, dy={dy:.1f}px"

        p_gain = 0.5 
        speed_az = max(0.1, min(spd, abs(az_deg) * p_gain))
        speed_alt = max(0.1, min(spd, abs(alt_deg) * p_gain))

        sent = False
        
        if abs(az_deg) > db_deg:
            self.acuter.start_move(1, az_deg, exact_goto=False, speed_deg_sec=speed_az)
            sent = True
        elif self.acuter.axis_moving.get(1):
            self.acuter.stop_axis(1)

        if abs(alt_deg) > db_deg:
            self.acuter.start_move(2, alt_deg, exact_goto=False, speed_deg_sec=speed_alt)
            sent = True
        elif self.acuter.axis_moving.get(2):
            self.acuter.stop_axis(2)

        if sent:
            self.debug_info_azalt = f"Speed: Az {speed_az:.1f} d/s, Alt {speed_alt:.1f} d/s"
            self.debug_info_status = "Status: Continuous P-Control Tracking"

    def start_auto_calib(self):
        if not self.acuter.is_connected or self.latest_frame is None: return
        self.btn_auto_track.setChecked(False)
        self.calib_state = 1
        self.calib_timer.start(1000)

    def stop_calib(self, msg):
        self.calib_state = 0
        self.calib_timer.stop()
        self.statusBar().showMessage(msg, 5000)

    def calib_step(self):
        if self.latest_frame is None: return
        gray = cv2.cvtColor(self.latest_frame, cv2.COLOR_BGR2GRAY)
        if self.calib_state == 1:
            self.calib_pts_prev = cv2.goodFeaturesToTrack(gray, maxCorners=100, qualityLevel=0.01, minDistance=30)
            if self.calib_pts_prev is None: return self.stop_calib("Failed")
            self.calib_gray_prev = gray
            self.acuter.start_move(1, 1.0, exact_goto=True, speed_deg_sec=2.0)
            self.calib_wait = 3
            self.calib_state = 2
        elif self.calib_state == 2:
            self.calib_wait -= 1
            if self.calib_wait <= 0:
                pts_next, status, _ = cv2.calcOpticalFlowPyrLK(self.calib_gray_prev, gray, self.calib_pts_prev, None)
                diff = pts_next[status == 1] - self.calib_pts_prev[status == 1]
                self.calib_dx1, self.calib_dy1 = np.median(diff[:, 0]), np.median(diff[:, 1])
                self.acuter.start_move(1, -1.0, exact_goto=True, speed_deg_sec=2.0)
                self.calib_wait = 3; self.calib_state = 3
        elif self.calib_state == 3:
            self.calib_wait -= 1
            if self.calib_wait <= 0:
                self.calib_pts_prev = cv2.goodFeaturesToTrack(gray, maxCorners=100, qualityLevel=0.01, minDistance=30)
                self.calib_gray_prev = gray
                self.acuter.start_move(2, 1.0, exact_goto=True, speed_deg_sec=2.0)
                self.calib_wait = 3; self.calib_state = 4
        elif self.calib_state == 4:
            self.calib_wait -= 1
            if self.calib_wait <= 0:
                pts_next, status, _ = cv2.calcOpticalFlowPyrLK(self.calib_gray_prev, gray, self.calib_pts_prev, None)
                diff = pts_next[status == 1] - self.calib_pts_prev[status == 1]
                self.calib_dx2, self.calib_dy2 = np.median(diff[:, 0]), np.median(diff[:, 1])
                self.acuter.start_move(2, -1.0, exact_goto=True, speed_deg_sec=2.0)
                self.calib_wait = 3; self.calib_state = 5
        elif self.calib_state == 5:
            self.calib_wait -= 1
            if self.calib_wait <= 0:
                if abs(self.calib_dx1) > abs(self.calib_dy1):
                    self.combo_x_axis.setCurrentIndex(0); self.combo_y_axis.setCurrentIndex(0)
                    self.cb_inv_x.setChecked(bool(self.calib_dx1 > 0))
                    self.cb_inv_y.setChecked(bool(self.calib_dy2 > 0))
                else:
                    self.combo_x_axis.setCurrentIndex(1); self.combo_y_axis.setCurrentIndex(1)
                    self.cb_inv_x.setChecked(bool(self.calib_dx2 > 0))
                    self.cb_inv_y.setChecked(bool(self.calib_dy1 > 0))
                self.stop_calib("Success")

    def closeEvent(self, event):
        self.acuter.disconnect()
        self.ef_controller.disconnect()
        if self.capture_thread: self.capture_thread.stop()
        self.camera.close()
        super().closeEvent(event)

if __name__ == '__main__':
    app = QApplication(sys.argv)
    dark_stylesheet = """
        QMainWindow { background-color: #2b2b2b; } QLabel { color: #e0e0e0; font-size: 13px; }
        QGroupBox { color: #e0e0e0; border: 1px solid #555; border-radius: 6px; margin-top: 16px; padding-top: 15px; font-weight: bold; }
        QGroupBox::title { subcontrol-origin: margin; subcontrol-position: top left; left: 10px; padding: 0 5px; }
        QTabWidget::pane { border: 1px solid #555; } QTabBar::tab { background: #3c3c3c; color: white; padding: 8px 12px; }
        QTabBar::tab:selected { background: #5c9eff; color: black; font-weight: bold; }
        QComboBox, QLineEdit, QSpinBox, QDoubleSpinBox { background-color: #3c3c3c; color: white; border: 1px solid #555; padding: 4px; border-radius: 4px; }
        QCheckBox { color: #e0e0e0; }
        QPushButton { background-color: #3c3c3c; color: #ffffff; border: 1px solid #555; border-radius: 4px; padding: 6px; font-weight: bold; }
        QPushButton:pressed { background-color: #5c9eff; color: #000; }
    """
    app.setStyleSheet(dark_stylesheet) 
    window = MainWindow("/Users/mars/acuter/ASI_Camera_SDK/ASI_linux_mac_SDK_V1.41/lib/mac_arm64/libASICamera2.dylib")
    window.show()
    sys.exit(app.exec())

/Users/mars/acuter/device_controllers.py

import time
import re
import serial
from typing import Optional
from PyQt6.QtCore import QThread, pyqtSignal

def hex_le_to_int(hex_str: str) -> int:
    if len(hex_str) % 2 != 0:
        hex_str = "0" + hex_str
    reversed_hex = "".join([hex_str[i:i+2] for i in range(0, len(hex_str), 2)][::-1])
    return int(reversed_hex, 16)

def int_to_hex_le(val: int, length_chars: int) -> str:
    hex_str = f"{val:0{length_chars}X}"
    reversed_hex = "".join([hex_str[i:i+2] for i in range(0, len(hex_str), 2)][::-1])
    return reversed_hex

class AcuterController:
    def __init__(self):
        self.ser = None
        self.cpr = {1: 1017435, 2: 1017435}
        self.timer_freq = {1: 16000000, 2: 16000000}
        self.current_deg = {1: None, 2: None}
        self.axis_moving = {1: False, 2: False}
        self.current_period = {1: None, 2: None}
        self.current_dir = {1: None, 2: None}

    @property
    def is_connected(self):
        return self.ser is not None and self.ser.is_open

    def connect(self, port: str, baudrate: int = 115200) -> bool:
        try:
            self.ser = serial.Serial(port, baudrate, timeout=0.05)
            for axis in (1, 2):
                cpr_hex = self._send_cmd(f":a{axis}\r")
                timer_hex = self._send_cmd(f":b{axis}\r")
                if cpr_hex and "Error" not in cpr_hex:
                    self.cpr[axis] = hex_le_to_int(cpr_hex)
                if timer_hex and "Error" not in timer_hex:
                    self.timer_freq[axis] = hex_le_to_int(timer_hex)
            return True
        except Exception as e:
            self.disconnect()
            raise e

    def disconnect(self):
        if self.is_connected:
            self.emergency_stop()
            self.ser.close()
        self.ser = None

    def _send_cmd(self, cmd: str) -> str:
        if not self.is_connected: return ""
        try:
            self.ser.reset_input_buffer()
            self.ser.write(cmd.encode('ascii'))
            resp_bytes = self.ser.read_until(b'\r')
            resp = resp_bytes.decode('ascii', errors='ignore').strip()
            return resp[1:] if resp.startswith('=') else resp
        except Exception:
            return ""

    def poll_position(self):
        if not self.is_connected: return
        for axis in (1, 2):
            pos_hex = self._send_cmd(f":j{axis}\r")
            if pos_hex and "Error" not in pos_hex and len(pos_hex) >= 5:
                try:
                    current_pos = hex_le_to_int(pos_hex)
                    center = 0x800000 if len(pos_hex) <= 6 else 0x80000000
                    deg = ((current_pos - center) / float(self.cpr[axis])) * 360.0
                    self.current_deg[axis] = (deg + 180) % 360 - 180
                except ValueError:
                    pass

    def start_move(self, axis: int, angle_degrees: float, exact_goto: bool = False, speed_deg_sec: float = 10.0):
        if not self.is_connected or speed_deg_sec <= 0 or angle_degrees == 0: return
        try:
            dir_char = "0" if angle_degrees >= 0 else "1"
            steps_per_sec = (speed_deg_sec / 360.0) * self.cpr[axis]
            period = max(1, int(self.timer_freq[axis] / steps_per_sec))
            period_hex_le = int_to_hex_le(period, 6)
            
            if exact_goto:
                pos_hex = self._send_cmd(f":j{axis}\r")
                if not pos_hex or "Error" in pos_hex: return
                cur_pos = hex_le_to_int(pos_hex)
                pos_len = len(pos_hex)
                offset_steps = int(self.cpr[axis] * (angle_degrees / 360.0))
                max_val = 1 << (pos_len * 4)
                target_pos = (cur_pos + offset_steps) % max_val
                target_hex_le = int_to_hex_le(target_pos, pos_len)
                
                mode = "0" + dir_char 
                self._send_cmd(f":S{axis}{target_hex_le}\r")
                self._send_cmd(f":I{axis}{period_hex_le}\r")
                self._send_cmd(f":G{axis}{mode}\r")
                self._send_cmd(f":J{axis}\r")
            else:
                if self.axis_moving.get(axis, False):
                    prev_dir = self.current_dir.get(axis)
                    prev_period = self.current_period.get(axis)
                    
                    if prev_dir == dir_char and prev_period is not None:
                        if abs(period - prev_period) / float(prev_period) < 0.15:
                            return
                    elif prev_dir != dir_char:
                        self.ser.reset_output_buffer()
                        self._send_cmd(f":L{axis}\r")
                        time.sleep(0.05)
                
                self.current_dir[axis] = dir_char
                self.current_period[axis] = period
                self.axis_moving[axis] = True

                mode = "3" + dir_char 
                self._send_cmd(f":I{axis}{period_hex_le}\r")
                self._send_cmd(f":G{axis}{mode}\r")
                self._send_cmd(f":J{axis}\r")
        except Exception:
            pass

    def stop_axis(self, axis: int):
        if not self.is_connected: return
        if self.axis_moving.get(axis, False):
            self.ser.reset_output_buffer() 
            self._send_cmd(f":L{axis}\r")
            self.axis_moving[axis] = False
            self.current_period[axis] = None
            self.current_dir[axis] = None

    def emergency_stop(self):
        if not self.is_connected: return
        self.ser.reset_output_buffer()
        self._send_cmd(":L1\r")
        self._send_cmd(":L2\r")
        self.axis_moving = {1: False, 2: False}
        self.current_period = {1: None, 2: None}
        self.current_dir = {1: None, 2: None}

class AstromechanicsEFController:
    def __init__(self, port: str = "", baudrate: int = 38400, timeout: float = 1.0):
        self.port = port
        self.baudrate = baudrate
        self.timeout = timeout
        self.ser: Optional[serial.Serial] = None

    def connect(self) -> bool:
        try:
            self.ser = serial.Serial(self.port, self.baudrate, timeout=self.timeout)
            time.sleep(2)
            self.ser.reset_input_buffer()
            return self.get_position() is not None
        except Exception as e:
            self.ser = None
            raise e

    def disconnect(self):
        if self.ser and self.ser.is_open:
            self.ser.close()
        self.ser = None

    @property
    def is_connected(self) -> bool:
        return self.ser is not None and self.ser.is_open

    def _send(self, cmd: str, expect_reply: bool = False) -> Optional[str]:
        if not self.is_connected: return None
        if not cmd.endswith("#"): cmd += "#"
        self.ser.reset_input_buffer()
        self.ser.write(cmd.encode("ascii"))
        self.ser.flush()

        if not expect_reply:
            time.sleep(0.1)
            return None

        response = b""
        start = time.time()
        while time.time() - start < self.timeout:
            if self.ser.in_waiting:
                response += self.ser.read(self.ser.in_waiting)
                if b"#" in response: break
            time.sleep(0.1)

        text = response.decode("ascii", errors="ignore").strip()
        m = re.search(r"(\d+)#?", text)
        return m.group(1) if m else None

    def get_position(self) -> Optional[int]:
        reply = self._send("P#", expect_reply=True)
        return int(reply) if reply else None

    def move_absolute(self, position: int):
        self._send(f"M{position}#", expect_reply=False)

    def set_aperture(self, index: int):
        self._send(f"A{index:02d}#", expect_reply=False)

class PositionWorker(QThread):
    position_ready = pyqtSignal(int)
    error = pyqtSignal(str)
    def __init__(self, controller):
        super().__init__()
        self.controller = controller
    def run(self):
        try:
            pos = self.controller.get_position()
            if pos is not None: self.position_ready.emit(pos)
            else: self.error.emit("位置を取得できません")
        except Exception as e:
            self.error.emit(str(e))

class MoveWorker(QThread):
    finished_ok = pyqtSignal(int)
    error = pyqtSignal(str)
    progress = pyqtSignal(int)
    def __init__(self, controller, target, tolerance=5):
        super().__init__()
        self.controller = controller
        self.target = target
        self.tolerance = tolerance
    def run(self):
        try:
            self.controller.move_absolute(self.target)
            start = time.time()
            while time.time() - start < 30.0:
                pos = self.controller.get_position()
                if pos is None:
                    time.sleep(0.2)
                    continue
                self.progress.emit(pos)
                if abs(pos - self.target) <= self.tolerance:
                    self.finished_ok.emit(pos)
                    return
                time.sleep(0.25)
            self.error.emit("移動タイムアウト")
        except Exception as e:
            self.error.emit(str(e))

/Users/mars/acuter/camera_yolo.py

import os
import time
import urllib.request
import cv2
import numpy as np
import zwoasi as asi
from ultralytics import YOLO

from PyQt6.QtCore import Qt, QThread, pyqtSignal
from PyQt6.QtGui import QPainter, QPen, QColor
from PyQt6.QtWidgets import QLabel, QWidget, QHBoxLayout, QSlider, QSpinBox, QCheckBox

YOLO_MODEL_URL = "https://github.com/ultralytics/assets/releases/download/v8.4.0/yolov8n.pt"
YOLO_MODEL_FILE = "/Users/mars/acuter/yolov8n.pt"

def download_model_files():
    if not os.path.exists(YOLO_MODEL_FILE):
        print(f"Downloading {YOLO_MODEL_FILE} from GitHub. Please wait...")
        try:
            os.makedirs(os.path.dirname(YOLO_MODEL_FILE), exist_ok=True)
            urllib.request.urlretrieve(YOLO_MODEL_URL, YOLO_MODEL_FILE)
            print("Download completed successfully.")
        except Exception as e:
            print(f"Error downloading the model: {e}")

def create_tracker():
    try:
        return cv2.TrackerKCF_create()
    except AttributeError:
        try:
            return cv2.TrackerMIL_create()
        except AttributeError:
            try:
                return cv2.legacy.TrackerKCF_create()
            except AttributeError:
                return None

class VideoLabel(QLabel):
    clicked = pyqtSignal(int, int)
    roi_selected = pyqtSignal(int, int, int, int)

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.start_point = None
        self.end_point = None
        self.is_drawing = False

    def mousePressEvent(self, event):
        if event.button() == Qt.MouseButton.LeftButton:
            self.start_point = event.position().toPoint()
            self.end_point = self.start_point
            self.is_drawing = True
        super().mousePressEvent(event)

    def mouseMoveEvent(self, event):
        if self.is_drawing:
            self.end_point = event.position().toPoint()
            self.update()
        super().mouseMoveEvent(event)

    def mouseReleaseEvent(self, event):
        if event.button() == Qt.MouseButton.LeftButton and self.is_drawing:
            self.end_point = event.position().toPoint()
            self.is_drawing = False
            self.update()
            dist = (self.start_point.x() - self.end_point.x())**2 + (self.start_point.y() - self.end_point.y())**2
            if dist < 25: 
                self.clicked.emit(self.start_point.x(), self.start_point.y())
            else:
                x1 = min(self.start_point.x(), self.end_point.x())
                y1 = min(self.start_point.y(), self.end_point.y())
                w = abs(self.start_point.x() - self.end_point.x())
                h = abs(self.start_point.y() - self.end_point.y())
                self.roi_selected.emit(x1, y1, w, h)
            self.start_point = None
            self.end_point = None
        super().mouseReleaseEvent(event)

    def paintEvent(self, event):
        super().paintEvent(event)
        if self.is_drawing and self.start_point and self.end_point:
            painter = QPainter(self)
            pen = QPen(QColor(0, 255, 255))
            pen.setWidth(2)
            pen.setStyle(Qt.PenStyle.DashLine)
            painter.setPen(pen)
            x = min(self.start_point.x(), self.end_point.x())
            y = min(self.start_point.y(), self.end_point.y())
            w = abs(self.start_point.x() - self.end_point.x())
            h = abs(self.start_point.y() - self.end_point.y())
            painter.drawRect(x, y, w, h)
            painter.end()

class CaptureThread(QThread):
    frame_ready = pyqtSignal(np.ndarray, list, object)
    error = pyqtSignal(str)

    def __init__(self, camera, model_file, parent=None):
        super().__init__(parent)
        self.camera = camera
        self._running = False
        self.model_file = model_file
        self.yolo_model = None
        self.target_classes = []
        self.detection_enabled = False
        
        self.motion_enabled = False
        self.camera_is_moving = False
        self.cv2_tracker = None
        self.yolo_target_locked = False
        self.prev_frame = None

    def run(self):
        self._running = True
        try:
            self.yolo_model = YOLO(self.model_file)
        except Exception as e:
            print(f"Failed to load YOLO model: {e}")
            self.yolo_model = None

        self.camera.start_video_capture()
        try:
            while self._running:
                try:
                    frame = self.camera.capture_video_frame(timeout=2000)
                except asi.ZWO_Error:
                    continue
                except Exception as exc: 
                    self.error.emit(str(exc))
                    continue
                
                detections = []
                if self.detection_enabled and self.yolo_model is not None and self.target_classes:
                    try:
                        results = self.yolo_model.track(
                            frame, imgsz=480, classes=self.target_classes, 
                            persist=True, verbose=False, tracker="bytetrack.yaml"
                        )
                        for r in results:
                            for box in r.boxes:
                                x1, y1, x2, y2 = box.xyxy[0].cpu().numpy().astype(int)
                                conf = float(box.conf[0])
                                cls_id = int(box.cls[0])
                                name = self.yolo_model.names[cls_id]
                                track_id = int(box.id[0]) if box.id is not None else None
                                if conf > 0.25:
                                    detections.append((x1, y1, x2, y2, conf, name, track_id))
                    except Exception:
                        pass

                motion_box = None
                if self.yolo_target_locked:
                    self.cv2_tracker = None
                    self.prev_frame = None
                elif self.motion_enabled:
                    if self.cv2_tracker is not None:
                        success, bbox = self.cv2_tracker.update(frame)
                        if success:
                            motion_box = bbox
                        else:
                            self.cv2_tracker = None
                    
                    if self.cv2_tracker is None:
                        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
                        if self.prev_frame is None or self.camera_is_moving or self.prev_frame.shape != gray.shape:
                            self.prev_frame = gray.copy()
                        else:
                            diff = cv2.absdiff(self.prev_frame, gray)
                            self.prev_frame = gray.copy()
                            _, thresh = cv2.threshold(diff, 20, 255, cv2.THRESH_BINARY)
                            thresh = cv2.dilate(thresh, None, iterations=2)
                            contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
                            
                            best_area = 0
                            best_bbox = None
                            for c in contours:
                                area = cv2.contourArea(c)
                                if 15 < area < 10000:
                                    if area > best_area:
                                        best_area = area
                                        best_bbox = cv2.boundingRect(c)
                            
                            if best_bbox is not None:
                                self.cv2_tracker = create_tracker()
                                if self.cv2_tracker is not None:
                                    try:
                                        self.cv2_tracker.init(frame, best_bbox)
                                        motion_box = best_bbox
                                    except Exception:
                                        self.cv2_tracker = None

                self.frame_ready.emit(frame.copy(), detections, motion_box)
        finally:
            try:
                self.camera.stop_video_capture()
            except:
                pass

    def stop(self):
        self._running = False
        self.wait(2000)

class VideoSaveWorker(QThread):
    finished_ok = pyqtSignal(str)
    error = pyqtSignal(str)

    def __init__(self, frames, filename, fps):
        super().__init__()
        self.frames = frames
        self.filename = filename
        self.fps = fps

    def run(self):
        try:
            if not self.frames:
                self.error.emit("保存するフレームがありません")
                return
            h, w = self.frames[0].shape[:2]
            fourcc = cv2.VideoWriter_fourcc(*'mp4v')
            out = cv2.VideoWriter(self.filename, fourcc, self.fps, (w, h))
            for f in self.frames: out.write(f)
            out.release()
            self.finished_ok.emit(self.filename)
        except Exception as e:
            self.error.emit(str(e))

class ControlRow(QWidget):
    def __init__(self, camera, caps, on_change, parent=None):
        super().__init__(parent)
        self.camera = camera
        self.caps = caps
        self.on_change = on_change
        self._updating = False

        control_type = caps['ControlType']
        min_v, max_v, default_v = caps['MinValue'], caps['MaxValue'], caps['DefaultValue']
        if control_type == 1:
            max_v = 40000
            if default_v > 40000: default_v = 40000

        current_v, is_auto = camera.get_control_value(control_type)
        if control_type == 1 and current_v > 40000: current_v = 40000

        layout = QHBoxLayout(self)
        layout.setContentsMargins(0, 0, 0, 0)

        self.slider = QSlider(Qt.Orientation.Horizontal)
        self.slider.setMinimum(min_v)
        self.slider.setMaximum(max_v)
        self.slider.setValue(current_v)

        self.spin = QSpinBox()
        self.spin.setMinimum(min_v)
        self.spin.setMaximum(max_v)
        self.spin.setMaximumWidth(120) 
        self.spin.setValue(current_v)

        self.auto_box = QCheckBox('Auto')
        self.auto_box.setChecked(is_auto)
        self.auto_box.setEnabled(bool(caps['IsAutoSupported']))

        layout.addWidget(self.slider, stretch=1)
        layout.addWidget(self.spin)
        layout.addWidget(self.auto_box)

        self.slider.valueChanged.connect(self._on_slider)
        self.spin.valueChanged.connect(self._on_spin)
        self.auto_box.toggled.connect(self._on_auto)

        if not caps['IsWritable']: self.setEnabled(False)

    def _sync(self, value):
        self._updating = True
        self.slider.setValue(value)
        self.spin.setValue(value)
        self._updating = False

    def _push(self, value, auto):
        self.camera.set_control_value(self.caps['ControlType'], value, auto)
        self.on_change()

    def _on_slider(self, value):
        if self._updating: return
        self._sync(value)
        self._push(value, self.auto_box.isChecked())

    def _on_spin(self, value):
        if self._updating: return
        self._sync(value)
        self._push(value, self.auto_box.isChecked())

    def _on_auto(self, checked):
        self._push(self.spin.value(), checked)

import sys
import os
import time
import cv2
import numpy as np
import zwoasi as asi

from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QImage, QPixmap
from PyQt6.QtWidgets import (
    QApplication, QCheckBox, QFormLayout, QGroupBox, QHBoxLayout, QLabel,
    QMainWindow, QScrollArea, QSpinBox, QDoubleSpinBox, QStatusBar, 
    QVBoxLayout, QWidget, QLineEdit, QPushButton, QMessageBox, QComboBox, 
    QGridLayout, QTabWidget
)

from device_controllers import AcuterController, AstromechanicsEFController, PositionWorker, MoveWorker
from camera_yolo import download_model_files, YOLO_MODEL_FILE, VideoLabel, CaptureThread, ControlRow, VideoSaveWorker

class MainWindow(QMainWindow):
    def __init__(self, sdk_path):
        super().__init__()
        self.setWindowTitle('ZWO ASI Camera + EF Lens + Acuter + YOLOv8 Complete (v2.39.0)')
        self.resize(1300, 900)

        asi.init(sdk_path)
        if asi.get_num_cameras() == 0:
            raise RuntimeError('ZWOカメラが見つかりません')

        self.camera = asi.Camera(0)
        self.camera.set_image_type(asi.ASI_IMG_RGB24)
        
        cam_prop = self.camera.get_camera_property()
        supported_bins = cam_prop.get('SupportedBins', [1])
        self.current_bins = 2 if 2 in supported_bins else 1
        current_w = cam_prop['MaxWidth'] // self.current_bins
        current_h = cam_prop['MaxHeight'] // self.current_bins
        
        self.camera.set_roi(start_x=0, start_y=0, width=current_w, height=current_h, bins=self.current_bins)
        self.current_start_x = 0
        self.current_start_y = 0

        self.target_id = None
        self.last_target_pos = None
        self.last_target_name = None
        self.current_detections = []
        self.last_frame_size = (current_w, current_h)
        self.latest_frame = None
        self.last_motion_box = None

        self.pixel_size = 0.0038
        self.next_track_time = 0.0
        self.lost_counter = 0
        
        self.is_recording_event = False
        self.video_buffer = []
        self.home_deg = {1: None, 2: None}
        
        self.debug_info_dxdy = "Target diff: N/A"
        self.debug_info_azalt = "Cmd: N/A"
        self.debug_info_status = "Status: Idle"
        
        self.calib_state = 0
        self.calib_timer = QTimer(self)
        self.calib_timer.timeout.connect(self.calib_step)
        self.calib_pts_prev = None
        self.calib_gray_prev = None

        self.acuter = AcuterController()
        self.acuter_poll_timer = QTimer(self)
        self.acuter_poll_timer.timeout.connect(self.poll_acuter_position)

        self.ef_controller = AstromechanicsEFController()
        self.capture_thread = None

        self._build_ui()
        self._update_ef_ui_state(False)
        self._set_acuter_controls_state(False)

        self._frame_count = 0
        self._start_capture()
        QTimer.singleShot(500, self.auto_connect_devices)

    def auto_connect_devices(self):
        port_ef = self.ef_port_edit.text().strip()
        if port_ef:
            self.ef_controller.port = port_ef
            self.statusBar().showMessage("EFレンズ自動接続試行中...")
            try:
                if self.ef_controller.connect():
                    self._update_ef_ui_state(True)
                    self.btn_ef_connect.setText("接続済")
                    self.btn_ef_connect.setStyleSheet("background-color: #28a745; color: white;")
                    self.on_ef_refresh_position()
            except Exception:
                self.ef_controller.disconnect()

        port_acuter = self.acuter_port_input.text().strip()
        if port_acuter:
            self.statusBar().showMessage("Acuterマウント自動接続試行中...")
            try:
                if self.acuter.connect(port_acuter):
                    self.btn_acuter_connect.setText("切断")
                    self.btn_acuter_connect.setStyleSheet("background-color: #28a745; color: white;")
                    self.lbl_acuter_status.setText("接続済み (GoTo/Slew有効)")
                    self.lbl_acuter_status.setStyleSheet("color: #00FF00; font-weight: bold;")
                    self._set_acuter_controls_state(True)
                    self.acuter_poll_timer.start(500)
            except Exception:
                self.statusBar().showMessage("自動接続に失敗しました。後で手動接続してください。")

    def _start_capture(self):
        if self.capture_thread and self.capture_thread.isRunning():
            self.capture_thread.stop()
        
        self.capture_thread = CaptureThread(self.camera, YOLO_MODEL_FILE)
        self.capture_thread.target_classes = self.get_current_target_classes()
        self.capture_thread.detection_enabled = self.cb_detection.isChecked()
        self.capture_thread.motion_enabled = self.cb_motion.isChecked()
        self.capture_thread.frame_ready.connect(self._on_frame)
        self.capture_thread.error.connect(lambda msg: self.statusBar().showMessage(f"取得エラー: {msg}"))
        self.capture_thread.start()

    def _build_ui(self):
        central = QWidget()
        self.setCentralWidget(central)
        root = QHBoxLayout(central)
        root.setContentsMargins(15, 15, 15, 15)
        root.setSpacing(15)

        # ========== 左ペイン ==========
        left_widget = QWidget()
        left_layout = QVBoxLayout(left_widget)
        left_layout.setContentsMargins(0, 0, 0, 0)
        left_layout.setSpacing(15)

        self.preview_label = VideoLabel('starting...')
        self.preview_label.setMinimumSize(640, 480)
        self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.preview_label.setStyleSheet("background-color: #000000; color: #FFFFFF; border-radius: 8px;")
        self.preview_label.clicked.connect(self._on_preview_clicked)
        self.preview_label.roi_selected.connect(self._on_roi_dragged)
        left_layout.addWidget(self.preview_label, stretch=1)

        self.ef_group = QGroupBox("Canon EF Lens (Astromechanics)")
        ef_layout = QVBoxLayout(self.ef_group)
        
        ef_conn_layout = QHBoxLayout()
        self.ef_port_edit = QLineEdit("/dev/tty.usbserial-AK06UIRD")
        ef_conn_layout.addWidget(QLabel("ポート:"))
        ef_conn_layout.addWidget(self.ef_port_edit, stretch=1)
        self.btn_ef_connect = QPushButton("接続")
        self.btn_ef_connect.clicked.connect(self.on_ef_connect)
        self.btn_ef_disconnect = QPushButton("切断")
        self.btn_ef_disconnect.clicked.connect(self.on_ef_disconnect)
        ef_conn_layout.addWidget(self.btn_ef_connect)
        ef_conn_layout.addWidget(self.btn_ef_disconnect)
        ef_layout.addLayout(ef_conn_layout)

        ef_pos_move_row = QHBoxLayout()
        self.lbl_ef_position = QLabel("—")
        self.lbl_ef_position.setStyleSheet("color: white; background-color: #333; padding: 4px; border-radius: 4px;")
        self.btn_ef_refresh = QPushButton("更新")
        self.btn_ef_refresh.clicked.connect(self.on_ef_refresh_position)
        self.spin_ef_target = QSpinBox()
        self.spin_ef_target.setRange(0, 32767)
        self.spin_ef_target.setValue(5000)
        self.spin_ef_target.setSingleStep(100)
        self.btn_ef_goto = QPushButton("移動")
        self.btn_ef_goto.clicked.connect(self.on_ef_goto)
        ef_pos_move_row.addWidget(QLabel("現在位置:"))
        ef_pos_move_row.addWidget(self.lbl_ef_position)
        ef_pos_move_row.addWidget(self.btn_ef_refresh)
        ef_pos_move_row.addSpacing(20)
        ef_pos_move_row.addWidget(QLabel("目標位置:"))
        ef_pos_move_row.addWidget(self.spin_ef_target)
        ef_pos_move_row.addWidget(self.btn_ef_goto)
        ef_layout.addLayout(ef_pos_move_row)

        ef_rel_ap_row = QHBoxLayout()
        self.spin_ef_rel = QSpinBox()
        self.spin_ef_rel.setRange(1, 5000)
        self.spin_ef_rel.setValue(100)
        self.spin_ef_rel.setSingleStep(50)
        self.btn_ef_in = QPushButton("← IN (−)")
        self.btn_ef_in.clicked.connect(lambda: self.on_ef_relative(-1))
        self.btn_ef_out = QPushButton("OUT (+) →")
        self.btn_ef_out.clicked.connect(lambda: self.on_ef_relative(+1))
        self.spin_ef_aperture = QSpinBox()
        self.spin_ef_aperture.setRange(0, 30)
        self.spin_ef_aperture.setValue(0)
        self.btn_ef_aperture = QPushButton("設定")
        self.btn_ef_aperture.clicked.connect(self.on_ef_set_aperture)
        ef_rel_ap_row.addWidget(QLabel("相対:"))
        ef_rel_ap_row.addWidget(self.spin_ef_rel)
        ef_rel_ap_row.addWidget(self.btn_ef_in)
        ef_rel_ap_row.addWidget(self.btn_ef_out)
        ef_rel_ap_row.addSpacing(20)
        ef_rel_ap_row.addWidget(QLabel("絞り(0=開放):"))
        ef_rel_ap_row.addWidget(self.spin_ef_aperture)
        ef_rel_ap_row.addWidget(self.btn_ef_aperture)
        ef_rel_ap_row.addStretch()
        ef_layout.addLayout(ef_rel_ap_row)
        left_layout.addWidget(self.ef_group)
        root.addWidget(left_widget, stretch=3)

        # ========== 右ペイン ==========
        right_scroll = QScrollArea()
        right_scroll.setWidgetResizable(True)
        right_scroll.setMinimumWidth(430)
        right_scroll.setFrameShape(QScrollArea.Shape.NoFrame)

        right_panel = QWidget()
        right_layout = QVBoxLayout(right_panel)
        right_layout.setContentsMargins(0, 0, 10, 0)
        
        self.ai_group = QGroupBox("AI Object Tracking")
        ai_layout = QVBoxLayout(self.ai_group)
        self.cb_detection = QCheckBox("YOLOv8 トラッキングを有効にする (ID保持)")
        self.cb_detection.setChecked(False)
        self.cb_detection.toggled.connect(self.on_detection_toggled)
        ai_layout.addWidget(self.cb_detection)

        self.cb_motion = QCheckBox("動体検知 (Motion Detect) [YOLO認識前の小目標用]")
        self.cb_motion.setChecked(True)
        self.cb_motion.setStyleSheet("color: #ffcc00; font-weight: bold;")
        self.cb_motion.toggled.connect(self.on_motion_toggled)
        ai_layout.addWidget(self.cb_motion)

        info_label = QLabel("※クリックで「対象ロックオン」、ドラッグで「ROI切り出し」")
        info_label.setStyleSheet("color: #aaaaaa; font-size: 11px;")
        ai_layout.addWidget(info_label)

        targets_layout = QHBoxLayout()
        self.cb_person = QCheckBox("人")
        self.cb_bicycle = QCheckBox("自転車")
        self.cb_car = QCheckBox("車")
        self.cb_airplane = QCheckBox("航空機")
        self.cb_bird = QCheckBox("鳥")
        for cb in [self.cb_person, self.cb_bicycle, self.cb_car, self.cb_airplane, self.cb_bird]:
            cb.setChecked(True)
            cb.toggled.connect(self.update_target_classes)
            targets_layout.addWidget(cb)
        ai_layout.addLayout(targets_layout)
        
        self.cb_auto_engage = QCheckBox("選択対象の自動検知&録画 (Auto Engage)")
        self.cb_auto_engage.setChecked(False)
        self.cb_auto_engage.setStyleSheet("color: #5c9eff; font-weight: bold;")
        ai_layout.addWidget(self.cb_auto_engage)
        right_layout.addWidget(self.ai_group)

        self.acuter_group = QGroupBox("Acuter Traverse Control")
        self.acuter_layout = QVBoxLayout(self.acuter_group)

        acuter_conn_layout = QHBoxLayout()
        self.acuter_port_input = QLineEdit("/dev/cu.usbmodem4E94509B34001")
        self.btn_acuter_connect = QPushButton("接続")
        self.btn_acuter_connect.clicked.connect(self.toggle_acuter_connection)
        acuter_conn_layout.addWidget(self.acuter_port_input)
        acuter_conn_layout.addWidget(self.btn_acuter_connect)
        self.acuter_layout.addLayout(acuter_conn_layout)

        self.lbl_acuter_status = QLabel("未接続")
        self.lbl_acuter_status.setStyleSheet("color: red; font-weight: bold;")
        self.acuter_layout.addWidget(self.lbl_acuter_status)
        
        self.btn_auto_track = QPushButton("Auto Tracking (OFF / 待機)")
        self.btn_auto_track.setCheckable(True)
        self.btn_auto_track.setFixedHeight(60)
        self.btn_auto_track.setStyleSheet("font-size: 18px; font-weight: bold; background-color: #555555; color: white;")
        self.btn_auto_track.toggled.connect(self.on_auto_track_toggled)
        self.acuter_layout.addWidget(self.btn_auto_track)

        pos_layout = QHBoxLayout()
        self.lbl_acuter_az = QLabel("Az : --.- °")
        self.lbl_acuter_az.setStyleSheet("font-family: 'Menlo', 'Consolas', 'Courier New'; font-size: 16px; font-weight: bold; color: white; background-color: #333; padding: 4px; border-radius: 4px;")
        self.lbl_acuter_alt = QLabel("Alt: --.- °")
        self.lbl_acuter_alt.setStyleSheet("font-family: 'Menlo', 'Consolas', 'Courier New'; font-size: 16px; font-weight: bold; color: white; background-color: #333; padding: 4px; border-radius: 4px;")
        pos_layout.addWidget(self.lbl_acuter_az)
        pos_layout.addWidget(self.lbl_acuter_alt)
        self.acuter_layout.addLayout(pos_layout)

        speed_form = QFormLayout()
        self.acuter_speed_combo = QComboBox()
        self.acuter_speed_combo.addItems(["1.0", "5.0", "10.0", "15.0"])
        self.acuter_speed_combo.setCurrentText("15.0")
        speed_form.addRow("最大回転速度(度/秒):", self.acuter_speed_combo)
        self.acuter_layout.addLayout(speed_form)

        goto_layout = QHBoxLayout()
        self.acuter_az_input = QLineEdit("10.0")
        self.acuter_az_input.setMaximumWidth(50)
        self.btn_az_goto = QPushButton("Az GoTo")
        self.btn_az_goto.setEnabled(False)
        self.btn_az_goto.clicked.connect(lambda: self.acuter.start_move(1, float(self.acuter_az_input.text()), exact_goto=True))
        self.acuter_alt_input = QLineEdit("10.0")
        self.acuter_alt_input.setMaximumWidth(50)
        self.btn_alt_goto = QPushButton("Alt GoTo")
        self.btn_alt_goto.setEnabled(False)
        self.btn_alt_goto.clicked.connect(lambda: self.acuter.start_move(2, float(self.acuter_alt_input.text()), exact_goto=True))
        goto_layout.addWidget(QLabel("Az:"))
        goto_layout.addWidget(self.acuter_az_input)
        goto_layout.addWidget(QLabel("°"))
        goto_layout.addWidget(self.btn_az_goto)
        goto_layout.addSpacing(15)
        goto_layout.addWidget(QLabel("Alt:"))
        goto_layout.addWidget(self.acuter_alt_input)
        goto_layout.addWidget(QLabel("°"))
        goto_layout.addWidget(self.btn_alt_goto)
        goto_layout.addStretch()
        self.acuter_layout.addLayout(goto_layout)

        dpad_layout = QGridLayout()
        self.btn_up = QPushButton("▲")
        self.btn_left = QPushButton("◀")
        self.btn_stop_center = QPushButton("■ STOP")
        self.btn_stop_center.setStyleSheet("background-color: #d9534f; color: white; font-weight: bold;")
        self.btn_right = QPushButton("▶")
        self.btn_down = QPushButton("▼")

        huge_angle = 1000.0 
        self.btn_up.pressed.connect(lambda: self.acuter.start_move(2, -huge_angle, speed_deg_sec=float(self.acuter_speed_combo.currentText())))
        self.btn_down.pressed.connect(lambda: self.acuter.start_move(2, huge_angle, speed_deg_sec=float(self.acuter_speed_combo.currentText())))
        self.btn_left.pressed.connect(lambda: self.acuter.start_move(1, -huge_angle, speed_deg_sec=float(self.acuter_speed_combo.currentText())))
        self.btn_right.pressed.connect(lambda: self.acuter.start_move(1, huge_angle, speed_deg_sec=float(self.acuter_speed_combo.currentText())))
        
        self.btn_up.released.connect(lambda: self.acuter.stop_axis(2))
        self.btn_down.released.connect(lambda: self.acuter.stop_axis(2))
        self.btn_left.released.connect(lambda: self.acuter.stop_axis(1))
        self.btn_right.released.connect(lambda: self.acuter.stop_axis(1))
        self.btn_stop_center.clicked.connect(self.acuter.emergency_stop)

        dpad_layout.addWidget(self.btn_up, 0, 1)
        dpad_layout.addWidget(self.btn_left, 1, 0)
        dpad_layout.addWidget(self.btn_stop_center, 1, 1)
        dpad_layout.addWidget(self.btn_right, 1, 2)
        dpad_layout.addWidget(self.btn_down, 2, 1)
        self.acuter_layout.addLayout(dpad_layout)
        right_layout.addWidget(self.acuter_group)

        # 3. ZWO ASI カメラ設定
        asi_group = QGroupBox('ZWO ASI & Tracking Settings')
        asi_layout = QVBoxLayout(asi_group)

        self.cam_tabs = QTabWidget()
        self.tab_main = QWidget()
        self.tab_adv = QWidget()
        form_main = QFormLayout(self.tab_main)
        form_adv = QFormLayout(self.tab_adv)

        self.btn_auto_calib = QPushButton("Run Auto Calibration")
        self.btn_auto_calib.clicked.connect(self.start_auto_calib)
        self.btn_auto_calib.setStyleSheet("background-color: #28a745; color: white; font-weight: bold; font-size: 16px; padding: 12px;")
        form_main.addRow(self.btn_auto_calib)

        track_calib_group = QGroupBox("Tracking Settings & Axis Mapping")
        track_calib_layout = QFormLayout(track_calib_group)

        self.focal_spin = QDoubleSpinBox()
        self.focal_spin.setRange(1.0, 2000.0)
        self.focal_spin.setValue(55.0) 
        self.kp_spin = QDoubleSpinBox()
        self.kp_spin.setRange(0.01, 5.0)
        self.kp_spin.setSingleStep(0.1)
        self.kp_spin.setValue(0.8)
        self.deadband_spin = QSpinBox()
        self.deadband_spin.setRange(5, 200)
        self.deadband_spin.setValue(40)

        self.combo_x_axis = QComboBox()
        self.combo_x_axis.addItems(["Az (Axis 1)", "Alt (Axis 2)"])
        self.combo_x_axis.setCurrentIndex(0) 
        self.combo_y_axis = QComboBox()
        self.combo_y_axis.addItems(["Alt (Axis 2)", "Az (Axis 1)"])
        self.combo_y_axis.setCurrentIndex(0)
        self.cb_inv_x = QCheckBox("Invert X-Axis (Reverse)")
        self.cb_inv_y = QCheckBox("Invert Y-Axis (Reverse)")
        self.cb_inv_y.setChecked(True)

        test_move_widget = QWidget()
        test_move_grid = QGridLayout(test_move_widget)
        self.btn_test_alt_p = QPushButton("Alt +1.0°")
        self.btn_test_alt_m = QPushButton("Alt -1.0°")
        self.btn_test_az_m = QPushButton("Az -1.0°")
        self.btn_test_az_p = QPushButton("Az +1.0°")
        self.btn_test_alt_p.clicked.connect(lambda: self.acuter.start_move(2, 1.0, exact_goto=True, speed_deg_sec=2.0))
        self.btn_test_alt_m.clicked.connect(lambda: self.acuter.start_move(2, -1.0, exact_goto=True, speed_deg_sec=2.0))
        self.btn_test_az_m.clicked.connect(lambda: self.acuter.start_move(1, -1.0, exact_goto=True, speed_deg_sec=2.0))
        self.btn_test_az_p.clicked.connect(lambda: self.acuter.start_move(1, 1.0, exact_goto=True, speed_deg_sec=2.0))
        
        self.calib_buttons = [self.btn_test_alt_p, self.btn_test_alt_m, self.btn_test_az_m, self.btn_test_az_p, self.btn_auto_calib]
        for btn in self.calib_buttons: btn.setEnabled(False)

        test_move_grid.addWidget(self.btn_test_alt_p, 0, 1)
        test_move_grid.addWidget(self.btn_test_az_m, 1, 0)
        test_move_grid.addWidget(self.btn_test_alt_m, 1, 1)
        test_move_grid.addWidget(self.btn_test_az_p, 1, 2)

        track_calib_layout.addRow("Focal Length (mm):", self.focal_spin)
        track_calib_layout.addRow("Tracking Gain (Kp):", self.kp_spin)
        track_calib_layout.addRow("Deadband (px):", self.deadband_spin)
        track_calib_layout.addRow("Camera X-Axis:", self.combo_x_axis)
        track_calib_layout.addRow("", self.cb_inv_x)
        track_calib_layout.addRow("Camera Y-Axis:", self.combo_y_axis)
        track_calib_layout.addRow("", self.cb_inv_y)
        track_calib_layout.addRow("Test Move:", test_move_widget)
        form_adv.addRow(track_calib_group)

        cam_prop = self.camera.get_camera_property()
        bin_group = QGroupBox("Sensor Binning")
        bin_layout = QHBoxLayout(bin_group)
        self.bin_combo = QComboBox()
        for b in cam_prop['SupportedBins']:
            if b != 0: self.bin_combo.addItem(f"Bin {b}x{b}", b)
        self.bin_combo.setCurrentText(f"Bin {self.current_bins}x{self.current_bins}")
        self.btn_apply_bin = QPushButton("Apply Binning")
        self.btn_apply_bin.clicked.connect(self.on_apply_binning)
        bin_layout.addWidget(QLabel("Binning:"))
        bin_layout.addWidget(self.bin_combo)
        bin_layout.addWidget(self.btn_apply_bin)

        roi_group = QGroupBox("Sensor ROI (Resolution)")
        roi_layout = QGridLayout(roi_group)
        
        current_w = cam_prop['MaxWidth'] // self.current_bins
        current_h = cam_prop['MaxHeight'] // self.current_bins
        
        self.roi_w_spin = QSpinBox()
        self.roi_w_spin.setRange(8, current_w)
        self.roi_w_spin.setSingleStep(8)
        self.roi_w_spin.setValue(current_w)
        self.roi_h_spin = QSpinBox()
        self.roi_h_spin.setRange(2, current_h)
        self.roi_h_spin.setSingleStep(2)
        self.roi_h_spin.setValue(current_h)
        self.btn_apply_roi = QPushButton("Apply ROI (Center)")
        self.btn_apply_roi.clicked.connect(self.on_apply_roi_center)
        self.btn_reset_roi = QPushButton("Reset to Full Frame")
        self.btn_reset_roi.clicked.connect(self.on_reset_roi)
        self.btn_reset_roi.setStyleSheet("background-color: #555555;")
        
        roi_layout.addWidget(QLabel("Width:"), 0, 0)
        roi_layout.addWidget(self.roi_w_spin, 0, 1)
        roi_layout.addWidget(QLabel("Height:"), 1, 0)
        roi_layout.addWidget(self.roi_h_spin, 1, 1)
        roi_layout.addWidget(self.btn_apply_roi, 2, 0, 1, 2)
        roi_layout.addWidget(self.btn_reset_roi, 3, 0, 1, 2)
        
        form_adv.addRow(roi_group)
        form_adv.addRow(bin_group)

        self.rows = []
        for name, caps in sorted(self.camera.get_controls().items()):
            row = ControlRow(self.camera, caps, lambda: self.statusBar().showMessage('ASIパラメータ更新', 1000))
            self.rows.append(row)
            form_adv.addRow(name, row)

        self.cam_tabs.addTab(self.tab_main, "Main Controls")
        self.cam_tabs.addTab(self.tab_adv, "Advanced")
        asi_layout.addWidget(self.cam_tabs)
        
        right_layout.addWidget(asi_group)
        right_layout.addStretch()

        right_scroll.setWidget(right_panel)
        root.addWidget(right_scroll, stretch=2)
        self.setStatusBar(QStatusBar())

    def _update_ef_ui_state(self, connected: bool):
        self.btn_ef_connect.setEnabled(not connected)
        self.btn_ef_disconnect.setEnabled(connected)
        self.btn_ef_goto.setEnabled(connected)
        self.btn_ef_in.setEnabled(connected)
        self.btn_ef_out.setEnabled(connected)

    def on_ef_connect(self):
        try:
            self.ef_controller.port = self.ef_port_edit.text()
            if self.ef_controller.connect():
                self._update_ef_ui_state(True)
                self.on_ef_refresh_position()
        except: pass

    def on_ef_disconnect(self):
        self.ef_controller.disconnect()
        self._update_ef_ui_state(False)

    def on_ef_refresh_position(self):
        if not self.ef_controller.is_connected: return
        pos = self.ef_controller.get_position()
        if pos is not None:
            self.lbl_ef_position.setText(str(pos))
            self.spin_ef_target.setValue(pos)

    def on_ef_goto(self):
        if self.ef_controller.is_connected:
            self.ef_controller.move_absolute(self.spin_ef_target.value())
            QTimer.singleShot(1000, self.on_ef_refresh_position)

    def on_ef_relative(self, direction):
        if self.ef_controller.is_connected:
            target = self.spin_ef_target.value() + (self.spin_ef_rel.value() * direction)
            self.ef_controller.move_absolute(target)
            QTimer.singleShot(1000, self.on_ef_refresh_position)

    def on_ef_set_aperture(self):
        if self.ef_controller.is_connected:
            self.ef_controller.set_aperture(self.spin_ef_aperture.value())

    def toggle_acuter_connection(self):
        if self.acuter.is_connected:
            self.acuter.disconnect()
            self.btn_acuter_connect.setText("接続")
            self._set_acuter_controls_state(False)
        else:
            if self.acuter.connect(self.acuter_port_input.text()):
                self.btn_acuter_connect.setText("切断")
                self._set_acuter_controls_state(True)
                self.acuter_poll_timer.start(500)

    def _set_acuter_controls_state(self, state):
        self.btn_az_goto.setEnabled(state)
        self.btn_alt_goto.setEnabled(state)
        self.btn_up.setEnabled(state)
        self.btn_down.setEnabled(state)
        self.btn_left.setEnabled(state)
        self.btn_right.setEnabled(state)
        self.btn_stop_center.setEnabled(state)
        for btn in self.calib_buttons: btn.setEnabled(state)

    def poll_acuter_position(self):
        self.acuter.poll_position()
        d1, d2 = self.acuter.current_deg.get(1), self.acuter.current_deg.get(2)
        if d1 is not None: self.lbl_acuter_az.setText(f"Az : {d1:+8.3f} °")
        if d2 is not None: self.lbl_acuter_alt.setText(f"Alt: {d2:+8.3f} °")
        
        if self.capture_thread:
            self.capture_thread.camera_is_moving = self.acuter.axis_moving.get(1, False) or self.acuter.axis_moving.get(2, False)

    def on_auto_track_toggled(self, checked):
        if checked:
            self.btn_auto_track.setText("Tracking (ON)")
            self.btn_auto_track.setStyleSheet("background-color: #d9534f; color: white; font-size: 18px; font-weight: bold;")
        else:
            self.btn_auto_track.setText("Tracking (OFF)")
            self.btn_auto_track.setStyleSheet("background-color: #555; color: white; font-size: 18px; font-weight: bold;")
            self.acuter.emergency_stop()
            if getattr(self, 'is_recording_event', False):
                self.stop_event_record_and_return()

    def on_motion_toggled(self, checked):
        if self.capture_thread:
            self.capture_thread.motion_enabled = checked
            if not checked:
                self.capture_thread.cv2_tracker = None

    def start_event_record_and_track(self):
        self.home_deg = {1: self.acuter.current_deg.get(1), 2: self.acuter.current_deg.get(2)}
        self.is_recording_event = True
        self.video_buffer = []
        if not self.btn_auto_track.isChecked(): self.btn_auto_track.setChecked(True)

    def stop_event_record_and_return(self):
        self.is_recording_event = False
        if self.video_buffer:
            save_dir = "/Users/mars/acuter/videos"
            os.makedirs(save_dir, exist_ok=True)
            filename = os.path.join(save_dir, time.strftime("track_%Y%m%d_%H%M%S.mp4"))
            
            self.statusBar().showMessage(f"動画を保存中... {filename}", 5000)
            self.video_saver = VideoSaveWorker(self.video_buffer, filename, 30.0)
            self.video_saver.finished_ok.connect(lambda f: self.statusBar().showMessage(f"保存完了: {f}", 5000))
            self.video_saver.error.connect(lambda err: self.statusBar().showMessage(f"保存エラー: {err}", 5000))
            self.video_saver.start()
            self.video_buffer = []
            
        for axis in (1, 2):
            h_deg = self.home_deg.get(axis)
            c_deg = self.acuter.current_deg.get(axis)
            if h_deg is not None and c_deg is not None:
                diff = h_deg - c_deg
                if diff > 180: diff -= 360
                elif diff < -180: diff += 360
                if abs(diff) > 0.05:
                    self.acuter.start_move(axis, diff, exact_goto=True, speed_deg_sec=15.0)

    def get_current_target_classes(self):
        targets = []
        if self.cb_person.isChecked(): targets.append(0)
        if self.cb_bicycle.isChecked(): targets.append(1)
        if self.cb_car.isChecked(): targets.append(2)
        if self.cb_airplane.isChecked(): targets.append(4)
        if self.cb_bird.isChecked(): targets.append(14)
        return targets

    def update_target_classes(self):
        if self.capture_thread:
            self.capture_thread.target_classes = self.get_current_target_classes()

    def on_detection_toggled(self, checked):
        if self.capture_thread:
            self.capture_thread.detection_enabled = checked
            if not checked:
                self.target_id = None
                self.btn_auto_track.setChecked(False)

    def _on_preview_clicked(self, lx, ly):
        scale = min(self.preview_label.width() / self.last_frame_size[0], self.preview_label.height() / self.last_frame_size[1])
        ox = (self.preview_label.width() - self.last_frame_size[0] * scale) / 2
        oy = (self.preview_label.height() - self.last_frame_size[1] * scale) / 2
        fx, fy = (lx - ox) / scale, (ly - oy) / scale

        clicked_id = None
        for d in self.current_detections:
            if d[0] <= fx <= d[2] and d[1] <= fy <= d[3]:
                clicked_id = d[6]
                break
        
        if clicked_id is not None:
            self.target_id = clicked_id
            if self.capture_thread: self.capture_thread.yolo_target_locked = True
            self.lost_counter = 0  
            if not getattr(self, 'is_recording_event', False):
                self.start_event_record_and_track()
            if not self.btn_auto_track.isChecked():
                self.btn_auto_track.setChecked(True)
            return

        if getattr(self, 'last_motion_box', None) is not None:
            mx, my, mw, mh = self.last_motion_box
            if mx <= fx <= mx+mw and my <= fy <= my+mh:
                self.target_id = None
                if self.capture_thread: self.capture_thread.yolo_target_locked = False
                if not getattr(self, 'is_recording_event', False):
                    self.start_event_record_and_track()
                if not self.btn_auto_track.isChecked():
                    self.btn_auto_track.setChecked(True)
                return

        self.target_id = None
        if self.capture_thread: self.capture_thread.yolo_target_locked = False
        self.cb_auto_engage.setChecked(False)
        self.btn_auto_track.setChecked(False)
        self.acuter.emergency_stop()

    def _on_roi_dragged(self, lx, ly, lw, lh): pass 

    def on_reset_roi(self):
        cam_prop = self.camera.get_camera_property()
        max_w = cam_prop['MaxWidth'] // self.current_bins
        max_h = cam_prop['MaxHeight'] // self.current_bins
        self.camera.set_roi(start_x=0, start_y=0, width=max_w, height=max_h, bins=self.current_bins)
        self._start_capture()
        
    def on_apply_binning(self):
        b = self.bin_combo.currentData()
        self.camera.set_roi(bins=b)
        self.current_bins = b
        self._start_capture()
        
    def on_apply_roi_center(self):
        w, h = (self.roi_w_spin.value() // 8) * 8, (self.roi_h_spin.value() // 2) * 2
        cam_prop = self.camera.get_camera_property()
        sx = ((cam_prop['MaxWidth'] // self.current_bins - w) // 2 // 4) * 4
        sy = ((cam_prop['MaxHeight'] // self.current_bins - h) // 2 // 2) * 2
        self.camera.set_roi(start_x=sx, start_y=sy, width=w, height=h, bins=self.current_bins)
        self._start_capture()

    def _on_frame(self, frame: np.ndarray, detections: list, motion_box: object = None):
        self._frame_count += 1
        self.last_motion_box = motion_box
        h, w = frame.shape[:2]
        self.last_frame_size = (w, h)
        self.current_detections = detections
        self.latest_frame = frame.copy()
        
        draw_frame = frame.copy()
        cx, cy = w // 2, h // 2
        
        db_px = self.deadband_spin.value()
        cv2.circle(draw_frame, (cx, cy), db_px, (255, 255, 255), 1, cv2.LINE_AA)
        cv2.line(draw_frame, (cx-20, cy), (cx+20, cy), (255,255,255), 1)
        cv2.line(draw_frame, (cx, cy-20), (cx, cy+20), (255,255,255), 1)

        motion_cx, motion_cy = None, None
        if motion_box is not None:
            mx, my, mw, mh = [int(v) for v in motion_box]
            motion_cx, motion_cy = mx + mw//2, my + mh//2
            cv2.rectangle(draw_frame, (mx, my), (mx+mw, my+mh), (0, 165, 255), 2)
            cv2.putText(draw_frame, "MOTION", (mx, max(my-10, 10)), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 165, 255), 2)

        if self.target_id is None and motion_box is not None and detections:
            for d in detections:
                dcx, dcy = (d[0]+d[2])//2, (d[1]+d[3])//2
                if np.hypot(dcx - motion_cx, dcy - motion_cy) < 100:
                    self.target_id = d[6]
                    self.last_target_name = d[5]
                    if self.capture_thread: self.capture_thread.yolo_target_locked = True
                    self.statusBar().showMessage(f"Upgraded to YOLO Target: ID {self.target_id}", 3000)
                    break

        if self.cb_auto_engage.isChecked() and self.target_id is None and detections:
            best_det = max(detections, key=lambda d: d[4])
            if best_det[6] is not None:
                self.target_id = best_det[6]
                self.last_target_name = best_det[5]
                self.last_target_pos = ((best_det[0]+best_det[2])//2, (best_det[1]+best_det[3])//2)
                self.lost_counter = 0
                if self.capture_thread: self.capture_thread.yolo_target_locked = True
                self.start_event_record_and_track()

        if self.cb_auto_engage.isChecked() and self.target_id is None and motion_box is not None:
            if not getattr(self, 'is_recording_event', False):
                self.start_event_record_and_track()

        is_tracking_active = False
        tcx, tcy = None, None
        tracking_color = (0, 255, 0)

        if self.target_id is not None:
            target_found = False
            for d in detections:
                if self.target_id == d[6]:
                    target_found = True
                    self.last_target_pos = ((d[0]+d[2])//2, (d[1]+d[3])//2)
                    break
            
            if not target_found and self.last_target_pos:
                best_d = None
                min_dist = float('inf')
                for d in detections:
                    if d[5] == self.last_target_name:
                        dist = np.hypot((((d[0]+d[2])//2) - self.last_target_pos[0]), (((d[1]+d[3])//2) - self.last_target_pos[1]))
                        if dist < min_dist:
                            min_dist = dist
                            best_d = d
                if best_d is not None:
                    self.target_id = best_d[6]
                    target_found = True
                    self.lost_counter = 0

            if target_found:
                tcx, tcy = self.last_target_pos
                is_tracking_active = True
                tracking_color = (0, 0, 255)
                self.lost_counter = 0
            else:
                self.lost_counter += 1
                if self.last_target_pos:
                    gx, gy = self.last_target_pos
                    cv2.circle(draw_frame, (gx, gy), 15, (0,165,255), 2)
                    cv2.putText(draw_frame, "SEARCHING...", (gx+20, gy), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0,165,255), 2)
                
                if self.lost_counter == 1:
                    self.acuter.emergency_stop()
                    self.debug_info_status = "Status: Target Lost (Braking)"
                
                if self.lost_counter > 150:
                    self.debug_info_status = "Status: Target completely LOST! (Aborted)"
                    self.acuter.emergency_stop()
                    self.btn_auto_track.setChecked(False)
                    self.target_id = None
                    if self.capture_thread: self.capture_thread.yolo_target_locked = False
                    self.last_target_pos = None
                    self.lost_counter = 0

        elif motion_box is not None:
            tcx, tcy = motion_cx, motion_cy
            is_tracking_active = True
            tracking_color = (0, 165, 255)

        for d in detections:
            tid = d[6]
            is_target = (self.target_id is not None and tid == self.target_id)
            color = (0,0,255) if is_target else (0,255,0)
            thickness = 4 if is_target else 2
            
            cv2.rectangle(draw_frame, (d[0], d[1]), (d[2], d[3]), color, thickness)
            
            id_str = f"ID:{tid} " if tid is not None else ""
            label_text = f"{id_str}{d[5]} {d[4]*100:.1f}%"
            if is_target:
                label_text = "[LOCKED] " + label_text
            
            cv2.putText(draw_frame, label_text, (d[0], max(d[1] - 10, 10)), cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2)

        if is_tracking_active and tcx is not None:
            cv2.line(draw_frame, (int(tcx) - 15, int(tcy)), (int(tcx) + 15, int(tcy)), tracking_color, 2)
            cv2.line(draw_frame, (int(tcx), int(tcy) - 15), (int(tcx), int(tcy) + 15), tracking_color, 2)
            cv2.line(draw_frame, (int(tcx), int(tcy)), (cx, cy), (0, 255, 255), 1)
            
            if self.btn_auto_track.isChecked() and self.calib_state == 0:
                self._update_tracking(tcx, tcy, w, h)

        if getattr(self, 'is_recording_event', False):
            cv2.circle(draw_frame, (40, 40), 12, (0, 0, 255), -1)
            cv2.putText(draw_frame, "REC", (60, 48), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 0, 255), 3)
            self.video_buffer.append(draw_frame.copy())

        cv2.putText(draw_frame, getattr(self, 'debug_info_dxdy', ''), (10, 90), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 255), 2)
        cv2.putText(draw_frame, getattr(self, 'debug_info_azalt', ''), (10, 120), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 255), 2)
        cv2.putText(draw_frame, getattr(self, 'debug_info_status', ''), (10, 150), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 255), 2)

        qimg = QImage(draw_frame.data, w, h, 3*w, QImage.Format.Format_BGR888)
        self.preview_label.setPixmap(QPixmap.fromImage(qimg).scaled(self.preview_label.size(), Qt.AspectRatioMode.KeepAspectRatio))

    def _update_tracking(self, tcx, tcy, w, h):
        if not self.acuter.is_connected or time.time() < self.next_track_time: return
        self.next_track_time = time.time() + 0.1 
        
        dx, dy = tcx - w/2.0, tcy - h/2.0
        db = self.deadband_spin.value()
        
        if abs(dx) < db and abs(dy) < db:
            if self.acuter.axis_moving.get(1) or self.acuter.axis_moving.get(2):
                self.acuter.emergency_stop()
            self.debug_info_status = f"Status: Target Centered (<{db}px)"
            return
            
        deg_per_px = (self.pixel_size * self.current_bins / self.focal_spin.value()) * (180.0 / np.pi)
        vx = dx * deg_per_px * self.kp_spin.value() * (-1.0 if self.cb_inv_x.isChecked() else 1.0)
        vy = dy * deg_per_px * self.kp_spin.value() * (-1.0 if self.cb_inv_y.isChecked() else 1.0)

        az_deg = vx if self.combo_x_axis.currentIndex() == 0 else vy
        alt_deg = vx if self.combo_x_axis.currentIndex() == 1 else vy

        spd = float(self.acuter_speed_combo.currentText())
        db_deg = db * deg_per_px * self.kp_spin.value()

        self.debug_info_dxdy = f"Target diff: dx={dx:.1f}px, dy={dy:.1f}px"

        p_gain = 0.5 
        speed_az = max(0.1, min(spd, abs(az_deg) * p_gain))
        speed_alt = max(0.1, min(spd, abs(alt_deg) * p_gain))

        sent = False
        
        if abs(az_deg) > db_deg:
            self.acuter.start_move(1, az_deg, exact_goto=False, speed_deg_sec=speed_az)
            sent = True
        elif self.acuter.axis_moving.get(1):
            self.acuter.stop_axis(1)

        if abs(alt_deg) > db_deg:
            self.acuter.start_move(2, alt_deg, exact_goto=False, speed_deg_sec=speed_alt)
            sent = True
        elif self.acuter.axis_moving.get(2):
            self.acuter.stop_axis(2)

        if sent:
            self.debug_info_azalt = f"Speed: Az {speed_az:.1f} d/s, Alt {speed_alt:.1f} d/s"
            self.debug_info_status = "Status: Continuous P-Control Tracking"

    def start_auto_calib(self):
        if not self.acuter.is_connected or self.latest_frame is None: return
        self.btn_auto_track.setChecked(False)
        self.calib_state = 1
        self.calib_timer.start(1000)

    def stop_calib(self, msg):
        self.calib_state = 0
        self.calib_timer.stop()
        self.statusBar().showMessage(msg, 5000)

    def calib_step(self):
        if self.latest_frame is None: return
        gray = cv2.cvtColor(self.latest_frame, cv2.COLOR_BGR2GRAY)
        if self.calib_state == 1:
            self.calib_pts_prev = cv2.goodFeaturesToTrack(gray, maxCorners=100, qualityLevel=0.01, minDistance=30)
            if self.calib_pts_prev is None: return self.stop_calib("Failed")
            self.calib_gray_prev = gray
            self.acuter.start_move(1, 1.0, exact_goto=True, speed_deg_sec=2.0)
            self.calib_wait = 3
            self.calib_state = 2
        elif self.calib_state == 2:
            self.calib_wait -= 1
            if self.calib_wait <= 0:
                pts_next, status, _ = cv2.calcOpticalFlowPyrLK(self.calib_gray_prev, gray, self.calib_pts_prev, None)
                diff = pts_next[status == 1] - self.calib_pts_prev[status == 1]
                self.calib_dx1, self.calib_dy1 = np.median(diff[:, 0]), np.median(diff[:, 1])
                self.acuter.start_move(1, -1.0, exact_goto=True, speed_deg_sec=2.0)
                self.calib_wait = 3; self.calib_state = 3
        elif self.calib_state == 3:
            self.calib_wait -= 1
            if self.calib_wait <= 0:
                self.calib_pts_prev = cv2.goodFeaturesToTrack(gray, maxCorners=100, qualityLevel=0.01, minDistance=30)
                self.calib_gray_prev = gray
                self.acuter.start_move(2, 1.0, exact_goto=True, speed_deg_sec=2.0)
                self.calib_wait = 3; self.calib_state = 4
        elif self.calib_state == 4:
            self.calib_wait -= 1
            if self.calib_wait <= 0:
                pts_next, status, _ = cv2.calcOpticalFlowPyrLK(self.calib_gray_prev, gray, self.calib_pts_prev, None)
                diff = pts_next[status == 1] - self.calib_pts_prev[status == 1]
                self.calib_dx2, self.calib_dy2 = np.median(diff[:, 0]), np.median(diff[:, 1])
                self.acuter.start_move(2, -1.0, exact_goto=True, speed_deg_sec=2.0)
                self.calib_wait = 3; self.calib_state = 5
        elif self.calib_state == 5:
            self.calib_wait -= 1
            if self.calib_wait <= 0:
                if abs(self.calib_dx1) > abs(self.calib_dy1):
                    self.combo_x_axis.setCurrentIndex(0); self.combo_y_axis.setCurrentIndex(0)
                    self.cb_inv_x.setChecked(bool(self.calib_dx1 > 0))
                    self.cb_inv_y.setChecked(bool(self.calib_dy2 > 0))
                else:
                    self.combo_x_axis.setCurrentIndex(1); self.combo_y_axis.setCurrentIndex(1)
                    self.cb_inv_x.setChecked(bool(self.calib_dx2 > 0))
                    self.cb_inv_y.setChecked(bool(self.calib_dy1 > 0))
                self.stop_calib("Success")

    def closeEvent(self, event):
        self.acuter.disconnect()
        self.ef_controller.disconnect()
        if self.capture_thread: self.capture_thread.stop()
        self.camera.close()
        super().closeEvent(event)

if __name__ == '__main__':
    app = QApplication(sys.argv)
    dark_stylesheet = """
        QMainWindow { background-color: #2b2b2b; } QLabel { color: #e0e0e0; font-size: 13px; }
        QGroupBox { color: #e0e0e0; border: 1px solid #555; border-radius: 6px; margin-top: 16px; padding-top: 15px; font-weight: bold; }
        QGroupBox::title { subcontrol-origin: margin; subcontrol-position: top left; left: 10px; padding: 0 5px; }
        QTabWidget::pane { border: 1px solid #555; } QTabBar::tab { background: #3c3c3c; color: white; padding: 8px 12px; }
        QTabBar::tab:selected { background: #5c9eff; color: black; font-weight: bold; }
        QComboBox, QLineEdit, QSpinBox, QDoubleSpinBox { background-color: #3c3c3c; color: white; border: 1px solid #555; padding: 4px; border-radius: 4px; }
        QCheckBox { color: #e0e0e0; }
        QPushButton { background-color: #3c3c3c; color: #ffffff; border: 1px solid #555; border-radius: 4px; padding: 6px; font-weight: bold; }
        QPushButton:pressed { background-color: #5c9eff; color: #000; }
    """
    app.setStyleSheet(dark_stylesheet) 
    window = MainWindow("/Users/mars/acuter/ASI_Camera_SDK/ASI_linux_mac_SDK_V1.41/lib/mac_arm64/libASICamera2.dylib")
    window.show()
    sys.exit(app.exec())

ローカルLLM(Ollama)とVOICEVOXで作る、英日翻訳&読み上げデスクトップアプリ

Macで動くローカルLLM環境を活かして、「英語テキストを読み込んで日本語に翻訳し、結果を表示しながら音声で読み上げる」ツールを作ってみました。さらに会話を重ねる中で、Ollamaのモデル一覧からの選択、VOICEVOXのインストール手順確認、BlackHole経由の音声入力、録音中の逐次文字起こし表示まで、機能を段階的に拡張していきました。この記事ではその過程と最終的なソースコード全体をまとめます。

前提となる環境

  • Mac mini (Apple Silicon)
  • ローカルLLM実行環境として Ollama(30B程度までのモデルを想定)
  • 日本語音声合成エンジンとして VOICEVOX
  • 音声入力用の仮想オーディオデバイスとして BlackHole

1. まず全体構成を整理する

最初の相談は「Mac上のローカルLLMを使って、英語テキストファイルを日本語に翻訳し、結果を表示しつつ音声で読み上げるには何が必要か」というものでした。整理すると、必要な要素は次の4つに分解できます。

  1. 翻訳エンジン: Ollama + 多言語対応モデル(Qwen2.5-32B-Instruct、Gemma2-27B-itなど)
  2. ファイル読み込み&パイプライン制御: Pythonでテキストを読み込み、Ollama APIに投げる
  3. 結果表示: ターミナルでも良いが、GUIならStreamlit/GradioやPyQt6が候補
  4. 音声読み上げ: macOS標準のsayコマンド、またはより高品質な日本語特化TTSであるVOICEVOX

全体のデータフローは次のようになります。

テキストファイル
   → Python(チャンク分割)
   → Ollama API(翻訳: Qwen2.5-32B等)
   → 表示(ターミナル or GUI)
   → VOICEVOX API(音声合成)
   → afplay(再生)

2. PyQt6でGUIアプリの土台を作る

方向性が固まったところで、実際にPyQt6ベースのGUIアプリとして実装することにしました。要件は次の4点です。

  • ファイル選択ボタン+読み込みボタン
  • 翻訳ボタン
  • 翻訳結果のテキスト表示
  • 読み上げ開始ボタン(VOICEVOX利用)

翻訳はOllamaの/api/generateエンドポイントを、読み上げはVOICEVOXの/audio_query/synthesisという2段階のAPIを叩き、生成したwavをafplay(macOS標準コマンド)で再生する構成です。GUIをフリーズさせないよう、翻訳・音声合成はいずれもQThreadのワーカースレッドで非同期実行しています。

3. Ollamaのモデルをollama list相当で選べるようにする

最初のバージョンはモデル名をコード内に固定していましたが、「ollama listで取得した中から選択できるように」という要望を受けて、Ollamaの/api/tagsエンドポイント(ollama listと同じ情報源)を叩き、インストール済みモデルをプルダウンに動的表示する機能を追加しました。「一覧更新」ボタンで再取得もできるようにしています。

4. VOICEVOXのインストール手順

VOICEVOXは公式サイトからMac版をダウンロードし、.dmgからApplicationsフォルダにインストールします。Apple Silicon環境では初回起動時にRosettaのインストールを求められることがあります。また、開発元未検証の警告が出た場合は「システム設定」→「プライバシーとセキュリティ」から「このまま開く」を選択することで起動できます。インストール後にアプリを起動しておくと、バックグラウンドでAPIエンジン(デフォルトポート50021)が立ち上がり、GUIアプリからの読み上げリクエストを受け付けられる状態になります。

読み上げに使われるキャラクターは、GUI上の「話者」プルダウンで選択されたものです。起動時にVOICEVOXの/speakersエンドポイントから全キャラクター×スタイルの一覧を取得して表示しています。

5. BlackHole経由の音声入力機能を追加する

テキストファイルの読み込みに加えて、「MacのBlackHole経由の音声入力からテキストを生成する機能」を追加しました。構成は次の通りです。

  • sounddeviceで入力デバイス一覧を取得し、名前に”BlackHole”を含むデバイスを自動選択
  • 録音開始/停止をトグルボタンで制御し、sd.InputStreamのコールバックで音声フレームを蓄積
  • 録音停止後、ローカルのfaster-whispersmallモデル、int8量子化でCPU動作)を使って文字起こし
  • 文字起こし結果は「原文」テキストボックスに反映され、そのまま翻訳ボタンに繋がる

これにより、システム音声(動画や配信の英語音声など)をBlackHole経由でキャプチャし、テキスト化してから翻訳・読み上げまで一気通貫で行えるようになりました。あわせて、原文テキストボックスをファイル読み込み専用の読み取り専用から、文字起こし結果を手直しできる編集可能な仕様に変更しています。

6. 録音中にテキストを逐次表示する

最後の改善は「録音中にテキストを逐次表示する」というものです。faster-whisperは真のストリーミング認識には対応していないため、録音バッファを一定間隔(デフォルト5秒)で区切り、チャンクごとに個別に文字起こしして結果を順次追記表示する方式を採用しました。

  • 録音中、PARTIAL_CHUNK_INTERVAL_SEC秒ごとに未処理の音声チャンクを取り出して文字起こし
  • 結果を蓄積し、partial_resultシグナルで「これまでの全文」を原文欄に反映(カーソルは自動的に末尾へ)
  • 録音停止時には、残っている末尾チャンクも処理して最終テキストを確定

この方式にはチャンク境界で単語が分割されると認識精度が落ちるというトレードオフがありますが、待ち時間なく認識結果を確認できる体感の良さを優先しました。間隔を短くすれば反応は早くなる一方、モデルの推論負荷が増えるため、WHISPER_MODEL_SIZE(tiny/base/small/medium/large-v3)とのバランス調整が必要です。

最終的な機能一覧

  • 英語テキストファイルの選択・読み込み
  • BlackHole経由の音声入力からの逐次文字起こし(faster-whisper使用)
  • Ollamaにインストール済みのモデル一覧からの動的選択、日本語への翻訳
  • 翻訳結果の表示・編集
  • VOICEVOXの話者一覧からの選択、音声読み上げ

必要な依存パッケージ

pip install PyQt6 requests
pip install sounddevice numpy soundfile faster-whisper   # 音声入力機能用(任意)

事前に以下も準備しておく必要があります。

  • Ollama起動+翻訳用モデルのpull(例: ollama pull qwen2.5:32b
  • VOICEVOX ENGINEの起動(公式Macアプリ、デフォルトポート50021)
  • BlackHole (2ch) のインストールと、システム音声をBlackHole経由でキャプチャできるMulti-Output Deviceの設定

最終ソースコード全体

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
英語テキストファイル → Ollama(ローカルLLM)で日本語翻訳 → VOICEVOXで読み上げ
PyQt6 GUIアプリ

前提:
  - Ollama がローカルで起動しており、翻訳用モデルが1つ以上 pull 済みであること
      $ ollama pull qwen2.5:32b
      $ ollama serve   (通常はインストール時に常駐起動される)
    起動中のOllamaが持つモデル一覧 (`ollama list` 相当) はGUI上のプルダウンから選択可能
  - VOICEVOX ENGINE がローカルで起動していること (デフォルト http://localhost:50021)
      公式サイトからmacOS版アプリ or Dockerで起動しておく
  - (任意) 音声入力機能を使う場合:
      BlackHole (2ch) をインストールし、システム音声の出力先 or Multi-Output Device に
      含めておく (https://existential.audio/blackhole/)
      文字起こしはローカルのfaster-whisperを使用
  - 依存パッケージ:
      pip install PyQt6 requests
      pip install sounddevice numpy soundfile faster-whisper   # 音声入力機能用(任意)

実行:
  python translator_app.py
"""

import sys
import os
import time
import tempfile
import subprocess

import requests
from PyQt6.QtWidgets import (
    QApplication, QWidget, QVBoxLayout, QHBoxLayout,
    QPushButton, QTextEdit, QFileDialog, QLabel, QMessageBox,
    QComboBox, QSpinBox
)
from PyQt6.QtCore import QThread, pyqtSignal

# 音声入力(録音+文字起こし)関連は任意依存。未インストールでも他機能は動作させる。
try:
    import sounddevice as sd
    import numpy as np
    AUDIO_LIBS_AVAILABLE = True
except ImportError:
    AUDIO_LIBS_AVAILABLE = False

# ===================== 設定 =====================
OLLAMA_BASE_URL = "http://localhost:11434"
OLLAMA_URL = f"{OLLAMA_BASE_URL}/api/generate"
OLLAMA_TAGS_URL = f"{OLLAMA_BASE_URL}/api/tags"
OLLAMA_MODEL_FALLBACK = "qwen2.5:32b"   # モデル一覧取得に失敗した場合のフォールバック

VOICEVOX_BASE_URL = "http://localhost:50021"
DEFAULT_SPEAKER_ID = 1   # 四国めたん(ノーマル)。VOICEVOXの /speakers で一覧取得可能

WHISPER_MODEL_SIZE = "small"   # tiny/base/small/medium/large-v3 (精度と速度のトレードオフ)
WHISPER_LANGUAGE = "en"        # 入力音声の言語。自動判定させたい場合は None に変更
PARTIAL_CHUNK_INTERVAL_SEC = 5.0  # 録音中、何秒ごとに区切って逐次文字起こしするか
# ==================================================

_whisper_model_cache = {}


def get_whisper_model(size: str):
    """faster-whisperモデルをロード(初回のみ)。以降はキャッシュを再利用する。"""
    from faster_whisper import WhisperModel
    if size not in _whisper_model_cache:
        _whisper_model_cache[size] = WhisperModel(size, device="cpu", compute_type="int8")
    return _whisper_model_cache[size]


class TranslateWorker(QThread):
    """Ollama APIで翻訳を実行するワーカースレッド"""
    finished = pyqtSignal(str)
    error = pyqtSignal(str)

    def __init__(self, text: str, model: str = OLLAMA_MODEL_FALLBACK):
        super().__init__()
        self.text = text
        self.model = model

    def run(self):
        if not self.model:
            self.error.emit("翻訳に使用するモデルが選択されていません。")
            return
        prompt = (
            "以下の英文を自然な日本語に翻訳してください。"
            "訳文のみを出力し、前置きや説明、注釈は一切付けないでください。\n\n"
            f"{self.text}"
        )
        try:
            resp = requests.post(
                OLLAMA_URL,
                json={
                    "model": self.model,
                    "prompt": prompt,
                    "stream": False,
                },
                timeout=600,
            )
            resp.raise_for_status()
            data = resp.json()
            translated = data.get("response", "").strip()
            if not translated:
                raise ValueError("翻訳結果が空でした。モデル名やOllamaの起動状態を確認してください。")
            self.finished.emit(translated)
        except requests.exceptions.ConnectionError:
            self.error.emit(
                "Ollamaに接続できません。`ollama serve` が起動しているか確認してください。"
            )
        except Exception as e:
            self.error.emit(str(e))


class TTSWorker(QThread):
    """VOICEVOX APIで音声合成し、afplayで再生するワーカースレッド"""
    finished = pyqtSignal()
    error = pyqtSignal(str)

    def __init__(self, text: str, speaker: int = DEFAULT_SPEAKER_ID):
        super().__init__()
        self.text = text
        self.speaker = speaker

    def run(self):
        temp_path = None
        try:
            # 1. audio_query: テキストから音声合成用パラメータを生成
            query_res = requests.post(
                f"{VOICEVOX_BASE_URL}/audio_query",
                params={"text": self.text, "speaker": self.speaker},
                timeout=60,
            )
            query_res.raise_for_status()
            query_json = query_res.json()

            # 2. synthesis: パラメータからwav音声データを生成
            synth_res = requests.post(
                f"{VOICEVOX_BASE_URL}/synthesis",
                params={"speaker": self.speaker},
                json=query_json,
                timeout=120,
            )
            synth_res.raise_for_status()
            wav_bytes = synth_res.content

            # 3. 一時ファイルに書き出してafplayで再生 (macOS標準コマンド)
            with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
                f.write(wav_bytes)
                temp_path = f.name

            subprocess.run(["afplay", temp_path], check=True)
            self.finished.emit()

        except requests.exceptions.ConnectionError:
            self.error.emit(
                "VOICEVOX ENGINEに接続できません。アプリ/Dockerが起動しているか確認してください。"
            )
        except Exception as e:
            self.error.emit(str(e))
        finally:
            if temp_path and os.path.exists(temp_path):
                os.remove(temp_path)


class RecordTranscribeWorker(QThread):
    """
    BlackHole等の入力デバイスから音声を録音し、一定間隔ごとにチャンク単位で
    faster-whisperによる文字起こしを行って逐次表示できるワーカースレッド。
    録音の停止は stop_recording() を外部(メインスレッド)から呼んで行う。

    partial_result: チャンクごとの文字起こし結果を反映した「これまでの全文」を通知
    finished: 停止後、最後のチャンクまで処理し終えた最終テキストを通知
    """
    partial_result = pyqtSignal(str)
    finished = pyqtSignal(str)
    error = pyqtSignal(str)

    def __init__(self, device_index, channels: int = 2, samplerate: int = 44100,
                 model_size: str = WHISPER_MODEL_SIZE, language=WHISPER_LANGUAGE,
                 chunk_interval: float = PARTIAL_CHUNK_INTERVAL_SEC):
        super().__init__()
        self.device_index = device_index
        self.channels = channels
        self.samplerate = samplerate
        self.model_size = model_size
        self.language = language
        self.chunk_interval = chunk_interval
        self._frames = []
        self._processed_index = 0  # self._frames のうち、既に文字起こし済みの位置
        self._text_parts = []
        self._stream = None
        self._stop_flag = False

    def stop_recording(self):
        self._stop_flag = True

    def run(self):
        if not AUDIO_LIBS_AVAILABLE:
            self.error.emit(
                "音声入力に必要なライブラリがありません。"
                "`pip install sounddevice numpy soundfile faster-whisper` を実行してください。"
            )
            return

        try:
            def callback(indata, frames, time_info, status):
                self._frames.append(indata.copy())

            self._stream = sd.InputStream(
                device=self.device_index,
                channels=self.channels,
                samplerate=self.samplerate,
                callback=callback,
            )
            self._stream.start()

            last_chunk_time = time.time()
            while not self._stop_flag:
                self.msleep(200)
                if time.time() - last_chunk_time >= self.chunk_interval:
                    self._process_pending_chunk()
                    last_chunk_time = time.time()

            self._stream.stop()
            self._stream.close()

            # 停止後、まだ処理していない末尾の音声を最終チャンクとして処理
            self._process_pending_chunk()

            final_text = "".join(self._text_parts).strip()
            if not final_text:
                raise ValueError("文字起こし結果が空でした。録音内容/デバイスを確認してください。")

            self.finished.emit(final_text)

        except Exception as e:
            self.error.emit(str(e))

    def _process_pending_chunk(self):
        """前回処理位置以降にたまった音声フレームをまとめて文字起こしし、結果を通知する"""
        frames_snapshot = self._frames[self._processed_index:]
        self._processed_index = len(self._frames)
        if not frames_snapshot:
            return

        chunk = np.concatenate(frames_snapshot, axis=0)
        # 極端に短いチャンク(数百ms未満)は認識精度が低いためスキップし、次回に回す
        if chunk.shape[0] < int(self.samplerate * 0.3):
            self._processed_index -= len(frames_snapshot)
            return

        tmp_path = None
        try:
            import soundfile as sf
            with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
                tmp_path = f.name
            sf.write(tmp_path, chunk, self.samplerate)

            model = get_whisper_model(self.model_size)
            segments, _info = model.transcribe(tmp_path, language=self.language)
            chunk_text = "".join(seg.text for seg in segments)

            if chunk_text:
                self._text_parts.append(chunk_text)
                self.partial_result.emit("".join(self._text_parts).strip())
        except Exception:
            # チャンク単位の失敗で録音全体を止めない。最終結果には反映されないが継続する。
            pass
        finally:
            if tmp_path and os.path.exists(tmp_path):
                os.remove(tmp_path)


class TranslatorApp(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("EN→JA 翻訳&読み上げ (Ollama + VOICEVOX)")
        self.resize(760, 640)
        self.selected_path = None
        self.original_text = ""
        self.speaker_map = {}  # 表示名 -> speaker_id
        self.audio_device_map = {}  # 表示名 -> device_index
        self.record_worker = None
        self.is_recording = False
        self.init_ui()
        self.load_speakers()
        self.load_ollama_models()
        self.load_audio_devices()

    # ---------------- UI構築 ----------------
    def init_ui(self):
        layout = QVBoxLayout()

        # ファイル選択行
        file_row = QHBoxLayout()
        self.file_label = QLabel("ファイル未選択")
        select_btn = QPushButton("ファイル選択")
        select_btn.clicked.connect(self.select_file)
        self.load_btn = QPushButton("読み込み")
        self.load_btn.clicked.connect(self.load_file)
        self.load_btn.setEnabled(False)
        file_row.addWidget(self.file_label, stretch=1)
        file_row.addWidget(select_btn)
        file_row.addWidget(self.load_btn)
        layout.addLayout(file_row)

        # 音声入力行 (BlackHole等から録音 → 文字起こし)
        audio_row = QHBoxLayout()
        audio_row.addWidget(QLabel("音声入力デバイス:"))
        self.audio_device_combo = QComboBox()
        audio_row.addWidget(self.audio_device_combo, stretch=1)
        self.refresh_devices_btn = QPushButton("デバイス更新")
        self.refresh_devices_btn.clicked.connect(self.load_audio_devices)
        audio_row.addWidget(self.refresh_devices_btn)
        self.record_btn = QPushButton("録音開始")
        self.record_btn.clicked.connect(self.toggle_recording)
        audio_row.addWidget(self.record_btn)
        layout.addLayout(audio_row)

        # 原文表示 (ファイル読み込み/音声文字起こし結果を表示。翻訳前に編集可)
        layout.addWidget(QLabel("原文 (英語 / 編集可):"))
        self.original_edit = QTextEdit()
        layout.addWidget(self.original_edit)

        # 翻訳ボタン行 (モデル選択付き)
        translate_row = QHBoxLayout()
        translate_row.addWidget(QLabel("モデル:"))
        self.model_combo = QComboBox()
        translate_row.addWidget(self.model_combo, stretch=1)
        self.refresh_models_btn = QPushButton("一覧更新")
        self.refresh_models_btn.clicked.connect(self.load_ollama_models)
        translate_row.addWidget(self.refresh_models_btn)
        self.translate_btn = QPushButton("翻訳開始")
        self.translate_btn.clicked.connect(self.start_translate)
        self.translate_btn.setEnabled(False)
        translate_row.addWidget(self.translate_btn)
        layout.addLayout(translate_row)

        # 訳文表示 (編集可能: 読み上げ前に手直しできる)
        layout.addWidget(QLabel("訳文 (日本語 / 読み上げ前に編集可):"))
        self.translated_edit = QTextEdit()
        layout.addWidget(self.translated_edit)

        # 読み上げ設定行
        speak_row = QHBoxLayout()
        speak_row.addWidget(QLabel("話者:"))
        self.speaker_combo = QComboBox()
        speak_row.addWidget(self.speaker_combo, stretch=1)
        self.speak_btn = QPushButton("読み上げ開始")
        self.speak_btn.clicked.connect(self.start_speak)
        self.speak_btn.setEnabled(False)
        speak_row.addWidget(self.speak_btn)
        layout.addLayout(speak_row)

        # ステータス表示
        self.status_label = QLabel("準備完了")
        layout.addWidget(self.status_label)

        self.setLayout(layout)

    # ---------------- VOICEVOX話者一覧取得 ----------------
    def load_speakers(self):
        try:
            res = requests.get(f"{VOICEVOX_BASE_URL}/speakers", timeout=10)
            res.raise_for_status()
            speakers = res.json()
            for sp in speakers:
                name = sp.get("name", "unknown")
                for style in sp.get("styles", []):
                    label = f"{name} - {style.get('name')}"
                    self.speaker_map[label] = style.get("id")
                    self.speaker_combo.addItem(label)
            if self.speaker_combo.count() == 0:
                self.speaker_combo.addItem("デフォルト話者")
                self.speaker_map["デフォルト話者"] = DEFAULT_SPEAKER_ID
        except Exception:
            # VOICEVOXが未起動でもアプリ自体は起動できるようにする
            self.speaker_combo.addItem("デフォルト話者 (VOICEVOX未接続)")
            self.speaker_map["デフォルト話者 (VOICEVOX未接続)"] = DEFAULT_SPEAKER_ID
            self.status_label.setText(
                "VOICEVOXに接続できませんでした。読み上げ時に再接続を試みます。"
            )

    # ---------------- Ollamaモデル一覧取得 (ollama list相当) ----------------
    def load_ollama_models(self):
        self.model_combo.clear()
        try:
            res = requests.get(OLLAMA_TAGS_URL, timeout=10)
            res.raise_for_status()
            data = res.json()
            models = [m.get("name") for m in data.get("models", []) if m.get("name")]
            if not models:
                raise ValueError("インストール済みのモデルが見つかりませんでした。")
            self.model_combo.addItems(models)
            self.status_label.setText(f"モデル一覧を取得しました ({len(models)}件)")
        except requests.exceptions.ConnectionError:
            self.model_combo.addItem(OLLAMA_MODEL_FALLBACK)
            self.status_label.setText(
                "Ollamaに接続できません。`ollama serve` の起動を確認して「一覧更新」を押してください。"
            )
        except Exception as e:
            self.model_combo.addItem(OLLAMA_MODEL_FALLBACK)
            self.status_label.setText(f"モデル一覧取得に失敗しました: {e}")

    # ---------------- 音声入力デバイス一覧取得 ----------------
    def load_audio_devices(self):
        self.audio_device_combo.clear()
        self.audio_device_map.clear()

        if not AUDIO_LIBS_AVAILABLE:
            self.audio_device_combo.addItem("(sounddevice未インストール)")
            self.record_btn.setEnabled(False)
            self.status_label.setText(
                "音声入力を使うには `pip install sounddevice numpy soundfile faster-whisper` が必要です。"
            )
            return

        try:
            devices = sd.query_devices()
            preferred_index = None
            for idx, dev in enumerate(devices):
                if dev.get("max_input_channels", 0) > 0:
                    label = f"[{idx}] {dev.get('name')}"
                    self.audio_device_map[label] = idx
                    self.audio_device_combo.addItem(label)
                    if "blackhole" in dev.get("name", "").lower() and preferred_index is None:
                        preferred_index = self.audio_device_combo.count() - 1

            if self.audio_device_combo.count() == 0:
                self.audio_device_combo.addItem("入力デバイスが見つかりません")
                self.record_btn.setEnabled(False)
            else:
                self.record_btn.setEnabled(True)
                if preferred_index is not None:
                    self.audio_device_combo.setCurrentIndex(preferred_index)
        except Exception as e:
            self.audio_device_combo.addItem("デバイス取得エラー")
            self.record_btn.setEnabled(False)
            self.status_label.setText(f"音声デバイス一覧の取得に失敗しました: {e}")

    # ---------------- 録音/文字起こし処理 ----------------
    def toggle_recording(self):
        if not self.is_recording:
            self.start_recording()
        else:
            self.stop_recording()

    def start_recording(self):
        label = self.audio_device_combo.currentText()
        device_index = self.audio_device_map.get(label)
        if device_index is None:
            QMessageBox.warning(self, "警告", "有効な音声入力デバイスを選択してください。")
            return

        try:
            dev_info = sd.query_devices(device_index)
            samplerate = int(dev_info.get("default_samplerate") or 44100)
            channels = min(2, dev_info.get("max_input_channels", 1)) or 1
        except Exception:
            samplerate, channels = 44100, 2

        self.is_recording = True
        self.record_btn.setText("録音停止")
        self.audio_device_combo.setEnabled(False)
        self.refresh_devices_btn.setEnabled(False)
        self.status_label.setText("録音中... (もう一度ボタンを押すと停止し、文字起こしを開始します)")

        self.record_worker = RecordTranscribeWorker(
            device_index=device_index, channels=channels, samplerate=samplerate
        )
        self.record_worker.partial_result.connect(self.on_record_partial)
        self.record_worker.finished.connect(self.on_record_done)
        self.record_worker.error.connect(self.on_record_error)
        self.record_worker.start()

    def stop_recording(self):
        if self.record_worker:
            self.record_btn.setEnabled(False)
            self.status_label.setText("最後のチャンクを文字起こし中...")
            self.record_worker.stop_recording()

    def on_record_partial(self, text_so_far: str):
        self.original_edit.setPlainText(text_so_far)
        # カーソルを末尾に移動して、追記されていく様子が見えるようにする
        cursor = self.original_edit.textCursor()
        cursor.movePosition(cursor.MoveOperation.End)
        self.original_edit.setTextCursor(cursor)
        self.status_label.setText("録音中... (逐次認識結果を表示しています)")

    def on_record_done(self, text: str):
        self.original_edit.setPlainText(text)
        self.translate_btn.setEnabled(bool(text.strip()))
        self._reset_recording_ui()
        self.status_label.setText("文字起こし完了")

    def on_record_error(self, msg: str):
        QMessageBox.critical(self, "音声入力エラー", msg)
        self._reset_recording_ui()
        self.status_label.setText("音声入力失敗")

    def _reset_recording_ui(self):
        self.is_recording = False
        self.record_btn.setText("録音開始")
        self.record_btn.setEnabled(True)
        self.audio_device_combo.setEnabled(True)
        self.refresh_devices_btn.setEnabled(True)

    # ---------------- ファイル選択/読み込み ----------------
    def select_file(self):
        path, _ = QFileDialog.getOpenFileName(
            self, "英語テキストファイルを選択", "", "Text Files (*.txt);;All Files (*)"
        )
        if path:
            self.selected_path = path
            self.file_label.setText(os.path.basename(path))
            self.load_btn.setEnabled(True)

    def load_file(self):
        if not self.selected_path:
            QMessageBox.warning(self, "警告", "先にファイルを選択してください")
            return
        try:
            with open(self.selected_path, "r", encoding="utf-8") as f:
                self.original_text = f.read()
            self.original_edit.setPlainText(self.original_text)
            self.translate_btn.setEnabled(bool(self.original_text.strip()))
            self.status_label.setText("ファイル読み込み完了")
        except UnicodeDecodeError:
            QMessageBox.critical(
                self, "エラー",
                "UTF-8として読み込めませんでした。ファイルの文字コードを確認してください。"
            )
        except Exception as e:
            QMessageBox.critical(self, "エラー", f"ファイル読み込みに失敗しました: {e}")

    # ---------------- 翻訳処理 ----------------
    def start_translate(self):
        text = self.original_edit.toPlainText().strip()
        if not text:
            return
        model = self.model_combo.currentText().strip()
        if not model:
            QMessageBox.warning(self, "警告", "モデルが選択されていません。「一覧更新」を押してください。")
            return
        self.translate_btn.setEnabled(False)
        self.status_label.setText(
            f"翻訳中... ({model} / モデルサイズによっては数分かかります)"
        )
        self.translate_worker = TranslateWorker(text, model=model)
        self.translate_worker.finished.connect(self.on_translate_done)
        self.translate_worker.error.connect(self.on_translate_error)
        self.translate_worker.start()

    def on_translate_done(self, translated: str):
        self.translated_edit.setPlainText(translated)
        self.translate_btn.setEnabled(True)
        self.speak_btn.setEnabled(True)
        self.status_label.setText("翻訳完了")

    def on_translate_error(self, msg: str):
        QMessageBox.critical(self, "翻訳エラー", msg)
        self.translate_btn.setEnabled(True)
        self.status_label.setText("翻訳失敗")

    # ---------------- 読み上げ処理 ----------------
    def start_speak(self):
        text = self.translated_edit.toPlainText().strip()
        if not text:
            return
        speaker_label = self.speaker_combo.currentText()
        speaker_id = self.speaker_map.get(speaker_label, DEFAULT_SPEAKER_ID)

        self.speak_btn.setEnabled(False)
        self.status_label.setText("音声合成中...")
        self.tts_worker = TTSWorker(text, speaker=speaker_id)
        self.tts_worker.finished.connect(self.on_speak_done)
        self.tts_worker.error.connect(self.on_speak_error)
        self.tts_worker.start()

    def on_speak_done(self):
        self.speak_btn.setEnabled(True)
        self.status_label.setText("読み上げ完了")

    def on_speak_error(self, msg: str):
        QMessageBox.critical(self, "読み上げエラー", msg)
        self.speak_btn.setEnabled(True)
        self.status_label.setText("読み上げ失敗")


def main():
    app = QApplication(sys.argv)
    win = TranslatorApp()
    win.show()
    sys.exit(app.exec())


if __name__ == "__main__":
    main()

まとめ

「ローカルLLMで英日翻訳+読み上げ」という単純な要件から出発し、対話を重ねる中でOllamaモデルの動的選択、VOICEVOXのキャラクター選択、BlackHole経由の音声入力、逐次文字起こし表示と、段階的に機能を積み上げていきました。すべてローカル完結(Ollama+faster-whisper+VOICEVOX)で動作するため、外部APIへの依存やコストを気にせず、システム音声のリアルタイム翻訳ツールとして拡張していける土台ができたと思います。

USAF HFGCS(High Frequency Global Communications System)

USAF HFGCS(High Frequency Global Communications System)の代替周波数は以下の通りです。

主な代替周波数(Voice / EAM用、すべてUSBモード)

  • 4724 kHz(夜間/低層伝搬に強い主用)
  • 8992 kHz(昼間・夜間ともに良好)
  • 11175 kHz(昼間の主力頻度)
  • 15016 kHz(質問の周波数、日中・夜間ともに使用)

これらは同時中継(simulcast)されることが非常に多く、1つのメッセージが複数の周波数で同時に流れるのが一般的です。

その他の関連周波数(バックアップ/補助)

  • 13200 kHz(旧バックアップ、日中寄り)
  • その他:6739 kHz(使用頻度は低下傾向)など

運用概要

  • HFGCSは米空軍の全球航空機通信網で、EAM(Emergency Action Messages)やSkykingなどの重要メッセージを扱います。
  • 15016 kHzが聞こえにくい場合(伝搬条件による)は、11175 kHz(昼間)または4724 kHz(夜間)を優先的に確認してください。
  • 伝搬状況により最適周波数が変わるため、複数周波数を順番にスキャンするのが効果的です。

参考:Priyom.org、Wikipedia、RadioReferenceなどの最新監視情報に基づきます。実際の受信時はWebSDR(KiwiSDRなど)でリアルタイム確認をおすすめします。

追加で特定の時間帯や受信環境の情報があれば、より詳しくアドバイスできます!

コンパス(ICM20948)

温度補正

ドリフト補正パラメータ調整ガイド

Madgwick AHRS

C++コード

Raspberry Pi PICOでシンセサイザー

Raspberry Pi Pico 2 (RP2350) とタッチパネル液晶、そしてUSB MIDIキーボードを組み合わせた自作シンセサイザー開発における、デバッグの記録を記事にまとめました。


【RP2350】Pico 2でタッチパネル液晶シンセを作る:CST328の座標ズレとUSB Host MIDI認識エラーとの闘い

背景

Raspberry Pi Pico 2 (RP2350) に Waveshareの2.8インチタッチLCDを接続し、ポリフォニック・シンセサイザーを作成していました。

音の生成やSDカードからのMIDIファイル再生までは順調でしたが、「タッチパネルの座標が正しく取れない」 問題と、「USB MIDIキーボードを接続しても認識しない(USBホスト機能)」 という2つの大きな壁にぶつかりました。

この記事は、その解決までの試行錯誤のログです。


エラーとの戦い:試行錯誤のプロセス

Round 1: タッチ座標がノコギリ波になる

最初に書いたコードでは、タッチコントローラー(CST328)からI2Cで単純に座標バイトを読み込んでいました。

Step 1: 最初のコード(抜粋)

C++

// よくあるI2C読み込み
Wire1.requestFrom(CST_ADDR, 4);
uint8_t x_h = Wire1.read();
uint8_t x_l = Wire1.read();
// ... 単純結合 ...

Step 2: 発生した現象

エラーメッセージは出ませんでしたが、シリアルモニタで座標を見ると以下の挙動を示しました。

  • 横方向(X軸)に指を動かすと、値が 0 -> 300 -> 1 ... のようにループする(ノコギリ波状)。
  • 画面の右に行くほど値が減る(逆転している)。

Step 3: 原因の解説

WaveshareのPythonサンプルコードを確認したところ、このタッチパネルは 12ビットのデータを変則的にパッキングして送信 しており、しかもレジスタアドレスが8bitではなく 16bit (0xD000) でアクセスする必要がありました。単純なバイト読み込みでは、上位ビットと下位ビットが正しく結合されていませんでした。

Step 4: 修正したコード(ビット演算の修正)

C++

// レジスタポインタを0xD000にセット
Wire1.beginTransmission(CST_ADDR);
Wire1.write(0xD0);
Wire1.write(0x00);
Wire1.endTransmission(false); // Repeated Start

// 変則的な12bitデコード(Pythonコードを移植)
// buf[3]にXとYの下位4ビットが混ざっている
int raw_x = ((int)buf[1] << 4) | ((buf[3] & 0xF0) >> 4);
int raw_y = ((int)buf[2] << 4) | (buf[3] & 0x0F);

Round 2: ボタンが連打されてしまう(ゴーストタッチ)

座標は取れるようになりましたが、画面上の「NEXT」ボタンを一回押したつもりが、ページが2つ3つ進んでしまう現象が発生しました。

Step 1: 問題のコード

C++

// タッチされた瞬間だけ反応するつもりだったが...
if (touch.touched) {
    // 処理
}

Step 2: 発生した現象

指を離しても、タッチパネルのレジスタに「最後の座標」が残り続け、プログラムが「まだ押されている」と誤認して処理をループさせていました。

Step 3: 修正案

「静止画検知(ジッターフィルタ)」と「リリース待ちフラグ」を導入しました。

  1. 静止検知: 人間の指は微妙に震えるため、座標が完全に一致し続ける場合は「指がない(レジスタのゴミ)」と判定して無視。
  2. リリース待ち: ボタンを押した後、一度指を離す(!touched になる)まで次の入力を受け付けないようにしました。

Round 3: MIDIコールバック関数のコンパイルエラー

次に、USB MIDIキーボードを接続するためのコードを追加したところでコンパイルエラーが発生しました。

Step 1: 追加したコード

C++

// 古いライブラリ仕様に基づいた記述
void tuh_midi_mount_cb(uint8_t d, uint8_t in_ep, uint16_t in_packet_size, uint8_t out_ep, uint16_t out_packet_size, void *ptr) {
    // ...
}

Step 2: 発生したエラー

Plaintext

error: conflicting declaration of C function 'void tuh_midi_mount_cb(...)'
note: previous declaration 'void tuh_midi_mount_cb(uint8_t, const tuh_midi_mount_cb_t*)'

Step 3: 原因の解説

使用している Adafruit TinyUSB ライブラリのバージョンが上がり、コールバック関数の引数の仕様が変わっていました。エラーログの note に正解が書いてありました。

Step 4: 修正したコード

C++

// 最新の仕様に合わせた引数
void tuh_midi_mount_cb(uint8_t idx, const tuh_midi_mount_cb_t *mount_cb_data) {
    usb_mounted = true;
}

Round 4: MIDIキーボードを認識しない(最大の山場)

コンパイルは通りましたが、Pico 2 WにUSBキーボードを繋いでも全く反応しません(Lチカによるデバッグでも反応なし)。しかし、単純なサンプルコードでは動作しました。

Step 1: 失敗していた構成

  • Arduino IDE設定: USB Stack: Adafruit TinyUSB
  • コード: USBDevice.attach()(PC接続用)と USBHost.begin(0)(キーボード接続用)を混在させていた。
  • コード: Serial1 (ハードウェアMIDI) を初期化していた。

Step 2: 発生した現象

プログラムは起動するが、USBポートにキーボードを挿しても tuh_midi_mount_cb が呼ばれない。

Step 3: 原因の解説

Pico 2 (RP2350) でUSBホスト機能を使う場合、以下の条件が必須でした。

  1. IDEの設定: メニューの「USB Stack」で “Adafruit TinyUSB (Host)” を明示的に選ぶ必要がある(無印のTinyUSBではデバイスモードが優先されるためNG)。
  2. 初期化順序: USBHost.begin(0)setup()一番最初に呼ぶ必要がある。

Step 4: 最終的な修正

IDEの設定を “Adafruit TinyUSB (Host)” に変更し、コードもUSBホスト専用に特化させました。


完成コード

これら全ての問題を解決し、タッチ操作とUSB MIDIキーボード演奏が両立した最終コードです。

<details>

<summary>クリックしてコードを展開: PolySynth_RP2350_LCD2_Touch_v16_00.ino</summary>

C++

// PolySynth_RP2350_LCD2_Touch_v16_00
// Target: Raspberry Pi Pico 2 / Pico 2 W (RP2350)
// REQUIRED IDE SETTING: Tools > USB Stack > "Adafruit TinyUSB (Host)"
//
// Fixes:
// 1. Validated for "TinyUSB (Host)" build option.
// 2. P4 (Visualizer) Touch Fix: Touch is polled continuously, independent of FFT framerate.
// 3. MIDI: Host Mode only.

#define ENABLE_TOUCH 

#include <Arduino.h>
#include <I2S.h>
#include <SPI.h> 
#include <Adafruit_GFX.h>
#include <Adafruit_ST7789.h>
#include <Adafruit_TinyUSB.h> // ★ Must use "Adafruit TinyUSB (Host)" setting
#include <Wire.h> 
#include "hardware/watchdog.h" 
#include <LittleFS.h>
#include "arduinoFFT.h"
#include "hardware/vreg.h"
#include "hardware/clocks.h"

const char* VERSION_STR = "v16.00 (Host/P4Fix)";

// --- Configuration ---
#define SAMPLE_RATE 44100
#define POLYPHONY 14
#define SINE_SIZE 1024
#define AUDIO_BLOCK 64 
#define FFT_SAMPLES 512 
#define SCOPE_SAMPLES 320
#define MAX_MIDI_FILES 20
#define MAX_TRACKS 16
#define MAX_PAGES 6 

#define DELAY_LEN 16537
#define COMB1_LEN 1601
#define COMB2_LEN 1811
#define AP_LEN    499

// --- Colors ---
#define C_BLACK   0x0000
#define C_WHITE   0xFFFF
#define C_CYAN    0x07FF 
#define C_MAGENTA 0xF81F 
#define C_ORANGE  0xFD20 
#define C_GREEN   0x07E0
#define C_YELLOW  0xFFE0
#define C_RED     0xF800
#define C_GRAY    0x4208
#define C_DARK    0x1082
#define C_BLUE    0x001F

// --- Pins ---
#define TFT_BL   16  
#define TFT_DC   14
#define TFT_CS   13  
#define TFT_SCLK 10 
#define TFT_MOSI 11  
#define TFT_RST  15  

#define TOUCH_SDA 6
#define TOUCH_SCL 7
#define TOUCH_RST 17 

#define I2S_BCLK 2  
#define I2S_DOUT 4  

#define CST_ADDR 0x1A

// --- USB Objects ---
Adafruit_USBH_Host USBHost;
volatile bool midi_active = false;

// --- Prototypes ---
void triggerNoteOn(uint8_t note, uint8_t velocity, bool is_drum);
void triggerNoteOff(uint8_t note, bool is_drum);
void apply_selection(int idx);

class CST328 {
public:
    int x = 0, y = 0;
    int raw_x = 0, raw_y = 0;
    bool touched = false;
    
    int last_raw_x = -1, last_raw_y = -1;
    int static_count = 0;

    void begin() {
        Wire1.setSDA(TOUCH_SDA);
        Wire1.setSCL(TOUCH_SCL);
        Wire1.begin(); 
        Wire1.setClock(400000);
        
        pinMode(TOUCH_RST, OUTPUT);
        digitalWrite(TOUCH_RST, HIGH); delay(50);
        digitalWrite(TOUCH_RST, LOW);  delay(20);
        digitalWrite(TOUCH_RST, HIGH); delay(100);
    }

    bool read() {
        Wire1.beginTransmission(CST_ADDR);
        Wire1.write(0x01); 
        if (Wire1.endTransmission() != 0) return false;

        Wire1.requestFrom(CST_ADDR, 6);
        if (Wire1.available() < 6) return false;

        uint8_t buf[6];
        for(int i=0; i<6; i++) buf[i] = Wire1.read();

        int val_A = ((uint16_t)buf[0] << 4) | ((buf[2] & 0xF0) >> 4); 
        int val_B = ((uint16_t)buf[1] << 4) | (buf[2] & 0x0F);        

        bool valid_read = (val_A > 0 || val_B > 0);
        
        if (valid_read) {
            if (val_A == last_raw_y && val_B == last_raw_x) {
                static_count++;
            } else {
                static_count = 0; 
            }
            last_raw_y = val_A;
            last_raw_x = val_B;

            if (static_count > 20) {
                touched = false;
            } else {
                touched = true;
                raw_y = val_A; 
                raw_x = val_B; 
                
                // Calibration (Proven)
                x = map(raw_x, 317, 1, 0, 320); 
                y = map(raw_y, 1, 239, 0, 240);
                
                if (x < 0) x = 0; if (x > 320) x = 320;
                if (y < 0) y = 0; if (y > 240) y = 240;
            }
        } else {
            touched = false;
            static_count = 0;
        }
        return touched;
    }
} touch;

// --- Globals ---
const char* ALL_NAMES[] = {"SINE", "SAW", "TRI", "SQR", "NOISE", "PIANO", "ORGAN", "VIOLIN", "MBOX", "SEA", "WIND"};
const uint8_t total_presets = 11;

struct Voice {
    int note = -1; float freq = 0, target_freq = 0, ph = 0, mod_ph = 0, env = 0;
    int stage = 0; bool active = false; bool is_drum = false;
    float low = 0, band = 0, f_coeff = 0; 
};

struct MidiTrackState {
    uint32_t start_offset; uint32_t cursor; uint32_t next_tick; uint8_t running_st; bool active;           
};

struct {
    volatile int page = 0, wave = 0, preset = 0, selected_file_idx = 0;
    int total_files = 0;
    volatile float gain = 0.8f, lfo_f = 0.2f, lfo_d = 0.0f, lfo_ph = 0;
    volatile float a=0.01f, d=0.2f, s=0.6f, r=0.5f, cut=8000.0f, res=0.1f;
    volatile float glide = 0.0f, fm_idx = 0.0f, chorus = 0.0f;
    volatile float delay_mix = 0.0f; volatile float reverb_mix = 0.0f;
    volatile float play_speed = 1.0f;
    volatile bool playing = false, dirty = true; 
    volatile bool note_active = false;
    uint32_t current_tick = 0; uint32_t us_per_tick = 1000;
    volatile bool req_file_reload = false; volatile bool req_panic = false;
} p;

Voice voices[POLYPHONY];
float sineTbl[SINE_SIZE];
MidiTrackState tracks[MAX_TRACKS];
int num_tracks_active = 0;

float delayBuf[DELAY_LEN]; int delay_idx = 0;
float comb1Buf[COMB1_LEN]; int c1_idx = 0;
float comb2Buf[COMB2_LEN]; int c2_idx = 0;
float apBuf[AP_LEN];       int ap_idx = 0;
volatile int16_t scope_buf[SCOPE_SAMPLES]; 
float vReal[FFT_SAMPLES], vImag[FFT_SAMPLES];
volatile bool fft_ready = false;
volatile int s_ptr = 0; volatile int f_ptr = 0;
int last_touch_note = -1;
uint32_t last_btn_action = 0;

bool finger_released = true;

I2S i2s(OUTPUT);
Adafruit_ST7789 tft = Adafruit_ST7789(&SPI1, TFT_CS, TFT_DC, TFT_RST);
ArduinoFFT<float> FFT = ArduinoFFT<float>(vReal, vImag, FFT_SAMPLES, SAMPLE_RATE);

// --- File System ---
char midi_filenames[MAX_MIDI_FILES][32];
File midiFile;

String formatMidiName(const char* name) {
    String s = String(name); s.replace(".mid", ""); s.replace(".MID", "");
    if(s.length() > 12) s = s.substring(0, 12);
    return s;
}

void scanMidiFiles() {
    p.total_files = 0;
    Dir dir = LittleFS.openDir("/midi");
    if (!dir.next()) dir = LittleFS.openDir("/"); 
    dir.rewind();
    while (dir.next() && p.total_files < MAX_MIDI_FILES) {
        String n = dir.fileName();
        if (n.endsWith(".mid") || n.endsWith(".MID")) {
            strncpy(midi_filenames[p.total_files], n.c_str(), 31);
            p.total_files++;
        }
    }
    p.dirty = true;
}

uint32_t xorshift32() {
    static uint32_t x = 123456789;
    x ^= x << 13; x ^= x >> 17; x ^= x << 5;
    return x;
}

// --- SOUND ENGINE ---
void triggerNoteOn(uint8_t note, uint8_t velocity, bool is_drum) {
    float tf = 440.0f * powf(2.0f, (note - 69.0f) / 12.0f);
    for(int i=0; i<POLYPHONY; i++) {
        if(voices[i].active && voices[i].note == note && voices[i].is_drum == is_drum) voices[i].active = false; 
    }
    for(int i=0; i<POLYPHONY; i++) if(!voices[i].active) {
        voices[i].note = note; voices[i].target_freq = tf; 
        if(p.glide == 0 || is_drum) voices[i].freq = tf; 
        if(p.glide > 0 && !is_drum) voices[i].freq = tf; 
        voices[i].ph = 0; voices[i].stage = 1; voices[i].env = 0; 
        voices[i].active = true; voices[i].low = 0; voices[i].band = 0;
        voices[i].is_drum = is_drum;
        p.note_active = true; break;
    }
}

void triggerNoteOff(uint8_t note, bool is_drum) {
    bool any_active = false;
    for(int i=0; i<POLYPHONY; i++) {
        if(voices[i].note == note && voices[i].active && voices[i].is_drum == is_drum) voices[i].stage = 4;
        if(voices[i].active && voices[i].stage != 4) any_active = true;
    }
    p.note_active = any_active;
}

void panic() { 
    for(int i=0; i<POLYPHONY; i++) {
        voices[i].active = false; voices[i].env = 0.0f; voices[i].stage = 0;
    }
    p.note_active = false;
}

// --- SEQUENCER ---
uint32_t readVarLen() {
    uint32_t val = 0; uint8_t c;
    do { c = midiFile.read(); val = (val << 7) | (c & 0x7F); } while (c & 0x80);
    return val;
}
void init_sequencer() {
    if (midiFile) midiFile.close();
    if (p.total_files == 0) { p.playing = false; p.dirty = true; return; }
    String path = "/midi/"; path += midi_filenames[p.selected_file_idx];
    midiFile = LittleFS.open(path, "r");
    if (!midiFile) midiFile = LittleFS.open("/" + String(midi_filenames[p.selected_file_idx]), "r");
    if (!midiFile) { p.playing = false; p.dirty = true; return; }
    midiFile.seek(0); char chunk[4]; midiFile.readBytes(chunk, 4);
    if (strncmp(chunk, "MThd", 4) != 0) { p.playing = false; p.dirty = true; return; }
    midiFile.seek(10); uint16_t numTrks; midiFile.readBytes((char*)&numTrks, 2); numTrks = __builtin_bswap16(numTrks);
    uint16_t timeDiv; midiFile.readBytes((char*)&timeDiv, 2); timeDiv = __builtin_bswap16(timeDiv);
    p.us_per_tick = 500000 / timeDiv; 
    num_tracks_active = 0; midiFile.seek(14); 
    while(midiFile.available() && num_tracks_active < MAX_TRACKS && num_tracks_active < numTrks) {
        uint32_t chunkStart = midiFile.position(); midiFile.readBytes(chunk, 4);
        uint32_t len; midiFile.readBytes((char*)&len, 4); len = __builtin_bswap32(len);
        if (strncmp(chunk, "MTrk", 4) == 0) {
            tracks[num_tracks_active].start_offset = midiFile.position();
            tracks[num_tracks_active].cursor = midiFile.position();
            tracks[num_tracks_active].active = true;
            tracks[num_tracks_active].running_st = 0;
            tracks[num_tracks_active].next_tick = readVarLen();
            tracks[num_tracks_active].cursor = midiFile.position(); 
            num_tracks_active++;
        }
        midiFile.seek(chunkStart + 8 + len);
    }
    p.current_tick = 0;
}
void update_sequencer() {
    if (!p.playing) return;
    if (!midiFile) { init_sequencer(); if(!p.playing) return; }
    static uint32_t last_time = 0;
    if (micros() - last_time < (uint32_t)(p.us_per_tick / p.play_speed)) return;
    last_time = micros();
    p.current_tick++;
    bool any_active = false;
    for (int i = 0; i < num_tracks_active; i++) {
        if (!tracks[i].active) continue;
        any_active = true;
        while (tracks[i].next_tick <= p.current_tick) {
            midiFile.seek(tracks[i].cursor); 
            uint8_t b = midiFile.read();
            if (b >= 0x80) { tracks[i].running_st = b; b = midiFile.read(); }
            uint8_t status = tracks[i].running_st;
            uint8_t type = status & 0xF0; 
            if (type == 0xF0) { 
                if (status == 0xFF) { 
                    uint8_t metaType = b; uint32_t len = readVarLen();
                    if (metaType == 0x2F) tracks[i].active = false; else midiFile.seek(midiFile.position() + len);
                } else if (status == 0xF0 || status == 0xF7) { uint32_t len = readVarLen(); midiFile.seek(midiFile.position() + len); }
            } else {
                uint8_t d1 = b; uint8_t d2 = 0; if (type != 0xC0 && type != 0xD0) d2 = midiFile.read();
                uint8_t ch = status & 0x0F; bool is_drum = (ch == 9);
                if (type == 0x90 && d2 > 0) triggerNoteOn(d1, d2, is_drum);
                else if (type == 0x80 || (type == 0x90 && d2 == 0)) triggerNoteOff(d1, is_drum);
            }
            if (tracks[i].active) { tracks[i].next_tick += readVarLen(); tracks[i].cursor = midiFile.position(); } else { break; }
        }
    }
    if (!any_active) { p.playing = false; p.req_panic = true; p.dirty = true; }
}

void apply_selection(int idx) {
    p.preset = constrain(idx, 0, (int)total_presets - 1);
    p.delay_mix = 0.0f; p.reverb_mix = 0.0f;
    if (p.preset < 5) {
        p.wave = p.preset; p.a=0.01; p.d=0.3; p.s=0.8; p.r=0.3; p.cut=8000.0f; p.res=0.1f; p.lfo_d=0.0f; p.glide=0.0f;
    } else {
        switch(p.preset) {
            case 5: p.wave=1; p.a=0.01; p.d=0.5; p.s=0.0; p.r=0.4; p.cut=2800; p.res=0.1; break;
            case 6: p.wave=3; p.a=0.02; p.d=0.1; p.s=1.0; p.r=0.1; p.cut=4500; p.res=0.0; break;
            case 7: p.wave=1; p.a=0.30; p.d=0.3; p.s=0.7; p.r=0.6; p.cut=2200; p.res=0.3; p.lfo_f=0.3; p.lfo_d=0.3; p.glide=0.06; break;
            case 8: p.wave=0; p.a=0.01; p.d=1.5; p.s=0.0; p.r=1.0; p.cut=3500; p.res=0.2; break;
            case 9: p.wave=4; p.a=2.5; p.d=2.0; p.s=0.4; p.r=2.5; p.cut=600; p.res=0.1; break;
            case 10: p.wave=4; p.a=1.5; p.d=1.5; p.s=0.5; p.r=2.0; p.cut=1000; p.res=0.88; break;
        }
    }
    if (p.preset == 7) p.reverb_mix = 0.3f;
    if (p.preset == 9) p.delay_mix = 0.4f;
    p.dirty = true;
}

// UI
void drawCell(int col, int row, const char* label, String valStr, float val, float maxVal, uint16_t color) {
    int x = (col == 0) ? 5 : 165; int y = 35 + row * 50; int w = 150;
    tft.setTextColor(C_YELLOW, C_BLACK); tft.setTextSize(1);
    tft.setCursor(x, y); tft.print("K"); tft.print(col == 0 ? row + 1 : row + 5); tft.print(" "); tft.print(label);
    tft.setTextColor(C_WHITE, C_BLACK); tft.setTextSize(2);
    tft.setCursor(x, y + 12); tft.print(valStr);
    int barY = y + 36;
    if (maxVal <= 1.0f) { // Toggle
        uint16_t btnColor = (val > 0.5f) ? color : C_DARK;
        tft.fillRect(x, barY - 4, w, 14, btnColor);
        tft.drawRect(x, barY - 4, w, 14, C_WHITE);
    } else { // Slider
        tft.drawRect(x, barY, w, 6, C_GRAY);
        int fillW = constrain((int)((val / maxVal) * (w - 2)), 0, w - 2);
        tft.fillRect(x + 1, barY + 1, fillW, 4, color);
    }
}

void drawKeyboard() {
    int wk_w = 40; int bk_w = 26; int bk_h = 130;
    int y_start = 40; int h = 200;
    for(int i=0; i<8; i++) {
        tft.fillRect(i*wk_w, y_start, wk_w-1, h, C_WHITE);
        tft.drawRect(i*wk_w, y_start, wk_w-1, h, C_GRAY); 
    }
    int bk_pos[] = {1, 2, 4, 5, 6}; 
    for(int i=0; i<5; i++) {
        int cx = bk_pos[i] * wk_w;
        tft.fillRect(cx - (bk_w/2), y_start, bk_w, bk_h, C_BLACK);
    }
    tft.setTextColor(C_BLACK); tft.setTextSize(1); 
    tft.setCursor(12, 220); tft.print("C4"); tft.setCursor(292, 220); tft.print("C5");
}

void drawSystemPage() {
    tft.setTextColor(C_WHITE, C_BLACK);
    tft.setCursor(10, 40); tft.setTextSize(2); tft.print("SYSTEM MENU");
    
    tft.setCursor(10, 70); tft.setTextSize(1); tft.setTextColor(C_GRAY); tft.print("USB MODE:");
    tft.setCursor(160, 70); tft.setTextColor(C_GREEN); tft.print("HOST (KEYS)");
    
    // Version Display
    tft.setCursor(80, 210); tft.setTextSize(1); tft.setTextColor(C_GRAY);
    tft.print("FIRMWARE: "); tft.print(VERSION_STR);
}

void handle_touch() {
    touch.read();
    if (!touch.touched) {
        if (last_touch_note != -1) { triggerNoteOff(last_touch_note, false); last_touch_note = -1; }
        finger_released = true;
        return;
    }
    int tx = touch.x; int ty = touch.y;
    if (tx == 0 && ty == 0) return;
    bool can_trigger_btn = (millis() - last_btn_action > 300);

    if (ty < 50 && tx > 200) { // Global Nav
        if (finger_released) {
            p.page = (p.page + 1) % MAX_PAGES;
            if(p.page >= MAX_PAGES) p.page = 0; 
            p.dirty = true; finger_released = false;
            if (last_touch_note != -1) { triggerNoteOff(last_touch_note, false); last_touch_note = -1; }
        }
        return; 
    }

    if (p.page == 4) { // Keyboard
        if (tx < 0 || tx > 320 || ty < 40) return; 
        int wk_w = 40; int bk_w = 26; int bk_h = 130 + 40; 
        int base_note = 60; int note = -1;
        int bk_centers[] = {40, 80, 160, 200, 240}; int bk_notes[] = {1, 3, 6, 8, 10}; 
        bool is_black = false;
        if (ty < bk_h) { 
            for(int i=0; i<5; i++) {
                if (tx >= (bk_centers[i] - bk_w/2) && tx <= (bk_centers[i] + bk_w/2)) {
                    note = base_note + bk_notes[i]; is_black = true; break;
                }
            }
        }
        if (!is_black) {
            int wk_idx = tx / wk_w; int wk_notes[] = {0, 2, 4, 5, 7, 9, 11, 12};
            if(wk_idx >= 0 && wk_idx < 8) note = base_note + wk_notes[wk_idx];
        }
        if (note != last_touch_note) {
            if (last_touch_note != -1) triggerNoteOff(last_touch_note, false);
            if (note != -1) triggerNoteOn(note, 100, false);
            last_touch_note = note;
        }
    }
    else if (p.page < 4) { // Main UI
        if (tx < 0 || tx > 320 || ty < 35 || ty > 235) return;
        int row = (ty - 35) / 50; int col = (tx < 160) ? 0 : 1;
        float cellX = (col == 0) ? tx - 5 : tx - 165;
        float normVal = constrain(cellX / 150.0f, 0.0f, 1.0f);

        if (p.page == 0) {
            if(col==0 && row==0) apply_selection((int)(normVal * total_presets));
            if(col==0 && row==1 && p.total_files > 0) {
                int new_idx = constrain((int)(normVal * p.total_files), 0, p.total_files - 1);
                if (p.selected_file_idx != new_idx) { p.selected_file_idx = new_idx; p.req_file_reload = true; p.dirty = true; }
            }
            if(col==0 && row==2 && finger_released) { p.playing = !p.playing; if(!p.playing) p.req_panic = true; p.dirty = true; finger_released = false; }
            if(col==0 && row==3) { p.play_speed = 0.5f + normVal * 1.5f; p.dirty = true; }
            if(col==1 && row==0) { p.wave = (int)(normVal * 5); p.dirty = true; }
            if(col==1 && row==1) { p.chorus = normVal; p.dirty = true; }
            if(col==1 && row==2 && finger_released) { p.page = (p.page + 1) % MAX_PAGES; p.dirty = true; finger_released = false; }
            if(col==1 && row==3) { p.gain = normVal; p.dirty = true; }
        }
        else if (p.page == 1) { 
            if(col==0 && row==0) p.glide=normVal; if(col==0 && row==1) p.cut=normVal*8000;
            if(col==0 && row==2) p.res=normVal; if(col==0 && row==3) p.delay_mix=normVal;
            if(col==1 && row==0) p.reverb_mix=normVal; if(col==1 && row==1) p.fm_idx=normVal*5.0f;
            if(col==1 && row==2 && finger_released) { p.page = (p.page + 1) % MAX_PAGES; p.dirty = true; finger_released = false; }
            if(col==1 && row==3) p.gain=normVal; p.dirty=true;
        }
        else if (p.page == 2) {
            if(col==0 && row==0) p.a=normVal*2.0; if(col==0 && row==1) p.d=normVal*2.0;
            if(col==0 && row==2) p.s=normVal; if(col==0 && row==3) p.r=normVal*2.0;
            if(col==1 && row==0) p.lfo_f=normVal; if(col==1 && row==1) p.lfo_d=normVal;
            if(col==1 && row==2 && finger_released) { p.page = (p.page + 1) % MAX_PAGES; p.dirty = true; finger_released = false; }
            if(col==1 && row==3) p.gain=normVal; p.dirty=true;
        }
    }
}

void core1_entry() {
    pinMode(TFT_BL, OUTPUT); digitalWrite(TFT_BL, HIGH);
    pinMode(TFT_RST, OUTPUT); digitalWrite(TFT_RST, LOW); delay(50); digitalWrite(TFT_RST, HIGH); delay(50);

    tft.init(240, 320); tft.setRotation(1); tft.fillScreen(C_BLACK);
    tft.setCursor(40, 100); tft.setTextSize(3); tft.setTextColor(C_CYAN); tft.print("PolySynth");
    tft.setCursor(100, 140); tft.setTextSize(2); tft.setTextColor(C_WHITE); tft.print(VERSION_STR);
    delay(2000); 

    int last_pg = -1; uint32_t last_draw = 0; 
    touch.begin();

    while (1) {
        // ★ Polling touch frequently is key
        handle_touch(); 
        
        int cur_pg = p.page;
        uint16_t theme = (cur_pg==0)?C_CYAN : (cur_pg==1)?C_MAGENTA : (cur_pg==2)?C_ORANGE : (cur_pg==3)?C_GREEN : (cur_pg==4)?C_WHITE : C_RED;
        
        if (cur_pg != last_pg) { tft.fillScreen(C_BLACK); last_pg = cur_pg; p.dirty = true; }

        if (p.dirty && (millis() - last_draw > 50)) {
            last_draw = millis();
            tft.fillRect(0, 0, 320, 25, C_DARK);
            tft.setTextColor(theme, C_DARK); tft.setTextSize(1); tft.setCursor(10, 8);
            tft.print("P"); tft.print(cur_pg + 1); tft.print(" "); 
            
            // ★ USB DIAGNOSTIC DISPLAY ★
            tft.setCursor(150, 8); 
            if(usb_mounted) {
                tft.setTextColor(C_GREEN, C_DARK); tft.print("USB:OK ");
            } else {
                tft.setTextColor(C_RED, C_DARK); tft.print("USB:-- ");
            }
            
            if(midi_rx_activity) {
                tft.setTextColor(C_YELLOW, C_RED); tft.print("MIDI!");
                midi_rx_activity = false; // Reset flash
            }

            tft.fillRect(260, 0, 60, 25, C_GRAY);
            tft.setCursor(270, 8); tft.setTextColor(C_WHITE, C_GRAY); tft.print("NEXT");

            if (cur_pg < 3) {
                 // ... Same drawing logic as before ...
                 if(cur_pg==0) {
                     drawCell(0,0,"PRESET",ALL_NAMES[p.preset],p.preset,10,theme);
                     String fName = "NO FILES"; if(p.total_files>0) fName = formatMidiName(midi_filenames[p.selected_file_idx]);
                     drawCell(0,1,"FILE",fName,1,1,(p.total_files>0?theme:C_RED));
                     drawCell(0,2,"PLAY",(p.playing?"ON":"OFF"),p.playing,1,theme);
                     drawCell(0,3,"SPEED",String((int)(p.play_speed*100))+"%",p.play_speed,2.0,theme);
                     drawCell(1,0,"WAVE",ALL_NAMES[p.wave],p.wave,10,theme);
                     drawCell(1,1,"CHORUS",String((int)(p.chorus*100))+"%",p.chorus,1.0,theme);
                     drawCell(1,2,"PAGE","NEXT",0,1,C_GRAY);
                     drawCell(1,3,"VOL",String((int)(p.gain*100)),p.gain,1.0,C_WHITE);
                 } 
                 else if(cur_pg==1) {
                     drawCell(0,0,"GLIDE",String(p.glide,2),p.glide,1.0,theme); drawCell(0,1,"CUTOFF",String((int)p.cut),p.cut,12000,theme);
                     drawCell(0,2,"RESON",String(p.res,2),p.res,1.0,theme); drawCell(0,3,"DELAY",String((int)(p.delay_mix*100))+"%",p.delay_mix,1.0,theme);
                     drawCell(1,0,"REVERB",String((int)(p.reverb_mix*100))+"%",p.reverb_mix,1.0,theme); drawCell(1,1,"FM IDX",String(p.fm_idx,1),p.fm_idx,5.0,theme);
                     drawCell(1,2,"PAGE","NEXT",0,1,C_GRAY); drawCell(1,3,"VOL",String((int)(p.gain*100)),p.gain,1.0,C_WHITE);
                 }
                 else if(cur_pg==2) {
                     drawCell(0,0,"ATTACK",String(p.a,2),p.a,2.0,theme); drawCell(0,1,"DECAY",String(p.d,2),p.d,2.0,theme);
                     drawCell(0,2,"SUSTAIN",String(p.s,2),p.s,1.0,theme); drawCell(0,3,"RELEASE",String(p.r,2),p.r,2.0,theme);
                     drawCell(1,0,"LFO F",String(p.lfo_f*20,1),p.lfo_f,1.0,theme); drawCell(1,1,"LFO D",String(p.lfo_d,1),p.lfo_d,1.0,theme);
                     drawCell(1,2,"PAGE","NEXT",0,1,C_GRAY); drawCell(1,3,"VOL",String((int)(p.gain*100)),p.gain,1.0,C_WHITE);
                 }
            } else if (cur_pg == 4) { drawKeyboard(); } 
            else if (cur_pg == 5) { drawSystemPage(); }
            p.dirty = false;
        }

        if (cur_pg == 3) { 
            // ★ Throttled to 66ms (15 FPS) to allow Touch priority
            if(millis() - last_draw > 66) { 
                tft.fillRect(0, 42, 320, 78, 0); 
                int cy = 81; for (int i = 0; i < SCOPE_SAMPLES - 1; i++) { int y1 = constrain(cy + (scope_buf[i] / 500), 42, 118); int y2 = constrain(cy + (scope_buf[i+1] / 500), 42, 118); tft.drawLine(i, y1, i+1, y2, C_CYAN); }
                if (fft_ready) {
                    tft.fillRect(0, 120, 320, 100, 0); 
                    FFT.windowing(FFTWindow::Hamming, FFTDirection::Forward); FFT.compute(FFTDirection::Forward); FFT.complexToMagnitude();
                    for (int i=1; i < 161; i++) { int h = (int)constrain(13.0f * log10f(vReal[i] + 1.0f), 0, 90); uint16_t barColor = C_GREEN; if(h>40) barColor=C_YELLOW; if(h>70) barColor=C_RED; tft.fillRect((i-1)*2, 210-h, 2, h, barColor); }
                    fft_ready = false;
                }
            }
        }
        // No heavy delay
    }
}

// ... (Setup/Loop) ...
void setup() {
    vreg_set_voltage(VREG_VOLTAGE_1_20); delay(10);
    set_sys_clock_khz(250000, true);
    Serial1.setTX(0); Serial1.begin(31250); 
    SPI1.setSCK(TFT_SCLK); SPI1.setTX(TFT_MOSI); SPI1.begin();
    
    pinMode(TOUCH_RST, OUTPUT); digitalWrite(TOUCH_RST, HIGH); delay(50); digitalWrite(TOUCH_RST, LOW);  delay(20); digitalWrite(TOUCH_RST, HIGH); delay(100);
    Wire1.setSDA(TOUCH_SDA); Wire1.setSCL(TOUCH_SCL); Wire1.begin(); Wire1.setClock(400000);

    if(LittleFS.begin()) { scanMidiFiles(); }
    
    // ★ USB HOST START (Always ON) ★
    USBHost.begin(0); 
    
    for (int i=0; i<SINE_SIZE; i++) sineTbl[i] = sinf(2.0f * PI * i / SINE_SIZE);
    i2s.setBCLK(I2S_BCLK); i2s.setDATA(I2S_DOUT); i2s.begin(SAMPLE_RATE);
    apply_selection(0); 
    multicore_launch_core1(core1_entry); 
}

void loop() {
    // ★ USB HOST TASK ★
    USBHost.task();
    
    update_sequencer();
    if (p.req_panic) { panic(); p.req_panic = false; }
    if (p.req_file_reload) { if (midiFile) midiFile.close(); p.playing = false; num_tracks_active = 0; p.req_file_reload = false; }

    float lfo_val = sinf(p.lfo_ph) * p.lfo_d * 10.0f;
    p.lfo_ph += (2.0f * PI * (p.lfo_f * 20.0f)) / SAMPLE_RATE;
    if(p.lfo_ph >= 2.0f * PI) p.lfo_ph -= 2.0f * PI;
    float g_factor = powf(0.001f, 1.0f / (max(p.glide, 0.001f) * SAMPLE_RATE));
    float q = 1.0f - p.res;

    for (int s=0; s<AUDIO_BLOCK; s++) {
        float mix = 0;
        for (int i=0; i<POLYPHONY; i++) {
            if (voices[i].active) {
                if (!voices[i].is_drum && p.glide > 0) voices[i].freq = voices[i].target_freq + (voices[i].freq - voices[i].target_freq) * g_factor;
                else voices[i].freq = voices[i].target_freq;
                float mod = 0;
                if(!voices[i].is_drum && p.fm_idx > 0) mod = voices[i].freq * p.fm_idx * sinf(voices[i].ph * 2.0f * PI);
                float target_cut = (p.wave == 4) ? voices[i].freq : p.cut;
                if (s == 0) voices[i].f_coeff = 2.0f * sinf(PI * constrain(target_cut, 50, 15000) / SAMPLE_RATE);
                
                float cur_ph = voices[i].ph; float osc_out = 0;
                if (voices[i].is_drum) { osc_out = (voices[i].note < 40) ? sinf(cur_ph * 20.0f * PI) + (((int32_t)xorshift32()) / 2147483648.0f) * 0.3f : ((int32_t)xorshift32()) / 2147483648.0f; } 
                else {
                    if(p.wave==0) osc_out = sineTbl[(int)(cur_ph*SINE_SIZE)%SINE_SIZE];
                    else if(p.wave==1) osc_out = 2.0f*(cur_ph-0.5f);
                    else if(p.wave==2) osc_out = (cur_ph < 0.5f) ? (4.0f * cur_ph - 1.0f) : (3.0f - 4.0f * cur_ph);
                    else if(p.wave==3) osc_out = (cur_ph<0.5f)?0.5f:-0.5f;
                    else osc_out = ((int32_t)xorshift32()) / 2147483648.0f;
                }
                voices[i].low += voices[i].f_coeff * voices[i].band;
                voices[i].band += voices[i].f_coeff * (osc_out - voices[i].low - q * voices[i].band);
                float env = voices[i].env;
                if(voices[i].is_drum) env *= (voices[i].note < 40) ? 0.9f : 0.6f;
                mix += voices[i].low * env * 0.20f;
                voices[i].ph += (voices[i].freq + mod + lfo_val)/SAMPLE_RATE;
                if(voices[i].ph >= 1.0f) voices[i].ph -= 1.0f;
                
                float stp = 1.0f / SAMPLE_RATE;
                float atk = voices[i].is_drum ? 0.001f : p.a;
                float dec = voices[i].is_drum ? 0.1f : p.d;
                float sus = voices[i].is_drum ? 0.0f : p.s;
                float rel = voices[i].is_drum ? 0.1f : p.r;

                if (voices[i].stage == 1) { voices[i].env += stp/max(atk,0.001f); if(voices[i].env>=1.0f) voices[i].stage=2; }
                else if (voices[i].stage == 2) { voices[i].env -= (stp/max(dec,0.001f))*(1.0 - sus); if(voices[i].env<=sus) voices[i].stage=3; }
                else if (voices[i].stage == 4) { voices[i].env -= stp/max(rel,0.001f); if(voices[i].env<=0) { voices[i].active=false; voices[i].note=-1; } }
            }
        }
        float d_out = delayBuf[delay_idx]; delayBuf[delay_idx] = mix + d_out * (0.5f + p.chorus * 0.2f); delay_idx = (delay_idx + 1) % DELAY_LEN;
        float dry_plus_delay = mix + d_out * p.delay_mix;
        float c1 = comb1Buf[c1_idx]; comb1Buf[c1_idx] = dry_plus_delay + c1 * 0.7f; c1_idx = (c1_idx + 1) % COMB1_LEN;
        float c2 = comb2Buf[c2_idx]; comb2Buf[c2_idx] = dry_plus_delay + c2 * 0.65f; c2_idx = (c2_idx + 1) % COMB2_LEN;
        float rev_in = c1 + c2;
        float ap_out = apBuf[ap_idx]; float ap_new = rev_in + ap_out * 0.5f; apBuf[ap_idx] = ap_new; ap_idx = (ap_idx + 1) % AP_LEN;
        float final_rev = ap_new - rev_in; 
        
        float output = (dry_plus_delay + final_rev * p.reverb_mix) * p.gain * 12000.0f;
        int16_t dry_int = (int16_t)constrain(output, -32000, 32000);
        i2s.write(dry_int); i2s.write(dry_int);
        
        if (p.page == 3) {
            if(s_ptr < SCOPE_SAMPLES) scope_buf[s_ptr++] = dry_int; else s_ptr = 0;
            if (!fft_ready) { vReal[f_ptr] = (float)dry_int; vImag[f_ptr] = 0; if(++f_ptr >= FFT_SAMPLES) { f_ptr = 0; fft_ready = true; } }
        }
    }
}

// ★ FIXED CALLBACKS (Updated for new library) ★
extern "C" {
// ★ FIXED SIGNATURE: use const tuh_midi_mount_cb_t *
void tuh_midi_mount_cb(uint8_t idx, const tuh_midi_mount_cb_t *mount_cb_data) {
    usb_mounted = true;
}
void tuh_midi_unmount_cb(uint8_t idx) {
    usb_mounted = false;
}
void tuh_midi_rx_cb(uint8_t d, uint32_t n_p) {
    uint8_t pkt[4];
    while (tuh_midi_packet_read(d, pkt)) {
        midi_rx_activity = true; 
        uint8_t st = pkt[1] & 0xF0, d1 = pkt[2], d2 = pkt[3];
        float val = d2 / 127.0f;
        
        if (st == 0x90 && d2 > 0) {
            float tf = 440.0f * powf(2.0f, (d1 - 69.0f) / 12.0f);
            for(int i=0; i<POLYPHONY; i++) if(!voices[i].active) {
                voices[i].note=d1; voices[i].target_freq=tf; 
                if(p.glide==0) voices[i].freq=tf;
                voices[i].ph=0; voices[i].stage=1; voices[i].env=0; voices[i].active=true;
                voices[i].low=0; voices[i].band=0; 
                p.note_active = true; break;
            }
        } else if (st == 0x80 || (st == 0x90 && d2 == 0)) {
            bool any_active = false;
            for(int i=0; i<POLYPHONY; i++) {
                if(voices[i].note==d1) voices[i].stage=4;
                if(voices[i].active && voices[i].stage!=4) any_active = true;
            }
            p.note_active = any_active;
        }
        
        if (st == 0xB0) {
            if (d1 == 7) { p.page = (d2 * 4) / 128; p.dirty = true; }
            else if (d1 == 8) { p.gain = val; p.dirty = true; }
            else {
                switch(p.page) {
                    case 0:
                        if(d1==1) apply_selection((d2 * total_presets) / 128);
                        if(d1==2 && p.total_files>0) { int nx=(d2*p.total_files)/128; if(p.selected_file_idx!=nx) { p.selected_file_idx=nx; p.req_file_reload=true; p.dirty=true; }}
                        if(d1==3) { bool q=(d2>64); if(p.playing!=q){p.playing=q; if(!q)p.req_panic=true; p.dirty=true;} }
                        if(d1==4) { p.play_speed = 0.5f+val*1.5f; p.dirty=true; }
                        if(d1==5) { p.wave=(d2*5)/128; p.dirty=true; }
                        if(d1==6) { p.chorus=val; p.dirty=true; }
                        break;
                    case 1:
                        if(d1==1)p.glide=val; if(d1==2)p.cut=val*8000.0f; 
                        if(d1==3)p.res=val; if(d1==4)p.delay_mix=val; 
                        if(d1==5)p.reverb_mix=val; if(d1==6)p.fm_idx=val*5.0f;
                        p.dirty = true; break;
                    case 2:
                        if(d1==1)p.a=val; if(d1==2)p.d=val; if(d1==3)p.s=val; if(d1==4)p.r=val;
                        if(d1==5)p.lfo_f=val; if(d1==6)p.lfo_d=val; p.dirty = true; break;
                }
            }
        }
    }
}
}

</details>

教訓

  • タッチパネルのデータ仕様はデータシートを読むか、サンプルコードを徹底的に解析する: 8bitだと思い込んでいたら、実は12bit変則パッキングだった。
  • ゴーストタッチ対策: 静電容量式タッチパネルでも、ドライバレベルでのチャタリング除去や静止画検知が必要な場合がある。
  • USBホスト機能はIDE設定が命: コードが正しくても、コンパイラの設定(USB Stack)が間違っていればハードウェアは動かない。これが今回の最大の落とし穴でした。
  • ライブラリの更新履歴: エラーメッセージの conflicting declaration は、API仕様変更の証拠。ヘッダファイルを確認するのが一番の近道。

USB 接続のmidi keyboardの認識に苦労

Grokの次のアドバイスで救われました。

Arduino IDEのライブラリフォルダを開く Windowsの場合: C:\Users\[ユーザー名]\AppData\Local\Arduino15\packages\rp2040\hardware\rp2040\[バージョン]\libraries\Adafruit_TinyUSB_Arduino\src\arduino\ports\rp2040\

そこに tusb_config_rp2040.h というファイルがあります。

そのファイルをテキストエディタで開き、以下の2箇所を修正:

① MIDI Hostを有効にする 以下の行を探して(なければ追加):

#define CFG_TUH_MIDI 1

(0 になっていたら1に変更)

② 列挙バッファを大きくする(MPK mini mk3の記述子が長いため必須) 以下の行を探して:

#define CFG_TUH_ENUMERATION_BUFSIZE 512

Waveshare RP2350-Touch-LCD-2

機能グループGPIO番号用途 / 接続先インターフェース備考
LCD (ST7789T3)GPIO15LCD_BL (バックライト PWM制御)GPIO (PWM)占有
LCDGPIO16LCD_DC (Data/Command)SPI占有
LCDGPIO17LCD_CS (Chip Select)SPI占有
LCDGPIO18LCD_CLK (SCK / Clock)SPI占有
LCDGPIO19LCD_DIN (MOSI / Data In)SPI占有
LCDGPIO20LCD_RST (Reset)GPIO占有
Touch (CST816D) + IMU (QMI8658)GPIO12TP_SDA / IMU_SDA (I2C データ)I2C共有
Touch / IMUGPIO13TP_SCL / IMU_SCL (I2C クロック)I2C共有
Touch / IMUGPIO14TP_INT / IMU_INT1 (割り込み)GPIO共有(割り込み)
TFカード (SD)GPIO24SD_SCLK (Clock)SPI占有
TFカードGPIO25SD_CS (Chip Select)SPI占有
TFカードGPIO26SD_MISO (DO / Data Out)SPI占有
TFカードGPIO27SD_MOSI (DI / Data In)SPI占有
カメラ (DVP)GPIO0CAM_D0 (Data 0)Parallel DVP占有
カメラGPIO1CAM_D1Parallel DVP占有
カメラGPIO2CAM_D2Parallel DVP占有
カメラGPIO3CAM_D3Parallel DVP占有
カメラGPIO4CAM_D4Parallel DVP占有
カメラGPIO5CAM_D5Parallel DVP占有
カメラGPIO6CAM_D6Parallel DVP占有
カメラGPIO7CAM_D7Parallel DVP占有
カメラGPIO8CAM_VSYNC (Vertical Sync)DVP占有
カメラGPIO9CAM_HREF (Horizontal Reference)DVP占有
カメラGPIO10CAM_PCLK (Pixel Clock)DVP占有
カメラGPIO11CAM_XCLK (Master Clock出力)GPIO (クロック)占有
カメラGPIO21CAM_PWDN (Power Down)GPIO占有
バッテリー監視GPIO28BAT_ADC (電池電圧 ADC入力)ADC占有

LLM任せでマイコンを使ったシンセサイザーを作る

以下は、コンパイルが通るまでの過程で、実行して期待通りの動作になるかは、別の問題です。
LLMに対する最初の問いかけ:マイコン、小型LCD、USB 接続のMIDIキーボーを使ってアナログシンセサイザーを作るには?

LLMの回答#1

LLMの回答コード#1

実行結果#1

実行結果をLLMへ報告して得た回答

修正版回答のコード

MIDI接続の問題解決
Gemini の回答

コンパイル時の undefined reference(リンクエラー)が発生したことで、鍵盤が全く反応しなかった本当の理由が完全に判明しました。

エラーの真因は、新しいRP2040コア(5.5.0)に同梱されているAdafruit TinyUSBライブラリの初期設定において、「MIDIホスト機能(CFG_TUH_MIDI)」自体がデフォルトで無効化され、コンパイル対象から完全に除外されていたためです。 前回、ポーリング方式で tuh_midi_stream_read 関数を無理やり直接呼び出したことで、「実体が存在しない」という事実がリンクエラーとして表面化しました(コールバック方式の際は、使用されないコードとしてコンパイラに自動消去されていたため、エラーが隠蔽されていました)。

根本的に解決するには、Arduino側のコア設定ファイルを1箇所だけ書き換える必要があります。スケッチ内の #define ではすでにコンパイル済みのライブラリに反映されないため、直接ファイルを編集します。

手順1:TinyUSBライブラリの設定変更

テキストエディタ(メモ帳など)で以下のファイルを開いてください。 C:\Users\mars\AppData\Local\Arduino15\packages\rp2040\hardware\rp2040\5.5.0\libraries\Adafruit_TinyUSB_Arduino\src\arduino\ports\rp2040\tusb_config_rp2040.h

138行目付近に #define CFG_TUH_DEVICE_MAX ... のような記述があります。その直下(またはファイル内の適当な空き行)に、以下の3行を追記して上書き保存してください。

C

#ifndef CFG_TUH_MIDI #define CFG_TUH_MIDI 1 #endif

Arduino IDE 2.3.xでLittleFSアップロード

Arduino IDE 2.3.x(最新版を含む)では、**ファイルシステム(SPIFFS/LittleFS)のアップロード(FS upload)**は標準機能ではなく、専用プラグインをインストールする必要があります。 Toolsメニューに直接「ESP32 Sketch Data Upload」などが表示されないのは正常です。

推奨プラグイン: arduino-littlefs-upload(ESP32/ESP8266/RP2040対応、LittleFS専用)

これはArduino IDE 2.2.1以降で動作するVSIX形式の拡張機能です。2026年現在も積極的にメンテナンスされています。

インストール手順(Windows/Mac/Linux共通)

  1. GitHubリリースページから最新の .vsix ファイルをダウンロード → https://github.com/earlephilhower/arduino-littlefs-upload/releases/latest (例: arduino-littlefs-upload-1.x.x.vsix)
  2. Arduino IDEのプラグインフォルダにコピー
    • Windows: C:\Users\<あなたのユーザー名>\.arduinoIDE\plugins\
    • Mac: ~/.arduinoIDE/plugins/
    • Linux: ~/.arduinoIDE/plugins/ (フォルダが存在しなければ自分で作成してください)
  3. Arduino IDEを完全に再起動
  4. インストール確認
    • キーボードショートカット Ctrl + Shift + P (Macは ⌘ + Shift + P)でコマンドパレットを開く
    • 「Upload LittleFS」と入力 → 「Upload LittleFS to Pico/ESP8266/ESP32」 というコマンドが表示されれば成功!

シンセサイザーをraspberry Pi4に実装する

FM音源方式

FM音源のレシピのコツ(自分で作る場合)

このシンセサイザーにおける各パラメータの音への影響は以下のとおりです。

  1. Mod Ratio (倍音構成)
    • 1.0, 2.0, 3.0... (整数): きれいな和音、楽器的な音になります。
    • 1.41, 2.5, 3.14... (非整数): 金属音、鐘、ノイズっぽい音になります。
    • 0.5: 1オクターブ下の音が混ざり、太くなります。
    • 1.5: 「ド」に対して「ソ」が混ざり、パワーコードのような響きになります。
  2. Mod Index (音の明るさ・激しさ)
    • 0.0: 純粋なサイン波(ポーという時報のような音)。
    • 1.0 ~ 3.0: 心地よいFMトーン(エレピやベース)。
    • 5.0 ~ 10.0: ギラギラした音、ビヨーンという音。
    • 10.0以上: ノイズに近い破壊的な音(Gainを下げないと耳が痛くなります)。
  3. Attack / Release
    • Pad系: Attackを 0.5 以上にすると、ふわっと立ち上がります。
    • Bass/Bell系: Attackを 0.01 (最速) にして、Releaseで余韻を調整します。

デスクトップ アナログ+Soundfont版(Python)

ST7789版(Python)

デスクトップ版 ポリフォニックアナログシンセサイザー(Python)

FM音源版(未完成 Python)

2026年年賀状

新年あけましておめでとうございます

2025年12月 ベランダで撮影(スマート望遠鏡Seestar S50)

馬頭星雲(ばとうせいうん、英: Horsehead Nebula)は、オリオン座にある暗黒星雲。オリオン座の三ツ星の東端にあるζ星の約27秒南に位置する。大きさは約7光年。

その名前の通り、馬の頭に似た形で非常に有名な星雲で、散光星雲IC434を背景に馬の頭の形に浮かびあがって見える。この星雲は巨大な暗黒星雲の一部である。1888年にハーバード大学天文台の写真観測によって初めて発見された。

星雲の西側の赤く光っている部分は、暗黒星雲の背景にある水素ガスが近くにあるオリオン座σ星からの紫外線を受けて電離したものである。馬頭星雲の黒い色は多量の塵を含んでいることによる。星雲から飛び出したガスは強い磁場によって細く集められている。馬頭星雲の根元近くの明るい点は生まれたばかりの若い星である。(出典WikiPedia)