DEV Community

Cover image for Let's GO WASM
Michał Kalbarczyk
Michał Kalbarczyk

Posted on • Originally published at puddleofcode.com

Let's GO WASM

WASM? WebAssembly?

Lately I've started to ask myself: "Is WASM worth paying attention to?"

Let's find out. There are few languages that can be directly compiled into WASM. Anyway let's try GO.

We will make a simple web application that converts image from your webcam into ascii art.
The goal is to write as much code in Go as possible.

Let's go!

# go mod init asciifyme
Enter fullscreen mode Exit fullscreen mode

And that's it, everything works!

Just kidding. It's not that simple.

We will need following pieces:

  • webcam - will initialize and fetch image from web cam
  • canvas - we need this to fetch pixel data from image
  • asciifyier - turn image data into string

The webcam:

This module will:

  • create a video element with document.createElement
  • initialize webcam with navigator.getUserMedia

We need to create a file webcam/webcam.go

First part looks like this:

package webcam

import (
    "fmt"
    "syscall/js"
)

var (
    navigator js.Value
    video     js.Value
)

func init() {
    navigator = js.Global().Get("navigator")
    video = js.Global().Get("document").Call("createElement", "video")
}
Enter fullscreen mode Exit fullscreen mode

With this code we are creating video element, and fetching navigator for future use.

It's time to setup webcam:

func Setup() js.Value {
    user_media_params := map[string]interface{}{
        "video": true,
    }

    navigator.Call("getUserMedia", user_media_params, js.FuncOf(stream), js.FuncOf(err))

    return video
}
Enter fullscreen mode Exit fullscreen mode

We will call this function from the main, it will setup webcam and return video object to fetch data from.
But wait! There are two callbacks stream and err we need to implement:

func err(this js.Value, args []js.Value) interface{} {
    fmt.Println("err")
    return nil
}

func stream(this js.Value, args []js.Value) interface{} {
    video.Set("srcObject", args[0])
    video.Call("addEventListener", "canplaythrough", js.FuncOf(canPlay))
    return nil
}
Enter fullscreen mode Exit fullscreen mode

For now we will ignore errors and write error on console.
stream function adds a stream to the video element and listen to canplaythrough event.
Another callback? Yes! video will call canPlay callback when there will be enough data.

func canPlay(this js.Value, args []js.Value) interface{} {
    video.Call("play")
    return nil
}
Enter fullscreen mode Exit fullscreen mode

When we have enough data press play!

We have a video, now we need a pixel data. Let's create canvas in canvas/canvas.go

package canvas

import (
    "syscall/js"
)

const (
    CanvasWidth  = 80
    CanvasHeight = 40
)

var (
    ctx js.Value
)

func init() {
    ctx = js.Global().Get("document").Call("createElement", "canvas").Call("getContext", "2d")
}
Enter fullscreen mode Exit fullscreen mode

We're creating canvas element and fetching context. Will use it to draw and fetch pixel data.

func DrawImage(video js.Value) {
    ctx.Call("drawImage", video, 0, 0, CanvasWidth, CanvasHeight)
}
Enter fullscreen mode Exit fullscreen mode

We can draw a frame from video by passing it into drawImage function.

func GetImageData() []uint8 {
    data := ctx.Call("getImageData", 0, 0, CanvasWidth, CanvasHeight).Get("data")

    lenght := data.Get("length").Int()

    goData := make([]uint8, lenght)

    js.CopyBytesToGo(goData, data)

    return goData
}
Enter fullscreen mode Exit fullscreen mode

Fetching pixel data is more complicated. We have to fetch JS array of uint8 into GO.
This function takes the length of data from canvas, create GO array, and copy whole data into go array.
Voila! We have a pixel data.

What's left? Convert it to asciiart.

asciifyier/asciifyier.go is what we need!

package asciifyier

import (
    "asciifyme/canvas"
)

const (
    Chars       = "   .,:;i1tfLCG08@"
    CharsLength = 16
)
Enter fullscreen mode Exit fullscreen mode

We don't need any JS stuff here. But need to import our canvas to fetch its size.

func Asciify(data []uint8) string {
    output := ""

    for y := 0; y < canvas.CanvasHeight; y++ {
        for x := 0; x < canvas.CanvasWidth; x++ {
            offset := (y*canvas.CanvasWidth + x) * 4

            red := data[offset]
            green := data[offset+1]
            blue := data[offset+2]
            //alpha := data[offset+3]

            brightness := (0.3*float64(red) + 0.59*float64(green) + 0.11*float64(blue)) / 255.0

            char_index := CharsLength - int(brightness*CharsLength)

            output += string(Chars[char_index])
        }
        output += "\n"
    }

    return output
}
Enter fullscreen mode Exit fullscreen mode

What we're doing here? We're taking each pixel data from array of uint8 and creating a string. Our asciiart.

It's time for main.go ...

package main

import (
    "asciifyme/asciifyier"
    "asciifyme/canvas"
    "asciifyme/webcam"
    "syscall/js"
)

var (
    camera js.Value
    window js.Value
    pre    js.Value
)

func init() {
    camera = webcam.Setup()
    window = js.Global().Get("window")
    pre = js.Global().Get("document").Call("getElementById", "pre")
}
Enter fullscreen mode Exit fullscreen mode

Taking all the pieces together. We will need a camera, window.requestAnimationFrame, and pre element to display our asciiart.

func loop(this js.Value, args []js.Value) interface{} {
    window.Call("requestAnimationFrame", js.FuncOf(loop))
    canvas.DrawImage(camera)
    imageData := canvas.GetImageData()
    output := asciifyier.Asciify(imageData)
    pre.Set("innerHTML", output)
    return nil
}

func main() {
    loop(js.ValueOf(nil), make([]js.Value, 0))

    select {}
}
Enter fullscreen mode Exit fullscreen mode

In main loop we're:

  • fetching data from video
  • drawing it on canvas
  • fetch pixel data from canvas
  • create asciiart using asciifyier
  • draw asciify into pre

One more thing! select {} make the wasm program don't quit!

That's it. Compile time!

To run this in the browser we need:

  • index.html
  • wasm_exec.js
  • compiled app

Simple index.html file

<html>
  <head>
    <title>asscify-me</title>
    <style>
      body{background-color:#000}pre{text-align:center}header{color:#daa520;font-size:18px;font-weight:700;text-shadow:0 0 3px gold}section{margin-top:30px;color:#32cd32;text-shadow:0 0 15px #0f0;font-size:14px}footer,footer a{margin-top:30px;color:red;text-shadow:0 0 15px tomato;font-size:14px}
    </style>
    <script src="wasm_exec.js"></script>
    <script>
    const go = new Go();
    WebAssembly.instantiateStreaming(fetch("asciifyme.wasm"), go.importObject).then((result) => {
    go.run(result.instance);
    });
    </script>
  </head>
  <body>
    <pre id="pre"></pre>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

And the build script:

#/bin/bash

export GOOS=js
export GOARCH=wasm
mkdir -p build
cp index.html build/
cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" build/
go build -o build/asciifyme.wasm
Enter fullscreen mode Exit fullscreen mode

Now we need to run:

# ./build.sh
Enter fullscreen mode Exit fullscreen mode

Serve files from build folder, and use the browser.

Notice that the browser will give camera access when you're using https:// or localhost

P.S

But wait! The size! ~2MB is way to much! Yes!

Try thinygo compiler, ~200KB is much better!

# tinygo build -o build/asciifyme.wasm -target wasm
Enter fullscreen mode Exit fullscreen mode

Don't need to write whole thing yourself if you don't want. Check out my github or working an app.

Top comments (0)