Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
const BUTTON_NAMES = ['a', 'b', 'c']
const MOD_INSTALL_HOOKS = ['_fxMainSetModArchive', '_wasmModInstallArchive']
const DEFAULT_CAMERA_WIDTH = 96
const DEFAULT_CAMERA_HEIGHT = 96
const DEFAULT_CAMERA_IMAGE_TYPE = 'rgb565le'
const HAVE_CURRENT_DATA = 2
function normalizeDimension(value, fallback) {
if (value === undefined) return fallback
const normalized = value | 0
return normalized > 0 ? normalized : fallback
}
function writeRgb565Le(view, width, height) {
let offset = 0
const widthScale = Math.max(1, width - 1)
const heightScale = Math.max(1, height - 1)
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const red = (x * 31) / widthScale
const green = ((x + y) * 63) / Math.max(1, width + height - 2)
const blue = (y * 31) / heightScale
const pixel = ((red & 0x1f) << 11) | ((green & 0x3f) << 5) | (blue & 0x1f)
view[offset] = pixel & 0xff
view[offset + 1] = (pixel >> 8) & 0xff
offset += 2
}
}
}
export function createHostDriverBridge({ onRotation = () => {}, onTorque = () => {} } = {}) {
let rotation = { y: 0, p: 0, r: 0 }
let torque = true
return {
applyRotation(message = {}) {
rotation = { ...rotation, ...(message.rotation ?? {}) }
onRotation(rotation, message.time)
},
getRotation() {
return rotation
},
setTorque(nextTorque) {
torque = Boolean(nextTorque)
onTorque(torque)
},
getTorque() {
return torque
},
}
}
export function createHostButtonBridge({
logger = console.log,
setTimeoutFn = globalThis.setTimeout,
resetDelayMs = 120,
} = {}) {
// Moddable's Button driver normalizes read() to 1 while pressed and 0 while
// released, regardless of the physical pin's active-low wiring. Host.Button
// must expose that normalized contract because SimButton passes read()
// directly to StackchanRuntimeInput.
const states = Object.fromEntries(BUTTON_NAMES.map((name) => [name, { pressed: 0, firmwareCallbacks: new Set() }]))
const Button = Object.fromEntries(
BUTTON_NAMES.map((name) => [
name,
class HtmlBridgeButton {
constructor({ onPush } = {}) {
if (onPush) states[name].firmwareCallbacks.add(onPush)
}
read() {
return states[name].pressed
}
},
])
)
Object.defineProperty(Button, 'read', {
value: (name) => states[name]?.pressed,
})
return {
Button,
push(name) {
const state = states[name]
if (!state) return
logger(`[bridge] Host.Button.${name} pushed`)
state.pressed = 1
const currentGeneration = (state.generation = (state.generation ?? 0) + 1)
for (const callback of state.firmwareCallbacks) callback()
setTimeoutFn(() => {
if (state.generation !== currentGeneration) return
state.pressed = 0
for (const callback of state.firmwareCallbacks) callback()
}, resetDelayMs)
},
read(name) {
return states[name]?.pressed
},
}
}
export function installModArchiveIntoWasm(wasmModule, installedMod) {
if (!installedMod) return { status: 'empty' }
const bytes = installedMod.bytes instanceof Uint8Array ? installedMod.bytes : new Uint8Array(installedMod.bytes ?? [])
const size = installedMod.size ?? bytes.byteLength
const hookName = MOD_INSTALL_HOOKS.find((name) => typeof wasmModule?.[name] === 'function')
if (typeof wasmModule?._malloc !== 'function' || !wasmModule.HEAPU8) {
return { status: 'unsupported', name: installedMod.name, size }
}
const pointer = wasmModule._malloc(bytes.byteLength)
wasmModule.HEAPU8.set(bytes, pointer)
if (!hookName) return { status: 'prepared', pointer, name: installedMod.name, size }
try {
const result = wasmModule[hookName](pointer, bytes.byteLength)
return { status: 'installed', hook: hookName, name: installedMod.name, size, result }
} finally {
wasmModule._free?.(pointer)
}
}
export function createHostAudioOutBridge({
createAudioContext = defaultAudioContextFactory,
setTimeoutFn = globalThis.setTimeout,
clearTimeoutFn = globalThis.clearTimeout,
} = {}) {
let context
return {
async tone({ hz, duration, volume } = {}) {
// Guard against non-finite values: the wasm audio bridge always passes a
// volume argument, so an omitted volume arrives as NaN (not undefined) and
// a destructuring default would not apply. Setting an AudioParam to a
// non-finite value throws.
const frequency = Number.isFinite(hz) ? hz : 440
const durationMs = Number.isFinite(duration) ? Math.max(0, duration) : 100
const level = Number.isFinite(volume) ? Math.min(1, Math.max(0, volume)) : 1
context ??= createAudioContext()
if (context.state === 'suspended' && typeof context.resume === 'function') {
await context.resume()
}
const oscillator = context.createOscillator()
const gain = context.createGain()
oscillator.frequency.value = frequency
gain.gain.value = level
oscillator.connect(gain)
gain.connect(context.destination)
const startTime = context.currentTime
await new Promise((resolve, reject) => {
let fallback
const finish = () => {
if (fallback !== undefined) clearTimeoutFn?.(fallback)
resolve()
}
oscillator.onended = finish
try {
oscillator.start(startTime)
oscillator.stop(startTime + durationMs / 1000)
fallback = setTimeoutFn?.(finish, durationMs + 250)
} catch (error) {
if (fallback !== undefined) clearTimeoutFn?.(fallback)
reject(error)
}
})
},
async play(buffer) {
if (!(buffer instanceof ArrayBuffer) || buffer.byteLength === 0) return false
context ??= createAudioContext()
if (context.state === 'suspended' && typeof context.resume === 'function') {
await context.resume()
}
if (typeof context.decodeAudioData !== 'function') return false
const audioBuffer = await decodeAudioData(context, buffer)
const source = context.createBufferSource()
source.buffer = audioBuffer
source.connect(context.destination)
await new Promise((resolve, reject) => {
let fallback
const durationMilliSec = Number.isFinite(audioBuffer.duration) ? audioBuffer.duration * 1000 : 0
const finish = () => {
if (fallback !== undefined) clearTimeoutFn?.(fallback)
resolve()
}
source.onended = finish
try {
source.start(0)
if (durationMilliSec > 0) {
fallback = setTimeoutFn?.(finish, durationMilliSec + 250)
}
} catch (error) {
if (fallback !== undefined) clearTimeoutFn?.(fallback)
reject(error)
}
})
return true
},
close() {
context?.close?.()
context = undefined
},
}
}
function writeImageDataRgb565Le(view, imageData) {
let offset = 0
const data = imageData.data
for (let index = 0; index < data.length; index += 4) {
const red = data[index] >> 3
const green = data[index + 1] >> 2
const blue = data[index + 2] >> 3
const pixel = (red << 11) | (green << 5) | blue
view[offset] = pixel & 0xff
view[offset + 1] = (pixel >> 8) & 0xff
offset += 2
}
}
function createSyntheticCameraFrame(options = {}) {
const imageType = options.imageType ?? DEFAULT_CAMERA_IMAGE_TYPE
if (imageType !== 'rgb565le') return undefined
const width = normalizeDimension(options.width, DEFAULT_CAMERA_WIDTH)
const height = normalizeDimension(options.height, DEFAULT_CAMERA_HEIGHT)
const buffer = new ArrayBuffer(width * height * 2)
writeRgb565Le(new Uint8Array(buffer), width, height)
return { width, height, imageType, buffer }
}
export function createHostCameraBridge({
documentObj = globalThis.document,
logger = console,
navigatorObj = globalThis.navigator,
videoElement,
canvasElement,
} = {}) {
let started = false
let browserCameraRequested = false
let browserCameraStarted = false
let mediaStream
let mediaVideo = videoElement
let mediaCanvas = canvasElement
let browserStartGeneration = 0
const logWarning = (message, error) => {
if (error) {
logger?.warn?.(message, error)
} else {
logger?.warn?.(message)
}
}
const ensureVideoElement = () => {
if (mediaVideo) return mediaVideo
if (!documentObj?.createElement) return undefined
mediaVideo = documentObj.createElement('video')
mediaVideo.muted = true
mediaVideo.playsInline = true
return mediaVideo
}
const ensureCanvasElement = () => {
if (mediaCanvas) return mediaCanvas
if (!documentObj?.createElement) return undefined
mediaCanvas = documentObj.createElement('canvas')
return mediaCanvas
}
const stopBrowserCamera = () => {
browserStartGeneration += 1
for (const track of mediaStream?.getTracks?.() ?? []) track.stop?.()
mediaStream = undefined
browserCameraStarted = false
if (mediaVideo) mediaVideo.srcObject = null
}
const startBrowserCamera = async (options = {}) => {
if (browserCameraStarted && mediaStream && mediaVideo?.srcObject === mediaStream) return true
stopBrowserCamera()
const getUserMedia = navigatorObj?.mediaDevices?.getUserMedia?.bind(navigatorObj.mediaDevices)
if (!getUserMedia) {
browserCameraStarted = false
return false
}
const video = ensureVideoElement()
if (!video) {
browserCameraStarted = false
return false
}
try {
const startGeneration = browserStartGeneration
const stream = await getUserMedia({ video: options.video ?? true })
if (startGeneration !== browserStartGeneration || !started || !browserCameraRequested) {
for (const track of stream?.getTracks?.() ?? []) track.stop?.()
return false
}
mediaStream = stream
video.srcObject = mediaStream
if (typeof video.play === 'function') await video.play()
browserCameraStarted = true
return true
} catch (error) {
stopBrowserCamera()
logWarning('[bridge] browser camera unavailable; using synthetic Host.Camera fallback', error)
return false
}
}
const captureBrowserCamera = (options = {}) => {
if (!started || !browserCameraRequested || !browserCameraStarted) return undefined
if (!mediaVideo || mediaVideo.readyState < HAVE_CURRENT_DATA || !mediaVideo.videoWidth || !mediaVideo.videoHeight) {
return undefined
}
const canvas = ensureCanvasElement()
const context = canvas?.getContext?.('2d', { willReadFrequently: true })
if (!canvas || !context?.drawImage || !context?.getImageData) return undefined
const width = normalizeDimension(options.width, DEFAULT_CAMERA_WIDTH)
const height = normalizeDimension(options.height, DEFAULT_CAMERA_HEIGHT)
try {
canvas.width = width
canvas.height = height
context.drawImage(mediaVideo, 0, 0, width, height)
const imageData = context.getImageData(0, 0, width, height)
if (!imageData?.data || imageData.data.length < width * height * 4) return undefined
const buffer = new ArrayBuffer(width * height * 2)
writeImageDataRgb565Le(new Uint8Array(buffer), imageData)
return { width, height, imageType: 'rgb565le', buffer }
} catch (error) {
logWarning('[bridge] browser camera capture failed; using synthetic Host.Camera fallback', error)
return undefined
}
}
return {
async start(options = {}) {
started = true
if (Object.hasOwn(options, 'useBrowserCamera')) {
browserCameraRequested = Boolean(options.useBrowserCamera)
if (browserCameraRequested) {
await startBrowserCamera(options)
} else {
stopBrowserCamera()
}
}
},
stop() {
started = false
browserCameraRequested = false
stopBrowserCamera()
},
isStarted() {
return started
},
isBrowserCameraStarted() {
return browserCameraStarted
},
capture(options = {}) {
const imageType = options.imageType ?? DEFAULT_CAMERA_IMAGE_TYPE
if (imageType !== 'rgb565le') return undefined
return captureBrowserCamera(options) ?? createSyntheticCameraFrame(options)
},
}
}
function decodeAudioData(context, buffer) {
return new Promise((resolve, reject) => {
const result = context.decodeAudioData(buffer.slice(0), resolve, reject)
if (result && typeof result.then === 'function') {
result.then(resolve, reject)
}
})
}
export function createHostAudioInBridge({
mediaDevices = globalThis.navigator?.mediaDevices,
MediaRecorder = globalThis.MediaRecorder,
setTimeoutFn = globalThis.setTimeout,
} = {}) {
return {
async record(durationMilliSec = 3000) {
if (!mediaDevices?.getUserMedia || !MediaRecorder) return new ArrayBuffer(0)
const format = selectAudioRecordingFormat(MediaRecorder)
if (!format) {
return new ArrayBuffer(0)
}
const stream = await mediaDevices.getUserMedia({ audio: true })
const chunks = []
try {
return await new Promise((resolve) => {
const recorder = new MediaRecorder(stream, { mimeType: format.mimeType })
recorder.ondataavailable = (event) => {
if (event.data) chunks.push(event.data)
}
recorder.onstop = async () => {
const buffer = await chunksToArrayBuffer(chunks)
resolve(attachAudioMetadata(isSupportedAudioBuffer(buffer, format) ? buffer : new ArrayBuffer(0), format))
}
recorder.start()
setTimeoutFn(() => recorder.stop(), durationMilliSec)
})
} finally {
for (const track of stream.getTracks?.() ?? []) track.stop?.()
}
},
}
}
const AUDIO_RECORDING_FORMATS = Object.freeze([
{ mimeType: 'audio/webm;codecs=opus', extension: 'webm' },
{ mimeType: 'audio/webm', extension: 'webm' },
{ mimeType: 'audio/mp4', extension: 'm4a' },
{ mimeType: 'audio/wav', extension: 'wav' },
])
function selectAudioRecordingFormat(MediaRecorder) {
if (typeof MediaRecorder.isTypeSupported !== 'function') return AUDIO_RECORDING_FORMATS[0]
return AUDIO_RECORDING_FORMATS.find(({ mimeType }) => MediaRecorder.isTypeSupported(mimeType))
}
function isSupportedAudioBuffer(buffer, format) {
if (format.mimeType === 'audio/wav') return isWavBuffer(buffer)
return buffer instanceof ArrayBuffer && buffer.byteLength > 0
}
function attachAudioMetadata(buffer, format) {
if (!(buffer instanceof ArrayBuffer) || buffer.byteLength === 0) return buffer
const metadata = {
mimeType: format.mimeType,
filename: `speak.${format.extension}`,
}
try {
Object.defineProperties(buffer, {
mimeType: { value: metadata.mimeType, configurable: true },
filename: { value: metadata.filename, configurable: true },
})
} catch {
buffer.mimeType = metadata.mimeType
buffer.filename = metadata.filename
}
return buffer
}
function defaultAudioContextFactory() {
const AudioContextConstructor = globalThis.AudioContext ?? globalThis.webkitAudioContext
if (!AudioContextConstructor) throw new Error('WebAudio AudioContext is not available')
return new AudioContextConstructor()
}
async function chunksToArrayBuffer(chunks) {
const buffers = await Promise.all(
chunks.map(async (chunk) => {
if (chunk instanceof ArrayBuffer) return chunk
if (ArrayBuffer.isView(chunk)) return chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength)
if (typeof chunk.arrayBuffer === 'function') return chunk.arrayBuffer()
return new ArrayBuffer(0)
})
)
const total = buffers.reduce((sum, buffer) => sum + buffer.byteLength, 0)
const bytes = new Uint8Array(total)
let offset = 0
for (const buffer of buffers) {
bytes.set(new Uint8Array(buffer), offset)
offset += buffer.byteLength
}
return bytes.buffer
}
function isWavBuffer(buffer) {
if (!(buffer instanceof ArrayBuffer) || buffer.byteLength < 12) return false
const bytes = new Uint8Array(buffer)
return (
bytes[0] === 0x52 &&
bytes[1] === 0x49 &&
bytes[2] === 0x46 &&
bytes[3] === 0x46 &&
bytes[8] === 0x57 &&
bytes[9] === 0x41 &&
bytes[10] === 0x56 &&
bytes[11] === 0x45
)
}
export function clientPointFromTouch(touch) {
return { x: touch.clientX, y: touch.clientY }
}
export function summarizeImageData(imageData, { sampleLimit = 1024 } = {}) {
const data = imageData?.data ?? imageData
if (!data?.length) return { samples: 0, nonZeroAlpha: 0, nonZeroRgb: 0, firstPixel: [] }
const pixels = Math.floor(data.length / 4)
const stride = Math.max(1, Math.floor(pixels / sampleLimit))
let samples = 0
let nonZeroAlpha = 0
let nonZeroRgb = 0
for (let pixel = 0; pixel < pixels; pixel += stride) {
const offset = pixel * 4
samples++
if (data[offset + 3] !== 0) nonZeroAlpha++
if (data[offset] !== 0 || data[offset + 1] !== 0 || data[offset + 2] !== 0) nonZeroRgb++
}
return {
samples,
nonZeroAlpha,
nonZeroRgb,
firstPixel: Array.from(data.slice(0, 4)),
}
}
3a9958f98This file is pinned to the selected release. Download the original to open it in your design tool.