Skip to content

P4 panel: playback and capture cannot run together — both directions open I2S(0) #23

Closed

Description

@bdbarnett

Reported by the camera session while verifying an unrelated I2C change, and passed to me to judge whether it is a bug or intended arbitration. It is a real limitation, and it is not the audio session contract refusing.

Symptom

On the Waveshare P4 panel board: open the microphone, then call play(), and it raises. Play first and then open the microphone and it sometimes succeeds and sometimes does not.

Not the session contract

AudioSession arbitrates owners of one shared codec, and this board correctly gives each direction its own session, since it has two codecs:

_SESSION       = AudioSession(codec_factory=lambda: _codec(),       duplex=False)   # ES8311, playback
_INPUT_SESSION = AudioSession(codec_factory=lambda: _input_codec(), duplex=False)   # ES7210, capture

Two separate sessions, so acquire() never sees a second owner and never raises. The contract permits simultaneous play and record.

What actually blocks it

Both directions construct the same I2S peripheral, board_peripherals.py around lines 91 and 108:

return I2S(0, sck=Pin(_SCLK), ws=Pin(_LRCK), sd=Pin(_DSDIN),  mode=I2S.TX, ...)   # audio_out
return I2S(0, sck=Pin(_SCLK), ws=Pin(_LRCK), sd=Pin(_ASDOUT), mode=I2S.RX, ...)   # audio_in

machine.I2S takes a direction at construction and one id cannot be both, so the second open fails. The order-dependence and the intermittency are about whether the first instance has been released, not about arbitration.

The wiring is genuinely full duplex: the two codecs share the bit and word clocks and have separate data lines (_DSDIN into the codec, _ASDOUT out of it). So this is one peripheral that should be running duplex, not two peripherals.

Why it matters

Under design principle 1, a pairing the hardware permits that the design does not deliver is a defect in the design rather than a feature request. The board has a speaker and a microphone on one bus, the API exposes both, and the two cannot be used together. Anything wanting monitoring, a loopback, an effects path, or a voice interface hits this.

It also cost real time this week: an attempt to prove speaker output by acoustic loopback was inconclusive, and could not be run at all in the obvious way for this reason.

Options, none free

  • Duplex machine.I2S. Correct, and a port-level change: the ESP32 port would need to expose one id in both directions. Upstream question.
  • A native duplex path in audiodev's I2S backend, bypassing machine.I2S for boards whose codecs share a bus.
  • Document the exclusion on the board and in audiodev, so it fails with a clear message naming the cause instead of an OSError from the second open. This is the cheap half and should happen regardless of which of the above is chosen.

Third option first, in any case: today the failure names neither the peripheral nor the reason.

Activity

  1. bdbarnett commented on Sep 4, 2026

    @bdbarnett
    ContributorAuthor

    The failure is worse than the OSError, and MicroPython's own source says how. ports/esp32/machine_i2s.c:402, in the pinned v1.28.0:

    static machine_i2s_obj_t *mp_machine_i2s_make_new_instance(mp_int_t i2s_id) {
        ...
        if (MP_STATE_PORT(machine_i2s_obj)[i2s_id] == NULL) {
            self = mp_obj_malloc_with_finaliser(machine_i2s_obj_t, &machine_i2s_type);
            MP_STATE_PORT(machine_i2s_obj)[i2s_id] = self;
            self->i2s_id = i2s_id;
        } else {
            self = MP_STATE_PORT(machine_i2s_obj)[i2s_id];
            machine_i2s_deinit(self);          // <-- silently tears down the existing use
        }
        return self;
    }

    Constructing I2S(0) while I2S(0) is already in use does not raise. It deinitialises the existing instance and hands back the same object, reinitialised in the new direction. Both Python-side handles are the one object, so the caller's playback handle silently becomes a capture instance.

    That accounts for the reported asymmetry exactly:

    • Play, then open the microphone: the open succeeds, returns real samples, and stops playback. No error anywhere. A caller holding the output object sees no failure and hears nothing.
    • Open the microphone, then play: the playback path finds its peripheral changed underneath it and fails, which is the OSError that was seen.

    So the danger is not that one direction is refused. It is that the direction opened second wins by silently killing the first, and only the reverse order produces a diagnostic.

    This also supplies a third explanation for an acoustic loopback on this board reading rms 71.2 during a tone against 69.2 in silence: not that the microphone cannot hear the speaker, and not only that the capture path was unavailable, but that opening the capture is what stopped the tone. That test could not have worked in any ordering.

    Consequence for the options above: documenting the exclusion is no longer the cheap half, it is the urgent half, because the current behaviour is silent data loss rather than a refused call. Whatever else happens, audiodev should refuse the second direction itself, with a message naming the peripheral, rather than letting machine.I2S reassign it.

    Not run on hardware — this is read from the pinned source and matches both observed behaviours. The board test that would confirm it: start a tone, open the microphone, and check whether the tone stops.

  2. bdbarnett commented on Sep 4, 2026

    @bdbarnett
    ContributorAuthor

    Confirmed on hardware, by ear. 440 Hz tone playing, microphone opened at the three-second mark. By ear, the tone stopped exactly when the mic opened. In the same run the software reported:

    mic opened, read 8000 bytes, 3003 non-zero samples
    playing flag says: True
    (no exception anywhere)
    

    Three things that follow:

    1. The successful path returns real samples, so it is a live capture, not a stale buffer. It costs you playback.
    2. out.playing reports True across the reassignment. The flag outlives the hardware it describes, so a caller cannot detect this even afterwards. Whatever is done about refusing the second direction, that flag needs to learn it lost its peripheral.
    3. The asymmetry is confirmed: mic-then-play raises, play-then-mic succeeds silently. The order that gives you a diagnostic is the one nobody writes.

    So the fix is a design call between two places: audiodev refuses the second direction and reports which peripheral is held, or machine.I2S stops silently reassigning an occupied id. The first is ours and cheap; the second is upstream and correct. They are not exclusive.

  3. bdbarnett commented on Sep 4, 2026

    @bdbarnett
    ContributorAuthor

    Restating this as three stacked findings, because they have different owners and only one of them is ours.

    1. The board models one duplex peripheral as two devices. (ours) _output_stream and _input_stream both construct I2S(0) with the same sck=Pin(12) and ws=Pin(10), differing only in sd (9 out, 11 in) and mode. Two AudioSession(..., duplex=False) objects sit on top, so the session layer never sees a conflict either. Collapsing this to one audio device is the fix we can make — but see 2: it buys a loud refusal instead of silent data loss, not duplex. That is an honest exclusion, and worth having.

    2. machine.I2S cannot express duplex at all. (upstream) One sd pin, mode=TX or RX. The capability exists one layer down: i2s_new_channel(&cfg, &tx_handle, &rx_handle) takes both handles in a single call, and machine_i2s.c:366 calls it twice, passing NULL for the unused half. This board's wiring — shared bit and word clocks, separate data lines — is exactly what that call expects. A duplex mode taking sd_out/sd_in is upstream-shaped work, not a port hack.

    And the obvious workaround is closed too: machine_i2s.c:360 hardcodes I2S_ROLE_MASTER, so capturing on I2S(1) as a slave sharing the clocks is not reachable from Python either, despite the P4 having three I2S blocks. There is no Python-level path to duplex on this board today, by any arrangement.

    3. Silent reassignment on re-construct. (upstream, and rank it first) machine_i2s.c:402: occupied slot, machine_i2s_deinit(self), same object handed back, no error. This is what turns 1 from a refused call into audible data loss, and it is independent of duplex — any code that constructs I2S(n) twice silently steals a live peripheral and leaves the first handle reporting playing == True. Confirmed on hardware by ear.

    So: 1 is a board-config fix here, 2 is a feature request upstream, 3 is a plain upstream bug. 3 stands alone and is the one to report first; nobody has to want duplex to be bitten by it.

  4. added
    pin-moveBelongs to the post-1.29 retool project, not the housekeeping sweep
    on Sep 22, 2026
  5. bdbarnett commented on Sep 26, 2026

    @bdbarnett
    ContributorAuthor

    The pin move this was waiting on is done, so I rechecked it against 1.29. Nothing upstream changed. ports/esp32/machine_i2s.c at v1.29.0 still deinitialises a live I2S(0) when it's constructed again (mp_machine_i2s_make_new_instance, lines 424–431). It still opens one direction per instance, passing NULL for the other handle to i2s_new_channel (lines 378 and 380), and it still hardcodes I2S_ROLE_MASTER (line 372).

    Our half is #109: the board now refuses the second direction by name, so it no longer silently stops the first. It's tested with fakes both ways and not yet on the P4, which is busy with the casting session. Duplex itself is firmware work and is tracked in micropython-pydevices#5.

    One question for you: do you want an upstream MicroPython issue drafted for the silent takeover? That's finding 3 above, and it bites anyone who constructs I2S(n) twice, duplex or not. Under the current posture I'd draft it for you to post, as with the WebREPL Ctrl-C case.

  6. removed
    pin-moveBelongs to the post-1.29 retool project, not the housekeeping sweep
    on Sep 26, 2026
  7. bdbarnett commented on Oct 8, 2026

    @bdbarnett
    ContributorAuthor

    Checked on the P4 panel tonight (MicroPython 1.29, ESP32_GENERIC_P4 C6_WIFI).

    The refusal from #109 works on the board, both ways. With the speaker open and playing, pcm_in().open() raises OSError: I2S(0) is in use by speaker: this board's speaker and microphone share one I2S port, so close the speaker before opening the microphone, and the speaker keeps taking writes. With the microphone open, the first speaker write raises the mirror message. Nothing is silently taken over any more.

    The hardware can do both at once on one port. Through the audio pump's driver, _audioif.i2s_start(0, 12, 10, 9, 24000, din=11, mclk=13, channels=2) opens TX and RX as one channel pair, and _audioif.rt_probe played a quiet step on TX while RX captured 24000 bytes (250 ms) on the same clock, with no error.

    What's missing is a way to use that from Python. pcm_in() opens machine.I2S, which takes one direction per id. The pump's Input is an audiosample the pump pulls on its own thread, so Python can't read it as PCM. Recording while playing needs a PCMInput over the pump's RX: C work in the pump's driver to hand RX blocks to Python, plus a board path that opens the duplex pair for both roles. That's beyond a board-config change, so this stays open for it.

    Found on the way: the board's package didn't install es7210.py, so pcm_in() failed with ImportError on a board installed with mip. That's #147.

    The earlier question about drafting an upstream MicroPython issue for the silent takeover of a live I2S(n) is still open.

  8. bdbarnett commented on Oct 9, 2026

    @bdbarnett
    ContributorAuthor

    Recording while playing on one port is now two PRs, both waiting for review: PyDevices/audioif#25 lets the pump's driver hand its RX half to Python and lets an output and a recorder share the channel, and #153 makes pcm_in() on both P4 boards read from that channel. On the panel, a tone played through audio_out() came back through pcm_in() 78 dB above the noise, with no dropouts. The measurements are in audioif#25. pcm_out is still machine.I2S, so it still refuses to open alongside a recording.

  9. bdbarnett commented on Oct 9, 2026

    @bdbarnett
    ContributorAuthor

    Playback and capture now run together on one I2S port on both ESP32-P4 boards: audioif#25 and #153. On the P4 panel a 1 kHz tone played through audio_out() came back through pcm_in() 78 dB above the spectrum's median, with no lost frames. On the ESP32-P4-WIFI6-DEV-KIT it came back 58 dB above the noise, falling to the noise floor with the amplifier off, with no dips and no dropped frames. The ES8311's left slot (take="left") is the right one there. Still open separately: pcm_out (machine.I2S) can't play alongside a recording; routing it through the pump would fix that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions