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.

UNIHIKER M10

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.

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:

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:

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:

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! 😉

