Skip to Content

Tutor de Idiomas AI con UNIHIKER M10

Tutor de Idiomas AI con UNIHIKER M10

En este tutorial aprenderás a construir tu propio Tutor de Idiomas con IA en un UNIHIKER M10 usando los modelos de IA de OpenAI. Necesitarás una cuenta de OpenAI y una clave API para ello.

El UNIHIKER M10 es una pequeña placa con 512 MB de RAM y 16 GB de almacenamiento eMMC, y una pantalla táctil de 2,8 pulgadas. Además de conectividad Wi-Fi y Bluetooth, la placa incluye sensores integrados como sensor de luz, acelerómetro, giroscopio y, lo más importante, un micrófono.

Funciona con Debian Linux, soporta programación en Python y viene con muchas librerías de Python preinstaladas. Esto facilita la implementación de soluciones de IA como un Tutor de Idiomas con IA. El siguiente video corto muestra el Tutor de Idiomas con IA que vamos a construir.

Puede que necesites subir el volumen de tu ordenador para escuchar mi voz y la respuesta de la IA.

Piezas necesarias

Para este tutorial necesitas una placa UNIHIKER M10. Puedes conseguirla en DFRobot o en Amazon. Si tienes un altavoz USB puedes usarlo y conectarlo al UNIHIKER M10. Pero si quieres añadir tus propios altavoces pequeños necesitarás un amplificador PAM8403 y uno o dos de los altavoces listados a continuación.

Amplificador PAM8403

2 x Altavoz 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 del UNIHIKER M10

El UNIHIKER M10 es un ordenador de placa única compacto para educación, prototipado y aplicaciones AIoT. Incluye un sistema basado en Linux, sensores e interfaces de hardware en una sola placa.

El dispositivo está basado en un procesador Arm Cortex-A35 de cuatro núcleos que funciona hasta 1,2 GHz. Incluye 512 MB de RAM y 16 GB de almacenamiento eMMC y ejecuta el sistema operativo Debian Linux.

Front and back of UNIHIKER M10
Frontal y trasera del UNIHIKER M10 (source)

La placa integra una pantalla táctil de 2,8 pulgadas con resolución 240 × 320 píxeles, conectividad Wi-Fi y Bluetooth. Los sensores integrados incluyen sensor de luz, acelerómetro, giroscopio y micrófono. La expansión de hardware está disponible a través de puertos USB, conectores Gravity para sensores y un conector compatible con micro:bit que expone interfaces GPIO, I2C, SPI y UART.

Para más detalles técnicos consulta nuestro Voice Assistant on UNIHIKER M10 with OpenAI tutorial. Este tutorial también te enseñará cómo programar el UNIHIKER, cómo instalar librerías de Python y cómo configurar el Wi-Fi. Todo esto es necesario para ejecutar el tutor de IA que construiremos en este tutorial.

Conexión de altavoces al UNIHIKER M10

Nuestro Tutor de IA tendrá la capacidad de generar voz. Para ello necesitaremos altavoces. Puedes conectar altavoces al UNIHIKER M10 vía USB o Bluetooth. Esta es la solución más sencilla y si optas por ella, puedes saltarte esta sección.

Quise usar altavoces más pequeños para hacer mi Tutor de IA portátil. El UNIHIKER M10 tiene una salida de línea pero desafortunadamente no tiene conector para ella. Necesitas soldar cables a pads específicos en la parte trasera de la placa y también necesitas un pequeño amplificador para alimentar los altavoces. Obtuve el esquema de conexión del AI Assistant with OpenAI GPT, Azure Speech API and UNIHIKER post.

Amplificador PAM8403

Usé un módulo PAM8403 como amplificador. El módulo PAM8403 es un pequeño amplificador de audio estéreo clase D basado en el chip PAM8403. Acepta un voltaje de alimentación de 2,5 V hasta 5,5 V. A 5V, y al alimentar altavoces de 4 Ω, cada canal puede producir hasta unos 3 W de potencia de salida. La siguiente imagen muestra el pinout del módulo amplificador PAM8403:

Pinout of PAM8403 Amplifier
Pinout del amplificador PAM8403

Para más información sobre el módulo amplificador PAM8403 consulta nuestro Audio with PAM8403, PCM5102 and ESP32 tutorial.

Salida de línea del UNIHIKER M10

Como se mencionó, no hay conector para la salida de línea en el UNIHIKER M10 pero puedes acceder a ella a través de pads (de prueba) en la parte trasera de la placa (schematics). La imagen siguiente muestra la ubicación de los pads de salida de línea y alimentación que necesitamos para conectar el amplificador PAM8403:

Lineout and power supply pads on UNIHIKER M10
Pads de salida de línea y alimentación en el UNIHIKER M10

Medí 4,7 V en el pad VCC, lo cual es extraño, ya que esperaba 3,3 V o 5 V, pero el PAM8403 funciona bien con 4,7 V. Además, mi placa tenía solo un pad para VCC, mientras que la foto anterior muestra dos pads. Pero todo funcionó bien, a pesar de eso.

Conexión del amplificador y altavoces al UNIHIKER M10

En esta sección te mostraré cómo conectar el amplificador PAM8403 y los altavoces al UNIHIKER M10. Los altavoces pequeños de 3W suelen venir con conectores que tendrás que cortar, ya que soldaremos los cables de los altavoces directamente al módulo PAM8403:

La siguiente imagen muestra cómo cablear el UNIHIKER M10 al amplificador PAM8403 y a los altavoces:

Soldar los cables a los pads del UNIHIKER M10 es fácil, pero asegúrate de conectar a los pads correctos y no dañar nada en el proceso. La foto siguiente muestra mi cableado terminado:

Ten en cuenta que no necesitas dos altavoces, ya que el sonido estéreo no es realmente necesario. Pero dos altavoces serán más fuertes que uno solo. Si quieres un sonido realmente alto, usa un altavoz USB con fuente de alimentación externa.

Manual de usuario para el Tutor de Idiomas con IA

En esta sección explicaré rápidamente cómo funciona la interfaz de usuario del Tutor de Idiomas con IA. Esto te facilitará entender el código en la siguiente sección y usar el software del Tutor. La imagen siguiente muestra la GUI con los elementos funcionales anotados:

Presionas el botón A y lo mantienes mientras hablas. Cuando sueltas el botón A, el audio grabado se envía a OpenAI para transcripción y traducción. La transcripción y traducción se muestran en el campo Respuesta y la traducción se reproduce en voz alta. Puedes reproducir el audio de la traducción pulsando el botón B.

Puedes seleccionar el idioma destino para la traducción pulsando el botón «Select Language» en la parte superior. Actualmente cicla entre «Japanese», «Italian» y «German» como idiomas. Pero puedes ampliar esto fácilmente a otros idiomas o más en el código.

Ten en cuenta que, aunque la interfaz de usuario está en inglés, puedes hablar en cualquier idioma soportado por el modelo TTS de OpenAI (link). El sistema entenderá el idioma (si está soportado) y traducirá al idioma destino seleccionado. Incluso puedes hablar en el idioma destino para comprobar tu pronunciación. Mira los siguientes dos videos para una demo, donde hablo en alemán y japonés:

Cuando pulsas el botón «Explain» en la parte inferior, se imprime una explicación de la gramática de la traducción. De forma similar, si pulsas el botón «Example», se añaden oraciones de ejemplo con una gramática similar. Los siguientes dos videos demuestran esta funcionalidad:

Finalmente, hay botones para aumentar y disminuir el volumen y el tamaño de la fuente en la parte superior de la pantalla y botones de desplazamiento en la parte inferior.

Aunque puedes usar tus dedos para controlar la interfaz, usar el pequeño lápiz que viene con el UNIHIKER M10 funciona mejor. Ten en cuenta que incluso puedes tocar el campo de respuesta y arrastrarlo/desplazarlo en lugar de usar los botones de desplazamiento o la barra de desplazamiento.

Obtener clave API de OpenAI

Nuestro Tutor de Idiomas con IA usará modelos de IA proporcionados por OpenAI. Por lo tanto, necesitarás una cuenta de OpenAI y una clave API. Ve a https://platform.openai.com y regístrate con una dirección de correo electrónico o una cuenta existente de Google o Microsoft.

Después de verificar tu correo y completar la configuración inicial, inicia sesión en el panel de OpenAI, platform.openai.com/api-keys y encuentra o crea tu clave API (=SECRET KEY) como se muestra a continuación:

OpenAI API keys
Claves API de OpenAI

La clave API es una cadena única y larga, que comienza con «sk-proj-«, necesaria para autenticar tus solicitudes API. Más adelante necesitarás copiar esta cadena completa en el código del Tutor de Idiomas con IA.

Código para el Tutor de Idiomas con IA

El siguiente código implementa nuestra aplicación de tutor de idiomas potenciada por IA. Permite a los usuarios hablar frases en cualquier idioma, que luego se transcriben, traducen al idioma destino seleccionado y se reproducen usando texto a voz (TTS). La app también ofrece explicaciones gramaticales y oraciones de ejemplo para ayudar en el aprendizaje de idiomas.

# 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()

Importaciones

El programa comienza importando los módulos y librerías de Python necesarios. Estos incluyen módulos estándar como sys, time, os y threading para interacción con el sistema, temporización, manejo de archivos y concurrencia. Usa numpy para operaciones numéricas en datos de audio, pyaudio para grabación de audio, y pygame para reproducción de audio.

El código también importa la librería cliente de OpenAI para acceder a servicios de IA, y botones de hardware específicos button_a y button_b de la extensión UNIHIKER. Para la interfaz gráfica (GUI), usa qtpy para crear widgets y gestionar señales y slots.

Ten en cuenta que necesitarás instalar la librería OpenAI, ya que no está preinstalada en el UNIHIKER M10. Si necesitas ayuda con esto, consulta el Voice Assistant on UNIHIKER M10 with OpenAI tutorial.

Constantes y configuración

Varias constantes definen el comportamiento y apariencia de la aplicación. Estas incluyen la clave API de OpenAI, parámetros del dispositivo de entrada de audio como tasa de muestreo y canales, y constantes relacionadas con la UI como idiomas soportados y códigos de color para estilizar botones y fondos.

También se establecen parámetros de control de volumen, incluyendo el volumen inicial y el tamaño del paso para ajustes de volumen.

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

Recuerda que debes reemplazar el valor de la constante API_KEY por tu propia clave API.

Estilo de botones

La función _btn_style() devuelve una cadena de hoja de estilos que define la apariencia de los botones en diferentes estados (normal, presionado, deshabilitado). Esta función centraliza el estilo para mantener una apariencia consistente en toda la UI.

Clase AssistantWorker

Esta clase encapsula la lógica principal que se ejecuta en un hilo separado para mantener la UI responsiva. Gestiona la grabación de audio, la interacción con las APIs de OpenAI y la gestión del estado.

La clase define señales para comunicar actualizaciones de estado, respuestas, estados de botones y cambios de idioma a la UI.

En el constructor, inicializa variables para almacenar la última pregunta, traducción, explicación gramatical y oraciones de ejemplo. También configura el flujo de entrada de audio usando pyaudio con el dispositivo y parámetros especificados.

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,
        )

La clase proporciona métodos slot para manejar clics en botones que solicitan explicaciones gramaticales, oraciones de ejemplo y cambios de idioma. Estos métodos establecen banderas seguras para hilos que el bucle principal monitorea.

Bucle de ejecución de AssistantWorker

El método run() contiene el bucle principal que revisa continuamente el estado de los botones de hardware y procesa la entrada del usuario en consecuencia.

Cuando se mantiene presionado el botón A, graba audio del micrófono. Cuando se suelta el botón A, normaliza el audio grabado, lo convierte a formato WAV y lo envía al modelo Whisper de OpenAI para transcripción. El texto transcrito en inglés se traduce luego al idioma seleccionado usando el modelo GPT de OpenAI.

A continuación, el texto traducido se muestra y se genera un archivo de audio TTS que se reproduce. El botón B permite reproducir el último audio generado. Pulsar el botón de idioma cicla entre los idiomas soportados.

Si el usuario solicita explicaciones gramaticales u oraciones de ejemplo, el worker llama a los endpoints apropiados de la API de OpenAI para generar el contenido y actualiza la pantalla.

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)

Métodos de gestión de estado

La clase worker incluye métodos auxiliares para reiniciar el estado interno, actualizar el texto mostrado y establecer el mensaje de estado por defecto. Estos métodos aseguran que la UI refleje con precisión el estado actual de la aplicación.

Clase AssistantUI

Esta clase define la interfaz gráfica usando widgets Qt. Crea una ventana de tamaño fijo con fondo naranja y texto negro, coincidiendo con el esquema de colores definido anteriormente.

La UI consiste en una fila superior con botones para ajustar tamaño de fuente, control de volumen y selección de idioma. Debajo, una etiqueta de estado muestra mensajes al usuario. El área principal de texto muestra el texto transcrito, traducido y explicativo.

En la parte inferior, botones permiten desplazarse por el texto y solicitar explicaciones gramaticales u oraciones de ejemplo. Los botones están estilizados consistentemente usando los estilos definidos previamente.

La clase proporciona métodos para actualizar el texto de estado, la respuesta mostrada, habilitar o deshabilitar botones y cambiar la etiqueta de idioma. También maneja interacciones del usuario para desplazamiento y ajuste de tamaño de fuente y volumen.

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)

Funciones auxiliares de audio

Varias funciones auxiliares manejan tareas de procesamiento de audio. La función normalize() ajusta el audio PCM grabado para maximizar el volumen sin distorsión. record_while_held() graba audio del micrófono mientras se presiona el botón A. Y la función pcm_to_wav() convierte bytes PCM crudos a formato WAV, necesario para la transcripción.

Luego tenemos la función generate_tts() que envía texto a la API TTS de OpenAI y guarda el archivo de audio resultante. Finalmente, play_audio() reproduce el audio generado usando pygame al volumen actual.

Funciones auxiliares de OpenAI

Estas funciones interactúan con la API de OpenAI para realizar transcripción, traducción, explicación gramatical y generación de oraciones de ejemplo.

La función transcribe() envía el audio WAV grabado al modelo Whisper para obtener texto en inglés. translate_only() traduce la pregunta en inglés al idioma seleccionado sin explicaciones adicionales.

Las funciones explain_grammar() y add_examples() solicitan explicaciones gramaticales y oraciones de ejemplo respectivamente, usando GPT con prompts adaptados para la enseñanza de idiomas.

Función principal

La función main() inicializa la aplicación Qt, crea instancias de las clases UI y worker, y configura un hilo separado para que el worker se ejecute concurrentemente.

Conecta señales y slots entre el worker y la UI para actualizar la interfaz según el progreso del worker y las interacciones del usuario. Finalmente, inicia el bucle de eventos de la aplicación.

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())

En resumen, este código integra la entrada de botones de hardware, procesamiento de audio, servicios de IA para idiomas y una GUI responsiva para crear un tutor de idiomas interactivo basado en voz. Aprovecha los modelos de OpenAI para transcripción, traducción y enseñanza de idiomas.

Conclusiones

En este tutorial aprendiste a implementar un Tutor de Idiomas con IA simple en el UNIHIKER M10 usando servicios de OpenAI. Te recomiendo también leer el Voice Assistant on UNIHIKER M10 with OpenAI tutorial, que cubre algunos conceptos básicos que no forman parte de este tutorial.

Aunque el Tutor de Idiomas con IA de este tutorial ya es útil para aprender idiomas, hay muchas posibles extensiones para hacerlo aún más útil. Por ejemplo, el programa podría almacenar las traducciones y explicaciones para revisarlas después. Podría generar cuentos cortos alrededor de las oraciones y leerlos. Podría extraer verbos y sustantivos para entrenarlos por separado. Y mucho más…

Diviértete extendiendo el tutor para tus propósitos, y si tienes alguna pregunta no dudes en dejarla en la sección de comentarios.

¡Feliz bricolaje! 😉