
Tried picking up 5-7-5 haiku patterns during conversation using Even G2 smart glasses and Amazon Transcribe Streaming
This page has been translated by machine translation. View original
Introduction
I finally purchased the Even G2 smart glasses that I had been curious about for a while. The Even G2 are glasses that can overlay green text and graphics on both lenses.


When actually worn, only the necessary information appears to float forward without obstructing your field of vision.

This is roughly what it looks like

Not visible to people not wearing the glasses; barely captured when photographed from the front with the glasses removed
Basic operation is performed via the touchpad on the side of the glasses and the Even Realities App on a smartphone.

There are touchpads on both sides
This time, I decided to try building a mock that detects 5-7-5 (haiku) patterns appearing by chance in conversation and displays them on the Even G2.
Audio from the Even G2 is sent to Amazon Transcribe Streaming, and confirmed transcriptions are processed incrementally. From the utterance "今日もまた静かな朝に風が吹く" (Today again, in the quiet morning, the wind blows), the following 5-7-5 was detected.
5・7・5 detected
今日もまた
静かな朝に
風が吹く

Now I'm a haiku poet starting today
Verification Environment
- AWS Region:
ap-northeast-1 - AWS SAM CLI:
1.166.1 - Node.js:
24.14.0 - Lambda runtime:
nodejs22.x @evenrealities/even_hub_sdk:0.0.14@faanau/kuromoji:0.2.1- Even Realities App:
2.2.9or later
References
- Even Hub Documentation
- Even Hub test method
- Amazon Transcribe streaming audio
- Amazon Transcribe WebSocket setup
Architecture
On the AWS side, an API Gateway REST API and Lambda are placed. The same Lambda handles POST /session, which issues a signed WebSocket URL for Amazon Transcribe, and POST /detect, which evaluates transcriptions. No resources are created to store conversation data.
Implementation
Streaming Transcription
The 16 kHz, 16-bit, mono PCM obtained from the Even G2 was aligned into chunks of 100 ms and 3,200 bytes before transmission. (Amazon Transcribe's recommended range is 50–200 ms)
100 ms / 1,000 × 16,000 Hz × 2 bytes = 3,200 bytes
Lambda issues a signed URL with a connection start deadline of 60 seconds.
In the app, Smithy's EventStream codec was used to convert PCM into AudioEvent and response TranscriptEvent into transcriptions.
5-7-5 Detection
The number of characters and morae in Japanese do not match. Therefore, in Lambda, confirmed strings are split into morphemes using Kuromoji and readings are obtained. Small kana are not counted independently, ッ, ン, and ー are each counted as 1 mora, and punctuation and spaces are counted as 0 morae. Mora counts are accumulated from morpheme boundaries, and only sequences where the cumulative value reaches 5 → 12 → 17 are treated as candidates and judged as 5-7-5.
Deployment and Installation on Physical Device
After installing dependencies and running automated tests, deployment was done with AWS SAM. For AllowedSourceIp, the global IPv4 address that serves as the smartphone's connection source was specified with /32.
npm ci --prefix backend
npm ci --prefix app
npm test --prefix backend
npm test --prefix app
sam build --template-file infra/template.yaml
sam validate --template-file .aws-sam/build/template.yaml --lint
sam deploy `
--template-file .aws-sam/build/template.yaml `
--stack-name even-g2-streaming-575-demo `
--region ap-northeast-1 `
--capabilities CAPABILITY_IAM `
--parameter-overrides AllowedSourceIp=203.0.113.10/32 `
--resolve-s3 `
--no-confirm-changeset
The API URL was retrieved from the CloudFormation output and the app was packaged.
$apiUrl = aws cloudformation describe-stacks `
--stack-name even-g2-streaming-575-demo `
--region ap-northeast-1 `
--query "Stacks[0].Outputs[?OutputKey=='ApiUrl'].OutputValue" `
--output text
Set-Location app
npm run configure -- --api-url $apiUrl
npm run build
npx evenhub pack app.json dist -o 575-detector.ehpk --sdk-ver 0.0.14
The generated 575-detector.ehpk was uploaded from My projects in Even Hub and registered as a Private build.

It was then installed from Even Hub > My Plugins > Developer Hub > In Development > Streaming 575 in the Even Realities App.

Verification
When the app was opened, a "Conversational 5-7-5 Detection" screen appeared on the smartphone.

The following guidance was displayed on the glasses.
Start on smartphone
Audio will be sent to AWS
When "Start" was pressed on the smartphone, the glasses displayed "Connecting," which immediately changed to "Recording."
When I said "これは文字起こしのテストです。" (This is a transcription test.), it was displayed on both the smartphone and the glasses.
Next, I naturally continued speaking: "今日もまた静かな朝に風が吹く" (Today again, in the quiet morning, the wind blows). The smartphone displayed one result: "今日もまた/静かな朝に/風が吹く."

The following 4 lines were displayed on the glasses.
5・7・5 detected
今日もまた
静かな朝に
風が吹く

Future Prospects
This time I built a playful tool for 5-7-5 detection, but actually using the Even G2 made me feel that there is still much more to try with smart glasses. Next, I plan to leverage the PCM acquisition and confirmed transcription pipeline confirmed this time to build a mechanism that updates key points as a conversation progresses. I hope this article will be helpful for those considering adopting smart glasses.
Appendix
The core code used in the implementation of this article is presented below.
AWS-side configuration
infra/template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Even G2 streaming transcription and 5-7-5 detection mock
Parameters:
AllowedSourceIp:
Type: String
Description: Global IPv4 address allowed to invoke the API, in /32 CIDR notation
AllowedPattern: '^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)/32$'
Globals:
Function:
Runtime: nodejs22.x
Architectures:
- x86_64
MemorySize: 512
Timeout: 10
Resources:
DetectorApi:
Type: AWS::Serverless::Api
Properties:
Name: !Sub '${AWS::StackName}-api'
StageName: demo
OpenApiVersion: 3.0.1
EndpointConfiguration: REGIONAL
Cors:
AllowOrigin: "'*'"
AllowMethods: "'POST,OPTIONS'"
AllowHeaders: "'Content-Type'"
Auth:
ResourcePolicy:
IpRangeWhitelist:
- !Ref AllowedSourceIp
DetectorFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: !Sub '${AWS::StackName}-api'
Description: Creates Transcribe sessions and detects 5-7-5 mora sequences
CodeUri: ../backend/
Handler: src/handler.handler
Policies:
- AWSLambdaBasicExecutionRole
- Version: '2012-10-17'
Statement:
- Sid: StartTranscribeWebSocket
Effect: Allow
Action: transcribe:StartStreamTranscriptionWebSocket
Resource: '*'
Events:
CreateSession:
Type: Api
Properties:
RestApiId: !Ref DetectorApi
Path: /session
Method: post
Detect575:
Type: Api
Properties:
RestApiId: !Ref DetectorApi
Path: /detect
Method: post
DetectorFunctionLogGroup:
Type: AWS::Logs::LogGroup
DeletionPolicy: Delete
UpdateReplacePolicy: Delete
Properties:
LogGroupName: !Sub '/aws/lambda/${DetectorFunction}'
RetentionInDays: 1
Outputs:
ApiUrl:
Description: Base URL for the Even Hub app
Value: !Sub 'https://${DetectorApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}/demo'
FunctionName:
Description: Lambda function name
Value: !Ref DetectorFunction
LogGroupName:
Description: CloudWatch Logs log group created by this stack
Value: !Ref DetectorFunctionLogGroup
5-7-5 detection
backend/src/detect-575.mjs
function hasKnownReading(token) {
return (
typeof token.reading === 'string' &&
Number.isInteger(token.moras) &&
token.moras >= 0
)
}
function joinTokens(tokens, field) {
return tokens.map(token => token[field]).join('')
}
function candidateFrom(tokens, boundaries, resultId, startIndex, endIndex) {
const ranges = [
[startIndex, boundaries[0]],
[boundaries[0] + 1, boundaries[1]],
[boundaries[1] + 1, boundaries[2]],
]
const lines = ranges.map(([start, end]) =>
joinTokens(tokens.slice(start, end + 1), 'surface'),
)
const readings = ranges.map(([start, end]) =>
joinTokens(tokens.slice(start, end + 1), 'reading'),
)
return {
id: `${resultId}:${startIndex}:${endIndex}:${readings.join('|')}`,
lines,
readings,
}
}
function findCandidates(tokens, resultId) {
const candidates = []
for (let startIndex = 0; startIndex < tokens.length; startIndex += 1) {
let total = 0
const boundaries = []
for (let endIndex = startIndex; endIndex < tokens.length; endIndex += 1) {
total += tokens[endIndex].moras
const boundaryIndex = [5, 12, 17].indexOf(total)
if (boundaryIndex >= 0) {
boundaries[boundaryIndex] = endIndex
}
if (total === 17) {
if (boundaries.length === 3 && tokens[endIndex].isNew) {
candidates.push(
candidateFrom(tokens, boundaries, resultId, startIndex, endIndex),
)
}
break
}
const nextBoundary = [5, 12, 17][boundaries.length]
if (total > nextBoundary) break
}
}
return candidates
}
function trailingContext(tokens) {
let startIndex = tokens.length
let total = 0
for (let index = tokens.length - 1; index >= 0; index -= 1) {
const nextTotal = total + tokens[index].moras
if (nextTotal > 16) break
total = nextTotal
startIndex = index
}
return tokens
.slice(startIndex)
.map(({ isNew: _isNew, ...token }) => token)
}
export function detect575({ context, newTokens, resultId }) {
const combined = [
...context.map(token => ({ ...token, isNew: false })),
...newTokens.map(token => ({ ...token, isNew: true })),
]
const lastUnknownIndex = combined.findLastIndex(
token => !hasKnownReading(token),
)
const usable = combined.slice(lastUnknownIndex + 1)
return {
context: trailingContext(usable),
candidates: findCandidates(usable, resultId),
}
}
backend/src/japanese.mjs
import { fileURLToPath } from 'node:url'
const IGNORED_CHARACTERS = /[\s、。!?,.!?]/u
const SMALL_KANA = /[ャュョァィゥェォヮゃゅょぁぃぅぇぉゎ]/u
const KANA_ONLY = /^[ぁ-ゖァ-ヺー]+$/u
export function countMoras(reading) {
let count = 0
for (const character of reading) {
if (!IGNORED_CHARACTERS.test(character) && !SMALL_KANA.test(character)) {
count += 1
}
}
return count
}
function normalizeReading(reading) {
return Array.from(reading, character => {
const codePoint = character.codePointAt(0)
if (codePoint >= 0x3041 && codePoint <= 0x3096) {
return String.fromCodePoint(codePoint + 0x60)
}
return character
}).join('')
}
export function toAnalyzedTokens(features) {
return features.map(feature => {
const readingSource =
feature.pronunciation ||
feature.reading ||
(KANA_ONLY.test(feature.surface_form) ? feature.surface_form : null)
const reading = readingSource ? normalizeReading(readingSource) : null
return {
surface: feature.surface_form,
reading,
moras: reading === null ? null : countMoras(reading),
}
})
}
export async function createJapaneseTokenizer(options = {}) {
const { builder } = await import('@faanau/kuromoji')
const dicPath =
options.dicPath ??
fileURLToPath(
new URL('../node_modules/@faanau/kuromoji/dict/', import.meta.url),
)
const tokenizer = await new Promise((resolve, reject) => {
builder({ dicPath }).build((error, builtTokenizer) => {
if (error) reject(error)
else resolve(builtTokenizer)
})
})
return text => toAnalyzedTokens(tokenizer.tokenize(text))
}
Streaming transcription
app/src/transcribe/eventstream.ts
import { EventStreamCodec } from '@smithy/core/event-streams'
import { fromUtf8, toUtf8 } from '@smithy/core/serde'
export interface TranscriptResult {
resultId: string
isPartial: boolean
transcript: string
}
export type TranscribeMessage =
| { kind: 'transcript'; results: TranscriptResult[] }
| { kind: 'exception'; type: string; message: string }
const codec = new EventStreamCodec(toUtf8, fromUtf8)
export function encodeAudioEvent(pcm: Uint8Array): Uint8Array {
return codec.encode({
headers: {
':message-type': { type: 'string', value: 'event' },
':event-type': { type: 'string', value: 'AudioEvent' },
':content-type': { type: 'string', value: 'application/octet-stream' },
},
body: pcm,
})
}
function stringHeader(
headers: ReturnType<EventStreamCodec['decode']>['headers'],
name: string,
): string | undefined {
const header = headers[name]
return header?.type === 'string' ? header.value : undefined
}
export function decodeTranscribeMessage(data: ArrayBufferView): TranscribeMessage {
const decoded = codec.decode(data)
const messageType = stringHeader(decoded.headers, ':message-type')
const body = JSON.parse(toUtf8(decoded.body)) as unknown
if (messageType === 'exception') {
const value = body as { Message?: unknown; message?: unknown }
const message = value.Message ?? value.message
return {
kind: 'exception',
type: stringHeader(decoded.headers, ':exception-type') ?? 'TranscribeException',
message: typeof message === 'string' ? message : 'An error occurred in Amazon Transcribe.',
}
}
const payload = body as {
Transcript?: {
Results?: Array<{
ResultId?: unknown
IsPartial?: unknown
Alternatives?: Array<{ Transcript?: unknown }>
}>
}
}
const results = payload.Transcript?.Results ?? []
return {
kind: 'transcript',
results: results.flatMap((result) => {
const transcript = result.Alternatives?.[0]?.Transcript
if (
typeof result.ResultId !== 'string' ||
typeof result.IsPartial !== 'boolean' ||
typeof transcript !== 'string'
) {
return []
}
return [{ resultId: result.ResultId, isPartial: result.IsPartial, transcript }]
}),
}
}
app/src/transcribe/pcm-chunker.ts
export class PcmChunker {
private pending = new Uint8Array()
constructor(private readonly chunkSize = 3200) {
if (chunkSize <= 0 || chunkSize % 2 !== 0) {
throw new Error('chunkSize must be a positive even number.')
}
}
push(input: Uint8Array): Uint8Array[] {
if (input.byteLength % 2 !== 0) {
throw new Error('16-bit PCM input must have an even number of bytes.')
}
const joined = new Uint8Array(this.pending.byteLength + input.byteLength)
joined.set(this.pending)
joined.set(input, this.pending.byteLength)
const chunks: Uint8Array[] = []
let offset = 0
while (joined.byteLength - offset >= this.chunkSize) {
chunks.push(joined.slice(offset, offset + this.chunkSize))
offset += this.chunkSize
}
this.pending = joined.slice(offset)
return chunks
}
flush(): Uint8Array {
const remainder = this.pending
this.pending = new Uint8Array()
return remainder
}
}
app/src/transcribe/transcribe-client.ts
import type { TranscribeSession } from '../api/detector-api'
import { decodeTranscribeMessage, encodeAudioEvent, type TranscriptResult } from './eventstream'
import { PcmChunker } from './pcm-chunker'
const OPEN = 1
const CLOSED = 3
export interface WebSocketLike {
binaryType: BinaryType
readyState: number
onopen: (() => void) | null
onmessage: ((event: { data: ArrayBuffer }) => void) | null
onerror: (() => void) | null
onclose: (() => void) | null
send(data: Uint8Array): void
close(): void
}
interface TranscribeClientOptions {
createSession: () => Promise<TranscribeSession>
onResult: (result: TranscriptResult) => void | Promise<void>
onFatal: (error: Error) => void
onFinishTimeout?: () => void
socketFactory?: (url: string) => WebSocketLike
}
type State = 'idle' | 'connecting' | 'open' | 'finishing' | 'closed'
export class TranscribeClient {
private readonly chunker = new PcmChunker(3200)
private readonly socketFactory: (url: string) => WebSocketLike
private socket?: WebSocketLike
private state: State = 'idle'
private finishPromise?: Promise<void>
private resolveFinish?: () => void
private finishTimer?: ReturnType<typeof setTimeout>
private failed = false
private readonly pendingResultTasks = new Set<Promise<void>>()
constructor(private readonly options: TranscribeClientOptions) {
this.socketFactory =
options.socketFactory ?? ((url) => new WebSocket(url) as unknown as WebSocketLike)
}
async start(): Promise<void> {
if (this.state !== 'idle') throw new Error('Transcription has already started.')
this.state = 'connecting'
const session = await this.options.createSession()
if (Date.parse(session.connectBefore) <= Date.now()) {
this.state = 'closed'
throw new Error('The transcription connection URL has expired.')
}
const socket = this.socketFactory(session.url)
this.socket = socket
socket.binaryType = 'arraybuffer'
return new Promise<void>((resolve, reject) => {
socket.onopen = () => {
this.state = 'open'
resolve()
}
socket.onmessage = (event) => void this.handleMessage(event.data)
socket.onerror = () => {
const error = new Error('An error occurred in the connection with Amazon Transcribe.')
if (this.state === 'connecting') reject(error)
this.fail(error)
}
socket.onclose = () => {
const previousState = this.state
this.state = 'closed'
this.clearFinishTimer()
void this.resolveFinishAfterPendingResults()
if (previousState === 'connecting') {
reject(new Error('Could not connect to Amazon Transcribe.'))
} else if (previousState === 'open' && !this.failed) {
this.fail(new Error('The connection with Amazon Transcribe was closed midway.'))
}
}
})
}
sendPcm(pcm: Uint8Array): void {
if (this.state !== 'open' || this.socket?.readyState !== OPEN) return
for (const chunk of this.chunker.push(pcm)) {
this.socket.send(encodeAudioEvent(chunk))
}
}
finish(): Promise<void> {
if (this.finishPromise !== undefined) return this.finishPromise
if (this.state !== 'open' || this.socket?.readyState !== OPEN) {
return Promise.resolve()
}
this.state = 'finishing'
this.finishPromise = new Promise<void>((resolve) => {
this.resolveFinish = resolve
this.finishTimer = setTimeout(() => {
this.options.onFinishTimeout?.()
this.closeSocket()
}, 5000)
})
const remainder = this.chunker.flush()
if (remainder.byteLength > 0) this.socket.send(encodeAudioEvent(remainder))
this.socket.send(encodeAudioEvent(new Uint8Array()))
return this.finishPromise
}
private async handleMessage(data: ArrayBuffer): Promise<void> {
try {
const message = decodeTranscribeMessage(new Uint8Array(data))
if (message.kind === 'exception') {
this.fail(new Error(`${message.type}: ${message.message}`))
return
}
await Promise.all(message.results.map((result) => this.trackResult(result)))
if (this.state === 'finishing' && message.results.some((result) => !result.isPartial)) {
this.closeSocket()
}
} catch {
this.fail(new Error('Could not parse the response from Amazon Transcribe.'))
}
}
private trackResult(result: TranscriptResult): Promise<void> {
const task = Promise.resolve(this.options.onResult(result)).catch((error: unknown) => {
this.fail(error instanceof Error ? error : new Error('Failed to process the finalized result.'))
})
this.pendingResultTasks.add(task)
void task.finally(() => this.pendingResultTasks.delete(task))
return task
}
private async resolveFinishAfterPendingResults(): Promise<void> {
await Promise.all([...this.pendingResultTasks])
this.resolveFinish?.()
}
private fail(error: Error): void {
if (!this.failed) {
this.failed = true
this.options.onFatal(error)
}
this.closeSocket()
}
private closeSocket(): void {
this.clearFinishTimer()
if (this.socket !== undefined && this.socket.readyState !== CLOSED) {
this.socket.close()
} else {
this.resolveFinish?.()
}
}
private clearFinishTimer(): void {
if (this.finishTimer !== undefined) {
clearTimeout(this.finishTimer)
this.finishTimer = undefined
}
}
}