{
  "$type": "site.standard.document",
  "bskyPostRef": {
    "cid": "bafyreidg45ezf2rri4tiwex25uoa5ekweuvytjqphssaykjaix7u5hulq4",
    "uri": "at://did:plc:pgryn3ephfd2xgft23qokfzt/app.bsky.feed.post/3mkmghzk3rxr2"
  },
  "path": "/t/audio-in-huggingface-space-not-working-on-ai-moved-a-piece-in-a-game-same-for-single-gradio-apps/175606#post_2",
  "publishedAt": "2026-04-29T05:08:11.000Z",
  "site": "https://discuss.huggingface.co",
  "tags": [
    "Gradio custom CSS and JS guide",
    "Gradio Textbox docs — Textbox.change",
    "Gradio file access docs — /gradio_api/file=... and allowed_paths",
    "Chrome autoplay policy",
    "Chrome Web Audio autoplay policy",
    "Gradio Audio docs — autoplay note",
    "MDN MutationObserver.observe()",
    "Hugging Face Spaces config reference",
    "Hugging Face Gradio Spaces docs",
    "MutationObserver.observe() docs",
    "MDN Web Audio API best practices",
    "file access docs",
    "Gradio Textbox docs",
    "Gradio Blocks/event listener guide",
    "Gradio file access guide",
    "Hugging Face Spaces configuration reference",
    "MDN ARIA live regions",
    "MDN aria-live",
    "W3C ARIA19 live-region error technique",
    "Sarah Higley on ARIA grids",
    "Accessible bingo web app",
    "Game Accessibility Guidelines"
  ],
  "textContent": "I’m not very familiar with JavaScript, but could it be that the front-end events aren’t being triggered properly? Since Gradio allows you to call JavaScript from event handlers, why not try using that?\n\n* * *\n\n## Short version\n\nYour Reversi AI probably **does make the move**. The board and status update because Python/Gradio returns the new game state.\n\nThe missing part is probably this:\n\n> The browser is not reliably receiving, detecting, or accepting the delayed “AI moved, now play sound” event.\n\nYour app currently has two different sound paths:\n\nSituation | How sound is triggered | Why it behaves differently\n---|---|---\nYour move | JavaScript handles the board click and plays sound immediately | This happens close to a real user gesture, so browser audio policy is friendlier\nAI move | Python computes the AI move, returns hidden metadata HTML, and JavaScript must notice a DOM mutation later | This is delayed, backend-driven, and depends on a fragile hidden-HTML/`MutationObserver` bridge\n\nThat difference explains the symptom very well: **your move sound works, but the AI move sound does not**.\n\nThe strongest fix is:\n\n> Stop using hidden HTML plus `MutationObserver` as the AI-sound event bus. Return plain JSON metadata from Python, place it in a real Gradio component such as a `Textbox`, and call your browser audio code through a documented Gradio `.change(..., js=...)` listener.\n\nRelevant docs:\n\n  * Gradio custom CSS and JS guide\n  * Gradio Textbox docs — Textbox.change\n  * Gradio file access docs — /gradio_api/file=... and allowed_paths\n  * Chrome autoplay policy\n  * Chrome Web Audio autoplay policy\n  * Gradio Audio docs — autoplay note\n  * MDN MutationObserver.observe()\n  * Hugging Face Spaces config reference\n  * Hugging Face Gradio Spaces docs\n\n\n\n* * *\n\n## 1. What your app is doing now\n\nYour project is a Gradio/Hugging Face Space Reversi game. It has:\n\n  * a Python backend that owns the game state and AI move;\n  * a Gradio UI with 64 board buttons;\n  * local WAV files under `sounds/`;\n  * JavaScript that loads sounds and plays spatialized move/flip/error/pass sounds;\n  * screen reader text and ARIA/live-region support;\n  * a hidden metadata channel intended to tell JavaScript what happened after Python processes a move.\n\n\n\nIn `app.py`, your UI has this hidden metadata output:\n\n\n    move_metadata_view = gr.HTML(\n        visible=False,\n        elem_id=\"move-metadata-container\"\n    )\n\n\nThen `_build_ui_payload()` builds metadata like this:\n\n\n    payload = {\n        \"moves\": moves_metadata,\n        \"ts\": time.time()\n    }\n\n    escaped_metadata = json.dumps(payload).replace(\"'\", \"'\")\n    metadata_html = f\"<div id='move-metadata' data-payload='{escaped_metadata}'></div>\"\n\n\nThat hidden HTML is returned as one of the Gradio outputs.\n\nIn `logic.py`, the backend creates metadata for things like:\n\n  * valid human move;\n  * invalid move;\n  * pass;\n  * AI move.\n\n\n\nFor AI moves, the backend does roughly this:\n\n\n    ai_move = ai_player.choose_move(board, ai_color)\n\n    if ai_move is not None:\n        before_ai = board.copy()\n        board.apply_move(ai_move[0], ai_move[1])\n        ai_flips = _changed_to_player(before_ai, board, ai_color)\n        moves_metadata.append({\n            \"type\": \"move\",\n            \"player\": ai_color,\n            \"r\": ai_move[0],\n            \"c\": ai_move[1],\n            \"flips\": ai_flips,\n        })\n\n\nSo the backend is not silent. It is producing a description of the AI move.\n\nThe weak part is not “does Python know the AI moved?” The weak part is:\n\n> Does the browser reliably notice that returned metadata and play audio after the Gradio update?\n\nRight now, probably not.\n\n* * *\n\n## 2. Why your move sound works\n\nYour human move sound is played optimistically in JavaScript.\n\nThat means the browser does not wait for Python first. When you click a board square, your frontend:\n\n  1. finds the clicked square;\n  2. checks whether it looks locally valid;\n  3. updates the local visual board;\n  4. immediately calls the audio engine;\n  5. only then waits for Gradio/Python to return the authoritative state.\n\n\n\nThat is a good design for responsive game feel.\n\nIt also happens near a real user gesture: a click, keypress, or touch. Browser audio systems are much more willing to allow sound when it follows a user interaction.\n\nThis path is direct:\n\n\n    user click\n    ↓\n    JavaScript click handler\n    ↓\n    AudioEngine.playMoveSequence(...)\n    ↓\n    sound plays\n\n\nThis is why your move sound can work even while the AI sound fails.\n\n* * *\n\n## 3. Why the AI move sound fails\n\nThe AI move sound uses a different path:\n\n\n    user click\n    ↓\n    Gradio sends event to Python\n    ↓\n    Python applies human move\n    ↓\n    Python computes AI move\n    ↓\n    Python returns hidden metadata HTML\n    ↓\n    Gradio updates hidden HTML component\n    ↓\n    MutationObserver should notice metadata\n    ↓\n    handleMetadataUpdate(...) should run\n    ↓\n    AudioEngine should play AI move sound\n\n\nThat chain has several fragile links.\n\nThe biggest one is the `MutationObserver`.\n\nYour current observer does this:\n\n\n    const metaContainer =\n      document.getElementById('move-metadata') ||\n      document.getElementById('move-metadata-container');\n\n    if (metaContainer) {\n      observer.observe(metaContainer, {\n        attributes: true,\n        attributeFilter: ['data-payload']\n      });\n    }\n\n\nThis watches only for `data-payload` attribute changes on the observed node.\n\nBut Gradio may update the `gr.HTML` component by replacing its child HTML, not by mutating the same existing node’s `data-payload` attribute.\n\nIn other words, your code is good at detecting this:\n\n\n    <div id=\"move-metadata\" data-payload=\"old\"></div>\n\n\nchanging into this on the **same DOM node** :\n\n\n    <div id=\"move-metadata\" data-payload=\"new\"></div>\n\n\nBut Gradio may effectively do this instead:\n\n\n    <div id=\"move-metadata-container\">\n      <div id=\"move-metadata\" data-payload=\"old\"></div>\n    </div>\n\n\nbecoming:\n\n\n    <div id=\"move-metadata-container\">\n      <div id=\"move-metadata\" data-payload=\"new\"></div>\n    </div>\n\n\nwhere the inner node is replaced.\n\nThat kind of update is a child-list/subtree mutation, not necessarily an attribute mutation on the exact node your observer is watching.\n\nMDN’s MutationObserver.observe() docs explain the relevant options:\n\n  * `attributes: true` watches attribute changes;\n  * `childList: true` watches child nodes being added or removed;\n  * `subtree: true` watches descendants too;\n  * `characterData: true` watches text node changes.\n\n\n\nYour board observer watches child changes, but your metadata observer only watches attributes. That makes the AI metadata trigger easy to miss.\n\n* * *\n\n## 4. Second major issue: browser autoplay policy\n\nEven if the metadata detection is fixed, browser audio policy still matters.\n\nModern browsers do not let pages freely play sound at arbitrary times. Chrome’s autoplay policy applies to Web Audio. Chrome says that if an `AudioContext` is created before the page receives a user gesture, it may start in the `\"suspended\"` state and must be resumed after the user interacts with the page.\n\nRelevant docs:\n\n  * Chrome autoplay policy\n  * Chrome Web Audio autoplay policy\n  * MDN Web Audio API best practices\n  * Gradio Audio docs — autoplay note\n\n\n\nThe key practical rule is:\n\n> Python cannot play sound in the user’s browser. Python can only return data. Browser JavaScript must receive that data and play sound from an already-unlocked audio context.\n\nYour human move sound is close to the click. Your AI move sound happens later, after a backend round trip. That delayed playback is more likely to fail unless the audio context has already been unlocked.\n\nYour JavaScript already tries to resume the context in several places, which is good. But your `AudioEngine.play()` currently logs a warning if the context is not running and then continues toward creating a source node.\n\nThis part should be stricter. If the context is not running, return `false` and log a clear failure.\n\n* * *\n\n## 5. Third issue: the file URL should be simpler\n\nYour app uses local WAV files from `sounds/`, and `app.py` launches with something like:\n\n\n    demo.launch(css=APP_CSS, js=APP_JS, allowed_paths=[sounds_path])\n\n\nThat general idea is correct.\n\nGradio’s file access docs say exposed files are available through:\n\n\n    /gradio_api/file=<local-file-path>\n\n\nand `allowed_paths` can expose additional files or directories.\n\nSo your JavaScript sound loading should use the documented route directly:\n\n\n    const url = `/gradio_api/file=sounds/${sound}`;\n    const response = await fetch(url);\n\n\nThis is simpler and less fragile than constructing a URL from `window.location.pathname`.\n\nThis may not be the main bug because your human move sound works, but it is still a good cleanup.\n\n* * *\n\n## 6. Things that are probably not the main cause\n\n### Not likely: “the AI did not move”\n\nIf the board visibly updates after the AI turn, the AI computation path is working. Your backend also explicitly appends AI move metadata after applying the AI move.\n\nSo the issue is probably not:\n\n\n    AI failed to choose a move\n\n\nIt is more likely:\n\n\n    AI move metadata was returned, but frontend did not reliably react to it\n\n\n### Not likely: “the WAV files are missing”\n\nIf your human move sound works, at least some sound files are loaded and decodable.\n\nYou should still test file serving directly, but missing WAV files are unlikely to be the core cause.\n\n### Not useful: `time.sleep()` in Python\n\nA backend delay like this does not help the browser play sound:\n\n\n    if DEBUG:\n        time.sleep(0.8)\n\n\nThat sleep happens before Python returns the response. The browser cannot play metadata it has not received yet.\n\nTiming between human and AI sounds belongs in JavaScript, after the metadata arrives.\n\n* * *\n\n## 7. The architecture I recommend\n\nUse this boundary:\n\n\n    Python owns:\n    - authoritative board state\n    - legal move validation\n    - AI move choice\n    - move metadata\n    - status text\n    - screen reader text\n\n    JavaScript owns:\n    - audio unlocking\n    - sound-file loading\n    - sound playback\n    - immediate human move feedback\n    - replaying returned AI metadata\n    - keyboard navigation\n    - animation timing\n\n\nThe bridge should be:\n\n\n    Python returns JSON metadata\n    ↓\n    Gradio Textbox value changes\n    ↓\n    Textbox.change JS listener fires\n    ↓\n    window.handleReversiMetadata(payload)\n    ↓\n    AudioEngine plays AI sound\n\n\nAvoid this as the main event bridge:\n\n\n    Python returns hidden HTML\n    ↓\n    Gradio updates internal DOM\n    ↓\n    MutationObserver guesses what changed\n    ↓\n    JavaScript maybe finds data-payload\n    ↓\n    AI sound maybe plays\n\n\nHidden HTML is markup. Your move metadata is data. Send it as data.\n\n* * *\n\n## 8. Main fix: replace hidden HTML metadata with JSON metadata\n\n### Step 1 — return plain JSON from `_build_ui_payload()`\n\nReplace the hidden-HTML metadata construction with a plain JSON string.\n\n\n    import json\n    import time\n\n    def _build_ui_payload(board: Board, status_text: str, moves_metadata=None):\n        if moves_metadata is None:\n            moves_metadata = []\n\n        final_status = logic.compose_status(board, status_text)\n        adv_html, _ = board.get_advantage_info()\n        legal_html, _ = board.get_legal_moves_info(board.turn)\n\n        metadata_json = json.dumps({\n            \"moves\": moves_metadata,\n            \"ts\": time.time(),\n        })\n\n        return [\n            board,\n            final_status,\n            metadata_json,\n            final_status,\n            board.get_screenreader_text(final_status),\n            logic.announce_to_screenreader(final_status),\n            gr.update(value=adv_html),\n            gr.update(value=legal_html),\n            *[gr.update(value=lbl) for lbl in board.get_button_labels()],\n        ]\n\n\nBenefits:\n\n  * no HTML escaping;\n  * no JSON inside an HTML attribute;\n  * no `'` conversion;\n  * no dependency on a specific hidden `<div>`;\n  * no dependency on Gradio’s internal DOM replacement behavior;\n  * easier to inspect in DevTools.\n\n\n\n* * *\n\n### Step 2 — replace hidden `gr.HTML` with CSS-hidden `gr.Textbox`\n\nReplace:\n\n\n    move_metadata_view = gr.HTML(\n        visible=False,\n        elem_id=\"move-metadata-container\"\n    )\n\n\nwith:\n\n\n    move_metadata_view = gr.Textbox(\n        value=\"\",\n        label=\"Move metadata\",\n        interactive=False,\n        elem_id=\"move-metadata-json\",\n        elem_classes=[\"visually-hidden-metadata\"],\n    )\n\n\nThen add CSS:\n\n\n    .visually-hidden-metadata {\n      position: absolute !important;\n      left: -10000px !important;\n      top: auto !important;\n      width: 1px !important;\n      height: 1px !important;\n      overflow: hidden !important;\n    }\n\n\nWhy a `Textbox`?\n\nBecause Gradio documents `Textbox.change`, and the docs say it fires when the value changes either because of user input or because of a backend function update. That is exactly what you need here.\n\nRelevant docs:\n\n  * Gradio Textbox docs\n  * Gradio Blocks/event listener guide\n\n\n\n* * *\n\n### Step 3 — attach a Gradio `.change(..., js=...)` listener\n\nAdd this after `move_metadata_view` is created:\n\n\n    move_metadata_view.change(\n        fn=None,\n        inputs=move_metadata_view,\n        outputs=[],\n        js=\"\"\"\n        async (payload) => {\n          if (!payload) return;\n\n          if (window.handleReversiMetadata) {\n            await window.handleReversiMetadata(payload);\n          } else {\n            console.warn(\"handleReversiMetadata is not available\");\n          }\n        }\n        \"\"\",\n        show_api=False,\n    )\n\n\nThis uses Gradio’s documented event system instead of DOM guessing.\n\nRelevant docs:\n\n  * Gradio custom CSS and JS guide\n  * Gradio Textbox docs\n\n\n\n* * *\n\n### Step 4 — expose your metadata handler globally\n\nAt the end of `assets/script.js`, add:\n\n\n    window.handleReversiMetadata = handleMetadataUpdate;\n    window.AudioEngine = AudioEngine;\n    window.Reversi = Reversi;\n\n\nYour file already exposes `window.AudioEngine` and `window.Reversi`. Add the metadata handler too.\n\n* * *\n\n## 9. Make the audio engine stricter\n\nAdd a helper like this:\n\n\n    async ensureRunning() {\n      if (!this.ctx || this.ctx.state === \"closed\") {\n        await this.init();\n      }\n\n      if (!this.ctx) {\n        console.warn(\"AudioContext missing\");\n        return false;\n      }\n\n      if (this.ctx.state === \"suspended\") {\n        try {\n          await this.ctx.resume();\n        } catch (e) {\n          console.warn(\"AudioContext resume failed:\", e);\n        }\n      }\n\n      if (this.ctx.state !== \"running\") {\n        console.warn(\"AudioContext is not running:\", this.ctx.state);\n        return false;\n      }\n\n      return true;\n    }\n\n\nThen start `play()` like this:\n\n\n    async play(soundName, r, c) {\n      console.log(`AudioEngine.play: ${soundName} at (${r}, ${c})`);\n\n      try {\n        const ok = await this.ensureRunning();\n        if (!ok) return false;\n\n        if (!this.buffers[soundName]) {\n          console.warn(`Sound ${soundName} not loaded.`);\n          return false;\n        }\n\n        const source = this.ctx.createBufferSource();\n        source.buffer = this.buffers[soundName];\n\n        if (r !== undefined) {\n          const targetFreq = this.BASE_FREQ + r * this.STEP;\n          source.playbackRate.value = targetFreq / this.DEFAULT_SAMPLE_RATE;\n        }\n\n        const panner = this.ctx.createStereoPanner();\n        panner.pan.value = c !== undefined ? (2 * c / 7) - 1.0 : 0;\n\n        source.connect(panner).connect(this.ctx.destination);\n        source.start();\n\n        return true;\n      } catch (e) {\n        console.error(\"Error playing sound\", e);\n        return false;\n      }\n    }\n\n\nThis makes debugging much clearer:\n\nSymptom | Likely meaning\n---|---\nno metadata log | Gradio event bridge problem\nmetadata log appears, but `AudioContext` is suspended | browser audio unlock problem\nmetadata log appears, context is running, but buffer missing | sound loading problem\nall checks pass | sound should play\n\n* * *\n\n## 10. Add an explicit “Enable sound” button\n\nFor accessibility and browser reliability, add a button:\n\n\n    enable_audio_btn = gr.Button(\"Enable sound\", elem_id=\"enable-audio-btn\")\n\n\nThen in JavaScript:\n\n\n    document.addEventListener(\"click\", async (e) => {\n      const btn = e.target.closest(\"#enable-audio-btn\");\n      if (!btn) return;\n\n      await AudioEngine.init();\n\n      if (AudioEngine.ensureRunning) {\n        const ok = await AudioEngine.ensureRunning();\n        if (!ok) {\n          console.warn(\"Could not enable audio\");\n          return;\n        }\n      }\n\n      await AudioEngine.play(\"disk.wav\", 3, 3);\n      console.log(\"Audio enabled\");\n    });\n\n\nThis gives the browser a clear user gesture for unlocking Web Audio.\n\nIt also helps screen reader users because sound activation becomes explicit instead of hidden behind the first board click.\n\n* * *\n\n## 11. Simplify sound-file loading\n\nUse Gradio’s documented file route:\n\n\n    const url = `/gradio_api/file=sounds/${sound}`;\n    const response = await fetch(url);\n\n\nThen test in the browser console:\n\n\n    await fetch(\"/gradio_api/file=sounds/disk.wav\")\n      .then(r => [r.status, r.headers.get(\"content-type\"), r.url]);\n\n\nExpected result:\n\n\n    [200, \"audio/wav\", \".../gradio_api/file=sounds/disk.wav\"]\n\n\nThe exact content type may vary slightly, but it should be audio-like.\n\nBad signs:\n\n\n    [404, \"...\", \"...\"]\n\n\nor:\n\n\n    [200, \"text/html\", \"...\"]\n\n\n`200` with `text/html` usually means you fetched the Gradio app page, not the WAV file.\n\nRelevant docs:\n\n  * Gradio file access guide\n\n\n\n* * *\n\n## 12. Remove backend sleep\n\nIf you have:\n\n\n    DEBUG = True\n\n\nand later:\n\n\n    if DEBUG:\n        time.sleep(0.8)\n\n\nremove it or set:\n\n\n    DEBUG = False\n\n\nThe browser cannot play AI-move metadata until Python returns it. Waiting inside Python does not give the browser time to play anything.\n\nIf you want timing between sounds, do that in JavaScript after metadata arrives:\n\n\n    await AudioEngine.playMoveSequence(move.player, move.r, move.c, move.flips);\n    await new Promise(resolve => setTimeout(resolve, 300));\n\n\nThat is the right layer for audio timing.\n\n* * *\n\n## 13. Pin Gradio exactly while debugging\n\nYour Space metadata uses a specific Gradio SDK version, but the Python requirement is loose if it says something like:\n\n\n    gradio >= 6.13.0\n\n\nFor a custom-JS-heavy app, avoid loose Gradio versions while debugging.\n\nUse one of these approaches:\n\n\n    gradio==6.13.0\n\n\nor rely on the Space README `sdk_version` and remove the duplicate loose Gradio requirement.\n\nHugging Face Spaces are configured through the YAML block in `README.md`, including `sdk`, `sdk_version`, `python_version`, and `app_file`.\n\nRelevant docs:\n\n  * Hugging Face Spaces configuration reference\n  * Hugging Face Gradio Spaces docs\n\n\n\nWhy this matters:\n\nCustom CSS/JS is more sensitive to Gradio version changes than plain Python-only demos. If the DOM shape or event timing changes, a hidden-HTML/observer trick can break even though the Python code is unchanged.\n\n* * *\n\n## 14. Optional quick diagnostic: repair the `MutationObserver`\n\nI would not make this the final architecture, but it can confirm the diagnosis.\n\nIf you keep the observer temporarily, watch child replacement too:\n\n\n    function attachMetadataObserver() {\n      const container =\n        document.getElementById(\"move-metadata-container\") ||\n        document.getElementById(\"move-metadata\");\n\n      if (!container) {\n        console.warn(\"[META] Metadata container not found; retrying\");\n        setTimeout(attachMetadataObserver, 250);\n        return;\n      }\n\n      const processMetadata = () => {\n        const metaElem = document.getElementById(\"move-metadata\");\n        if (!metaElem) return;\n\n        const currentMeta = metaElem.getAttribute(\"data-payload\");\n\n        if (currentMeta && currentMeta !== window.__lastMeta) {\n          window.__lastMeta = currentMeta;\n          console.log(\"[OBSERVER] Metadata found; dispatching\");\n          handleMetadataUpdate(currentMeta);\n        }\n      };\n\n      const observer = new MutationObserver(() => {\n        processMetadata();\n      });\n\n      observer.observe(container, {\n        childList: true,\n        subtree: true,\n        attributes: true,\n        attributeFilter: [\"data-payload\"],\n      });\n\n      processMetadata();\n    }\n\n    attachMetadataObserver();\n\n\nThis watches both:\n\n  * children inserted/replaced inside the metadata container;\n  * `data-payload` attribute changes inside the subtree.\n\n\n\nAgain: this is a diagnostic or quick patch, not my preferred final solution. The cleaner fix is still `Textbox.change`.\n\nRelevant docs:\n\n  * MDN MutationObserver.observe()\n\n\n\n* * *\n\n## 15. Debug checklist for the Space\n\nOpen the browser console on the Space and run these in order.\n\n### A. Did custom JavaScript load?\n\n\n    !!window.AudioEngine\n\n\nExpected:\n\n\n    true\n\n\nAfter the patch:\n\n\n    !!window.handleReversiMetadata\n\n\nExpected:\n\n\n    true\n\n\n* * *\n\n### B. Can the Space serve a WAV file?\n\n\n    await fetch(\"/gradio_api/file=sounds/disk.wav\")\n      .then(r => [r.status, r.headers.get(\"content-type\"), r.url]);\n\n\nExpected:\n\n\n    [200, \"audio/wav\", \"...disk.wav\"]\n\n\nIf the content type is `text/html`, the path is wrong.\n\n* * *\n\n### C. Did sounds decode?\n\nAfter clicking “Enable sound” or making one move:\n\n\n    Object.keys(window.AudioEngine.buffers)\n\n\nExpected something like:\n\n\n    [\"disk.wav\", \"white.wav\", \"black.wav\", \"error.wav\", \"pass.wav\", \"border.wav\"]\n\n\n* * *\n\n### D. Is the audio context running?\n\n\n    window.AudioEngine.ctx?.state\n\n\nExpected after user interaction:\n\n\n    \"running\"\n\n\nIf it says:\n\n\n    \"suspended\"\n\n\nthen the browser has not unlocked audio yet.\n\n* * *\n\n### E. Does AI metadata arrive?\n\nAdd this temporarily to your Gradio `.change(..., js=...)` listener:\n\n\n    console.log(\"[GRADIO CHANGE] payload:\", payload);\n\n\nMake a move.\n\nExpected payload shape:\n\n\n    {\n      \"moves\": [\n        {\n          \"type\": \"move\",\n          \"player\": \"B\",\n          \"r\": 2,\n          \"c\": 3,\n          \"flips\": [[3, 3]]\n        },\n        {\n          \"type\": \"move\",\n          \"player\": \"W\",\n          \"r\": 2,\n          \"c\": 2,\n          \"flips\": [[3, 3]]\n        }\n      ],\n      \"ts\": 1760000000.123\n    }\n\n\nThe exact coordinates will differ.\n\n* * *\n\n### F. Does your handler skip only the optimistic human move?\n\nYour current `handleMetadataUpdate()` logic intentionally skips the human move that was already sounded locally, then plays the AI move.\n\nExpected log pattern:\n\n\n    [META] Processing 2 moves from metadata\n    [META] isOptimisticHumanMove: true\n    [META] Skipping optimistic human move sound\n    [META] isOptimisticHumanMove: false\n    [META] Playing move sound for W at (...)\n\n\nIf you get the metadata logs but no sound, the problem is the audio context or sound buffer.\n\nIf you never get metadata logs, the problem is the event bridge.\n\n* * *\n\n## 16. Accessibility notes\n\nYour accessibility direction is good.\n\nSound is useful for non-visual orientation, but sound should not be the only feedback channel. Keep three feedback layers:\n\nLayer | Purpose | Example\n---|---|---\nSound | fast spatial/game feedback | disk tone, flip tone, pass sound\nVisible status | sighted users and debugging | “White played C4 and flipped 2 disks”\nScreen reader text / live region | semantic non-visual feedback | “White played C4. Flipped 2 disks. Your turn.”\n\nA good normal move announcement is short:\n\n\n    White played C4. Flipped 2 disks. Your turn.\n\n\nA good invalid-move announcement is also short:\n\n\n    Invalid move at C4.\n\n\nKeep normal game updates polite rather than assertive so the screen reader does not interrupt too aggressively.\n\nUseful accessibility references:\n\n  * MDN ARIA live regions\n  * MDN aria-live\n  * W3C ARIA19 live-region error technique\n  * Sarah Higley on ARIA grids\n  * Accessible bingo web app\n  * Game Accessibility Guidelines\n\n\n\n* * *\n\n## 17. Why this was hard for AI agents to fix\n\nThis bug crosses several layers:\n\n\n    Python game state\n    + Gradio outputs\n    + hidden component rendering\n    + DOM mutation detection\n    + browser autoplay policy\n    + Web Audio context state\n    + optimistic local playback\n    + screen reader behavior\n\n\nA tool can check one layer and think everything is fine:\n\n  * “The AI move exists.”\n  * “The board updates.”\n  * “The WAV files exist.”\n  * “The browser can play a test sound.”\n  * “The metadata is generated.”\n  * “The observer exists.”\n\n\n\nAll of those can be true while the AI sound still fails.\n\nThe real question is more specific:\n\n> After Python returns the AI move, does the browser receive a reliable frontend event, and is the Web Audio context already running when that event fires?\n\nRight now, the likely answer is “not reliably.”\n\n* * *\n\n## 18. Recommended patch order\n\nDo this in order:\n\n  1. Replace hidden `gr.HTML` metadata with CSS-hidden `gr.Textbox`.\n  2. Return plain JSON metadata from `_build_ui_payload()`.\n  3. Attach `move_metadata_view.change(..., js=...)`.\n  4. Expose `window.handleReversiMetadata = handleMetadataUpdate`.\n  5. Add `AudioEngine.ensureRunning()`.\n  6. Make `AudioEngine.play()` return early if the context is not running.\n  7. Add an explicit “Enable sound” button.\n  8. Load WAV files through `/gradio_api/file=sounds/${sound}`.\n  9. Remove backend `time.sleep()`.\n  10. Pin Gradio exactly while debugging.\n\n\n\n* * *\n\n## 19. Final judgment\n\nThe core problem is probably not Reversi logic. It is the event bridge between Gradio’s Python return value and browser-side audio playback.\n\nYour current design says:\n\n\n    AI move happened\n    ↓\n    Python returns hidden HTML\n    ↓\n    JavaScript watches for one specific attribute mutation\n    ↓\n    sound plays if that exact mutation is observed\n\n\nThat is too brittle.\n\nUse this instead:\n\n\n    AI move happened\n    ↓\n    Python returns JSON metadata\n    ↓\n    Gradio Textbox value changes\n    ↓\n    Gradio .change JavaScript listener runs\n    ↓\n    browser-side audio handler plays the AI move sound\n\n\nThat makes every step explicit, inspectable, and testable.\n\nThe fix is not “try another sound file” or “sleep longer in Python.” The fix is to make the backend-to-frontend event channel robust and to unlock Web Audio intentionally from a user gesture.",
  "title": "Audio in huggingface space not working on AI moved a piece in a game, same for single gradio apps"
}