Skip to Content

KI-Sprachtrainer mit UNIHIKER M10

KI-Sprachtrainer mit UNIHIKER M10

In diesem Tutorial lernst du, wie du deinen eigenen KI-Sprachlehrer auf einem UNIHIKER M10 mit OpenAIs KI-Modellen erstellst. Dafür benötigst du ein OpenAI-Konto und einen API-Schlüssel.

Der UNIHIKER M10 ist ein kleines Board mit 512 MB RAM, 16 GB eMMC-Speicher und einem 2,8-Zoll-Touchscreen. Neben Wi-Fi und Bluetooth verfügt das Board über integrierte Sensoren wie einen Lichtsensor, Beschleunigungssensor, Gyroskop und vor allem ein Mikrofon.

Es läuft mit Debian Linux, unterstützt Python-Programmierung und bringt viele vorinstallierte Python-Bibliotheken mit. Das erleichtert die Umsetzung von KI-Lösungen wie einem KI-Sprachlehrer. Das folgende kurze Video zeigt den KI-Sprachlehrer, den wir bauen werden.

Möglicherweise musst du die Lautstärke deines Computers erhöhen, um meine Stimme und die Antwort der KI gut zu hören.

Benötigte Teile

Für dieses Tutorial benötigst du ein UNIHIKER M10 Board. Du kannst es bei DFRobot oder Amazon kaufen. Wenn du einen USB-Lautsprecher hast, kannst du diesen verwenden und an den UNIHIKER M10 anschließen. Möchtest du jedoch eigene kleine Lautsprecher hinzufügen, benötigst du einen PAM8403-Verstärker und einen oder zwei der unten aufgeführten Lautsprecher.

PAM8403-Verstärker

2 x Lautsprecher 3 Watt 4 Ohm

Makerguides is a participant in affiliate advertising programs designed to provide a means for sites to earn advertising fees by linking to Amazon, AliExpress, Elecrow, and other sites. As an Affiliate we may earn from qualifying purchases.

Hardware des UNIHIKER M10

Der UNIHIKER M10 ist ein kompakter Einplatinencomputer für Bildung, Prototyping und AIoT-Anwendungen. Er vereint ein Linux-basiertes System, Sensoren und Hardware-Schnittstellen auf einem Board.

Das Gerät basiert auf einem Quad-Core Arm Cortex-A35 Prozessor mit bis zu 1,2 GHz. Es verfügt über 512 MB RAM, 16 GB eMMC-Speicher und läuft mit dem Debian Linux Betriebssystem.

Front and back of UNIHIKER M10
Vorder- und Rückseite des UNIHIKER M10 (source)

Das Board integriert einen 2,8-Zoll-Touchscreen mit 240 × 320 Pixel Auflösung, Wi-Fi und Bluetooth. Eingebaute Sensoren sind Lichtsensor, Beschleunigungssensor, Gyroskop und Mikrofon. Hardware-Erweiterungen sind über USB-Ports, Gravity-Sensoranschlüsse und einen micro:bit-kompatiblen Edge-Connector möglich, der GPIO, I2C, SPI und UART Schnittstellen bereitstellt.

Für weitere technische Details siehe unser Voice Assistant on UNIHIKER M10 with OpenAI Tutorial. Dieses Tutorial erklärt auch, wie man den UNIHIKER programmiert, Python-Bibliotheken installiert und das Wi-Fi konfiguriert. All das brauchst du, um den KI-Sprachlehrer aus diesem Tutorial zum Laufen zu bringen.

Lautsprecher an den UNIHIKER M10 anschließen

Unser KI-Tutor soll sprechen können. Dafür brauchen wir Lautsprecher. Du kannst Lautsprecher per USB oder Bluetooth an den UNIHIKER M10 anschließen. Das ist die einfachste Lösung, und wenn du dich dafür entscheidest, kannst du diesen Abschnitt überspringen.

Ich wollte kleinere Lautsprecher verwenden, um meinen KI-Tutor portabel zu machen. Der UNIHIKER M10 hat einen Lineout-Ausgang, aber leider keinen Anschluss dafür. Du musst Drähte an bestimmte Pads auf der Rückseite des Boards löten und einen kleinen Verstärker verwenden, um die Lautsprecher anzutreiben. Die Verkabelung habe ich aus dem AI Assistant with OpenAI GPT, Azure Speech API and UNIHIKER Beitrag übernommen.

PAM8403-Verstärker

Ich habe ein PAM8403-Modul als Verstärker verwendet. Das PAM8403-Modul ist ein kleiner Class-D Stereo-Audioverstärker basierend auf dem PAM8403 Chip. Es akzeptiert eine Versorgungsspannung von 2,5 V bis 5,5 V. Bei 5 V und 4 Ω Lautsprechern kann jeder Kanal bis zu etwa 3 W Ausgangsleistung liefern. Das folgende Bild zeigt die Pinbelegung des PAM8403-Verstärkermoduls:

Pinout of PAM8403 Amplifier
Pinbelegung des PAM8403-Verstärkers

Für weitere Informationen zum PAM8403-Verstärkermodul siehe unser Audio with PAM8403, PCM5102 and ESP32 Tutorial.

Lineout-Ausgang des UNIHIKER M10

Wie erwähnt, gibt es keinen Anschluss für den Lineout-Ausgang am UNIHIKER M10, aber du kannst ihn über (Test-)Pads auf der Rückseite des Boards erreichen (schematics). Das Bild unten zeigt die Position der Lineout- und Stromversorgungs-Pads, die wir zum Anschluss des PAM8403-Verstärkers benötigen:

Lineout and power supply pads on UNIHIKER M10
Lineout- und Stromversorgungs-Pads am UNIHIKER M10

Ich habe 4,7 V am VCC-Pad gemessen, was ungewöhnlich ist, da ich 3,3 V oder 5 V erwartet hatte, aber der PAM8403 funktioniert einwandfrei mit 4,7 V. Außerdem hatte mein Board nur ein VCC-Pad, während das Foto oben zwei zeigt. Trotzdem hat alles gut funktioniert.

Verstärker und Lautsprecher an den UNIHIKER M10 anschließen

In diesem Abschnitt zeige ich dir, wie du den PAM8403-Verstärker und die Lautsprecher mit dem UNIHIKER M10 verbindest. Die kleinen 3W-Lautsprecher haben oft Stecker, die du abschneiden musst, da wir die Lautsprecherdrähte direkt an das PAM8403-Modul löten:

Das folgende Bild zeigt, wie du den UNIHIKER M10 mit dem PAM8403-Verstärker und den Lautsprechern verkabelst:

Das Löten der Drähte an die Pads des UNIHIKER M10 ist einfach, aber achte darauf, die richtigen Pads zu verbinden und nichts zu beschädigen. Das Foto unten zeigt meine fertige Verkabelung:

Beachte, dass du nicht unbedingt zwei Lautsprecher brauchst, da Stereo-Sound nicht wirklich erforderlich ist. Zwei Lautsprecher sind aber lauter als einer. Für wirklich lauten Sound verwende einen USB-Lautsprecher mit externer Stromversorgung.

Benutzerhandbuch für den KI-Sprachlehrer

In diesem Abschnitt erkläre ich kurz, wie die Benutzeroberfläche des KI-Sprachlehrers funktioniert. Das erleichtert dir das Verständnis des Codes im nächsten Abschnitt und die Nutzung der Tutor-Software. Das Bild unten zeigt die GUI mit beschrifteten Funktionselementen:

Du drückst die A-Taste und hältst sie gedrückt, während du sprichst. Wenn du die A-Taste loslässt, wird die aufgezeichnete Audioaufnahme an OpenAI zur Transkription und Übersetzung gesendet. Die Transkription und Übersetzung werden dann im Antwortfeld angezeigt und die Übersetzung wird vorgelesen. Du kannst die Übersetzungs-Audioaufnahme durch Drücken der B-Taste erneut abspielen.

Die Zielsprache für die Übersetzung kannst du durch Drücken der Schaltfläche „Select Language“ oben auswählen. Derzeit wird zwischen „Japanese“, „Italian“ und „German“ gewechselt. Du kannst dies im Code leicht auf weitere Sprachen erweitern.

Beachte, dass die Benutzeroberfläche auf Englisch ist, du aber tatsächlich in jeder vom TTS-Modell bei OpenAI unterstützten Sprache sprechen kannst (link). Das System erkennt die Sprache (sofern unterstützt) und übersetzt in die ausgewählte Zielsprache. Du kannst sogar in der Zielsprache sprechen, um deine Aussprache zu überprüfen. Die folgenden zwei Videoclips zeigen eine Demo, in der ich Deutsch und Japanisch spreche:

Wenn du die Schaltfläche „Explain“ unten drückst, wird eine Erklärung zur Grammatik der Übersetzung ausgegeben. Wenn du die Schaltfläche „Example“ drückst, werden Beispielsätze mit ähnlicher Grammatik hinzugefügt. Die folgenden zwei Videoclips demonstrieren diese Funktionen:

Oben auf dem Bildschirm gibt es außerdem Schaltflächen zum Erhöhen und Verringern der Lautstärke und Schriftgröße sowie unten Scroll-Schaltflächen.

Du kannst die Benutzeroberfläche mit den Fingern bedienen, aber der kleine Stift, der mit dem UNIHIKER M10 geliefert wird, funktioniert besser. Du kannst auch das Antwortfeld berühren und darin ziehen/scrollen, anstatt die Scroll-Schaltflächen oder die Scroll-Leiste zu verwenden.

OpenAI API-Schlüssel erhalten

Unser KI-Sprachlehrer nutzt KI-Modelle von OpenAI. Du benötigst daher ein OpenAI-Konto und einen API-Schlüssel. Gehe zu https://platform.openai.com und melde dich mit einer E-Mail-Adresse oder einem bestehenden Google- oder Microsoft-Konto an.

Nach der Verifizierung deiner E-Mail und dem Abschluss der Ersteinrichtung logge dich im OpenAI-Dashboard ein, platform.openai.com/api-keys und finde oder erstelle deinen API-Schlüssel (=SECRET KEY) wie unten gezeigt:

OpenAI API keys
OpenAI API-Schlüssel

Der API-Schlüssel ist eine einzigartige, lange Zeichenkette, die mit „sk-proj-“ beginnt und zur Authentifizierung deiner API-Anfragen benötigt wird. Später musst du diese gesamte Zeichenkette in den Code des KI-Sprachlehrers kopieren.

Code für den KI-Sprachlehrer

Der folgende Code implementiert unsere KI-gestützte Sprachlehrer-Anwendung. Sie ermöglicht es Nutzern, Phrasen in jeder Sprache zu sprechen, die dann transkribiert, in eine ausgewählte Zielsprache übersetzt und per Text-zu-Sprache (TTS) wiedergegeben werden. Die App bietet außerdem Grammatik-Erklärungen und Beispielsätze zur Unterstützung beim Sprachenlernen.

# www.makerguides.com
# Python 3.7
# openai 1.39.0
# PyAudio 0.2.11
# pinpong 0.6.1
# numpy 1.21.6

import sys
import time
import os
import threading
import tempfile
import numpy as np
import pyaudio
import io
import wave
import pygame

from openai import OpenAI
from pinpong.extension.unihiker import button_a, button_b

from qtpy.QtWidgets import (
    QApplication,
    QWidget,
    QVBoxLayout,
    QHBoxLayout,
    QLabel,
    QTextEdit,
    QScroller,
    QPushButton,
)
from qtpy.QtCore import Qt, QObject, Signal, QThread, Slot


API_KEY = "sk-proj-my-api_key"  # SET YOUR API KEY HERE!
DEVICE_INDEX = 2
SAMPLE_RATE  = 16000
CHANNELS     = 1
CHUNK        = 1024

LANGUAGES       = ["Japanese", "Italian", "German"]
DEFAULT_STATUS  = "Hold A: new  |  B: replay"
VOLUME_STEP     = 0.1      # increment per button press
INITIAL_VOLUME  = 0.8      # volume level when not muted
_volume = INITIAL_VOLUME   # float, 0.0 – 1.0

TTS_FILE = os.path.join(tempfile.gettempdir(), "tts_translation.mp3")

client = OpenAI(api_key=API_KEY)

pygame.mixer.init()


# STYLE ----------------------------------------------------------

ORA_BG      = "#fcdb03"   # bright yellow
ORA_DARK    = "#d4a800"   # darker yellow (pressed / language btn)
ORA_DIM     = "#fef08a"   # disabled button bg
ORA_TEXT    = "#000000"   # black text everywhere
ORA_DT      = "#888800"   # disabled button text

def _btn_style(bg=ORA_BG, fg=ORA_TEXT, en=ORA_BG,
               pr=ORA_DARK, ds=ORA_DIM, dt=ORA_DT):
    return (
        f"QPushButton          {{ background: {bg}; color: {fg};"
        f"                        border: none; font-size: 13px; }}"
        f"QPushButton:enabled  {{ background: {en}; }}"
        f"QPushButton:pressed  {{ background: {pr}; }}"
        f"QPushButton:disabled {{ background: {ds}; color: {dt}; }}"
    )



# WORKER THREAD ----------------------------------------------------

class AssistantWorker(QObject):

    status          = Signal(str)
    answer          = Signal(str)
    btn_explain_on  = Signal(bool)
    btn_examples_on = Signal(bool)
    language_changed = Signal(str)   

    def __init__(self):
        super().__init__()
        self._last_question    = ""
        self._last_translation = ""
        self._last_grammar     = ""
        self._last_examples    = ""
        self._tts_ready        = False

        self._lang_index = 0  
        self._language   = LANGUAGES[0]

        # Thread-safe flags
        self._explain_requested  = False
        self._examples_requested = False
        self._lang_requested     = False
        self._lock = threading.Lock()

        self._pa     = pyaudio.PyAudio()
        self._stream = self._pa.open(
            format=pyaudio.paInt16,
            channels=CHANNELS,
            rate=SAMPLE_RATE,
            input=True,
            input_device_index=DEVICE_INDEX,
            frames_per_buffer=CHUNK,
        )

    @Slot()
    def on_explain_clicked(self):
        with self._lock:
            self._explain_requested = True

    @Slot()
    def on_examples_clicked(self):
        with self._lock:
            self._examples_requested = True

    @Slot()
    def on_language_clicked(self):
        with self._lock:
            self._lang_requested = True

    def run(self):
        self.status.emit("Hold Button A to speak")

        while True:
            a_pressed = button_a.is_pressed()
            b_pressed = button_b.is_pressed()

            with self._lock:
                explain_req  = self._explain_requested
                examples_req = self._examples_requested
                lang_req     = self._lang_requested
                self._explain_requested  = False
                self._examples_requested = False
                self._lang_requested     = False

            if lang_req and not a_pressed:
                self._lang_index = (self._lang_index + 1) % len(LANGUAGES)
                self._language   = LANGUAGES[self._lang_index]
                self.language_changed.emit(self._language)

            elif a_pressed:
                self._clear_state()
                raw_pcm = record_while_held(self, self._stream)
                if not raw_pcm:
                    self.status.emit("Hold Button A to speak")
                    continue

                self.status.emit("Transcribing...")
                normalized = normalize(raw_pcm)
                wav_bytes  = pcm_to_wav(normalized)
                question   = transcribe(wav_bytes)

                self.status.emit(f"Translating to {self._language}...")
                translation = translate_only(question, self._language)

                self._last_question    = question
                self._last_translation = translation

                self._refresh_display()
                self.btn_explain_on.emit(True)
                self.btn_examples_on.emit(True)

                self.status.emit("Generating audio...")
                tts_text = extract_first_line(translation)
                generate_tts(tts_text, TTS_FILE)
                self._tts_ready = True
                self._set_default_status()
                play_audio(TTS_FILE)

            elif b_pressed:
                while button_b.is_pressed():
                    time.sleep(0.01)
                if self._tts_ready:
                    self.status.emit("Replaying...")
                    play_audio(TTS_FILE)
                    self._set_default_status()

            elif explain_req and self._last_translation:
                self.status.emit("Explaining grammar...")
                self._last_grammar = explain_grammar(
                    self._last_question, self._last_translation, self._language
                )
                self._refresh_display()
                self._set_default_status()

            elif examples_req and self._last_translation:
                self.status.emit("Generating examples...")
                self._last_examples = add_examples(
                    self._last_question, self._last_translation, self._language
                )
                self._refresh_display()
                self._set_default_status()

            else:
                time.sleep(0.02)

    def _set_default_status(self):
        self.status.emit(DEFAULT_STATUS)

    def _clear_state(self):
        self._last_question    = ""
        self._last_translation = ""
        self._last_grammar     = ""
        self._last_examples    = ""
        self._tts_ready        = False
        if pygame.mixer.music.get_busy():
            pygame.mixer.music.stop()
        if os.path.exists(TTS_FILE):
            try:
                os.remove(TTS_FILE)
            except OSError:
                pass
        self.answer.emit("")
        self.btn_explain_on.emit(False)
        self.btn_examples_on.emit(False)

    def _refresh_display(self):
        parts = []
        if self._last_question:
            parts.append(self._last_question)
        if self._last_translation:
            parts.append(self._last_translation)
        if self._last_grammar:
            parts.append(f"── Grammar ──\n{self._last_grammar}")
        if self._last_examples:
            parts.append(f"── Examples ──\n{self._last_examples}")
        self.answer.emit("\n\n".join(parts))



# GUI ----------------------------------------------------

class AssistantUI(QWidget):

    def __init__(self):
        super().__init__()
        self.setWindowTitle("Voice Chatbot")
        self.setFixedSize(240, 320)

        # Orange window background, black text; white text area
        self.setStyleSheet(
            f"QWidget  {{ background-color: {ORA_BG}; color: {ORA_TEXT}; }}"
            f"QTextEdit {{ background-color: #ffffff; color: {ORA_TEXT};"
            f"             border: none; }}"
        )

        layout = QVBoxLayout(self)
        layout.setContentsMargins(4, 4, 4, 4)
        layout.setSpacing(3)

        lang_row = QHBoxLayout()
        lang_row.setSpacing(4)

        _dark_style = _btn_style(bg=ORA_DARK, en=ORA_DARK, pr="#8a3d00")

        self.btn_font_minus = QPushButton("-")
        self.btn_font_minus.setFixedSize(28, 26)
        self.btn_font_minus.setStyleSheet(_dark_style)
        lang_row.addWidget(self.btn_font_minus)

        self.btn_vol_down = QPushButton("<")
        self.btn_vol_down.setFixedSize(28, 26)
        self.btn_vol_down.setStyleSheet(_dark_style)
        lang_row.addWidget(self.btn_vol_down)

        self.btn_language = QPushButton(LANGUAGES[0])
        self.btn_language.setFixedHeight(26)
        self.btn_language.setStyleSheet(_dark_style)
        lang_row.addWidget(self.btn_language, stretch=1)

        self.btn_vol_up = QPushButton(">")
        self.btn_vol_up.setFixedSize(28, 26)
        self.btn_vol_up.setStyleSheet(_dark_style)
        lang_row.addWidget(self.btn_vol_up)

        self.btn_font_plus = QPushButton("+")
        self.btn_font_plus.setFixedSize(28, 26)
        self.btn_font_plus.setStyleSheet(_dark_style)
        lang_row.addWidget(self.btn_font_plus)

        layout.addLayout(lang_row)

        self._font_size = 10
        self.btn_font_plus.clicked.connect(self._font_increase)
        self.btn_font_minus.clicked.connect(self._font_decrease)
        self.btn_vol_down.clicked.connect(self._vol_decrease)
        self.btn_vol_up.clicked.connect(self._vol_increase)

        self.status = QLabel("Starting...")
        self.status.setAlignment(Qt.AlignCenter)
        self.status.setStyleSheet(
            "QLabel { background-color: #ffffff; color: #000000; padding: 1px; }"
        )
        layout.addWidget(self.status)

        self.text = QTextEdit()
        self.text.setReadOnly(True)
        self.text.setTextInteractionFlags(Qt.NoTextInteraction)
        layout.addWidget(self.text, stretch=1)

        QScroller.grabGesture(
            self.text.viewport(),
            QScroller.LeftMouseButtonGesture
        )

        btn_row = QHBoxLayout()
        btn_row.setSpacing(4)

        _scroll_style = _btn_style(bg=ORA_DARK, en=ORA_DARK, pr="#8a3d00")

        self.btn_scroll_up = QPushButton("▲")
        self.btn_scroll_up.setFixedSize(40, 26)
        self.btn_scroll_up.setStyleSheet(_scroll_style)
        btn_row.addWidget(self.btn_scroll_up)

        self.btn_explain  = QPushButton("Explain")
        self.btn_examples = QPushButton("Examples")

        for btn in (self.btn_explain, self.btn_examples):
            btn.setEnabled(False)
            btn.setFixedHeight(26)
            btn.setStyleSheet(_btn_style())
            btn_row.addWidget(btn, stretch=1)

        self.btn_scroll_down = QPushButton("▼")
        self.btn_scroll_down.setFixedSize(40, 26)
        self.btn_scroll_down.setStyleSheet(_scroll_style)
        btn_row.addWidget(self.btn_scroll_down)

        layout.addLayout(btn_row)

        self.btn_scroll_down.clicked.connect(self._scroll_down)
        self.btn_scroll_up.clicked.connect(self._scroll_up)


    def update_status(self, txt):
        self.status.setText(txt)

    def update_answer(self, txt):
        self.text.clear()
        self.text.setPlainText(txt)
        self.text.verticalScrollBar().setValue(0)

    def set_explain_enabled(self, enabled: bool):
        self.btn_explain.setEnabled(enabled)

    def set_examples_enabled(self, enabled: bool):
        self.btn_examples.setEnabled(enabled)

    def update_language_label(self, lang: str):
        self.btn_language.setText(lang)

    def _scroll_down(self):
        sb = self.text.verticalScrollBar()
        sb.setValue(sb.value() + self.text.viewport().height())

    def _scroll_up(self):
        sb = self.text.verticalScrollBar()
        sb.setValue(sb.value() - self.text.viewport().height())

    def _font_increase(self):
        self._font_size = min(self._font_size + 1, 28)
        self._apply_font_size()

    def _font_decrease(self):
        self._font_size = max(self._font_size - 1, 6)
        self._apply_font_size()

    def _apply_font_size(self):
        font = self.text.font()
        font.setPointSize(self._font_size)
        self.text.setFont(font)


    def _vol_increase(self):
        global _volume
        _volume = min(round(_volume + VOLUME_STEP, 1), 1.0)

    def _vol_decrease(self):
        global _volume
        _volume = max(round(_volume - VOLUME_STEP, 1), 0.0)



# AUDIO HELPERS  ----------------------------------------------------

def normalize(pcm_bytes: bytes) -> bytes:
    samples = np.frombuffer(pcm_bytes, dtype=np.int16).astype(np.float32)
    peak = np.max(np.abs(samples))
    if peak == 0:
        return pcm_bytes
    gain = (0.9 * 32767) / peak
    return np.clip(samples * gain, -32768, 32767).astype(np.int16).tobytes()


def record_while_held(worker: AssistantWorker, stream) -> bytes:
    try:
        while stream.get_read_available() > 0:
            stream.read(stream.get_read_available(), exception_on_overflow=False)
    except Exception:
        pass

    worker.status.emit("Recording...")
    frames = []
    while button_a.is_pressed():
        frames.append(stream.read(CHUNK, exception_on_overflow=False))
    return b"".join(frames)


def pcm_to_wav(pcm_bytes: bytes) -> bytes:
    buf = io.BytesIO()
    with wave.open(buf, "wb") as wf:
        wf.setnchannels(CHANNELS)
        wf.setsampwidth(2)
        wf.setframerate(SAMPLE_RATE)
        wf.writeframes(pcm_bytes)
    return buf.getvalue()


def generate_tts(text: str, filepath: str) -> None:
    """Call OpenAI TTS and save to filepath (mp3)."""
    response = client.audio.speech.create(
        model="tts-1",
        voice="alloy",
        input=text,
    )
    with open(filepath, "wb") as f:
        f.write(response.content)


def play_audio(filepath: str) -> None:
    """Play audio at the current volume, then mute the output again."""
    pygame.mixer.music.set_volume(_volume)
    pygame.mixer.music.load(filepath)
    pygame.mixer.music.play()
    while pygame.mixer.music.get_busy():
        time.sleep(0.05)
    pygame.mixer.music.set_volume(0.0) 


# OPENAI HELPERS ------------------------------------------------------

def transcribe(wav_bytes: bytes) -> str:
    audio_file      = io.BytesIO(wav_bytes)
    audio_file.name = "recording.wav"
    response = client.audio.transcriptions.create(
        model="whisper-1",
        file=audio_file,
        language="en",
        temperature=0,
    )
    return response.text


def _script_hint(language: str) -> str:
    """Return a script hint for the system prompt based on language."""
    hints = {
        "Japanese": "in Kanji/Kana on one line, then the romaji reading on the next line",
        "Italian":  "in Italian script",
        "German":   "in German script",
    }
    return hints.get(language, "in the target language's script")


def translate_only(question: str, language: str) -> str:
    hint = _script_hint(language)
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": (
                    f"Translate the given English phrase to {language}. "
                    f"Provide the translation {hint}. "
                    "Do NOT include grammar explanations or example sentences."
                ),
            },
            {"role": "user", "content": question},
        ],
    )
    return response.choices[0].message.content


def extract_first_line(translation: str) -> str:
    """Return the first line of the translation for TTS (skips romaji line)."""
    return translation.splitlines()[0].strip() if translation else translation


def explain_grammar(question: str, translation: str, language: str) -> str:
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": (
                    f"You are a {language} language teacher. "
                    f"Explain the grammar of the {language} translation clearly and concisely. "
                    "Do NOT provide additional example sentences."
                ),
            },
            {
                "role": "user",
                "content": (
                    f"Original English: {question}\n"
                    f"{language} translation: {translation}\n\n"
                    "Please explain the grammar."
                ),
            },
        ],
    )
    return response.choices[0].message.content


def add_examples(question: str, translation: str, language: str) -> str:
    hint = _script_hint(language)
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": (
                    f"You are a {language} language teacher. "
                    f"Provide 2-3 natural example sentences in {language} ({hint}) "
                    "that illustrate the same grammar pattern or vocabulary. "
                    "Include a short English translation for each."
                ),
            },
            {
                "role": "user",
                "content": (
                    f"Original English: {question}\n"
                    f"{language} translation: {translation}\n\n"
                    "Please provide example sentences."
                ),
            },
        ],
    )
    return response.choices[0].message.content



# MAIN ------------------------------------------------------

def main():
    app = QApplication(sys.argv)

    ui     = AssistantUI()
    worker = AssistantWorker()
    thread = QThread()

    worker.moveToThread(thread)
    thread.started.connect(worker.run)
    worker.status.connect(ui.update_status)
    worker.answer.connect(ui.update_answer)
    worker.btn_explain_on.connect(ui.set_explain_enabled)
    worker.btn_examples_on.connect(ui.set_examples_enabled)
    worker.language_changed.connect(ui.update_language_label)

    ui.btn_explain.clicked.connect(worker.on_explain_clicked,  Qt.DirectConnection)
    ui.btn_examples.clicked.connect(worker.on_examples_clicked, Qt.DirectConnection)
    ui.btn_language.clicked.connect(worker.on_language_clicked, Qt.DirectConnection)

    ui.show()
    thread.start()

    sys.exit(app.exec())


if __name__ == "__main__":
    main()

Imports

Das Programm beginnt mit dem Import der notwendigen Python-Module und Bibliotheken. Dazu gehören Standardmodule wie sys, time, os und threading für Systeminteraktion, Zeitsteuerung, Dateiverwaltung und Nebenläufigkeit. Es verwendet numpy für numerische Operationen auf Audiodaten, pyaudio für die Audioaufnahme und pygame für die Audiowiedergabe.

Der Code importiert außerdem die OpenAI-Clientbibliothek für den Zugriff auf KI-Dienste sowie die spezifischen Hardware-Tasten button_a und button_b aus der UNIHIKER-Erweiterung. Für die grafische Benutzeroberfläche (GUI) wird qtpy verwendet, um Widgets zu erstellen und Signale sowie Slots zu verwalten.

Beachte, dass du die OpenAI-Bibliothek installieren musst, da sie auf dem UNIHIKER M10 nicht vorinstalliert ist. Wenn du Hilfe dabei brauchst, siehe das Voice Assistant on UNIHIKER M10 with OpenAI Tutorial.

Konstanten und Konfiguration

Mehrere Konstanten definieren das Verhalten und Aussehen der Anwendung. Dazu gehören der OpenAI API-Schlüssel, Parameter für das Audioeingabegerät wie Abtastrate und Kanäle sowie UI-bezogene Konstanten wie unterstützte Sprachen und Farb-Codes für die Gestaltung von Buttons und Hintergründen.

Auch Parameter für die Lautstärkeregelung sind gesetzt, einschließlich der Anfangslautstärke und der Schrittgröße für Lautstärkeanpassungen.

API_KEY = "sk-proj-my-api_key"
DEVICE_INDEX = 2
SAMPLE_RATE  = 16000
CHANNELS     = 1
CHUNK        = 1024

LANGUAGES       = ["Japanese", "Italian", "German"]
DEFAULT_STATUS  = "Hold A: new  |  B: replay"
VOLUME_STEP     = 0.1
INITIAL_VOLUME  = 0.8
_volume = INITIAL_VOLUME

Denke daran, den Wert der Konstanten API_KEY durch deinen eigenen API-Schlüssel zu ersetzen!

Button-Styling

Die Funktion _btn_style() liefert einen Stylesheet-String, der das Aussehen der Buttons in verschiedenen Zuständen (normal, gedrückt, deaktiviert) definiert. Diese Funktion zentralisiert das Styling, um ein einheitliches Erscheinungsbild der UI zu gewährleisten.

AssistantWorker-Klasse

Diese Klasse kapselt die Kernlogik, die in einem separaten Thread läuft, um die UI reaktionsfähig zu halten. Sie verwaltet die Audioaufnahme, die Interaktion mit den OpenAI-APIs und das Zustandsmanagement.

Die Klasse definiert Signale, um Statusupdates, Antworten, Button-Zustände und Sprachwechsel an die UI zurückzumelden.

Im Konstruktor initialisiert sie Variablen zur Speicherung der letzten Frage, Übersetzung, Grammatik-Erklärung und Beispielsätze. Außerdem richtet sie den Audioeingangsstrom mit pyaudio unter Verwendung des angegebenen Geräts und der Parameter ein.

class AssistantWorker(QObject):
    status          = Signal(str)
    answer          = Signal(str)
    btn_explain_on  = Signal(bool)
    btn_examples_on = Signal(bool)
    language_changed = Signal(str)

    def __init__(self):
        super().__init__()
        self._last_question    = ""
        self._last_translation = ""
        self._last_grammar     = ""
        self._last_examples    = ""
        self._tts_ready        = False

        self._lang_index = 0
        self._language   = LANGUAGES[0]

        self._explain_requested  = False
        self._examples_requested = False
        self._lang_requested     = False
        self._lock = threading.Lock()

        self._pa     = pyaudio.PyAudio()
        self._stream = self._pa.open(
            format=pyaudio.paInt16,
            channels=CHANNELS,
            rate=SAMPLE_RATE,
            input=True,
            input_device_index=DEVICE_INDEX,
            frames_per_buffer=CHUNK,
        )

Die Klasse bietet Slot-Methoden, um Button-Klicks für Grammatik-Erklärungen, Beispielsätze und Sprachwechsel zu verarbeiten. Diese Methoden setzen threadsichere Flags, die die Hauptschleife überwacht.

AssistantWorker Run Loop

Die run() Methode enthält die Hauptschleife, die kontinuierlich den Zustand der Hardware-Tasten prüft und die Benutzereingaben entsprechend verarbeitet.

Wenn die Taste A gedrückt gehalten wird, nimmt sie Audio vom Mikrofon auf. Beim Loslassen der Taste A wird die Aufnahme normalisiert, in WAV-Format konvertiert und an OpenAIs Whisper-Modell zur Transkription gesendet. Der transkribierte englische Text wird dann mit OpenAIs GPT-Modell in die ausgewählte Sprache übersetzt.

Anschließend wird der übersetzte Text angezeigt, eine TTS-Audiodatei erzeugt und abgespielt. Mit Taste B kann die letzte Audiodatei erneut abgespielt werden. Die Sprachwahltaste wechselt zwischen den unterstützten Sprachen.

Wenn der Nutzer Grammatik-Erklärungen oder Beispielsätze anfordert, ruft der Worker die entsprechenden OpenAI-API-Endpunkte auf, um die Inhalte zu generieren und aktualisiert die Anzeige.

def run(self):
    self.status.emit("Hold Button A to speak")

    while True:
        a_pressed = button_a.is_pressed()
        b_pressed = button_b.is_pressed()

        with self._lock:
            explain_req  = self._explain_requested
            examples_req = self._examples_requested
            lang_req     = self._lang_requested
            self._explain_requested  = False
            self._examples_requested = False
            self._lang_requested     = False

        if lang_req and not a_pressed:
            self._lang_index = (self._lang_index + 1) % len(LANGUAGES)
            self._language   = LANGUAGES[self._lang_index]
            self.language_changed.emit(self._language)

        elif a_pressed:
            self._clear_state()
            raw_pcm = record_while_held(self, self._stream)
            if not raw_pcm:
                self.status.emit("Hold Button A to speak")
                continue

            self.status.emit("Transcribing...")
            normalized = normalize(raw_pcm)
            wav_bytes  = pcm_to_wav(normalized)
            question   = transcribe(wav_bytes)

            self.status.emit(f"Translating to {self._language}...")
            translation = translate_only(question, self._language)

            self._last_question    = question
            self._last_translation = translation

            self._refresh_display()
            self.btn_explain_on.emit(True)
            self.btn_examples_on.emit(True)

            self.status.emit("Generating audio...")
            tts_text = extract_first_line(translation)
            generate_tts(tts_text, TTS_FILE)
            self._tts_ready = True
            self._set_default_status()
            play_audio(TTS_FILE)

        elif b_pressed:
            while button_b.is_pressed():
                time.sleep(0.01)
            if self._tts_ready:
                self.status.emit("Replaying...")
                play_audio(TTS_FILE)
                self._set_default_status()

        elif explain_req and self._last_translation:
            self.status.emit("Explaining grammar...")
            self._last_grammar = explain_grammar(
                self._last_question, self._last_translation, self._language
            )
            self._refresh_display()
            self._set_default_status()

        elif examples_req and self._last_translation:
            self.status.emit("Generating examples...")
            self._last_examples = add_examples(
                self._last_question, self._last_translation, self._language
            )
            self._refresh_display()
            self._set_default_status()

        else:
            time.sleep(0.02)

Zustandsverwaltungs-Methoden

Die Worker-Klasse enthält Hilfsmethoden zum Zurücksetzen des internen Zustands, Aktualisieren des angezeigten Texts und Setzen der Standard-Statusmeldung. Diese Methoden sorgen dafür, dass die UI den aktuellen Zustand der Anwendung korrekt widerspiegelt.

AssistantUI-Klasse

Diese Klasse definiert die grafische Benutzeroberfläche mit Qt-Widgets. Sie erstellt ein Fenster mit fester Größe, orangefarbenem Hintergrund und schwarzem Text, passend zum zuvor definierten Farbschema.

Die UI besteht aus einer oberen Reihe mit Buttons zur Schriftgrößenanpassung, Lautstärkeregelung und Sprachauswahl. Darunter zeigt ein Statuslabel Nachrichten an den Nutzer. Der Haupttextbereich zeigt transkribierten, übersetzten und erklärenden Text.

Unten befinden sich Buttons zum Scrollen durch den Text sowie zum Anfordern von Grammatik-Erklärungen oder Beispielsätzen. Die Buttons sind einheitlich mit den zuvor definierten Styles gestaltet.

Die Klasse bietet Methoden zum Aktualisieren des Status-Texts, der angezeigten Antwort, zum Aktivieren/Deaktivieren von Buttons und zum Ändern des Sprachlabels. Sie verarbeitet auch Nutzerinteraktionen für Scrollen sowie Schriftgrößen- und Lautstärkeanpassungen.

class AssistantUI(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Voice Chatbot")
        self.setFixedSize(240, 320)

        self.setStyleSheet(
            f"QWidget  {{ background-color: {ORA_BG}; color: {ORA_TEXT}; }}"
            f"QTextEdit {{ background-color: #ffffff; color: {ORA_TEXT};"
            f"             border: none; }}"
        )

        layout = QVBoxLayout(self)
        layout.setContentsMargins(4, 4, 4, 4)
        layout.setSpacing(3)

        lang_row = QHBoxLayout()
        lang_row.setSpacing(4)

        _dark_style = _btn_style(bg=ORA_DARK, en=ORA_DARK, pr="#8a3d00")

        self.btn_font_minus = QPushButton("-")
        self.btn_font_minus.setFixedSize(28, 26)
        self.btn_font_minus.setStyleSheet(_dark_style)
        lang_row.addWidget(self.btn_font_minus)

        self.btn_vol_down = QPushButton("<")
        self.btn_vol_down.setFixedSize(28, 26)
        self.btn_vol_down.setStyleSheet(_dark_style)
        lang_row.addWidget(self.btn_vol_down)

        self.btn_language = QPushButton(LANGUAGES[0])
        self.btn_language.setFixedHeight(26)
        self.btn_language.setStyleSheet(_dark_style)
        lang_row.addWidget(self.btn_language, stretch=1)

        self.btn_vol_up = QPushButton(">")
        self.btn_vol_up.setFixedSize(28, 26)
        self.btn_vol_up.setStyleSheet(_dark_style)
        lang_row.addWidget(self.btn_vol_up)

        self.btn_font_plus = QPushButton("+")
        self.btn_font_plus.setFixedSize(28, 26)
        self.btn_font_plus.setStyleSheet(_dark_style)
        lang_row.addWidget(self.btn_font_plus)

        layout.addLayout(lang_row)

        self._font_size = 10
        self.btn_font_plus.clicked.connect(self._font_increase)
        self.btn_font_minus.clicked.connect(self._font_decrease)
        self.btn_vol_down.clicked.connect(self._vol_decrease)
        self.btn_vol_up.clicked.connect(self._vol_increase)

        self.status = QLabel("Starting...")
        self.status.setAlignment(Qt.AlignCenter)
        self.status.setStyleSheet(
            "QLabel { background-color: #ffffff; color: #000000; padding: 1px; }"
        )
        layout.addWidget(self.status)

        self.text = QTextEdit()
        self.text.setReadOnly(True)
        self.text.setTextInteractionFlags(Qt.NoTextInteraction)
        layout.addWidget(self.text, stretch=1)

        QScroller.grabGesture(
            self.text.viewport(),
            QScroller.LeftMouseButtonGesture
        )

        btn_row = QHBoxLayout()
        btn_row.setSpacing(4)

        _scroll_style = _btn_style(bg=ORA_DARK, en=ORA_DARK, pr="#8a3d00")

        self.btn_scroll_up = QPushButton("▲")
        self.btn_scroll_up.setFixedSize(40, 26)
        self.btn_scroll_up.setStyleSheet(_scroll_style)
        btn_row.addWidget(self.btn_scroll_up)

        self.btn_explain  = QPushButton("Explain")
        self.btn_examples = QPushButton("Examples")

        for btn in (self.btn_explain, self.btn_examples):
            btn.setEnabled(False)
            btn.setFixedHeight(26)
            btn.setStyleSheet(_btn_style())
            btn_row.addWidget(btn, stretch=1)

        self.btn_scroll_down = QPushButton("▼")
        self.btn_scroll_down.setFixedSize(40, 26)
        self.btn_scroll_down.setStyleSheet(_scroll_style)
        btn_row.addWidget(self.btn_scroll_down)

        layout.addLayout(btn_row)

        self.btn_scroll_down.clicked.connect(self._scroll_down)
        self.btn_scroll_up.clicked.connect(self._scroll_up)

Audio-Hilfsfunktionen

Mehrere Hilfsfunktionen übernehmen Aufgaben der Audiobearbeitung. Die normalize() Funktion passt die aufgenommene PCM-Audioaufnahme an, um die Lautstärke ohne Übersteuerung zu maximieren. record_while_held() nimmt Audio vom Mikrofon auf, solange Taste A gedrückt ist. Und die pcm_to_wav() Funktion wandelt rohe PCM-Bytes in WAV-Format um, das für die Transkription benötigt wird.

Anschließend gibt es die generate_tts() Funktion, die Text an OpenAIs TTS-API sendet und die resultierende Audiodatei speichert. Schließlich play_audio() spielt die erzeugte Audiodatei mit pygame in der aktuellen Lautstärke ab.

OpenAI-Hilfsfunktionen

Diese Funktionen interagieren mit OpenAIs API, um Transkription, Übersetzung, Grammatik-Erklärungen und Beispielsatz-Generierung durchzuführen.

Die transcribe() Funktion sendet die aufgezeichnete WAV-Audiodatei an das Whisper-Modell, um englischen Text zu erhalten. translate_only() übersetzt die englische Frage in die ausgewählte Sprache ohne zusätzliche Erklärungen.

Die explain_grammar() und add_examples() Funktionen fordern Grammatik-Erklärungen bzw. Beispielsätze an, indem sie GPT mit speziell auf den Sprachunterricht zugeschnittenen Prompts nutzen.

Main-Funktion

Die main() Funktion initialisiert die Qt-Anwendung, erstellt Instanzen der UI- und Worker-Klassen und richtet einen separaten Thread für den Worker ein, der parallel läuft.

Sie verbindet Signale und Slots zwischen Worker und UI, um die Oberfläche basierend auf dem Fortschritt des Workers und Nutzerinteraktionen zu aktualisieren. Schließlich startet sie die Ereignisschleife der Anwendung.

def main():
    app = QApplication(sys.argv)

    ui     = AssistantUI()
    worker = AssistantWorker()
    thread = QThread()

    worker.moveToThread(thread)
    thread.started.connect(worker.run)
    worker.status.connect(ui.update_status)
    worker.answer.connect(ui.update_answer)
    worker.btn_explain_on.connect(ui.set_explain_enabled)
    worker.btn_examples_on.connect(ui.set_examples_enabled)
    worker.language_changed.connect(ui.update_language_label)

    ui.btn_explain.clicked.connect(worker.on_explain_clicked,  Qt.DirectConnection)
    ui.btn_examples.clicked.connect(worker.on_examples_clicked, Qt.DirectConnection)
    ui.btn_language.clicked.connect(worker.on_language_clicked, Qt.DirectConnection)

    ui.show()
    thread.start()

    sys.exit(app.exec())

Zusammenfassend integriert dieser Code Hardware-Button-Eingaben, Audiobearbeitung, KI-Sprachdienste und eine reaktionsfähige GUI, um einen interaktiven sprachbasierten Sprachlehrer zu schaffen. Er nutzt OpenAIs Modelle für Transkription, Übersetzung und Sprachunterricht.

Fazit

In diesem Tutorial hast du gelernt, wie man einen einfachen KI-Sprachlehrer auf dem UNIHIKER M10 mit OpenAI-Diensten implementiert. Ich empfehle dir auch, das Voice Assistant on UNIHIKER M10 with OpenAI Tutorial zu lesen, das einige Grundlagen behandelt, die in diesem Tutorial nicht enthalten sind.

Obwohl der KI-Sprachlehrer in diesem Tutorial bereits nützlich für das Sprachenlernen ist, gibt es viele mögliche Erweiterungen, um ihn noch hilfreicher zu machen. Zum Beispiel könnte das Programm Übersetzungen und Erklärungen speichern, um sie später erneut anzusehen. Es könnte kurze Geschichten um Sätze generieren und vorlesen. Es könnte Verben und Substantive extrahieren, um sie separat zu trainieren. Und vieles mehr …

Viel Spaß beim Erweitern des Tutors für deine Zwecke, und wenn du Fragen hast, hinterlasse sie gerne im Kommentarbereich.

Viel Spaß beim Tüfteln 😉