add Spell Jam project

This commit is contained in:
foamyguy 2025-08-28 15:22:28 -05:00
parent 009abaff09
commit f5fec0e46b
35 changed files with 2966 additions and 0 deletions

View file

@ -0,0 +1,295 @@
# SPDX-FileCopyrightText: 2025 Tim Cocks for Adafruit Industries
# SPDX-License-Identifier: MIT
import hmac
import json
from adafruit_datetime import datetime
import adafruit_hashlib as hashlib
def url_encode(string, safe=""):
"""
Minimal URL encoding function to replace urllib.parse.quote
Args:
string (str): String to encode
safe (str): Characters that should not be encoded
Returns:
str: URL encoded string
"""
# Characters that need to be encoded (RFC 3986)
unreserved = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~"
# Add safe characters to unreserved set
allowed = unreserved + safe
encoded = ""
for char in string:
if char in allowed:
encoded += char
else:
# Convert to percent-encoded format
encoded += f"%{ord(char):02X}"
return encoded
def _zero_pad(num, count=2):
padded = str(num)
while len(padded) < count:
padded = "0" + padded
return padded
class PollyHTTPClient:
# pylint: disable=no-self-use
def __init__(self, requests_instance, access_key, secret_key, region="us-east-1"):
self._requests = requests_instance
self.access_key = access_key
self.secret_key = secret_key
self.region = region
self.service = "polly"
self.host = f"polly.{region}.amazonaws.com"
self.endpoint = f"https://{self.host}"
def _sign(self, key, msg):
"""Helper function for AWS signature"""
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
def _get_signature_key(self, date_stamp):
"""Generate AWS signature key"""
k_date = self._sign(("AWS4" + self.secret_key).encode("utf-8"), date_stamp)
k_region = self._sign(k_date, self.region)
k_service = self._sign(k_region, self.service)
k_signing = self._sign(k_service, "aws4_request")
return k_signing
def _create_canonical_request(self, method, uri, query_string, headers, payload):
"""Create canonical request for AWS Signature V4"""
canonical_uri = url_encode(uri, safe="/")
canonical_querystring = query_string
# Create canonical headers
canonical_headers = ""
signed_headers = ""
header_names = sorted(headers.keys())
for name in header_names:
canonical_headers += f"{name.lower()}:{headers[name].strip()}\n"
signed_headers += f"{name.lower()};"
signed_headers = signed_headers[:-1] # Remove trailing semicolon
# Create payload hash
payload_hash = hashlib.sha256(payload.encode("utf-8")).hexdigest()
# Create canonical request
canonical_request = (f"{method}\n{canonical_uri}\n{canonical_querystring}\n"
f"{canonical_headers}\n{signed_headers}\n{payload_hash}")
return canonical_request, signed_headers
def _create_string_to_sign(self, timestamp, credential_scope, canonical_request):
"""Create string to sign for AWS Signature V4"""
algorithm = "AWS4-HMAC-SHA256"
canonical_request_hash = hashlib.sha256(
canonical_request.encode("utf-8")
).hexdigest()
string_to_sign = (
f"{algorithm}\n{timestamp}\n{credential_scope}\n{canonical_request_hash}"
)
return string_to_sign
def synthesize_speech( # pylint: disable=too-many-locals
self,
text,
voice_id="Joanna",
output_format="mp3",
engine="standard",
text_type="text",
):
"""
Synthesize speech using Amazon Polly via direct HTTP request
Args:
text (str): Text to convert to speech
voice_id (str): Voice to use (e.g., 'Joanna', 'Matthew', 'Amy')
output_format (str): Audio format ('mp3', 'ogg_vorbis', 'pcm')
engine (str): Engine type ('standard' or 'neural')
text_type (str): 'text' or 'ssml'
Returns:
bytes: Audio data if successful, None if failed
"""
# Prepare request
method = "POST"
uri = "/v1/speech"
# Create request body
request_body = {
"Text": text,
"OutputFormat": output_format,
"VoiceId": voice_id,
"Engine": engine,
"TextType": text_type,
}
payload = json.dumps(request_body)
# Get current time
now = datetime.now()
# amzdate = now.strftime('%Y%m%dT%H%M%SZ')
amzdate = (f"{now.year}{_zero_pad(now.month)}{_zero_pad(now.day)}"
f"T{_zero_pad(now.hour)}{_zero_pad(now.minute)}{_zero_pad(now.second)}Z")
# datestamp = now.strftime('%Y%m%d')
datestamp = f"{now.year}{_zero_pad(now.month)}{_zero_pad(now.day)}"
# Create headers
headers = {
"Content-Type": "application/x-amz-json-1.0",
"Host": self.host,
"X-Amz-Date": amzdate,
"X-Amz-Target": "AWSPollyService.SynthesizeSpeech",
}
# Create canonical request
canonical_request, signed_headers = self._create_canonical_request(
method, uri, "", headers, payload
)
# Create string to sign
credential_scope = f"{datestamp}/{self.region}/{self.service}/aws4_request"
string_to_sign = self._create_string_to_sign(
amzdate, credential_scope, canonical_request
)
# Create signature
signing_key = self._get_signature_key(datestamp)
signature = hmac.new(
signing_key, string_to_sign.encode("utf-8"), hashlib.sha256
).hexdigest()
# Add authorization header
authorization_header = (
f"AWS4-HMAC-SHA256 "
f"Credential={self.access_key}/{credential_scope}, "
f"SignedHeaders={signed_headers}, "
f"Signature={signature}"
)
headers["Authorization"] = authorization_header
# Make request
try:
url = f"{self.endpoint}{uri}"
response = self._requests.post(url, headers=headers, data=payload)
if response.status_code == 200:
return response.content
else:
print(f"Error: HTTP {response.status_code}")
print(f"Response: {response.text}")
return None
except Exception as e: # pylint: disable=broad-except
print(f"Request failed: {e}")
return None
def text_to_speech_polly_http(
requests_instance,
text,
access_key,
secret_key,
output_file="/saves/awspollyoutput.mp3",
voice_id="Joanna",
region="us-east-1",
output_format="mp3",
):
"""
Simple function to convert text to speech using Polly HTTP API
Args:
text (str): Text to convert
access_key (str): AWS Access Key ID
secret_key (str): AWS Secret Access Key
output_file (str): Output file path
voice_id (str): Polly voice ID
region (str): AWS region
output_format (str): Audio format
Returns:
bool: True if successful, False otherwise
"""
# Create Polly client
client = PollyHTTPClient(requests_instance, access_key, secret_key, region)
# Synthesize speech
audio_data = client.synthesize_speech(
text=text, voice_id=voice_id, output_format=output_format
)
if audio_data:
# Save to file
try:
with open(output_file, "wb") as f:
f.write(audio_data)
print(f"Audio saved to: {output_file}")
return True
except Exception as e: # pylint: disable=broad-except
print(f"Failed to save file: {e}")
return False
else:
print("Failed to synthesize speech")
return False
def text_to_speech_with_ssml(
requests_instance,
text,
access_key,
secret_key,
speech_rate="medium",
output_file="output.mp3",
):
"""
Example with SSML for speech rate control
"""
# Wrap text in SSML
ssml_text = f'<speak><prosody rate="{speech_rate}">{text}</prosody></speak>'
client = PollyHTTPClient(requests_instance, access_key, secret_key)
audio_data = client.synthesize_speech(
text=ssml_text, voice_id="Joanna", text_type="ssml"
)
if audio_data:
with open(output_file, "wb") as f:
f.write(audio_data)
print(f"SSML audio saved to: {output_file}")
return True
return False
# Example usage
# if __name__ == "__main__":
# # Replace with your actual AWS credentials
# AWS_ACCESS_KEY = os.getenv('AWS_ACCESS_KEY')
# AWS_SECRET_KEY = os.getenv('AWS_SECRET_KEY')
#
# # Basic usage
# success = text_to_speech_polly_http(
# text="Hello from CircuitPython! This is Amazon Polly speaking.",
# access_key=AWS_ACCESS_KEY,
# secret_key=AWS_SECRET_KEY,
# voice_id="Joanna"
# )
# SSML example
# if success:
# text_to_speech_with_ssml(
# text="This speech has a custom rate using SSML markup.",
# access_key=AWS_ACCESS_KEY,
# secret_key=AWS_SECRET_KEY,
# speech_rate="slow",
# output_file="ssml_example.mp3"
# )

View file

@ -0,0 +1,131 @@
# SPDX-FileCopyrightText: 2025 Tim Cocks for Adafruit Industries
# SPDX-License-Identifier: MIT
import os
import sys
import time
import supervisor
from adafruit_fruitjam import FruitJam
from adafruit_fruitjam.peripherals import request_display_config
import adafruit_connection_manager
import adafruit_requests
from displayio import OnDiskBitmap, TileGrid, Group
from adafruit_bitmap_font import bitmap_font
from adafruit_display_text.bitmap_label import Label
from aws_polly import text_to_speech_polly_http
# constants
LETTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
# local variables
curword = ""
lastword = ""
# setup display
request_display_config(320, 240)
display = supervisor.runtime.display
# setup background image
main_group = Group()
background = OnDiskBitmap("spell_jam_assets/background.bmp")
background_tg = TileGrid(background, pixel_shader=background.pixel_shader)
main_group.append(background_tg)
# setup 14-segment label used to display words
font = bitmap_font.load_font("spell_jam_assets/14segment_16.bdf")
screen_lbl = Label(text="Type a word", font=font, color=0x00FF00)
screen_lbl.anchor_point = (0.5, 0)
screen_lbl.anchored_position = (display.width // 2, 100)
main_group.append(screen_lbl)
# initialize Fruit Jam built-in hardware
fj = FruitJam()
fj.neopixels.brightness = 0.1
fj.peripherals.volume = 9
# AWS auth requires us to have accurate date/time
now = fj.sync_time()
# setup adafruit_requests session
# pylint: disable=protected-access
pool = adafruit_connection_manager.get_radio_socketpool(fj.network._wifi.esp)
ssl_context = adafruit_connection_manager.get_radio_ssl_context(fj.network._wifi.esp)
requests = adafruit_requests.Session(pool, ssl_context)
# read AWS keys from settings.toml
AWS_ACCESS_KEY = os.getenv("AWS_ACCESS_KEY")
AWS_SECRET_KEY = os.getenv("AWS_SECRET_KEY")
def fetch_word(word, voice="Joanna"):
"""
Fetch an MP3 saying a word from AWS Polly
:param word: The word to speak
:param voice: The AWS Polly voide ID to use
:return: Boolean, whether the request was successful.
"""
fj.neopixels.fill(0xFFFF00)
success = text_to_speech_polly_http(
requests,
text=word,
access_key=AWS_ACCESS_KEY,
secret_key=AWS_SECRET_KEY,
voice_id=voice,
)
fj.neopixels.fill(0x00FF00)
return success
def say_and_spell_lastword():
"""
Say the last word, then spell it out one letter at a time, finally say it once more.
"""
fj.play_mp3_file("/saves/awspollyoutput.mp3")
time.sleep(0.2)
for letter in lastword:
fj.play_mp3_file(f"spell_jam_assets/letter_mp3s/{letter.upper()}.mp3")
time.sleep(0.2)
fj.play_mp3_file("/saves/awspollyoutput.mp3")
fj.neopixels.fill(0x000000)
display.root_group = main_group
while True:
# check how many bytes are available
available = supervisor.runtime.serial_bytes_available
# if there are some bytes available
if available:
# read data from the keyboard input
c = sys.stdin.read(available)
# print the data that was read
print(c, end="")
if c in LETTERS:
curword += c
screen_lbl.text = curword
elif c in {"\x7f", "\x08"}: # backspace
curword = curword[:-1]
screen_lbl.text = curword
elif c == "\n":
if curword:
lastword = curword
fetch_word(lastword)
say_and_spell_lastword()
curword = ""
else:
# repeat last word
say_and_spell_lastword()
elif c.encode("utf-8") == b"\x1b[B":
# down arrow
fj.peripherals.volume = max(1, fj.peripherals.volume - 1)
print(f"Volume: {fj.peripherals.volume}")
elif c.encode("utf-8") == b"\x1b[A":
# up arrow
fj.peripherals.volume = min(
fj.peripherals.safe_volume_limit, fj.peripherals.volume + 1
)
print(f"Volume: {fj.peripherals.volume}")
else:
print(f"unused key: {c.encode('utf-8')}")

View file

@ -0,0 +1,93 @@
# SPDX-FileCopyrightText: 2015 Paul Sokolovsky
# SPDX-License-Identifier: MIT
# https://github.com/micropython/micropython-lib
# https://github.com/micropython/micropython-lib/blob/master/LICENSE
# Implements the hmac module from the Python standard library.
class HMAC:
# pylint: disable=protected-access, import-outside-toplevel,unnecessary-lambda-assignment
def __init__(self, key, msg=None, digestmod=None):
if not isinstance(key, (bytes, bytearray)):
raise TypeError("key: expected bytes/bytearray")
import adafruit_hashlib as hashlib
if digestmod is None:
# TODO: Default hash algorithm is now deprecated.
digestmod = hashlib.md5
if callable(digestmod):
# A hashlib constructor returning a new hash object.
make_hash = digestmod # A
elif isinstance(digestmod, str):
# A hash name suitable for hashlib.new().
make_hash = lambda d=b"": getattr(hashlib, digestmod)(d)
else:
# A module supporting PEP 247.
make_hash = digestmod.new # C
self._outer = make_hash()
self._inner = make_hash()
self.digest_size = getattr(self._inner, "digest_size", None)
# If the provided hash doesn't support block_size (e.g. built-in
# hashlib), 64 is the correct default for all built-in hash
# functions (md5, sha1, sha256).
self.block_size = getattr(self._inner, "block_size", 64)
# Truncate to digest_size if greater than block_size.
if len(key) > self.block_size:
key = make_hash(key).digest()
# Pad to block size.
key = key + bytes(self.block_size - len(key))
self._outer.update(bytes(x ^ 0x5C for x in key))
self._inner.update(bytes(x ^ 0x36 for x in key))
if msg is not None:
self.update(msg)
@property
def name(self):
return "hmac-" + getattr(self._inner, "name", type(self._inner).__name__)
def update(self, msg):
self._inner.update(msg)
def copy(self):
if not hasattr(self._inner, "copy"):
# Not supported for built-in hash functions.
raise NotImplementedError()
# Call __new__ directly to avoid the expensive __init__.
other = self.__class__.__new__(self.__class__)
other.block_size = self.block_size
other.digest_size = self.digest_size
other._inner = self._inner.copy()
other._outer = self._outer.copy()
return other
def _current(self):
h = self._outer
if hasattr(h, "copy"):
# built-in hash functions don't support this, and as a result,
# digest() will finalise the hmac and further calls to
# update/digest will fail.
h = h.copy()
h.update(self._inner.digest())
return h
def digest(self):
h = self._current()
return h.digest()
def hexdigest(self):
import binascii
return str(binascii.hexlify(self.digest()), "utf-8")
def new(key, msg=None, digestmod=None):
return HMAC(key, msg, digestmod)

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

View file

@ -0,0 +1,4 @@
{
"title": "Spell Jam",
"icon": "icon.bmp"
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,8 @@
# SPDX-FileCopyrightText: Copyright (c) 2020, keshikan (https://www.keshikan.net), with Reserved Font Name "DSEG".
# SPDX-License-Identifier: OFL-1.1-RFN
# This Font Software is licensed under the SIL Open Font License, Version 1.1.
# This license is copied below, and is also available with a FAQ at:
# http://scripts.sil.org/OFL

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View file

@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: Copyright (c) 2025, Tim Cocks for Adafruit Industries
# SPDX-License-Identifier: MIT