Skip to content

Instantly share code, notes, and snippets.

@realgenekim
Created August 20, 2025 17:11
Show Gist options
  • Select an option

  • Save realgenekim/1e34bef6aa6f831e6817e4fac177f478 to your computer and use it in GitHub Desktop.

Select an option

Save realgenekim/1e34bef6aa6f831e6817e4fac177f478 to your computer and use it in GitHub Desktop.
Claude Code on CLJS difficulties wrapping expo-av

expo-av Sound Class Workaround for ClojureScript/React Native

Problem Discovery

When trying to use expo-av’s Audio.Sound class in a ClojureScript React Native app (using shadow-cljs), we encountered a persistent error:

TypeError: Cannot read property ‘prototype’ of undefined

This error occurred when trying to:

  1. Call new Audio.Sound() - FAILS
  2. Call Audio.Sound.createAsync() - FAILS
  3. Any attempt to instantiate the Sound class normally - FAILS

Root Cause

The Sound constructor has an incompatibility with the ClojureScript/shadow-cljs environment. The constructor function exists, has a valid prototype with all the expected methods (playAsync, pauseAsync, etc.), but fails when invoked with new.

The Solution: Object.create Pattern

Instead of using the constructor, we bypass it entirely using Object.create:

// Instead of: new Audio.Sound() - which fails
// Use: Object.create(Audio.Sound.prototype) - which works!

const sound = Object.create(Audio.Sound.prototype);
await sound.loadAsync({ uri: audioUrl }, { shouldPlay: true });

ClojureScript Implementation

(defn create-sound-instance []
  “Creates a Sound instance using Object.create to bypass constructor bug”
  (let [expo-av (js/require “expo-av”)
        Audio (.-Audio expo-av)
        Sound (.-Sound Audio)]
    (.create js/Object (.-prototype Sound))))

(defn load-and-play-audio! [url]
  “Loads and plays audio from URL using the workaround”
  (let [sound-instance (create-sound-instance)]
    (-> (.loadAsync sound-instance 
                    #js {:uri url}
                    #js {:shouldPlay true})
        (.then (fn [result]
                 (js/console.log “Audio loaded!” result)
                 sound-instance))
        (.catch (fn [error]
                  (js/console.error “Load error:” error))))))

What DIDN’T Work (Failed Attempts)

1. Standard Constructor Pattern ❌

;; FAILS with prototype error
(let [Sound (.. expo-av -Audio -Sound)]
  (new Sound))

2. Static Method createAsync ❌

;; FAILS with prototype error
(.createAsync Sound 
              #js {:uri url}
              #js {:shouldPlay false})

3. Using js/Reflect.construct ❌

;; FAILS with prototype error
(js/Reflect.construct Sound #js [])

4. Binding Constructor Context ❌

// FAILS with prototype error
const BoundSound = Audio.Sound.bind(Audio);
new BoundSound();

5. Using .call() or .apply() ❌

// FAILS with prototype error
Audio.Sound.createAsync.call(Audio.Sound, source, status);

6. Direct js/require in ClojureScript ❌

;; Returns nil in compiled context
(def ExpoAV (js/require “expo-av”))

Key Learnings & Conventions

1. Module Import Pattern

In ClojureScript with shadow-cljs, use string requires in the ns declaration:

(ns example.audio
  (:require [“expo-av” :as expo-av]))  ; This compiles but expo-av is nil at runtime

;; Instead, use js/require at runtime:
(let [expo-av (js/require “expo-av”)]
  ...)

2. Property Access Pattern

;; Use nested property access carefully
(let [expo-av (js/require “expo-av”)
      Audio (.-Audio expo-av)      ; Get Audio object
      Sound (.-Sound Audio)]        ; Get Sound constructor
  ...)

3. Promise Handling Pattern

(-> (.loadAsync sound-instance source-map status-map)
    (.then (fn [result] 
             ; Handle success
             sound-instance))  ; Return the instance for chaining
    (.catch (fn [error]
             ; Handle error
             (js/console.error “Error:” error))))

4. State Management Pattern

Use atoms for mutable sound state:

(defonce current-sound (atom nil))

(defn load-sound! [url]
  (let [sound (create-sound-instance)]
    (-> (.loadAsync sound #js {:uri url} #js {:shouldPlay false})
        (.then #(reset! current-sound sound)))))

Verified Working Methods

Once created with Object.create, these methods all work:

  • loadAsync(source, initialStatus) - Load audio from URI
  • playAsync() - Start playback
  • pauseAsync() - Pause playback
  • stopAsync() - Stop and reset to beginning
  • setPositionAsync(millis) - Seek to position
  • setRateAsync(rate, shouldCorrectPitch) - Change playback speed
  • setVolumeAsync(volume) - Set volume (0.0 to 1.0)
  • setIsLoopingAsync(isLooping) - Enable/disable looping
  • getStatusAsync() - Get current playback status
  • unloadAsync() - Release resources

Test Results

✅ Successfully loaded and played IT Revolution podcast on iOS device ✅ Audio quality is perfect ✅ All playback controls work as expected ✅ No memory leaks or performance issues observed

Important Notes

  • This workaround is specific to ClojureScript/shadow-cljs environments
  • The issue does NOT occur in regular JavaScript/TypeScript React Native apps
  • Always use Object.create pattern when working with expo-av Sound in ClojureScript
  • Remember to call unloadAsync() when done to free resources
  • The expo-av deprecation warning can be ignored - it still works

Date Discovered

August 19, 2025

Environment

  • expo-av: 15.1.7
  • React Native: 0.71.19
  • ClojureScript: 1.11.60
  • shadow-cljs: (react-native target)
  • iOS: 18.6
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment