Tried picking up 5-7-5 haiku patterns during conversation using Even G2 smart glasses and Amazon Transcribe Streaming

Tried picking up 5-7-5 haiku patterns during conversation using Even G2 smart glasses and Amazon Transcribe Streaming

I tried putting a mock that detects 5-7-5 from spoken audio into Even G2. It is a configuration where audio is not relayed through Lambda, but only signed URL issuance and a stateless judgment API are placed on the AWS side.
2026.09.06

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.

https://www.evenrealities.com/ja-JP/smart-glasses

Even G2 package

Even G2 unboxed

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

Even G2 display image when worn
This is roughly what it looks like

Even G2 display photographed through the lens
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.

Even G2 side touchpad
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
今日もまた
静かな朝に
風が吹く

5-7-5 detection result displayed on Even G2
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.9 or later

References

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.

https://hub.evenrealities.com/hub

Even Hub My projects screen

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

Even Realities App Private build installation screen

Verification

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

Streaming 575 start screen

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: "今日もまた/静かな朝に/風が吹く."

5-7-5 detection result displayed on smartphone

The following 4 lines were displayed on the glasses.

5・7・5 detected
今日もまた
静かな朝に
風が吹く

5-7-5 detection result displayed on Even G2

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
    }
  }
}

Share this article