diff --git a/abi/snapshot.json b/abi/snapshot.json index 2d2c913d6e..8eb570e683 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -1,5 +1,5 @@ { - "abi_version": 39, + "abi_version": 40, "channel_buffers": { "data_offset": 72, "data_size": 65536, @@ -113,7 +113,7 @@ }, "host_adapter": { "manifest": { - "abi_version": 39, + "abi_version": 40, "channel_data_offset": 72, "channel_data_size": 65536, "channel_header_size": 72, @@ -204,6 +204,11 @@ "kernel_host_adapter_manifest_len", "kernel_host_adapter_manifest_ptr", "kernel_mark_process_signaled", + "kernel_pcm_claim_transport", + "kernel_pcm_clock_update", + "kernel_pcm_reconcile", + "kernel_pcm_transport_len", + "kernel_pcm_transport_ptr", "kernel_pipe_has_readers", "kernel_posix_timer_fire", "kernel_prepare_write_operation", @@ -1076,6 +1081,31 @@ "name": "kernel_pause", "signature": "() -> (i32)" }, + { + "kind": "func", + "name": "kernel_pcm_claim_transport", + "signature": "(i32) -> (i32)" + }, + { + "kind": "func", + "name": "kernel_pcm_clock_update", + "signature": "(i32) -> (i32)" + }, + { + "kind": "func", + "name": "kernel_pcm_reconcile", + "signature": "() -> (i32)" + }, + { + "kind": "func", + "name": "kernel_pcm_transport_len", + "signature": "() -> (i32)" + }, + { + "kind": "func", + "name": "kernel_pcm_transport_ptr", + "signature": "() -> (i32)" + }, { "kind": "func", "name": "kernel_pick_signal_target_tid", @@ -1722,6 +1752,60 @@ } ], "marshalled_structs": { + "AudioBufInfo": { + "align": 4, + "fields": [ + { + "name": "fragments", + "offset": 0, + "span": 4, + "type": "i32" + }, + { + "name": "fragstotal", + "offset": 4, + "span": 4, + "type": "i32" + }, + { + "name": "fragsize", + "offset": 8, + "span": 4, + "type": "i32" + }, + { + "name": "bytes", + "offset": 12, + "span": 4, + "type": "i32" + } + ], + "size": 16 + }, + "CountInfo": { + "align": 4, + "fields": [ + { + "name": "bytes", + "offset": 0, + "span": 4, + "type": "i32" + }, + { + "name": "blocks", + "offset": 4, + "span": 4, + "type": "i32" + }, + { + "name": "ptr", + "offset": 8, + "span": 4, + "type": "i32" + } + ], + "size": 12 + }, "FbBitfield": { "fields": [ { @@ -2107,6 +2191,174 @@ ], "size": 160 }, + "PcmSharedControl": { + "align": 4, + "fields": [ + { + "name": "magic", + "offset": 0, + "span": 4, + "type": "u32" + }, + { + "name": "version", + "offset": 4, + "span": 4, + "type": "u32" + }, + { + "name": "header_bytes", + "offset": 8, + "span": 4, + "type": "u32" + }, + { + "name": "physical_capacity_bytes", + "offset": 12, + "span": 4, + "type": "u32" + }, + { + "name": "active_capacity_bytes", + "offset": 16, + "span": 4, + "type": "u32" + }, + { + "name": "format", + "offset": 20, + "span": 4, + "type": "u32" + }, + { + "name": "rate", + "offset": 24, + "span": 4, + "type": "u32" + }, + { + "name": "channels", + "offset": 28, + "span": 4, + "type": "u32" + }, + { + "name": "frame_bytes", + "offset": 32, + "span": 4, + "type": "u32" + }, + { + "name": "fragment_bytes", + "offset": 36, + "span": 4, + "type": "u32" + }, + { + "name": "fragment_count", + "offset": 40, + "span": 4, + "type": "u32" + }, + { + "name": "state", + "offset": 44, + "span": 4, + "type": "u32" + }, + { + "name": "generation", + "offset": 48, + "span": 4, + "type": "u32" + }, + { + "name": "flags", + "offset": 52, + "span": 4, + "type": "u32" + }, + { + "name": "transport_mode", + "offset": 56, + "span": 4, + "type": "u32" + }, + { + "name": "producer_seq", + "offset": 60, + "span": 4, + "type": "u32" + }, + { + "name": "producer_lo", + "offset": 64, + "span": 4, + "type": "u32" + }, + { + "name": "producer_hi", + "offset": 68, + "span": 4, + "type": "u32" + }, + { + "name": "consumer_seq", + "offset": 72, + "span": 4, + "type": "u32" + }, + { + "name": "consumer_lo", + "offset": 76, + "span": 4, + "type": "u32" + }, + { + "name": "consumer_hi", + "offset": 80, + "span": 4, + "type": "u32" + }, + { + "name": "discard_seq", + "offset": 84, + "span": 4, + "type": "u32" + }, + { + "name": "discard_lo", + "offset": 88, + "span": 4, + "type": "u32" + }, + { + "name": "discard_hi", + "offset": 92, + "span": 4, + "type": "u32" + }, + { + "name": "underruns", + "offset": 96, + "span": 4, + "type": "u32" + }, + { + "name": "wake_seq", + "offset": 100, + "span": 4, + "type": "u32" + }, + { + "name": "reserved", + "offset": 104, + "span": 24, + "type": "[u32; 6]" + } + ], + "size": 128 + }, "WasmDirent": { "fields": [ { @@ -3133,6 +3385,83 @@ "size": 16 } }, + "oss_source_abi": { + "capabilities": { + "PCM_CAP_BATCH": 1024, + "PCM_CAP_BIND": 32768, + "PCM_CAP_COPROC": 2048, + "PCM_CAP_DEFAULT": 1073741824, + "PCM_CAP_DUPLEX": 256, + "PCM_CAP_INPUT": 65536, + "PCM_CAP_MMAP": 8192, + "PCM_CAP_MULTI": 16384, + "PCM_CAP_OUTPUT": 131072, + "PCM_CAP_REALTIME": 512, + "PCM_CAP_REVISION": 255, + "PCM_CAP_TRIGGER": 4096, + "PCM_CAP_VIRTUAL": 262144 + }, + "formats": { + "AFMT_AC3": 1024, + "AFMT_A_LAW": 2, + "AFMT_F32_BE": 536870912, + "AFMT_F32_LE": 268435456, + "AFMT_IMA_ADPCM": 4, + "AFMT_MPEG": 512, + "AFMT_MU_LAW": 1, + "AFMT_QUERY": 0, + "AFMT_S16_BE": 32, + "AFMT_S16_LE": 16, + "AFMT_S24_BE": 131072, + "AFMT_S24_LE": 65536, + "AFMT_S32_BE": 8192, + "AFMT_S32_LE": 4096, + "AFMT_S8": 64, + "AFMT_U16_BE": 256, + "AFMT_U16_LE": 128, + "AFMT_U24_BE": 524288, + "AFMT_U24_LE": 262144, + "AFMT_U32_BE": 32768, + "AFMT_U32_LE": 16384, + "AFMT_U8": 8 + }, + "ioctls": { + "SNDCTL_DSP_CHANNELS": 3221508102, + "SNDCTL_DSP_GETBLKSIZE": 3221508100, + "SNDCTL_DSP_GETCAPS": 2147766287, + "SNDCTL_DSP_GETFMTS": 2147766283, + "SNDCTL_DSP_GETIPTR": 2148290577, + "SNDCTL_DSP_GETISPACE": 2148552717, + "SNDCTL_DSP_GETODELAY": 2147766295, + "SNDCTL_DSP_GETOPTR": 2148290578, + "SNDCTL_DSP_GETOSPACE": 2148552716, + "SNDCTL_DSP_GETTRIGGER": 2147766288, + "SNDCTL_DSP_MAPINBUF": 2148028435, + "SNDCTL_DSP_MAPOUTBUF": 2148028436, + "SNDCTL_DSP_NONBLOCK": 20494, + "SNDCTL_DSP_POST": 20488, + "SNDCTL_DSP_RESET": 20480, + "SNDCTL_DSP_SETBLKSIZE": 1074024452, + "SNDCTL_DSP_SETDUPLEX": 20502, + "SNDCTL_DSP_SETFMT": 3221508101, + "SNDCTL_DSP_SETFRAGMENT": 3221508106, + "SNDCTL_DSP_SETSYNCRO": 20501, + "SNDCTL_DSP_SETTRIGGER": 1074024464, + "SNDCTL_DSP_SPEED": 3221508098, + "SNDCTL_DSP_STEREO": 3221508099, + "SNDCTL_DSP_SUBDIVIDE": 3221508105, + "SNDCTL_DSP_SYNC": 20481, + "SOUND_PCM_READ_BITS": 2147766277, + "SOUND_PCM_READ_CHANNELS": 2147766278, + "SOUND_PCM_READ_FILTER": 2147766279, + "SOUND_PCM_READ_RATE": 2147766274, + "SOUND_PCM_WRITE_FILTER": 3221508103 + }, + "trigger_values": { + "PCM_ENABLE_INPUT": 1, + "PCM_ENABLE_OUTPUT": 2 + } + }, "pathconf_names": { "ALLOC_SIZE_MIN": 18, "ASYNC_IO": 10, @@ -3159,6 +3488,27 @@ "TIMESTAMP_RESOLUTION": 23, "VDISABLE": 8 }, + "pcm_transport_abi": { + "flag_configuring": 1, + "flag_fatal_error": 4, + "flag_underrun_active": 2, + "format_s16_be": 3, + "format_s16_le": 2, + "format_u8": 1, + "format_unknown": 0, + "header_bytes": 128, + "magic": 827147088, + "ring_bytes": 65536, + "state_closed": 0, + "state_draining": 3, + "state_running": 2, + "state_stopped": 1, + "total_bytes": 65664, + "transport_legacy_pull": 1, + "transport_shared_clock": 2, + "transport_unclaimed": 0, + "version": 1 + }, "process_expected_globals": [ "__channel_base", "__tls_base" @@ -4590,9 +4940,10 @@ { "argIndex": 2, "direction": "inout", + "nullable": true, "size": { - "size": 256, - "type": "fixed" + "requestArgIndex": 1, + "type": "ioctl" } } ], diff --git a/apps/browser-demos/pages/kandelo/app/App.tsx b/apps/browser-demos/pages/kandelo/app/App.tsx index 8787dc65d8..c0617e7e1b 100644 --- a/apps/browser-demos/pages/kandelo/app/App.tsx +++ b/apps/browser-demos/pages/kandelo/app/App.tsx @@ -14,6 +14,7 @@ import type { BootDescriptor, GalleryItem, LazyDownloadEvent, + MachineAudioState, } from "../../../../../web-libs/kandelo-session/src/kernel-host"; type InternalsTab = "syslog" | "procs" | "vfs" | "lazy-load" | "config" | "syscalls"; @@ -70,12 +71,41 @@ export const App: React.FC = () => { const [themeOpen, setThemeOpen] = React.useState(false); const [terminals, setTerminals] = React.useState(() => [createShellTerminal(1)]); const [activeTerminalId, setActiveTerminalId] = React.useState("tty-1"); + const [audioState, setAudioState] = React.useState(() => host.getAudioState()); + const [audioError, setAudioError] = React.useState(null); const nextTerminalIndex = React.useRef(2); const autoOpenedDemoGuideKey = React.useRef(null); const desc = host.getBootDescriptor(); const resolvedThemeMode = theme.mode === "auto" ? systemThemeMode : theme.mode; + React.useEffect( + () => host.subscribeAudioState((state) => { + setAudioState(state); + if (state === "running") setAudioError(null); + }), + [host], + ); + + const activateAudio = React.useCallback(() => { + if (host.getAudioState() === "running") return; + void host.resumeAudio().then( + () => setAudioError(null), + (error) => setAudioError(error instanceof Error ? error.message : String(error)), + ); + }, [host]); + + // Web Audio starts only after a trusted gesture. Keep activation at the + // machine shell so terminal-only SDL applications use the same PCM sink. + React.useEffect(() => { + window.addEventListener("pointerdown", activateAudio, { capture: true }); + window.addEventListener("keydown", activateAudio, { capture: true }); + return () => { + window.removeEventListener("pointerdown", activateAudio, { capture: true }); + window.removeEventListener("keydown", activateAudio, { capture: true }); + }; + }, [activateAudio]); + React.useEffect(() => { const query = window.matchMedia("(prefers-color-scheme: dark)"); const onChange = () => setSystemThemeMode(query.matches ? "dark" : "light"); @@ -228,7 +258,7 @@ export const App: React.FC = () => { }, []); return ( -
+
{isEmpty ? ( { )} + {surface.status === "running" && audioState !== "running" && ( + + )} { ); }; +const AudioStatusToast: React.FC<{ + state: MachineAudioState; + error: string | null; + onEnable: () => void; +}> = ({ state, error, onEnable }) => { + const detail = error ?? ( + state === "interrupted" + ? "Audio output was interrupted by the browser or operating system." + : state === "unavailable" + ? "This browser does not provide the required Web Audio output." + : state === "error" + ? "The browser audio sink could not be started." + : "Browser policy pauses audio until you interact with this machine." + ); + return ( + + ); +}; + const TerminalDockControls: React.FC<{ terminals: ShellTerminal[]; activeTerminalId: string; diff --git a/apps/browser-demos/pages/kandelo/panes/Framebuffer.tsx b/apps/browser-demos/pages/kandelo/panes/Framebuffer.tsx index 7e4806ce49..43d0741d77 100644 --- a/apps/browser-demos/pages/kandelo/panes/Framebuffer.tsx +++ b/apps/browser-demos/pages/kandelo/panes/Framebuffer.tsx @@ -1,6 +1,6 @@ // Framebuffer pane — paints whatever process is bound to /dev/fb0, forwards // focused keyboard input as Linux input keycodes encoded in MEDIUMRAW, forwards -// pointer-lock mouse input to /dev/input/mice, and drains /dev/dsp audio. +// pointer-lock mouse input to /dev/input/mice. PCM output is machine-level. // // Painting: host.attachFramebuffer(canvas) returns a FramebufferHandle; the // host owns the requestAnimationFrame loop and BGRA→RGBA swizzle (see @@ -21,10 +21,7 @@ import { attachPointerLockMouse, type PointerLockMouseHandle, } from "../../../../../host/src/framebuffer/browser-controls"; -import type { - AudioOutputHandle, - FramebufferHandle, -} from "../../../../../web-libs/kandelo-session/src/kernel-host"; +import type { FramebufferHandle } from "../../../../../web-libs/kandelo-session/src/kernel-host"; import { useFittedCanvasStyle } from "./canvasFit"; export interface FramebufferProps { @@ -43,7 +40,6 @@ export const Framebuffer: React.FC = ({ autoFocus = false, onD const canvasRef = React.useRef(null); const handleRef = React.useRef(null); const mouseRef = React.useRef(null); - const audioRef = React.useRef(null); const [error, setError] = React.useState(null); const [boundPid, setBoundPid] = React.useState(null); const [focused, setFocused] = React.useState(false); @@ -55,27 +51,16 @@ export const Framebuffer: React.FC = ({ autoFocus = false, onD let handle: FramebufferHandle | null = null; let offBound: (() => void) | null = null; - let cancelled = false; try { handle = host.attachFramebuffer(canvasRef.current); handleRef.current = handle; setBoundPid(handle.getBoundPid()); offBound = handle.onBoundPidChange(setBoundPid); setError(null); - void handle.startAudio().then((audio) => { - if (cancelled) { - audio?.close(); - return; - } - audioRef.current = audio; - }); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } return () => { - cancelled = true; - try { audioRef.current?.close(); } catch { /* noop */ } - audioRef.current = null; try { offBound?.(); } catch { /* noop */ } try { handle?.close(); } catch { /* noop */ } handleRef.current = null; @@ -151,7 +136,7 @@ export const Framebuffer: React.FC = ({ autoFocus = false, onD const onCanvasClick = () => { canvasRef.current?.focus(); - void audioRef.current?.resume(); + void host.resumeAudio().catch(() => {}); mouseRef.current?.requestCapture(); }; diff --git a/apps/browser-demos/pages/kandelo/presets.ts b/apps/browser-demos/pages/kandelo/presets.ts index 421df2efc9..776c142df8 100644 --- a/apps/browser-demos/pages/kandelo/presets.ts +++ b/apps/browser-demos/pages/kandelo/presets.ts @@ -109,7 +109,7 @@ export const PRESET_LIBRARY: Preset[] = [ { id: "doom", title: "fbDOOM", - summary: "id Software's DOOM rendering directly to /dev/fb0.", + summary: "DOOM on /dev/fb0 with OSS audio through /dev/dsp.", base: SHELL_BASE, packages: ["fbdoom@local", "doom-shareware@local", "bash@local", "coreutils@local"], accent: "#b5301c", diff --git a/apps/browser-demos/pages/kandelo/styles.css b/apps/browser-demos/pages/kandelo/styles.css index 4d0f45c938..0657ed6ebb 100644 --- a/apps/browser-demos/pages/kandelo/styles.css +++ b/apps/browser-demos/pages/kandelo/styles.css @@ -1228,6 +1228,25 @@ animation: kdownload-toast-fade-error 5s ease-out forwards; } +.kpcm-audio-status { + top: auto; + bottom: calc(var(--kdock-height, 0px) + 14px); +} + +.kpcm-audio-error { + border-color: color-mix(in oklch, var(--k-err) 52%, var(--k-border)); +} + +.kpcm-audio-enable { + margin-left: auto; + border: 1px solid color-mix(in oklch, var(--k-accent) 42%, var(--k-border)); + border-radius: 6px; + background: color-mix(in oklch, var(--k-accent) 12%, var(--k-surface)); + color: var(--k-accent); + cursor: pointer; + padding: 3px 8px; +} + .kdownload-toast-top { display: flex; align-items: center; diff --git a/apps/browser-demos/pages/kandelo/views/Config.tsx b/apps/browser-demos/pages/kandelo/views/Config.tsx index 22365a0874..d56b710a0e 100644 --- a/apps/browser-demos/pages/kandelo/views/Config.tsx +++ b/apps/browser-demos/pages/kandelo/views/Config.tsx @@ -367,7 +367,6 @@ const CAPS: ReadonlyArray
Loading kernel...
+
+ + Audio test idle +
diff --git a/apps/browser-demos/pages/test-runner/main.ts b/apps/browser-demos/pages/test-runner/main.ts index 4babb8d7e3..446dbf590c 100644 --- a/apps/browser-demos/pages/test-runner/main.ts +++ b/apps/browser-demos/pages/test-runner/main.ts @@ -6,6 +6,15 @@ * and returns { exitCode, stdout, stderr }. */ import { BrowserKernel } from "@host/browser-kernel-host"; +import pcmAudioWorkletUrl from "@host/audio/pcm-audio-worklet.js?url"; +import { + pcmControlWords, + readConsumerPosition, + readDiscardPosition, + readPcmConfig, + readProducerPosition, + type PcmTransportDescriptor, +} from "@host/audio/pcm-transport"; import { createBuildFsWithEtc, finalizeKernelOwnedImage, @@ -17,6 +26,7 @@ import coreutilsWasmUrl from "@binaries/programs/wasm32/coreutils.wasm?url"; import grepWasmUrl from "@binaries/programs/wasm32/grep.wasm?url"; import sedWasmUrl from "@binaries/programs/wasm32/sed.wasm?url"; import genCatWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/gencat.wasm?url"; +import audioTestWasmUrl from "@binaries/programs/wasm32/audiotest.wasm?url"; interface DataFile { path: string; @@ -24,9 +34,50 @@ interface DataFile { useWasmBytes?: boolean; // if true, use the wasmBytes as file content } +interface AudioTestSnapshot { + audioState: ReturnType; + audioStates: ReturnType[]; + workletAssetUrl: string; + workletPrepared: boolean; + producerBytes: number; + consumerBytes: number; + discardBytes: number; + queuedBytes: number; + activeCapacityBytes: number; + settled: boolean; + resumeAttempts: number; + trustedResumeAttempts: number; + lastResumeError: string | null; + stdout: string; + stderr: string; + hostDiagnostics: string[]; +} + +interface AudioTestResult extends AudioTestSnapshot { + exitCode: number; + elapsedMs: number; +} + +interface AudioTestSession { + kernel: BrowserKernel; + transport: PcmTransportDescriptor; + stdout: string; + stderr: string; + hostDiagnostics: string[]; + audioStates: ReturnType[]; + workletPrepared: boolean; + settled: boolean; + resumeAttempts: number; + trustedResumeAttempts: number; + lastResumeError: string | null; + result?: Promise; + unsubscribeAudioState?: () => void; +} + declare global { interface Window { __testRunnerReady: boolean; + __audioTestProgramUrl: string; __runTest: ( wasmBytes: ArrayBuffer, argv?: string[], @@ -39,6 +90,20 @@ declare global { combined: string; }>; __testCount: number; + /** + * Start one real-browser `/dev/dsp` run with the AudioContext deliberately + * suspended. The guest is allowed to fill the bounded PCM ring and block + * in close; `#resume-audio` is the only path that resumes the audio clock. + */ + __prepareAudioTest: ( + wasmBytes: ArrayBuffer, + argv?: string[], + timeoutMs?: number, + ) => Promise; + __audioTestSnapshot: () => AudioTestSnapshot; + __waitForAudioTest: () => Promise; + __suspendAudioTest: () => Promise; + __finishAudioTest: () => Promise; } } @@ -49,6 +114,7 @@ let coreutilsBytes: ArrayBuffer | null = null; let grepBytes: ArrayBuffer | null = null; let sedBytes: ArrayBuffer | null = null; let genCatBytes: ArrayBuffer | null = null; +let activeAudioTest: AudioTestSession | null = null; const COREUTILS_NAMES = [ "arch", "b2sum", "base32", "base64", "basename", "basenc", "cat", @@ -68,7 +134,11 @@ const COREUTILS_NAMES = [ ]; /** Write a binary file to the virtual filesystem. */ -function writeFileToFs(fs: import("@host/browser-kernel-host").BrowserKernel["fs"], path: string, data: ArrayBuffer): void { +function writeFileToFs( + fs: import("@host/vfs/memory-fs").MemoryFileSystem, + path: string, + data: ArrayBuffer, +): void { const bytes = new Uint8Array(data); const fd = fs.open(path, 0x241 /* O_WRONLY|O_CREAT|O_TRUNC */, 0o755); fs.write(fd, bytes, null, bytes.length); @@ -118,6 +188,120 @@ function populateExecBinaries(fs: import("@host/vfs/memory-fs").MemoryFileSystem } } +function audioTransportFor(kernel: BrowserKernel): PcmTransportDescriptor { + // The transport is intentionally not part of BrowserKernel's public app + // API. This test-only page inspects it to prove that the production + // AudioWorklet, rather than a main-thread timer or legacy pull drain, + // advances the consumer clock. + const transport = ( + kernel as unknown as { pcmTransport: PcmTransportDescriptor | null } + ).pcmTransport; + if (!transport) { + throw new Error("PCM transport was not published by the kernel worker"); + } + return transport; +} + +function safeCursorNumber(value: bigint, label: string): number { + const number = Number(value); + if (!Number.isSafeInteger(number)) { + throw new Error(`${label} PCM cursor is outside JavaScript's safe integer range`); + } + return number; +} + +function snapshotAudioTest(session = activeAudioTest): AudioTestSnapshot { + if (!session) throw new Error("No browser audio test is active"); + const words = pcmControlWords(session.transport); + const producer = readProducerPosition(words); + const consumer = readConsumerPosition(words); + const discard = readDiscardPosition(words); + const effectiveConsumer = consumer > discard ? consumer : discard; + const config = readPcmConfig(words); + return { + audioState: session.kernel.getAudioState(), + audioStates: session.audioStates.slice(), + workletAssetUrl: pcmAudioWorkletUrl, + workletPrepared: session.workletPrepared, + producerBytes: safeCursorNumber(producer, "producer"), + consumerBytes: safeCursorNumber(consumer, "consumer"), + discardBytes: safeCursorNumber(discard, "discard"), + queuedBytes: safeCursorNumber( + producer > effectiveConsumer ? producer - effectiveConsumer : 0n, + "queued", + ), + activeCapacityBytes: config.activeCapacityBytes, + settled: session.settled, + resumeAttempts: session.resumeAttempts, + trustedResumeAttempts: session.trustedResumeAttempts, + lastResumeError: session.lastResumeError, + stdout: session.stdout, + stderr: session.stderr, + hostDiagnostics: session.hostDiagnostics.slice(), + }; +} + +function withTimeout( + promise: Promise, + timeoutMs: number, + label: string, +): Promise { + return new Promise((resolve, reject) => { + const timer = window.setTimeout( + () => reject(new Error(`${label} timed out after ${timeoutMs} ms`)), + timeoutMs, + ); + promise.then( + (value) => { + window.clearTimeout(timer); + resolve(value); + }, + (error) => { + window.clearTimeout(timer); + reject(error); + }, + ); + }); +} + +async function finishAudioTest(): Promise { + const session = activeAudioTest; + activeAudioTest = null; + const resumeButton = document.getElementById("resume-audio") as HTMLButtonElement; + resumeButton.disabled = true; + document.getElementById("audio-status")!.textContent = "Audio test idle"; + if (!session) return; + session.unsubscribeAudioState?.(); + await session.kernel.destroy().catch(() => {}); + await settleWebKitReclaim(); +} + +function installAudioResumeButton(): void { + const resumeButton = document.getElementById("resume-audio") as HTMLButtonElement; + resumeButton.addEventListener("click", (event) => { + const session = activeAudioTest; + if (!session) return; + session.resumeAttempts++; + if (event.isTrusted && navigator.userActivation?.isActive) { + session.trustedResumeAttempts++; + } + session.lastResumeError = null; + resumeButton.disabled = true; + document.getElementById("audio-status")!.textContent = "Resuming audio..."; + void session.kernel.resumeAudio().then( + () => { + document.getElementById("audio-status")!.textContent = "Audio running"; + }, + (error) => { + session.lastResumeError = error instanceof Error ? error.message : String(error); + document.getElementById("audio-status")!.textContent = + `Audio resume failed: ${session.lastResumeError}`; + resumeButton.disabled = false; + }, + ); + }); +} + async function init() { // Fetch kernel wasm and tool binaries in parallel const fetches = await Promise.allSettled([ @@ -140,6 +324,108 @@ async function init() { } window.__testCount = 0; + window.__audioTestProgramUrl = audioTestWasmUrl; + + installAudioResumeButton(); + + window.__prepareAudioTest = async ( + wasmBytes: ArrayBuffer, + argv = ["audiotest"], + timeoutMs = 30_000, + ) => { + await finishAudioTest(); + + const buildFs = await createBuildFsWithEtc(); + const vfsImage = await finalizeKernelOwnedImage(buildFs); + let session: AudioTestSession | null = null; + const decoder = new TextDecoder(); + const hostDiagnostics: string[] = []; + const kernel = new BrowserKernel({ + kernelOwnedFs: true, + onStdout: (data: Uint8Array) => { + if (session) session.stdout += decoder.decode(data); + }, + onStderr: (data: Uint8Array) => { + if (session) session.stderr += decoder.decode(data); + }, + onHostDiagnostic: (diagnostic) => { + hostDiagnostics.push(`${diagnostic.source}: ${diagnostic.message}`); + }, + }); + + try { + await kernel.initFromImage({ kernelWasm: kernelWasmBytes!, vfsImage }); + session = { + kernel, + transport: audioTransportFor(kernel), + stdout: "", + stderr: "", + hostDiagnostics, + audioStates: [], + workletPrepared: false, + settled: false, + resumeAttempts: 0, + trustedResumeAttempts: 0, + lastResumeError: null, + }; + activeAudioTest = session; + session.unsubscribeAudioState = kernel.onAudioStateChange((state) => { + if (session && session.audioStates.at(-1) !== state) { + session.audioStates.push(state); + } + }); + + // Loading the default worklet URL is part of preparation. Force a + // suspended starting point even in browsers whose autoplay policy lets + // a newly-created context run, then queue guest PCM behind that clock. + await kernel.prepareAudio(); + session.workletPrepared = true; + await kernel.suspendAudio(); + const startedAt = performance.now(); + session.result = withTimeout( + kernel.spawn(wasmBytes, argv, { env: ["SDL_AUDIODRIVER=dsp"] }), + timeoutMs, + "browser /dev/dsp guest", + ).then((exitCode) => { + if (!session) throw new Error("Browser audio session disappeared"); + session.settled = true; + return { + ...snapshotAudioTest(session), + exitCode, + elapsedMs: performance.now() - startedAt, + }; + }); + + const resumeButton = document.getElementById( + "resume-audio", + ) as HTMLButtonElement; + resumeButton.disabled = false; + document.getElementById("audio-status")!.textContent = "Audio suspended; PCM may queue"; + return snapshotAudioTest(session); + } catch (error) { + if (activeAudioTest === session) activeAudioTest = null; + session?.unsubscribeAudioState?.(); + await kernel.destroy().catch(() => {}); + throw error; + } + }; + + window.__audioTestSnapshot = () => snapshotAudioTest(); + window.__waitForAudioTest = async () => { + const result = activeAudioTest?.result; + if (!result) throw new Error("Browser audio test has not started"); + return result; + }; + window.__suspendAudioTest = async () => { + const session = activeAudioTest; + if (!session) throw new Error("No browser audio test is active"); + await session.kernel.suspendAudio(); + const resumeButton = document.getElementById("resume-audio") as HTMLButtonElement; + resumeButton.disabled = false; + document.getElementById("audio-status")!.textContent = "Audio suspended"; + return snapshotAudioTest(session); + }; + window.__finishAudioTest = finishAudioTest; window.__runTest = async ( wasmBytes: ArrayBuffer, diff --git a/apps/browser-demos/test/dsp-audio-worklet.spec.ts b/apps/browser-demos/test/dsp-audio-worklet.spec.ts new file mode 100644 index 0000000000..0601a6d59a --- /dev/null +++ b/apps/browser-demos/test/dsp-audio-worklet.spec.ts @@ -0,0 +1,154 @@ +import { expect, test } from "@playwright/test"; + +type AudioSnapshot = { + audioState: string; + audioStates: string[]; + workletAssetUrl: string; + workletPrepared: boolean; + producerBytes: number; + consumerBytes: number; + discardBytes: number; + queuedBytes: number; + activeCapacityBytes: number; + settled: boolean; + resumeAttempts: number; + trustedResumeAttempts: number; + lastResumeError: string | null; + stdout: string; + stderr: string; + hostDiagnostics: string[]; +}; + +type AudioResult = AudioSnapshot & { + exitCode: number; + elapsedMs: number; +}; + +test("the production AudioWorklet drains /dev/dsp after a trusted resume", async ({ + page, + baseURL, + browserName, +}) => { + test.skip(browserName !== "chromium", "the aggregate browser gate uses Chromium"); + expect(baseURL).toBeTruthy(); + + const runtimeErrors: string[] = []; + page.on("pageerror", (error) => { + runtimeErrors.push(`pageerror: ${error.message}`); + }); + page.on("console", (message) => { + if (message.type() === "error") { + runtimeErrors.push(`console: ${message.text()}`); + } + }); + + await page.goto(new URL("/pages/test-runner/", baseURL).href); + await page.waitForFunction(() => (window as any).__testRunnerReady === true); + + try { + const programUrl = await page.evaluate( + () => (window as any).__audioTestProgramUrl as string, + ); + const initial = await page.evaluate(async ({ programUrl }): Promise => { + const response = await fetch(programUrl); + if (!response.ok) { + throw new Error(`audiotest fetch failed: ${response.status} ${response.url}`); + } + return (window as any).__prepareAudioTest( + await response.arrayBuffer(), + ["audiotest"], + 30_000, + ); + }, { programUrl }); + + expect(initial.workletPrepared).toBe(true); + expect(initial.audioState).toBe("suspended"); + expect(initial.activeCapacityBytes).toBeGreaterThan(0); + const workletAsset = await page.evaluate(async (url) => { + const response = await fetch(url, { cache: "no-store" }); + return { + status: response.status, + url: response.url, + source: await response.text(), + }; + }, initial.workletAssetUrl); + expect(workletAsset.status, workletAsset.url).toBe(200); + expect(workletAsset.source).toContain("kandelo-pcm-output"); + expect(workletAsset.source).toContain("registerProcessor"); + + // The guest writes its deterministic buffer, then close(SYNC) must remain + // blocked while the AudioContext clock is suspended. No timer-based or + // instantaneous host drain is allowed to move the consumer cursor. + await expect.poll( + () => page.evaluate((): AudioSnapshot => (window as any).__audioTestSnapshot()), + { timeout: 15_000 }, + ).toMatchObject({ + audioState: "suspended", + settled: false, + stdout: expect.stringContaining("wrote 256"), + }); + const queued = await page.evaluate( + (): AudioSnapshot => (window as any).__audioTestSnapshot(), + ); + expect(queued.producerBytes).toBeGreaterThan(queued.consumerBytes); + expect(queued.queuedBytes).toBe(256); + + await page.waitForTimeout(250); + const held = await page.evaluate( + (): AudioSnapshot => (window as any).__audioTestSnapshot(), + ); + expect(held.consumerBytes).toBe(queued.consumerBytes); + expect(held.queuedBytes).toBe(queued.queuedBytes); + expect(held.settled).toBe(false); + + // Playwright's physical click dispatches a trusted event. The page's click + // handler calls BrowserKernel.resumeAudio() directly in that activation. + await page.getByRole("button", { name: "Resume audio" }).click(); + await expect.poll( + () => page.evaluate((): AudioSnapshot => (window as any).__audioTestSnapshot()), + { timeout: 10_000 }, + ).toMatchObject({ + audioState: "running", + resumeAttempts: 1, + trustedResumeAttempts: 1, + lastResumeError: null, + }); + + const result = await page.evaluate( + (): Promise => (window as any).__waitForAudioTest(), + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout.trim().split("\n")).toEqual([ + "ready 44100 2", + "wrote 256", + ]); + expect(result.stderr).toBe(""); + expect(result.hostDiagnostics).toEqual([]); + expect(result.producerBytes).toBeGreaterThan(0); + expect(result.consumerBytes).toBe(result.producerBytes); + expect(result.queuedBytes).toBe(0); + expect(result.settled).toBe(true); + + // Exercise the machine-level lifecycle once more after the clean drain: + // suspension is observable, and a second trusted gesture restores the + // same worklet-backed sink without rebuilding or replacing the transport. + const suspended = await page.evaluate( + (): Promise => (window as any).__suspendAudioTest(), + ); + expect(suspended.audioState).toBe("suspended"); + await page.getByRole("button", { name: "Resume audio" }).click(); + await expect.poll( + () => page.evaluate((): AudioSnapshot => (window as any).__audioTestSnapshot()), + { timeout: 10_000 }, + ).toMatchObject({ + audioState: "running", + resumeAttempts: 2, + trustedResumeAttempts: 2, + lastResumeError: null, + }); + + expect(runtimeErrors).toEqual([]); + } finally { + await page.evaluate(() => (window as any).__finishAudioTest?.()).catch(() => {}); + } +}); diff --git a/apps/browser-demos/test/kandelo-merge-gate.spec.ts b/apps/browser-demos/test/kandelo-merge-gate.spec.ts index b30f421fa8..96fe3eca82 100644 --- a/apps/browser-demos/test/kandelo-merge-gate.spec.ts +++ b/apps/browser-demos/test/kandelo-merge-gate.spec.ts @@ -336,15 +336,23 @@ test("Kandelo WordPress MariaDB demo is preinstalled and logs into wp-admin", as await runWordPressPreinstalledLogin(page, "wordpress-mariadb", "WordPress MariaDB"); }); -test("Kandelo fbDOOM demo renders to the framebuffer", async ({ page }) => { +test("Kandelo fbDOOM demo renders and starts the OSS audio sink", async ({ page }) => { test.setTimeout(240_000); await gotoOrSkip(page, "/?demo=doom"); - await expect(page.locator("canvas").first()).toBeVisible({ timeout: 180_000 }); + const canvas = page.locator("canvas").first(); + await expect(canvas).toBeVisible({ timeout: 180_000 }); + + await canvas.click({ position: { x: 8, y: 8 } }); + await expect(page.locator("[data-audio-state]").first()).toHaveAttribute( + "data-audio-state", + "running", + { timeout: 10_000 }, + ); await expect .poll(async () => { - return page.locator("canvas").first().evaluate((canvas: HTMLCanvasElement) => { + return canvas.evaluate((canvas: HTMLCanvasElement) => { if (canvas.width === 0 || canvas.height === 0) return false; const ctx = canvas.getContext("2d"); if (!ctx) return false; diff --git a/crates/kernel/src/audio.rs b/crates/kernel/src/audio.rs index 86aea81e81..90c9640052 100644 --- a/crates/kernel/src/audio.rs +++ b/crates/kernel/src/audio.rs @@ -1,300 +1,1310 @@ -//! `/dev/dsp` — OSS-style PCM audio sink. +//! Implementation-neutral, playback-only PCM stream core. //! -//! Surface mirrors what the Linux Open Sound System (OSS) `dsp` device -//! exposes: a character device that user-space writes raw PCM frames -//! into, with a handful of `ioctl`s for sample rate / format / channel -//! count. The kernel does **not** synthesize or mix audio — DOOM's own -//! mixer fills its 16-bit-stereo buffer and `write()`s it here. The host -//! periodically drains the resulting byte stream via -//! [`drain_into`] (exposed as the `kernel_drain_audio` wasm export) and -//! feeds it to a Web Audio AudioContext. -//! -//! ## Format -//! -//! We accept exactly the format fbDOOM (and most OSS clients) configure -//! by default: signed-16-bit little-endian, stereo, ~11025–48000 Hz. The -//! `ioctl` handler validates each request and stores the chosen rate / -//! channel count so the host can pick them up via -//! [`current_config`]; anything else is `EINVAL`. -//! -//! ## Single-owner -//! -//! Like `/dev/fb0` and `/dev/input/mice`, `/dev/dsp` is single-open. A -//! second `open` from a different pid is `EBUSY`. Re-opens by the -//! current owner are accepted (matches the typical OSS exclusive-grab -//! model). A non-CLOEXEC fd retains ownership and queued samples across -//! `execve`; last close or process exit releases ownership and clears the -//! ring so a successor open starts from silence. -//! -//! ## Backpressure -//! -//! The ring is bounded ([`MAX_QUEUED_BYTES`]) so a misbehaving program -//! that writes faster than the host drains can't OOM the kernel. When -//! the ring fills up, the oldest **whole frame** is dropped to make -//! room — never a partial frame, since downstream tooling assumes -//! interleaved L/R samples are paired. This is the same trade-off OSS -//! made on overrun: drop now, keep recent audio. +//! OSS `/dev/dsp` is a frontend in `syscalls.rs`. The authoritative playback +//! state is a refcounted open-file-description backing, while one fixed, +//! versioned transport exposes the default physical device to browser and +//! Node audio clocks. No mixer, routing policy, capture, or Web Audio concept +//! is part of this module's guest-facing model. extern crate alloc; -use alloc::collections::VecDeque; use core::cell::UnsafeCell; -use core::sync::atomic::{AtomicI32, AtomicU32, Ordering}; - -/// Owning pid of `/dev/dsp`, or `-1` if free. -pub(crate) static DSP_OWNER: AtomicI32 = AtomicI32::new(-1); - -/// Currently configured sample rate (Hz). Defaults to fbDOOM's preferred -/// 11025 Hz so a process that opens the device without ever calling -/// `SNDCTL_DSP_SPEED` still produces something playable. -pub(crate) static SAMPLE_RATE: AtomicU32 = AtomicU32::new(11025); - -/// Currently configured channel count (1 = mono, 2 = stereo). Defaults -/// to stereo to match fbDOOM's default. -pub(crate) static CHANNELS: AtomicU32 = AtomicU32::new(2); - -/// Bytes per S16_LE sample times channel count. Used to align ring -/// drops and reads to whole frames. -fn frame_bytes() -> usize { - 2 * (CHANNELS.load(Ordering::Relaxed) as usize).max(1) -} - -/// Ring capacity in bytes. ~256 KiB → ~1.5 s of stereo S16 at 44100 Hz, -/// or ~6 s at 11025 Hz. Generous enough that the host can fall behind a -/// few RAFs without dropping audio, small enough that kernel memory -/// pressure stays bounded. -const MAX_QUEUED_BYTES: usize = 256 * 1024; - -struct GlobalRing(UnsafeCell>); -unsafe impl Sync for GlobalRing {} - -static RING: GlobalRing = GlobalRing(UnsafeCell::new(VecDeque::new())); - -fn ring() -> &'static mut VecDeque { - unsafe { &mut *RING.0.get() } -} - -/// PCM format the device accepts. Matches OSS `AFMT_S16_LE` — signed -/// 16-bit little-endian. We don't allow other formats; `set_format` -/// rejects anything else with `EINVAL`. -pub(crate) const AFMT_S16_LE: u32 = 0x10; - -/// Append `data` to the ring as raw bytes. Drops oldest *whole frames* -/// to fit `data.len()` if the ring is near capacity. Returns the number -/// of bytes actually buffered (always `data.len()` — overflow is -/// silent, mirroring what a real OSS device does on hardware overrun). -pub fn write_pcm(data: &[u8]) { - let r = ring(); - let frame = frame_bytes(); - while r.len() + data.len() > MAX_QUEUED_BYTES { - // Drop one frame from the front. A frame is 2-channel S16 by - // default = 4 bytes. Tearing a frame would shift L/R alignment - // for every subsequent drain, producing inverted-channel hiss. - let drop = frame.min(r.len()); - if drop == 0 { - break; +use core::sync::atomic::{AtomicBool, AtomicI32, AtomicU32, Ordering}; + +use wasm_posix_shared::{Errno, pcm}; + +const DEFAULT_RATE: u32 = 48_000; +const DEFAULT_CHANNELS: u32 = 2; +const DEFAULT_FORMAT: u32 = pcm::PCM_FORMAT_S16_LE; +const DEFAULT_FRAGMENT_BYTES: u32 = 1024; +const DEFAULT_FRAGMENT_COUNT: u32 = 4; +const MIN_RATE: u32 = 8_000; +const MAX_RATE: u32 = 192_000; +const MIN_FRAGMENT_EXP: u32 = 4; +const MAX_FRAGMENT_EXP: u32 = 16; +const PCM_WAKE_BASE: u32 = 0x2000_0000; + +/// State owned by one logical PCM open file description. Forked process OFD +/// copies refer to the same backing table entry; `dup` aliases the local OFD. +#[derive(Debug)] +pub struct PcmStream { + pub requested_format: u32, + pub actual_format: u32, + pub requested_rate: u32, + pub actual_rate: u32, + pub requested_channels: u32, + pub actual_channels: u32, + pub fragment_bytes: u32, + pub fragment_count: u32, + pub optr_played_base: u64, + pub optr_consumer_base: u64, + pub last_optr_blocks: u64, + pub nonblock: bool, +} + +/// Implementation-neutral output-buffer availability returned to frontends. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PcmOutputSpace { + pub available_fragments: u32, + pub total_fragments: u32, + pub fragment_bytes: u32, + pub available_bytes: u32, +} + +/// Implementation-neutral audio-clock playback position returned to frontends. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PcmPosition { + pub played_bytes: u64, + pub completed_fragments: u32, + pub ring_offset: u32, +} + +impl PcmStream { + fn new() -> Self { + Self { + requested_format: DEFAULT_FORMAT, + actual_format: DEFAULT_FORMAT, + requested_rate: DEFAULT_RATE, + actual_rate: DEFAULT_RATE, + requested_channels: DEFAULT_CHANNELS, + actual_channels: DEFAULT_CHANNELS, + fragment_bytes: DEFAULT_FRAGMENT_BYTES, + fragment_count: DEFAULT_FRAGMENT_COUNT, + optr_played_base: 0, + optr_consumer_base: 0, + last_optr_blocks: 0, + nonblock: false, } - for _ in 0..drop { - r.pop_front(); + } + + fn frame_bytes(&self) -> u32 { + sample_bytes(self.actual_format) * self.actual_channels + } + + fn capacity(&self) -> u32 { + self.fragment_bytes * self.fragment_count + } +} + +#[repr(C)] +struct AtomicPcmControl { + magic: AtomicU32, + version: AtomicU32, + header_bytes: AtomicU32, + physical_capacity_bytes: AtomicU32, + active_capacity_bytes: AtomicU32, + format: AtomicU32, + rate: AtomicU32, + channels: AtomicU32, + frame_bytes: AtomicU32, + fragment_bytes: AtomicU32, + fragment_count: AtomicU32, + state: AtomicU32, + generation: AtomicU32, + flags: AtomicU32, + transport_mode: AtomicU32, + producer_seq: AtomicU32, + producer_lo: AtomicU32, + producer_hi: AtomicU32, + consumer_seq: AtomicU32, + consumer_lo: AtomicU32, + consumer_hi: AtomicU32, + discard_seq: AtomicU32, + discard_lo: AtomicU32, + discard_hi: AtomicU32, + underruns: AtomicU32, + wake_seq: AtomicU32, + reserved: [AtomicU32; 6], +} + +impl AtomicPcmControl { + const fn new() -> Self { + Self { + magic: AtomicU32::new(pcm::PCM_TRANSPORT_MAGIC), + version: AtomicU32::new(pcm::PCM_TRANSPORT_VERSION), + header_bytes: AtomicU32::new(pcm::PCM_TRANSPORT_HEADER_BYTES), + physical_capacity_bytes: AtomicU32::new(pcm::PCM_TRANSPORT_RING_BYTES), + active_capacity_bytes: AtomicU32::new(DEFAULT_FRAGMENT_BYTES * DEFAULT_FRAGMENT_COUNT), + format: AtomicU32::new(DEFAULT_FORMAT), + rate: AtomicU32::new(DEFAULT_RATE), + channels: AtomicU32::new(DEFAULT_CHANNELS), + frame_bytes: AtomicU32::new(4), + fragment_bytes: AtomicU32::new(DEFAULT_FRAGMENT_BYTES), + fragment_count: AtomicU32::new(DEFAULT_FRAGMENT_COUNT), + state: AtomicU32::new(pcm::PCM_STATE_CLOSED), + generation: AtomicU32::new(0), + flags: AtomicU32::new(0), + transport_mode: AtomicU32::new(pcm::PCM_TRANSPORT_UNCLAIMED), + producer_seq: AtomicU32::new(0), + producer_lo: AtomicU32::new(0), + producer_hi: AtomicU32::new(0), + consumer_seq: AtomicU32::new(0), + consumer_lo: AtomicU32::new(0), + consumer_hi: AtomicU32::new(0), + discard_seq: AtomicU32::new(0), + discard_lo: AtomicU32::new(0), + discard_hi: AtomicU32::new(0), + underruns: AtomicU32::new(0), + wake_seq: AtomicU32::new(0), + reserved: [const { AtomicU32::new(0) }; 6], } } - for &b in data { - r.push_back(b); +} + +#[repr(C, align(64))] +struct SharedTransport { + control: AtomicPcmControl, + ring: UnsafeCell<[u8; pcm::PCM_TRANSPORT_RING_BYTES as usize]>, +} + +// SAFETY: ring bytes have one kernel producer and one host audio-clock +// consumer. Release/acquire publication of the cursors synchronizes access. +unsafe impl Sync for SharedTransport {} + +static TRANSPORT: SharedTransport = SharedTransport { + control: AtomicPcmControl::new(), + ring: UnsafeCell::new([0; pcm::PCM_TRANSPORT_RING_BYTES as usize]), +}; + +static ACTIVE_STREAM: AtomicI32 = AtomicI32::new(-1); +static ORPHAN_DRAINING: AtomicBool = AtomicBool::new(false); + +struct KernelCursor(UnsafeCell); +unsafe impl Sync for KernelCursor {} +static LAST_RECONCILED_CONSUMER: KernelCursor = KernelCursor(UnsafeCell::new(0)); +static PLAYED_BYTES: KernelCursor = KernelCursor(UnsafeCell::new(0)); + +fn sample_bytes(format: u32) -> u32 { + match format { + pcm::PCM_FORMAT_U8 => 1, + pcm::PCM_FORMAT_S16_LE | pcm::PCM_FORMAT_S16_BE => 2, + _ => 0, + } +} + +fn read_cursor(seq: &AtomicU32, lo: &AtomicU32, hi: &AtomicU32) -> u64 { + loop { + let before = seq.load(Ordering::Acquire); + if before & 1 != 0 { + core::hint::spin_loop(); + continue; + } + let low = lo.load(Ordering::Relaxed); + let high = hi.load(Ordering::Relaxed); + let after = seq.load(Ordering::Acquire); + if before == after { + return ((high as u64) << 32) | low as u64; + } } } -/// Drain up to `out.len()` bytes from the ring into `out`. Returns the -/// number of bytes copied. Stops at whole-frame boundaries — never -/// returns a torn frame. -pub fn drain_into(out: &mut [u8]) -> usize { - let r = ring(); - let frame = frame_bytes(); - let avail = r.len(); - let want = out.len(); - // Round both ends down to a whole frame so the host always receives - // L/R pairs (when stereo) — feeding a torn frame to AudioContext - // would swap channels for the rest of the stream. - let n = core::cmp::min(want, avail); - let n = (n / frame) * frame; - for i in 0..n { - out[i] = r.pop_front().unwrap_or(0); +fn write_cursor(seq: &AtomicU32, lo: &AtomicU32, hi: &AtomicU32, value: u64) { + seq.fetch_add(1, Ordering::AcqRel); + lo.store(value as u32, Ordering::Relaxed); + hi.store((value >> 32) as u32, Ordering::Relaxed); + seq.fetch_add(1, Ordering::Release); +} + +fn producer() -> u64 { + let c = &TRANSPORT.control; + read_cursor(&c.producer_seq, &c.producer_lo, &c.producer_hi) +} + +fn consumer() -> u64 { + let c = &TRANSPORT.control; + read_cursor(&c.consumer_seq, &c.consumer_lo, &c.consumer_hi) +} + +fn discard() -> u64 { + let c = &TRANSPORT.control; + read_cursor(&c.discard_seq, &c.discard_lo, &c.discard_hi) +} + +fn effective_consumer() -> u64 { + consumer().max(discard()).min(producer()) +} + +fn queued_bytes() -> u64 { + producer().saturating_sub(effective_consumer()) +} + +fn publish_producer(value: u64) { + let c = &TRANSPORT.control; + write_cursor(&c.producer_seq, &c.producer_lo, &c.producer_hi, value); +} + +fn publish_consumer(value: u64) { + let c = &TRANSPORT.control; + write_cursor(&c.consumer_seq, &c.consumer_lo, &c.consumer_hi, value); +} + +fn publish_discard(value: u64) { + let c = &TRANSPORT.control; + write_cursor(&c.discard_seq, &c.discard_lo, &c.discard_hi, value); +} + +pub fn stream_handle(idx: usize) -> i64 { + -(idx as i64) - 1 +} + +pub fn stream_index(handle: i64) -> Result { + if handle >= 0 { + return Err(Errno::EBADF); } - n + usize::try_from(-(handle + 1)).map_err(|_| Errno::EBADF) } -/// Bytes currently buffered. -pub fn pending_bytes() -> usize { - ring().len() +fn wake_token(idx: usize) -> u32 { + PCM_WAKE_BASE.saturating_add(idx as u32) +} + +pub fn wake_token_for_handle(handle: i64) -> Result { + Ok(wake_token(stream_index(handle)?)) } -/// Drop all buffered samples. Called when the owner exits or closes its last -/// fd, and by `SNDCTL_DSP_RESET`. -pub fn reset() { - ring().clear(); +fn configure_transport(stream: &PcmStream) { + let c = &TRANSPORT.control; + c.active_capacity_bytes + .store(stream.capacity(), Ordering::Release); + c.format.store(stream.actual_format, Ordering::Release); + c.rate.store(stream.actual_rate, Ordering::Release); + c.channels.store(stream.actual_channels, Ordering::Release); + c.frame_bytes.store(stream.frame_bytes(), Ordering::Release); + c.fragment_bytes + .store(stream.fragment_bytes, Ordering::Release); + c.fragment_count + .store(stream.fragment_count, Ordering::Release); } -/// Set the sample rate (Hz). Returns the rate actually stored — OSS -/// behavior: clamp to a hardware-sensible range and report back what we -/// landed on. We accept the full range fbDOOM and similar consumers -/// emit (11025, 22050, 44100, 48000) plus reasonable extremes. -pub fn set_sample_rate(hz: u32) -> u32 { - let clamped = hz.clamp(4000, 192000); - SAMPLE_RATE.store(clamped, Ordering::Relaxed); - clamped +/// Publish configuration as one host-observable generation. The configuring +/// bit closes the interval in which the individual atomic fields would +/// otherwise form a torn snapshot for a concurrent host audio clock. +fn publish_configuration(stream: &PcmStream) { + let c = &TRANSPORT.control; + c.flags + .fetch_or(pcm::PCM_FLAG_CONFIGURING, Ordering::AcqRel); + configure_transport(stream); + c.generation.fetch_add(1, Ordering::AcqRel); + c.flags.fetch_and( + !(pcm::PCM_FLAG_CONFIGURING | pcm::PCM_FLAG_UNDERRUN_ACTIVE), + Ordering::Release, + ); } -/// Set channel count. Returns the count actually stored. Accepts 1 or -/// 2; anything else clamps to 2 (matches what real OSS drivers tend to -/// do — most cards don't support 3+ channels in dsp mode). -pub fn set_channels(n: u32) -> u32 { - let n = if n == 1 { 1 } else { 2 }; - CHANNELS.store(n, Ordering::Relaxed); +fn clear_underrun_episode() { + TRANSPORT + .control + .flags + .fetch_and(!pcm::PCM_FLAG_UNDERRUN_ACTIVE, Ordering::AcqRel); +} + +fn note_underrun_episode() { + let was_active = TRANSPORT + .control + .flags + .fetch_or(pcm::PCM_FLAG_UNDERRUN_ACTIVE, Ordering::AcqRel) + & pcm::PCM_FLAG_UNDERRUN_ACTIVE + != 0; + if !was_active { + TRANSPORT.control.underruns.fetch_add(1, Ordering::AcqRel); + } +} + +fn enter_stopped_generation() { + clear_underrun_episode(); + TRANSPORT + .control + .state + .store(pcm::PCM_STATE_STOPPED, Ordering::Release); + TRANSPORT.control.generation.fetch_add(1, Ordering::AcqRel); +} + +pub fn has_fatal_error(handle: i64) -> Result { + with_stream(handle, |_| { + TRANSPORT.control.flags.load(Ordering::Acquire) & pcm::PCM_FLAG_FATAL_ERROR != 0 + }) +} + +fn reset_transport_for_open(stream: &mut PcmStream) { + let c = &TRANSPORT.control; + c.state.store(pcm::PCM_STATE_CLOSED, Ordering::Release); + + // The host audio clock can still be finishing a quantum from the previous + // generation while RESET + close + reopen runs in the kernel worker. Keep + // the live transport cursors monotonic across opens so such a stale + // consumer publication can never jump ahead of the new stream. The new + // discard floor makes every byte from the prior generation unreachable; + // subsequent writes continue from the same absolute producer position. + let base = producer(); + publish_discard(base); + unsafe { *LAST_RECONCILED_CONSUMER.0.get() = base }; + unsafe { *PLAYED_BYTES.0.get() = 0 }; + stream.optr_played_base = 0; + stream.optr_consumer_base = base; + stream.last_optr_blocks = 0; + c.underruns.store(0, Ordering::Release); + publish_configuration(stream); + c.state.store(pcm::PCM_STATE_STOPPED, Ordering::Release); + ORPHAN_DRAINING.store(false, Ordering::Release); +} + +/// Allocate and exclusively claim the one default playback stream. +pub fn open_stream() -> Result { + if ACTIVE_STREAM.load(Ordering::Acquire) != -1 { + return Err(Errno::EBUSY); + } + let stream = PcmStream::new(); + let idx = crate::descriptor_backing::with_pcm_streams(|table| table.alloc(stream)); + if ACTIVE_STREAM + .compare_exchange(-1, idx as i32, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + crate::descriptor_backing::with_pcm_streams(|table| { + table.release(idx); + }); + return Err(Errno::EBUSY); + } + crate::descriptor_backing::with_pcm_streams(|table| { + let stream = table.get_mut(idx).expect("new PCM backing"); + reset_transport_for_open(stream); + }); + Ok(stream_handle(idx)) +} + +pub fn rollback_open(handle: i64) { + if let Ok(idx) = stream_index(handle) { + crate::descriptor_backing::with_pcm_streams(|table| { + table.release(idx); + }); + finish_closed(idx); + } +} + +fn finish_closed(idx: usize) { + if ACTIVE_STREAM + .compare_exchange(idx as i32, -1, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + ORPHAN_DRAINING.store(false, Ordering::Release); + clear_underrun_episode(); + TRANSPORT + .control + .state + .store(pcm::PCM_STATE_CLOSED, Ordering::Release); + } +} + +/// Called after the final cross-process OFD reference disappears. A queued +/// tail becomes an orphan drain and keeps exclusive ownership until the audio +/// clock reaches the producer; empty streams release immediately. +pub fn on_last_ofd_released(idx: usize) { + if ACTIVE_STREAM.load(Ordering::Acquire) != idx as i32 { + return; + } + if TRANSPORT.control.flags.load(Ordering::Acquire) & pcm::PCM_FLAG_FATAL_ERROR != 0 { + publish_discard(producer()); + finish_closed(idx); + return; + } + pad_terminal_frame(); + if queued_bytes() == 0 { + finish_closed(idx); + } else { + ORPHAN_DRAINING.store(true, Ordering::Release); + clear_underrun_episode(); + TRANSPORT + .control + .state + .store(pcm::PCM_STATE_DRAINING, Ordering::Release); + } +} + +fn with_stream(handle: i64, f: impl FnOnce(&mut PcmStream) -> R) -> Result { + let idx = stream_index(handle)?; + crate::descriptor_backing::with_pcm_streams(|table| { + table.get_mut(idx).map(f).ok_or(Errno::EBADF) + }) +} + +fn ensure_configurable() -> Result<(), Errno> { + match TRANSPORT.control.state.load(Ordering::Acquire) { + pcm::PCM_STATE_RUNNING | pcm::PCM_STATE_DRAINING => Err(Errno::EBUSY), + _ => Ok(()), + } +} + +pub fn set_rate(handle: i64, requested: u32) -> Result { + if requested == 0 { + return with_stream(handle, |stream| stream.actual_rate); + } + ensure_configurable()?; + with_stream(handle, |stream| { + stream.requested_rate = requested; + stream.actual_rate = requested.clamp(MIN_RATE, MAX_RATE); + publish_configuration(stream); + stream.actual_rate + }) +} + +pub fn set_channels(handle: i64, requested: u32) -> Result { + if requested == 0 { + return with_stream(handle, |stream| stream.actual_channels); + } + ensure_configurable()?; + with_stream(handle, |stream| { + stream.requested_channels = requested; + stream.actual_channels = if requested == 1 { 1 } else { 2 }; + publish_configuration(stream); + stream.actual_channels + }) +} + +pub fn set_format(handle: i64, requested: u32) -> Result { + if requested == pcm::PCM_FORMAT_UNKNOWN { + return with_stream(handle, |stream| stream.actual_format); + } + if !matches!( + requested, + pcm::PCM_FORMAT_U8 | pcm::PCM_FORMAT_S16_LE | pcm::PCM_FORMAT_S16_BE + ) { + return Err(Errno::EINVAL); + } + ensure_configurable()?; + with_stream(handle, |stream| { + stream.requested_format = requested; + stream.actual_format = requested; + publish_configuration(stream); + stream.actual_format + }) +} + +pub fn set_fragment(handle: i64, encoded: u32) -> Result { + ensure_configurable()?; + with_stream(handle, |stream| { + let exp = (encoded & 0xffff).clamp(MIN_FRAGMENT_EXP, MAX_FRAGMENT_EXP); + let fragment_bytes = 1u32 << exp; + let requested_count = encoded >> 16; + let max_count = (pcm::PCM_TRANSPORT_RING_BYTES / fragment_bytes).max(1); + let fragment_count = if requested_count == 0 { + max_count + } else { + requested_count.clamp(max_count.min(2), max_count) + }; + stream.fragment_bytes = fragment_bytes; + stream.fragment_count = fragment_count; + let played = unsafe { *PLAYED_BYTES.0.get() }; + stream.last_optr_blocks = + played.saturating_sub(stream.optr_played_base) / fragment_bytes as u64; + publish_configuration(stream); + (fragment_count << 16) | exp + }) +} + +pub fn config(handle: i64) -> Result<(u32, u32, u32), Errno> { + with_stream(handle, |stream| { + ( + stream.actual_format, + stream.actual_rate, + stream.actual_channels, + ) + }) +} + +pub fn geometry(handle: i64) -> Result<(u32, u32), Errno> { + with_stream(handle, |stream| { + (stream.fragment_bytes, stream.fragment_count) + }) +} + +pub fn set_nonblock(handle: i64, enabled: bool) -> Result<(), Errno> { + with_stream(handle, |stream| stream.nonblock = enabled) +} + +pub fn is_nonblock(handle: i64) -> Result { + with_stream(handle, |stream| stream.nonblock) +} + +pub fn write(handle: i64, data: &[u8], nonblock: bool) -> Result { + if data.is_empty() { + return Ok(0); + } + with_stream(handle, |stream| { + if TRANSPORT.control.flags.load(Ordering::Acquire) & pcm::PCM_FLAG_FATAL_ERROR != 0 { + return Err(Errno::EIO); + } + let capacity = stream.capacity() as usize; + let queued = queued_bytes().min(capacity as u64) as usize; + let free = capacity.saturating_sub(queued); + if free == 0 { + return Err(Errno::EAGAIN); + } + if !nonblock && data.len() <= capacity && free < data.len() { + // A queued partial frame cannot be consumed by the audio clock. + // Permit a short write when it completes that frame; insisting on + // room for the entire request here would deadlock both sides. + let frame = stream.frame_bytes().max(1) as usize; + let remainder = queued % frame; + let completes_partial = remainder != 0 && free >= frame - remainder; + if !completes_partial { + return Err(Errno::EAGAIN); + } + } + let n = data.len().min(free); + if n == 0 { + return Err(Errno::EAGAIN); + } + let producer_before = producer(); + let start = (producer_before % capacity as u64) as usize; + let first = n.min(capacity - start); + unsafe { + let ring = (*TRANSPORT.ring.get()).as_mut_ptr(); + core::ptr::copy_nonoverlapping(data.as_ptr(), ring.add(start), first); + if first < n { + core::ptr::copy_nonoverlapping(data.as_ptr().add(first), ring, n - first); + } + } + publish_producer(producer_before + n as u64); + TRANSPORT + .control + .state + .store(pcm::PCM_STATE_RUNNING, Ordering::Release); + Ok(n) + })? +} + +/// Complete a final partial frame before a drain. OSS exposes `/dev/dsp` as a +/// byte stream, while the physical sink advances in whole frames. Padding is +/// therefore part of drain/close, not an alignment restriction on `write()`. +fn pad_terminal_frame() { + let frame = TRANSPORT.control.frame_bytes.load(Ordering::Acquire).max(1) as u64; + let queued = queued_bytes(); + let remainder = queued % frame; + if remainder == 0 { + return; + } + let padding = (frame - remainder) as usize; + let capacity = TRANSPORT + .control + .active_capacity_bytes + .load(Ordering::Acquire) as usize; + if capacity == 0 || queued.saturating_add(padding as u64) > capacity as u64 { + return; + } + let fill = if TRANSPORT.control.format.load(Ordering::Acquire) == pcm::PCM_FORMAT_U8 { + 0x80 + } else { + 0 + }; + let before = producer(); + let start = (before % capacity as u64) as usize; + unsafe { + let ring = (*TRANSPORT.ring.get()).as_mut_ptr(); + for offset in 0..padding { + ring.add((start + offset) % capacity).write(fill); + } + } + publish_producer(before + padding as u64); +} + +pub fn poll_writable(handle: i64) -> Result { + with_stream(handle, |stream| { + if TRANSPORT.control.flags.load(Ordering::Acquire) & pcm::PCM_FLAG_FATAL_ERROR != 0 { + return Err(Errno::EIO); + } + let free = stream.capacity() as u64 - queued_bytes().min(stream.capacity() as u64); + Ok(free >= stream.fragment_bytes as u64) + })? +} + +pub fn output_space(handle: i64) -> Result { + with_stream(handle, |stream| { + if TRANSPORT.control.flags.load(Ordering::Acquire) & pcm::PCM_FLAG_FATAL_ERROR != 0 { + return Err(Errno::EIO); + } + let free = stream.capacity() as u64 - queued_bytes().min(stream.capacity() as u64); + Ok(PcmOutputSpace { + available_fragments: (free / stream.fragment_bytes as u64) as u32, + total_fragments: stream.fragment_count, + fragment_bytes: stream.fragment_bytes, + available_bytes: free as u32, + }) + })? +} + +pub fn output_delay(handle: i64) -> Result { + with_stream(handle, |stream| { + queued_bytes().min(stream.capacity() as u64) as i32 + }) +} + +pub fn output_pointer(handle: i64) -> Result { + with_stream(handle, |_| ())?; + // GETOPTR observes the audio clock, so first account for a consumer + // publication that has not yet passed through the syscall path. + reconcile(); + with_stream(handle, |stream| { + let played = unsafe { *PLAYED_BYTES.0.get() }.saturating_sub(stream.optr_played_base); + let blocks = played / stream.fragment_bytes as u64; + let delta = blocks.saturating_sub(stream.last_optr_blocks); + stream.last_optr_blocks = blocks; + PcmPosition { + played_bytes: played, + completed_fragments: delta.min(u32::MAX as u64) as u32, + ring_offset: (effective_consumer().saturating_sub(stream.optr_consumer_base) + % stream.capacity() as u64) as u32, + } + }) +} + +pub fn post(handle: i64) -> Result<(), Errno> { + with_stream(handle, |_| ())?; + if has_fatal_error(handle)? { + return Err(Errno::EIO); + } + clear_underrun_episode(); + TRANSPORT + .control + .state + .store(pcm::PCM_STATE_RUNNING, Ordering::Release); + Ok(()) +} + +pub fn sync(handle: i64) -> Result<(), Errno> { + with_stream(handle, |_| ())?; + if has_fatal_error(handle)? { + return Err(Errno::EIO); + } + pad_terminal_frame(); + reconcile(); + if queued_bytes() == 0 { + enter_stopped_generation(); + Ok(()) + } else { + clear_underrun_episode(); + TRANSPORT + .control + .state + .store(pcm::PCM_STATE_DRAINING, Ordering::Release); + Err(Errno::EAGAIN) + } +} + +pub fn reset_stream(handle: i64) -> Result<(), Errno> { + with_stream(handle, |_| ())?; + // Account for a shared-clock consumer update before discard changes the + // effective-consumer floor; otherwise RESET could erase played position. + reconcile(); + let end = producer(); + publish_discard(end); + TRANSPORT.control.generation.fetch_add(1, Ordering::AcqRel); + TRANSPORT + .control + .state + .store(pcm::PCM_STATE_STOPPED, Ordering::Release); + clear_underrun_episode(); + reconcile(); + let played_base = unsafe { *PLAYED_BYTES.0.get() }; + let consumer_base = effective_consumer(); + with_stream(handle, |stream| { + stream.optr_played_base = played_base; + stream.optr_consumer_base = consumer_base; + stream.last_optr_blocks = 0; + })?; + Ok(()) +} + +/// Explicit final close drains before fd/OFD removal. The host turns this +/// internal EAGAIN into a blocking retry, leaving the descriptor valid. +pub fn preflight_close(handle: i64) -> Result<(), Errno> { + let idx = stream_index(handle)?; + let last = crate::descriptor_backing::with_pcm_streams(|table| table.ref_count(idx) == Some(1)); + if !last { + return Ok(()); + } + if has_fatal_error(handle)? { + publish_discard(producer()); + return Err(Errno::EIO); + } + pad_terminal_frame(); + reconcile(); + if queued_bytes() == 0 { + Ok(()) + } else { + clear_underrun_episode(); + TRANSPORT + .control + .state + .store(pcm::PCM_STATE_DRAINING, Ordering::Release); + Err(Errno::EAGAIN) + } +} + +pub fn reconcile() -> i32 { + let active = ACTIVE_STREAM.load(Ordering::Acquire); + if active < 0 { + return 0; + } + let now = effective_consumer(); + let discard_floor = discard(); + let previous = unsafe { &mut *LAST_RECONCILED_CONSUMER.0.get() }; + let advanced = now > *previous; + let played_delta = now.saturating_sub((*previous).max(discard_floor)); + if played_delta != 0 { + unsafe { + *PLAYED_BYTES.0.get() = (*PLAYED_BYTES.0.get()).saturating_add(played_delta); + } + } + *previous = (*previous).max(now); + if advanced { + crate::wakeup::push(wake_token(active as usize), crate::wakeup::WAKE_WRITABLE); + } + // There is no OFD left to observe EIO once an implicit close has turned a + // queued tail into an orphan drain. If the physical sink fails after that + // transition, it can never advance the consumer to empty the queue. Drop + // only the unplayed tail and release exclusive ownership; consumer bytes + // reconciled above remain the sole source of played-position accounting. + let fatal_orphan = ORPHAN_DRAINING.load(Ordering::Acquire) + && TRANSPORT.control.flags.load(Ordering::Acquire) & pcm::PCM_FLAG_FATAL_ERROR != 0; + if fatal_orphan { + publish_discard(producer()); + finish_closed(active as usize); + } else if queued_bytes() == 0 { + if ORPHAN_DRAINING.load(Ordering::Acquire) { + finish_closed(active as usize); + } else if TRANSPORT.control.state.load(Ordering::Acquire) == pcm::PCM_STATE_DRAINING { + enter_stopped_generation(); + } + } + if advanced { 1 } else { 0 } +} + +pub fn claim_transport(mode: u32) -> Result<(), Errno> { + if !matches!( + mode, + pcm::PCM_TRANSPORT_LEGACY_PULL | pcm::PCM_TRANSPORT_SHARED_CLOCK + ) { + return Err(Errno::EINVAL); + } + let slot = &TRANSPORT.control.transport_mode; + loop { + let current = slot.load(Ordering::Acquire); + if current == mode { + return Ok(()); + } + if current != pcm::PCM_TRANSPORT_UNCLAIMED { + return Err(Errno::EBUSY); + } + if slot + .compare_exchange(current, mode, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + return Ok(()); + } + } +} + +pub fn clock_update(requested_frames: u32) -> u32 { + if claim_transport(pcm::PCM_TRANSPORT_SHARED_CLOCK).is_err() { + return 0; + } + let frame = TRANSPORT.control.frame_bytes.load(Ordering::Acquire).max(1) as u64; + let available_frames = queued_bytes() / frame; + let consumed_frames = available_frames.min(requested_frames as u64); + let state = TRANSPORT.control.state.load(Ordering::Acquire); + if consumed_frames != 0 || state != pcm::PCM_STATE_RUNNING { + clear_underrun_episode(); + } + if requested_frames != 0 + && consumed_frames < requested_frames as u64 + && state == pcm::PCM_STATE_RUNNING + { + note_underrun_episode(); + } + if consumed_frames != 0 { + publish_consumer(effective_consumer() + consumed_frames * frame); + } + TRANSPORT.control.wake_seq.fetch_add(1, Ordering::AcqRel); + reconcile(); + consumed_frames as u32 +} + +/// Compatibility pull consumer retained for existing hosts. A claimed shared +/// clock wins exclusively, so this path can never race an AudioWorklet. +pub fn drain_into(out: &mut [u8]) -> usize { + if claim_transport(pcm::PCM_TRANSPORT_LEGACY_PULL).is_err() { + return 0; + } + let frame = TRANSPORT.control.frame_bytes.load(Ordering::Acquire).max(1) as usize; + let capacity = TRANSPORT + .control + .active_capacity_bytes + .load(Ordering::Acquire) + .max(1) as usize; + let available = queued_bytes().min(capacity as u64) as usize; + let n = out.len().min(available) / frame * frame; + if n == 0 { + return 0; + } + let before = effective_consumer(); + let start = (before % capacity as u64) as usize; + let first = n.min(capacity - start); + unsafe { + let ring = (*TRANSPORT.ring.get()).as_ptr(); + core::ptr::copy_nonoverlapping(ring.add(start), out.as_mut_ptr(), first); + if first < n { + core::ptr::copy_nonoverlapping(ring, out.as_mut_ptr().add(first), n - first); + } + } + publish_consumer(before + n as u64); + TRANSPORT.control.wake_seq.fetch_add(1, Ordering::AcqRel); + reconcile(); n } -/// Validate the format. Only `AFMT_S16_LE` is supported — return -/// `false` for anything else so the ioctl handler can map it to -/// `EINVAL`. -pub fn set_format(fmt: u32) -> bool { - fmt == AFMT_S16_LE +pub fn pending_bytes() -> usize { + queued_bytes() as usize } -/// Snapshot of the current device config. Returned to the host so it -/// can configure its AudioContext. `(sample_rate_hz, channels)`. pub fn current_config() -> (u32, u32) { ( - SAMPLE_RATE.load(Ordering::Relaxed), - CHANNELS.load(Ordering::Relaxed), + TRANSPORT.control.rate.load(Ordering::Acquire), + TRANSPORT.control.channels.load(Ordering::Acquire), ) } -/// Serializes tests that touch the global ring + atomics. Shared -/// across `audio::tests` and `syscalls::tests` because both touch the -/// same process-global state — using two separate mutexes would let -/// them race when cargo runs them concurrently. Public-in-test only. +pub fn transport_ptr() -> *const u8 { + core::ptr::addr_of!(TRANSPORT).cast() +} + +pub const fn transport_len() -> u32 { + pcm::PCM_TRANSPORT_BYTES +} + #[cfg(test)] -pub static TEST_RING_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +pub static TEST_AUDIO_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[cfg(test)] +pub fn reset_for_test() { + let active = ACTIVE_STREAM.swap(-1, Ordering::AcqRel); + if active >= 0 { + crate::descriptor_backing::with_pcm_streams(|table| { + while table.ref_count(active as usize).is_some() { + if table.release(active as usize) { + break; + } + } + }); + } + ORPHAN_DRAINING.store(false, Ordering::Release); + TRANSPORT + .control + .state + .store(pcm::PCM_STATE_CLOSED, Ordering::Release); + TRANSPORT + .control + .transport_mode + .store(pcm::PCM_TRANSPORT_UNCLAIMED, Ordering::Release); + TRANSPORT.control.flags.store(0, Ordering::Release); + TRANSPORT.control.underruns.store(0, Ordering::Release); + publish_producer(0); + publish_consumer(0); + publish_discard(0); + unsafe { *LAST_RECONCILED_CONSUMER.0.get() = 0 }; + unsafe { *PLAYED_BYTES.0.get() = 0 }; +} + +#[cfg(test)] +pub fn mark_fatal_error_for_test() { + TRANSPORT + .control + .flags + .fetch_or(pcm::PCM_FLAG_FATAL_ERROR, Ordering::AcqRel); +} #[cfg(test)] mod tests { use super::*; + use core::mem::{offset_of, size_of}; - fn fresh() -> std::sync::MutexGuard<'static, ()> { - // Tolerate poisoned locks from earlier failed assertions — the - // ring is reset before each test, so prior panics don't leave - // observable state behind. - let g = TEST_RING_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - reset(); - SAMPLE_RATE.store(11025, Ordering::Relaxed); - CHANNELS.store(2, Ordering::Relaxed); - g + fn fresh() -> (std::sync::MutexGuard<'static, ()>, i64) { + let guard = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_for_test(); + let handle = open_stream().unwrap(); + (guard, handle) } #[test] - fn empty_ring_drains_zero() { - let _g = fresh(); - let mut buf = [0u8; 16]; - assert_eq!(drain_into(&mut buf), 0); - assert_eq!(pending_bytes(), 0); + fn atomic_transport_matches_shared_layout() { + assert_eq!( + size_of::(), + pcm::PCM_TRANSPORT_HEADER_BYTES as usize + ); + assert_eq!( + offset_of!(SharedTransport, ring), + pcm::PCM_TRANSPORT_HEADER_BYTES as usize + ); + assert_eq!( + size_of::(), + pcm::PCM_TRANSPORT_BYTES as usize + ); } #[test] - fn write_then_drain_roundtrip_preserves_bytes() { - let _g = fresh(); - let frame = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]; - write_pcm(&frame); - assert_eq!(pending_bytes(), 8); + fn ring_wraparound_preserves_pcm() { + let (_guard, handle) = fresh(); + set_fragment(handle, (2 << 16) | 4).unwrap(); + let first = [1u8; 28]; + assert_eq!(write(handle, &first, false).unwrap(), 28); + let mut drained = [0u8; 24]; + assert_eq!(drain_into(&mut drained), 24); + let second = [2u8; 24]; + assert_eq!(write(handle, &second, false).unwrap(), 24); + let mut all = [0u8; 28]; + assert_eq!(drain_into(&mut all), 28); + assert_eq!(&all[..4], &[1; 4]); + assert_eq!(&all[4..], &[2; 24]); + } + + #[test] + fn blocking_and_nonblocking_backpressure() { + let (_guard, handle) = fresh(); + set_fragment(handle, (2 << 16) | 4).unwrap(); + let full = [0u8; 32]; + assert_eq!(write(handle, &full, false).unwrap(), 32); + assert_eq!(write(handle, &[0; 4], false), Err(Errno::EAGAIN)); let mut out = [0u8; 8]; assert_eq!(drain_into(&mut out), 8); - assert_eq!(out, frame); + assert_eq!(write(handle, &[0; 12], false), Err(Errno::EAGAIN)); + assert_eq!(write(handle, &[0; 12], true).unwrap(), 8); + } + + #[test] + fn blocking_write_can_complete_a_queued_partial_frame() { + let (_guard, handle) = fresh(); + set_fragment(handle, (2 << 16) | 4).unwrap(); + assert_eq!(write(handle, &[1], false).unwrap(), 1); + + // Waiting for all 32 bytes would deadlock: the audio clock cannot + // consume the queued single byte until this write completes a frame. + assert_eq!(write(handle, &[2; 32], false).unwrap(), 31); + assert_eq!(pending_bytes(), 32); + assert_eq!(drain_into(&mut [0; 32]), 32); + } + + #[test] + fn reset_discards_without_rewinding_monotonic_producer() { + let (_guard, handle) = fresh(); + write(handle, &[0; 16], false).unwrap(); + let before = producer(); + reset_stream(handle).unwrap(); assert_eq!(pending_bytes(), 0); + assert_eq!(producer(), before); + assert_eq!(discard(), before); } #[test] - fn drain_rounds_down_to_whole_stereo_frame() { - let _g = fresh(); - // Stereo S16 → 4 bytes/frame. - let bytes: [u8; 12] = [0; 12]; - write_pcm(&bytes); - let mut out = [0u8; 7]; // 7 < 8, should drain 4 bytes (one frame) - assert_eq!(drain_into(&mut out), 4); - assert_eq!(pending_bytes(), 8); + fn fragment_encoding_clamps_and_zero_count_means_maximum() { + let (_guard, handle) = fresh(); + assert_eq!(set_fragment(handle, 3).unwrap(), (4096 << 16) | 4); + assert_eq!(geometry(handle).unwrap(), (16, 4096)); + assert_eq!( + set_fragment(handle, (1 << 16) | 10).unwrap(), + (2 << 16) | 10 + ); + reset_stream(handle).unwrap(); + assert_eq!( + set_fragment(handle, (7 << 16) | 20).unwrap(), + (1 << 16) | 16 + ); + assert_eq!(geometry(handle).unwrap(), (65_536, 1)); } #[test] - fn mono_drain_uses_2_byte_frames() { - let _g = fresh(); - set_channels(1); - let bytes: [u8; 6] = [1, 2, 3, 4, 5, 6]; - write_pcm(&bytes); - let mut out = [0u8; 3]; // 3 < 4, should round down to 2 (one mono frame) - assert_eq!(drain_into(&mut out), 2); - assert_eq!(out[0], 1); - assert_eq!(out[1], 2); + fn reset_discard_does_not_advance_played_position() { + let (_guard, handle) = fresh(); + write(handle, &[1; 8], false).unwrap(); + assert_eq!(drain_into(&mut [0; 8]), 8); + assert_eq!(output_pointer(handle).unwrap().played_bytes, 8); + write(handle, &[2; 8], false).unwrap(); + reset_stream(handle).unwrap(); + assert_eq!( + output_pointer(handle).unwrap(), + PcmPosition { + played_bytes: 0, + completed_fragments: 0, + ring_offset: 0, + } + ); } #[test] - fn set_sample_rate_clamps_to_supported_range() { - let _g = fresh(); - assert_eq!(set_sample_rate(44100), 44100); - assert_eq!(set_sample_rate(0), 4000); - assert_eq!(set_sample_rate(1_000_000), 192000); - assert_eq!(current_config().0, 192000); + fn reset_reconciles_shared_consumer_before_discarding_tail() { + let (_guard, handle) = fresh(); + write(handle, &[1; 16], false).unwrap(); + publish_consumer(8); + reset_stream(handle).unwrap(); + assert_eq!(output_pointer(handle).unwrap().played_bytes, 0); + assert_eq!(pending_bytes(), 0); } #[test] - fn set_channels_only_accepts_mono_or_stereo() { - let _g = fresh(); - assert_eq!(set_channels(1), 1); - assert_eq!(set_channels(2), 2); - // Anything weird normalizes to stereo — what real OSS drivers do. - assert_eq!(set_channels(7), 2); - assert_eq!(set_channels(0), 2); + fn getoptr_reconciles_and_reports_reset_relative_position() { + let (_guard, handle) = fresh(); + set_fragment(handle, (4 << 16) | 4).unwrap(); + write(handle, &[1; 64], false).unwrap(); + + // Model a shared-clock publication that has not reached reconcile(). + publish_consumer(48); + assert_eq!( + output_pointer(handle).unwrap(), + PcmPosition { + played_bytes: 48, + completed_fragments: 3, + ring_offset: 48, + } + ); + + reset_stream(handle).unwrap(); + assert_eq!( + output_pointer(handle).unwrap(), + PcmPosition { + played_bytes: 0, + completed_fragments: 0, + ring_offset: 0, + } + ); + + write(handle, &[2; 32], false).unwrap(); + publish_consumer(producer()); + assert_eq!( + output_pointer(handle).unwrap(), + PcmPosition { + played_bytes: 32, + completed_fragments: 2, + ring_offset: 32, + } + ); } #[test] - fn set_format_rejects_anything_but_s16_le() { - let _g = fresh(); - assert!(set_format(AFMT_S16_LE)); - assert!(!set_format(0x08)); // AFMT_U8 - assert!(!set_format(0x20)); // AFMT_S16_BE - assert!(!set_format(0)); + fn delay_space_position_sync_and_reconfiguration_follow_the_audio_clock() { + let (_guard, handle) = fresh(); + set_fragment(handle, (2 << 16) | 4).unwrap(); + write(handle, &[1; 32], false).unwrap(); + + assert_eq!(output_delay(handle).unwrap(), 32); + assert_eq!( + output_space(handle).unwrap(), + PcmOutputSpace { + available_fragments: 0, + total_fragments: 2, + fragment_bytes: 16, + available_bytes: 0, + } + ); + assert_eq!(set_rate(handle, 44_100), Err(Errno::EBUSY)); + assert_eq!(sync(handle), Err(Errno::EAGAIN)); + + assert_eq!(drain_into(&mut [0; 16]), 16); + assert_eq!(output_delay(handle).unwrap(), 16); + assert_eq!( + output_pointer(handle).unwrap(), + PcmPosition { + played_bytes: 16, + completed_fragments: 1, + ring_offset: 16, + } + ); + assert_eq!(output_pointer(handle).unwrap().completed_fragments, 0); + + assert_eq!(drain_into(&mut [0; 16]), 16); + let before_stop = TRANSPORT.control.generation.load(Ordering::Acquire); + assert_eq!(sync(handle), Ok(())); + let stopped = TRANSPORT.control.generation.load(Ordering::Acquire); + assert_eq!(stopped, before_stop.wrapping_add(1)); + assert_eq!(set_rate(handle, 44_100), Ok(44_100)); + assert_eq!( + TRANSPORT.control.generation.load(Ordering::Acquire), + stopped.wrapping_add(1) + ); + assert_eq!(TRANSPORT.control.rate.load(Ordering::Acquire), 44_100); + assert_eq!( + TRANSPORT.control.flags.load(Ordering::Acquire) & pcm::PCM_FLAG_CONFIGURING, + 0 + ); } #[test] - fn overflow_drops_oldest_whole_frame() { - let _g = fresh(); - // Stereo S16 → 4-byte frames. Fill exactly to capacity, then add - // one more frame: the head frame must drop. - let mut head_frame = [0u8; 4]; - head_frame.copy_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]); - write_pcm(&head_frame); - - // Pad up to capacity with a recognizable pattern. - let pad = [0x11u8; MAX_QUEUED_BYTES - 4]; - write_pcm(&pad); - assert_eq!(pending_bytes(), MAX_QUEUED_BYTES); - - // One more frame — head must drop. - let new_frame = [0x42, 0x43, 0x44, 0x45]; - write_pcm(&new_frame); - - // Drain everything: should NOT see the original head frame. - let mut all = vec![0u8; pending_bytes()]; - let n = drain_into(&mut all); - assert_eq!(n, MAX_QUEUED_BYTES); - // First 4 bytes are the start of the pad pattern, NOT the head_frame. - assert_eq!(&all[0..4], &[0x11, 0x11, 0x11, 0x11]); - // Tail is the freshest frame. - assert_eq!(&all[MAX_QUEUED_BYTES - 4..], &new_frame); + fn shared_clock_underrun_consumes_available_frames_and_records_silence_gap() { + let (_guard, handle) = fresh(); + write(handle, &[1; 4], false).unwrap(); + + assert_eq!(clock_update(2), 1); + assert_eq!(pending_bytes(), 0); + assert_eq!(TRANSPORT.control.underruns.load(Ordering::Acquire), 1); + assert_eq!(clock_update(2), 0); + assert_eq!(TRANSPORT.control.underruns.load(Ordering::Acquire), 1); + + write(handle, &[2; 4], false).unwrap(); + assert_eq!(clock_update(2), 1); + assert_eq!(TRANSPORT.control.underruns.load(Ordering::Acquire), 2); } #[test] - fn reset_drops_pending() { - let _g = fresh(); - write_pcm(&[1, 2, 3, 4]); - assert_eq!(pending_bytes(), 4); - reset(); + fn draining_a_short_tail_is_not_an_underrun() { + let (_guard, handle) = fresh(); + write(handle, &[1; 8], false).unwrap(); + assert_eq!(sync(handle), Err(Errno::EAGAIN)); + let before_stop = TRANSPORT.control.generation.load(Ordering::Acquire); + + assert_eq!(clock_update(128), 2); + assert_eq!(TRANSPORT.control.underruns.load(Ordering::Acquire), 0); + assert_eq!( + TRANSPORT.control.flags.load(Ordering::Acquire) & pcm::PCM_FLAG_UNDERRUN_ACTIVE, + 0 + ); + assert_eq!( + TRANSPORT.control.state.load(Ordering::Acquire), + pcm::PCM_STATE_STOPPED + ); + assert_eq!( + TRANSPORT.control.generation.load(Ordering::Acquire), + before_stop.wrapping_add(1) + ); + } + + #[test] + fn fatal_sink_error_fails_io_and_implicit_close_does_not_stall() { + let (_guard, handle) = fresh(); + write(handle, &[1; 8], false).unwrap(); + TRANSPORT + .control + .flags + .fetch_or(pcm::PCM_FLAG_FATAL_ERROR, Ordering::AcqRel); + + assert_eq!(write(handle, &[2; 4], false), Err(Errno::EIO)); + assert_eq!(poll_writable(handle), Err(Errno::EIO)); + assert_eq!(output_space(handle), Err(Errno::EIO)); + assert_eq!(sync(handle), Err(Errno::EIO)); + assert_eq!(preflight_close(handle), Err(Errno::EIO)); + + let idx = stream_index(handle).unwrap(); + let freed = crate::descriptor_backing::with_pcm_streams(|table| table.release(idx)); + assert!(freed); + on_last_ofd_released(idx); + assert_eq!(ACTIVE_STREAM.load(Ordering::Acquire), -1); + assert_eq!(pending_bytes(), 0); + } + + #[test] + fn fatal_sink_after_orphaning_tail_discards_without_counting_playback() { + let (_guard, handle) = fresh(); + write(handle, &[1; 16], false).unwrap(); + // Model consumer progress that the host published immediately before + // process teardown, but which no syscall has reconciled yet. + publish_consumer(8); + + let idx = stream_index(handle).unwrap(); + let freed = crate::descriptor_backing::with_pcm_streams(|table| table.release(idx)); + assert!(freed); + on_last_ofd_released(idx); + assert_eq!(ACTIVE_STREAM.load(Ordering::Acquire), idx as i32); + assert!(ORPHAN_DRAINING.load(Ordering::Acquire)); + assert_eq!(pending_bytes(), 8); + assert_eq!(open_stream(), Err(Errno::EBUSY)); + + mark_fatal_error_for_test(); + assert_eq!(reconcile(), 1); + assert_eq!(ACTIVE_STREAM.load(Ordering::Acquire), -1); + assert!(!ORPHAN_DRAINING.load(Ordering::Acquire)); + assert_eq!(pending_bytes(), 0); + assert_eq!(discard(), producer()); + assert_eq!(unsafe { *PLAYED_BYTES.0.get() }, 8); + + // The physical failure remains sticky, but it must not retain the old + // stream's exclusive-open claim. + let reopened = open_stream().unwrap(); + assert!(has_fatal_error(reopened).unwrap()); + rollback_open(reopened); + } + + #[test] + fn drain_pads_partial_u8_stereo_frame_with_silence() { + let (_guard, handle) = fresh(); + set_format(handle, pcm::PCM_FORMAT_U8).unwrap(); + set_channels(handle, 2).unwrap(); + write(handle, &[1, 2, 3], false).unwrap(); + assert_eq!(sync(handle), Err(Errno::EAGAIN)); + let mut out = [0; 4]; + assert_eq!(drain_into(&mut out), 4); + assert_eq!(out, [1, 2, 3, 0x80]); + } + + #[test] + fn transport_claim_prevents_competing_consumers() { + let (_guard, handle) = fresh(); + write(handle, &[0; 16], false).unwrap(); + claim_transport(pcm::PCM_TRANSPORT_SHARED_CLOCK).unwrap(); + assert_eq!(drain_into(&mut [0; 16]), 0); + assert_eq!(clock_update(4), 4); assert_eq!(pending_bytes(), 0); } + + #[test] + fn reset_close_reopen_keeps_live_cursors_monotonic() { + let (_guard, first) = fresh(); + set_format(first, pcm::PCM_FORMAT_U8).unwrap(); + set_channels(first, 1).unwrap(); + write(first, &[1, 2, 3, 4], false).unwrap(); + + let mut played = [0; 2]; + assert_eq!(drain_into(&mut played), 2); + assert_eq!(played, [1, 2]); + assert_eq!(output_pointer(first).unwrap().played_bytes, 2); + + // RESET permits the final close without waiting for the old tail. This + // is the lifecycle in which an already-running AudioWorklet quantum + // can otherwise publish an old absolute consumer after the reopen. + reset_stream(first).unwrap(); + let base = producer(); + let old_consumer = consumer(); + assert_eq!(base, 4); + assert_eq!(old_consumer, 2); + assert_eq!(discard(), base); + + let first_idx = stream_index(first).unwrap(); + let freed = crate::descriptor_backing::with_pcm_streams(|table| table.release(first_idx)); + assert!(freed); + on_last_ofd_released(first_idx); + + let second = open_stream().unwrap(); + assert_eq!(producer(), base, "producer must not rewind across opens"); + assert_eq!( + consumer(), + old_consumer, + "kernel must not race the host consumer writer" + ); + assert_eq!( + discard(), + base, + "the new generation starts at the old producer" + ); + assert_eq!(output_pointer(second).unwrap().played_bytes, 0); + + // Model a late publication from the old worklet generation. Because it + // cannot exceed that generation's producer, the new discard floor wins + // and no byte from the new stream is consumed or skipped. + publish_consumer(base - 1); + assert_eq!(pending_bytes(), 0); + + set_format(second, pcm::PCM_FORMAT_U8).unwrap(); + set_channels(second, 1).unwrap(); + assert_eq!(write(second, &[9, 10], false).unwrap(), 2); + assert_eq!(producer(), base + 2); + let mut next = [0; 2]; + assert_eq!(drain_into(&mut next), 2); + assert_eq!(next, [9, 10]); + assert_eq!(consumer(), base + 2); + assert_eq!(output_pointer(second).unwrap().played_bytes, 2); + } } diff --git a/crates/kernel/src/descriptor_backing.rs b/crates/kernel/src/descriptor_backing.rs index 81b1843828..bad671b649 100644 --- a/crates/kernel/src/descriptor_backing.rs +++ b/crates/kernel/src/descriptor_backing.rs @@ -112,7 +112,6 @@ impl SharedBackingTable { true } - #[cfg(test)] pub fn ref_count(&self, idx: usize) -> Option { self.entries .get(idx) @@ -204,6 +203,7 @@ static TIMERFDS: GlobalBackingTable = GlobalBackingTable::new(); static SIGNALFDS: GlobalBackingTable = GlobalBackingTable::new(); static MEMFDS: GlobalBackingTable = GlobalBackingTable::new(); static PROCFS_BUFS: GlobalBackingTable = GlobalBackingTable::new(); +static PCM_STREAMS: GlobalBackingTable = GlobalBackingTable::new(); pub fn with_eventfds( f: impl for<'a> FnOnce(&'a mut SharedBackingTable) -> R, @@ -233,6 +233,12 @@ pub fn with_procfs_bufs( PROCFS_BUFS.with(f) } +pub fn with_pcm_streams( + f: impl for<'a> FnOnce(&'a mut SharedBackingTable) -> R, +) -> R { + PCM_STREAMS.with(f) +} + fn negative_handle_idx(host_handle: i64) -> Result { if host_handle >= 0 { return Err(Errno::EBADF); @@ -251,7 +257,11 @@ fn negative_handle_idx(host_handle: i64) -> Result { pub fn manages_ofd(file_type: FileType, host_handle: i64) -> bool { matches!( file_type, - FileType::EventFd | FileType::TimerFd | FileType::SignalFd | FileType::MemFd + FileType::EventFd + | FileType::TimerFd + | FileType::SignalFd + | FileType::MemFd + | FileType::PcmPlayback ) || (file_type == FileType::Regular && crate::procfs::is_procfs_buf_handle(host_handle)) } @@ -380,6 +390,9 @@ pub fn add_ref_for_ofd(file_type: FileType, host_handle: i64) -> Result with_memfds(|table| table.add_ref(negative_handle_idx(host_handle)?))?, + FileType::PcmPlayback => { + with_pcm_streams(|table| table.add_ref(negative_handle_idx(host_handle)?))? + } FileType::Regular if crate::procfs::is_procfs_buf_handle(host_handle) => { with_procfs_bufs(|table| table.add_ref(crate::procfs::procfs_buf_idx(host_handle)))? } @@ -416,6 +429,15 @@ pub fn release_for_ofd(file_type: FileType, host_handle: i64) -> bool { } true } + FileType::PcmPlayback => { + if let Ok(idx) = negative_handle_idx(host_handle) { + let freed = with_pcm_streams(|table| table.release(idx)); + if freed { + crate::audio::on_last_ofd_released(idx); + } + } + true + } FileType::Regular if crate::procfs::is_procfs_buf_handle(host_handle) => { with_procfs_bufs(|table| table.release(crate::procfs::procfs_buf_idx(host_handle))); true diff --git a/crates/kernel/src/fork.rs b/crates/kernel/src/fork.rs index 323e726b0f..d1a8503e80 100644 --- a/crates/kernel/src/fork.rs +++ b/crates/kernel/src/fork.rs @@ -33,10 +33,9 @@ use crate::terminal::{NCCS, TerminalState, WinSize}; const FORK_MAGIC: u32 = 0x464F524B; // "FORK" const EXEC_MAGIC: u32 = 0x45584543; // "EXEC" -// v11 keeps the main thread's directed pending queue distinct from the shared -// process queue across legacy exec. Fork children still start with no pending -// signals, so the fork payload itself needs no new directed-signal section. -const FORK_VERSION: u32 = 11; +// v12 adds the stable PCM playback OFD type. Its negative host handle names a +// kernel-global backing, so fork/exec retain the same stream and ownership. +const FORK_VERSION: u32 = 12; // Bounds for deserialization to prevent OOM from malformed buffers. const MAX_FDS: u32 = 65536; @@ -458,6 +457,7 @@ fn file_type_to_u32(ft: FileType) -> u32 { FileType::MemFd => 9, FileType::PtyMaster => 10, FileType::PtySlave => 11, + FileType::PcmPlayback => 12, } } @@ -475,6 +475,7 @@ fn u32_to_file_type(v: u32) -> Result { 9 => Ok(FileType::MemFd), 10 => Ok(FileType::PtyMaster), 11 => Ok(FileType::PtySlave), + 12 => Ok(FileType::PcmPlayback), _ => Err(Errno::EINVAL), } } diff --git a/crates/kernel/src/ofd.rs b/crates/kernel/src/ofd.rs index 3546cfe57c..e9ba25c56a 100644 --- a/crates/kernel/src/ofd.rs +++ b/crates/kernel/src/ofd.rs @@ -75,6 +75,8 @@ pub enum FileType { MemFd, PtyMaster, PtySlave, + /// Playback-only PCM stream backing used by the OSS `/dev/dsp` frontend. + PcmPlayback, } /// Live cmdbuf mapping for a process's GLES2 fd. diff --git a/crates/kernel/src/process_table.rs b/crates/kernel/src/process_table.rs index 80bfae6cd0..cbad61e3e1 100644 --- a/crates/kernel/src/process_table.rs +++ b/crates/kernel/src/process_table.rs @@ -946,7 +946,7 @@ impl ProcessTable { match action { FileAction::Close { fd } => { // POSIX: close errors are silently ignored for spawn. - let _ = crate::syscalls::sys_close(child, host, *fd); + let _ = crate::syscalls::sys_close_implicit(child, host, *fd); } FileAction::Dup2 { srcfd, fd } => { if srcfd == fd { @@ -971,7 +971,7 @@ impl ProcessTable { let r = crate::syscalls::sys_dup2(child, host, opened, *fd); // Always close the temporary fd, even if dup2 failed — // we don't want to leak it on the error path. - let _ = crate::syscalls::sys_close(child, host, opened); + let _ = crate::syscalls::sys_close_implicit(child, host, opened); let _ = r?; } } @@ -1001,7 +1001,7 @@ impl ProcessTable { for fd in cloexec_fds { // POSIX: close errors here are silently ignored — same policy // as the FileAction::Close handler above. - let _ = crate::syscalls::sys_close(child, host, fd); + let _ = crate::syscalls::sys_close_implicit(child, host, fd); } Ok(()) diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 1057d45874..c2b590fd0c 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -303,122 +303,178 @@ fn proc_has_mice_fd(proc: &Process) -> bool { proc_has_virtual_device_fd(proc, VirtualDevice::Mice) } -/// Try to claim `/dev/dsp` for the calling process. +/// Convert the PCM core's internal "park until the audio clock advances" +/// sentinel into a real signal interruption at the syscall boundary. /// -/// Single-owner like `/dev/fb0` and `/dev/input/mice` — second open from -/// a different pid is `EBUSY`. Re-opens by the current owner are -/// accepted (matches the typical OSS exclusive-grab model). -fn acquire_dsp_or_busy(pid: u32) -> Result<(), Errno> { - let pid = pid as i32; - let owner = crate::audio::DSP_OWNER.load(core::sync::atomic::Ordering::SeqCst); - if owner != -1 && owner != pid { - return Err(Errno::EBUSY); +/// The host owns the asynchronous retry loop for `EAGAIN`, so allowing a +/// caught signal to pass through as `EAGAIN` would leave the guest asleep and +/// could let a later retry overwrite the undelivered channel signal record. +/// Only an operation which made no progress reaches this helper: a partial +/// write remains a successful short write, as POSIX requires. +fn interrupt_pcm_wait(proc: &Process, result: Result) -> Result { + if !matches!(result, Err(Errno::EAGAIN)) { + return result; } - let _ = crate::audio::DSP_OWNER.compare_exchange( - -1, - pid, - core::sync::atomic::Ordering::SeqCst, - core::sync::atomic::Ordering::SeqCst, - ); - Ok(()) -} -/// Release `/dev/dsp` ownership held by `pid`, if any. Drops any -/// pending samples so the next opener starts from silence. Idempotent. -pub(crate) fn maybe_release_dsp(pid: u32) { - let prev = crate::audio::DSP_OWNER.compare_exchange( - pid as i32, - -1, - core::sync::atomic::Ordering::SeqCst, - core::sync::atomic::Ordering::SeqCst, - ); - if prev.is_ok() { - crate::audio::reset(); + let tid = crate::process_table::current_tid(); + let caught = proc + .next_deliverable_signal(tid) + .is_some_and(|signum| { + matches!( + proc.signals.get_action(signum).handler, + SignalHandler::Handler(_) + ) + }); + if caught { + Err(Errno::EINTR) + } else { + result } } -/// True iff `proc` still has an open fd referencing `/dev/dsp`. -fn proc_has_dsp_fd(proc: &Process) -> bool { - proc_has_virtual_device_fd(proc, VirtualDevice::Dsp) -} - -/// Handle ioctl on `/dev/dsp`. -/// -/// Implements the OSS commands fbDOOM (and most OSS clients) actually -/// emit during init: -/// - `SNDCTL_DSP_RESET` — clear the ring (no-op besides side effect). -/// - `SNDCTL_DSP_SPEED` — set sample rate; in/out: i32 hz. -/// - `SNDCTL_DSP_STEREO` — set channel count; in/out: i32 (0=mono, 1=stereo). -/// - `SNDCTL_DSP_SETFMT` — set sample format; only `AFMT_S16_LE`. -/// - `SNDCTL_DSP_GETFMTS` — bitmask of supported formats; we report only `AFMT_S16_LE`. -/// - `SNDCTL_DSP_SETFRAGMENT` — accept and ignore (host buffering is RAF-paced). -/// - `SNDCTL_DSP_SYNC` — accept; the kernel ring is the boundary. -/// -/// Anything else returns `ENOTTY`. -fn handle_dsp_ioctl(request: u32, buf: &mut [u8]) -> Result<(), Errno> { +/// OSS `/dev/dsp` translation frontend. The PCM core deliberately exposes no +/// OSS ioctl numbers or ABI structs. +fn handle_dsp_ioctl( + proc: &mut Process, + ofd_idx: usize, + request: u32, + buf: &mut [u8], +) -> Result<(), Errno> { use wasm_posix_shared::oss::*; - match request { - SNDCTL_DSP_RESET => { - crate::audio::reset(); - Ok(()) + let handle = proc + .ofd_table + .get(ofd_idx) + .filter(|ofd| ofd.file_type == FileType::PcmPlayback) + .map(|ofd| ofd.host_handle) + .ok_or(Errno::EBADF)?; + + fn read_i32(buf: &[u8]) -> Result { + let bytes: [u8; 4] = buf.get(..4).ok_or(Errno::EINVAL)?.try_into().unwrap(); + Ok(i32::from_le_bytes(bytes)) + } + fn write_i32(buf: &mut [u8], value: i32) -> Result<(), Errno> { + buf.get_mut(..4) + .ok_or(Errno::EINVAL)? + .copy_from_slice(&value.to_le_bytes()); + Ok(()) + } + fn oss_to_pcm(format: u32) -> Option { + match format { + AFMT_QUERY => Some(wasm_posix_shared::pcm::PCM_FORMAT_UNKNOWN), + AFMT_U8 => Some(wasm_posix_shared::pcm::PCM_FORMAT_U8), + AFMT_S16_LE => Some(wasm_posix_shared::pcm::PCM_FORMAT_S16_LE), + AFMT_S16_BE => Some(wasm_posix_shared::pcm::PCM_FORMAT_S16_BE), + _ => None, + } + } + fn pcm_to_oss(format: u32) -> Result { + match format { + wasm_posix_shared::pcm::PCM_FORMAT_U8 => Ok(AFMT_U8), + wasm_posix_shared::pcm::PCM_FORMAT_S16_LE => Ok(AFMT_S16_LE), + wasm_posix_shared::pcm::PCM_FORMAT_S16_BE => Ok(AFMT_S16_BE), + _ => Err(Errno::EIO), } - SNDCTL_DSP_SYNC => Ok(()), + } + + match request { + SNDCTL_DSP_RESET => crate::audio::reset_stream(handle), + SNDCTL_DSP_SYNC => interrupt_pcm_wait(proc, crate::audio::sync(handle)), + SNDCTL_DSP_POST => crate::audio::post(handle), SNDCTL_DSP_SPEED => { - if buf.len() < 4 { + let requested = read_i32(buf)?; + if requested < 0 { return Err(Errno::EINVAL); } - let req = i32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]).max(0) as u32; - let actual = crate::audio::set_sample_rate(req); - buf[0..4].copy_from_slice(&(actual as i32).to_le_bytes()); - Ok(()) + write_i32(buf, crate::audio::set_rate(handle, requested as u32)? as i32) } SNDCTL_DSP_STEREO => { - if buf.len() < 4 { - return Err(Errno::EINVAL); - } - let req = i32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); - // OSS: arg = 0 means mono, anything else = stereo. - let chans = if req == 0 { 1 } else { 2 }; - let actual = crate::audio::set_channels(chans); - // Report back the boolean (1 = stereo, 0 = mono). - let report: i32 = if actual == 2 { 1 } else { 0 }; - buf[0..4].copy_from_slice(&report.to_le_bytes()); - Ok(()) + let requested = if read_i32(buf)? == 0 { 1 } else { 2 }; + let actual = crate::audio::set_channels(handle, requested)?; + write_i32(buf, i32::from(actual == 2)) } SNDCTL_DSP_CHANNELS => { - if buf.len() < 4 { + let requested = read_i32(buf)?; + if requested < 0 { return Err(Errno::EINVAL); } - let req = i32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]).max(0) as u32; - let actual = crate::audio::set_channels(req); - buf[0..4].copy_from_slice(&(actual as i32).to_le_bytes()); - Ok(()) + write_i32( + buf, + crate::audio::set_channels(handle, requested as u32)? as i32, + ) } SNDCTL_DSP_SETFMT => { - if buf.len() < 4 { + let requested = read_i32(buf)? as u32; + let pcm = oss_to_pcm(requested).ok_or(Errno::EINVAL)?; + let actual = pcm_to_oss(crate::audio::set_format(handle, pcm)?)?; + write_i32(buf, actual as i32) + } + SNDCTL_DSP_GETFMTS => write_i32(buf, SUPPORTED_FORMATS as i32), + SNDCTL_DSP_SETFRAGMENT => { + let actual = crate::audio::set_fragment(handle, read_i32(buf)? as u32)?; + write_i32(buf, actual as i32) + } + SNDCTL_DSP_GETOSPACE => { + let space = crate::audio::output_space(handle)?; + if buf.len() < 16 { return Err(Errno::EINVAL); } - let req = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); - if !crate::audio::set_format(req) { - return Err(Errno::EINVAL); + for (offset, value) in [ + space.available_fragments, + space.total_fragments, + space.fragment_bytes, + space.available_bytes, + ] + .into_iter() + .enumerate() + { + let start = offset * 4; + buf[start..start + 4].copy_from_slice(&(value as i32).to_le_bytes()); } - // Echo the format back. - buf[0..4].copy_from_slice(&req.to_le_bytes()); Ok(()) } - SNDCTL_DSP_GETFMTS => { - if buf.len() < 4 { + SNDCTL_DSP_GETBLKSIZE => { + let (fragment_bytes, _) = crate::audio::geometry(handle)?; + write_i32(buf, fragment_bytes as i32) + } + SNDCTL_DSP_GETODELAY => write_i32(buf, crate::audio::output_delay(handle)?), + SNDCTL_DSP_GETCAPS => write_i32(buf, SUPPORTED_CAPS as i32), + SNDCTL_DSP_GETOPTR => { + let position = crate::audio::output_pointer(handle)?; + if buf.len() < 12 { return Err(Errno::EINVAL); } - buf[0..4].copy_from_slice(&crate::audio::AFMT_S16_LE.to_le_bytes()); + let values = [ + position.played_bytes as u32 as i32, + position.completed_fragments as i32, + position.ring_offset as i32, + ]; + for (index, value) in values.into_iter().enumerate() { + let start = index * 4; + buf[start..start + 4].copy_from_slice(&value.to_le_bytes()); + } Ok(()) } - SNDCTL_DSP_SETFRAGMENT => { - // Accept silently — fragment hints don't apply to the - // host-side AudioContext path. Echo the value the caller - // provided so it doesn't second-guess us. + SNDCTL_DSP_NONBLOCK => { + crate::audio::set_nonblock(handle, true)?; + proc.ofd_table + .get_mut(ofd_idx) + .ok_or(Errno::EBADF)? + .status_flags |= O_NONBLOCK; Ok(()) } + SOUND_PCM_READ_RATE => { + let (_, rate, _) = crate::audio::config(handle)?; + write_i32(buf, rate as i32) + } + SOUND_PCM_READ_CHANNELS => { + let (_, _, channels) = crate::audio::config(handle)?; + write_i32(buf, channels as i32) + } + SOUND_PCM_READ_BITS => { + let (format, _, _) = crate::audio::config(handle)?; + let bits = if format == wasm_posix_shared::pcm::PCM_FORMAT_U8 { 8 } else { 16 }; + write_i32(buf, bits) + } _ => Err(Errno::ENOTTY), } } @@ -662,9 +718,8 @@ pub(crate) fn release_exec_image_state(proc: &mut Process, host: &mut dyn HostIO maybe_release_fb0(proc.pid); } } - // Mouse and DSP state belongs to their surviving open descriptions, not - // the discarded Wasm image. A CLOEXEC close (or the eventual last close) - // releases ownership and drains the corresponding queue. + // Device state belongs to surviving open descriptions, not the discarded + // Wasm image. A final PCM CLOEXEC close leaves its tail to the audio clock. release_process_dri_mappings(proc, host); } @@ -703,7 +758,7 @@ pub(crate) fn commit_exec_state( .map(|(fd, _)| fd) .collect(); for fd in cloexec_fds { - let _ = sys_close(proc, host, fd); + let _ = sys_close_implicit(proc, host, fd); } for stream in proc.dir_streams.iter_mut().filter_map(Option::take) { let _ = host.host_closedir(stream.host_handle); @@ -2288,8 +2343,13 @@ pub fn sys_open( let ofd_ref = entry.ofd_ref; proc.ofd_table.inc_ref(ofd_ref.0); let fd_flags = oflags_to_fd_flags(oflags); - let fd = proc.fd_table.alloc(ofd_ref, fd_flags)?; - return Ok(fd); + return match proc.fd_table.alloc(ofd_ref, fd_flags) { + Ok(fd) => Ok(fd), + Err(err) => { + proc.ofd_table.dec_ref(ofd_ref.0); + Err(err) + } + }; } // Virtual device nodes — handle in-kernel, no host call @@ -2300,10 +2360,29 @@ pub fn sys_open( if dev == VirtualDevice::Mice { acquire_mice_or_busy(proc.pid)?; } + let status_flags = oflags & !CREATION_FLAGS; if dev == VirtualDevice::Dsp { - acquire_dsp_or_busy(proc.pid)?; + if status_flags & O_ACCMODE != O_WRONLY { + return Err(Errno::EOPNOTSUPP); + } + let pcm_handle = crate::audio::open_stream()?; + crate::audio::set_nonblock(pcm_handle, status_flags & O_NONBLOCK != 0)?; + let ofd_idx = proc.ofd_table.create( + FileType::PcmPlayback, + status_flags, + pcm_handle, + resolved, + ); + let fd_flags = oflags_to_fd_flags(oflags); + return match proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags) { + Ok(fd) => Ok(fd), + Err(error) => { + proc.ofd_table.dec_ref(ofd_idx); + crate::audio::rollback_open(pcm_handle); + Err(error) + } + }; } - let status_flags = oflags & !CREATION_FLAGS; let ofd_idx = proc.ofd_table.create( FileType::CharDevice, status_flags, @@ -2441,6 +2520,45 @@ pub fn sys_open( /// Close a file descriptor. pub fn sys_close(proc: &mut Process, host: &mut dyn HostIO, fd: i32) -> Result<(), Errno> { + sys_close_impl(proc, host, fd, true) +} + +/// Close without waiting for a PCM tail. Used by process teardown and other +/// implicit-close paths where POSIX does not permit delaying descriptor-table +/// mutation. The final PCM backing remains exclusively owned while it drains. +pub(crate) fn sys_close_implicit( + proc: &mut Process, + host: &mut dyn HostIO, + fd: i32, +) -> Result<(), Errno> { + sys_close_impl(proc, host, fd, false) +} + +fn sys_close_impl( + proc: &mut Process, + host: &mut dyn HostIO, + fd: i32, + drain_pcm: bool, +) -> Result<(), Errno> { + let mut deferred_pcm_error = None; + if drain_pcm { + let entry = proc.fd_table.get(fd)?; + let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + // A dup-shared OFD is not being destroyed until its final local fd + // closes. Cross-process inherited OFDs are checked by the PCM table. + if ofd.file_type == FileType::PcmPlayback && ofd.ref_count == 1 { + match interrupt_pcm_wait(proc, crate::audio::preflight_close(ofd.host_handle)) { + Ok(()) => {} + // A fatal sink cannot drain. The PCM core has discarded its + // tail, so close must still release the fd/OFD and exclusive + // device ownership while reporting the sink failure. + Err(Errno::EIO) => deferred_pcm_error = Some(Errno::EIO), + // EAGAIN and its caught-signal EINTR translation leave the fd + // live so the explicit close can be retried deterministically. + Err(err) => return Err(err), + } + } + } let ofd_ref = proc.fd_table.free(fd)?; let idx = ofd_ref.0; @@ -2640,6 +2758,9 @@ pub fn sys_close(proc: &mut Process, host: &mut dyn HostIO, fd: i32) -> Result<( FileType::MemFd => { crate::descriptor_backing::release_for_ofd(file_type, host_handle); } + FileType::PcmPlayback => { + crate::descriptor_backing::release_for_ofd(file_type, host_handle); + } FileType::PtyMaster => { let pty_idx = host_handle as usize; if let Some(pty) = crate::pty::get_pty(pty_idx) { @@ -2712,17 +2833,10 @@ pub fn sys_close(proc: &mut Process, host: &mut dyn HostIO, fd: i32) -> Result<( maybe_release_mice(proc.pid); } - // /dev/dsp ownership: same pattern — release once the last Dsp fd - // is gone and drop any unflushed PCM bytes so a successor open - // starts from silence. - if file_type == FileType::CharDevice - && VirtualDevice::from_host_handle(host_handle) == Some(VirtualDevice::Dsp) - && !proc_has_dsp_fd(proc) - { - maybe_release_dsp(proc.pid); + match deferred_pcm_error { + Some(err) => Err(err), + None => Ok(()), } - - Ok(()) } /// Read from a file descriptor into `buf`, returning the number of bytes read. @@ -2942,13 +3056,10 @@ pub fn sys_read( // /dev/fb0 doesn't support direct read — software is // expected to mmap. Return 0 (EOF-like) rather than // making up pixel bytes, matching the existing - // "no-op for unsupported access" pattern. /dev/dsp - // is write-only too: the host drains via the - // dedicated wasm export, not via user-space read(). - VirtualDevice::Null - | VirtualDevice::Fb0 - | VirtualDevice::Dsp - | VirtualDevice::DriRenderD128 => 0, + // "no-op for unsupported access" pattern. + VirtualDevice::Null | VirtualDevice::Fb0 | VirtualDevice::DriRenderD128 => 0, + // Real DSP descriptors use PcmPlayback and O_WRONLY. + VirtualDevice::Dsp => return Err(Errno::EBADF), VirtualDevice::DriCard0 => { // Drain queued DRM events (DRM_EVENT_FLIP_COMPLETE) // into the caller buffer, one byte at a time so a @@ -3111,6 +3222,15 @@ pub fn sys_write( } match file_type { + FileType::PcmPlayback => { + let nonblock = crate::audio::is_nonblock(host_handle)?; + let result = crate::audio::write(host_handle, buf, nonblock); + if nonblock { + result + } else { + interrupt_pcm_wait(proc, result) + } + } FileType::Pipe => { if host_handle >= 0 { // Host-delegated pipe (cross-process): use host_write @@ -3298,16 +3418,9 @@ pub fn sys_write( // when capping at smem_len; we mirror that. Ok(buf.len()) } - VirtualDevice::Dsp => { - // OSS write semantics: append PCM to the device - // queue. The kernel ring drops oldest whole - // frames on overflow (matches what real OSS - // drivers do under hardware overrun) but always - // reports `buf.len()` to the caller — same as - // /dev/null/zero/urandom: we never short-write. - crate::audio::write_pcm(buf); - Ok(buf.len()) - } + // `/dev/dsp` opens are represented by PcmPlayback, + // never by this legacy virtual-character path. + VirtualDevice::Dsp => Err(Errno::EBADF), _ => Ok(buf.len()), // Null, Zero, Urandom, Mice: discard }; } @@ -3383,6 +3496,7 @@ pub fn sys_lseek( | FileType::SignalFd | FileType::PtyMaster | FileType::PtySlave + | FileType::PcmPlayback ) { return Err(Errno::ESPIPE); } @@ -3603,6 +3717,7 @@ pub fn sys_pread( | FileType::SignalFd | FileType::PtyMaster | FileType::PtySlave + | FileType::PcmPlayback ) { return Err(Errno::ESPIPE); } @@ -3787,6 +3902,7 @@ fn validate_transfer_input(proc: &Process, fd: i32, offset: Option) -> Resu | FileType::SignalFd | FileType::PtyMaster | FileType::PtySlave + | FileType::PcmPlayback ) { return Err(Errno::ESPIPE); @@ -3824,6 +3940,7 @@ pub fn sys_pwrite( | FileType::SignalFd | FileType::PtyMaster | FileType::PtySlave + | FileType::PcmPlayback ) { return Err(Errno::ESPIPE); } @@ -4151,7 +4268,7 @@ pub fn sys_dup2( } // Close newfd if it's open (ignore errors). - let _ = sys_close(proc, host, newfd); + let _ = sys_close_implicit(proc, host, newfd); // Re-read oldfd entry since sys_close may have mutated tables // (only if newfd happened to share an OFD with oldfd). @@ -4311,6 +4428,16 @@ pub fn sys_fstat(proc: &mut Process, host: &mut dyn HostIO, fd: i32) -> Result Result { let entry = proc.fd_table.get(fd)?; let ofd_idx = entry.ofd_ref.0; + let pcm_handle = proc + .ofd_table + .get(ofd_idx) + .filter(|ofd| ofd.file_type == FileType::PcmPlayback) + .map(|ofd| ofd.host_handle); proc.ofd_table.set_status_flags(ofd_idx, arg); + if let Some(handle) = pcm_handle { + crate::audio::set_nonblock(handle, arg & O_NONBLOCK != 0)?; + } Ok(0) } F_SETOWN => { @@ -6228,7 +6371,7 @@ fn cleanup_process_for_exit(proc: &mut Process, host: &mut dyn HostIO) { // and F_DUPFD can place live descriptors above the default 1024 slots. let open_fds: Vec = proc.fd_table.iter().map(|(fd, _)| fd).collect(); for fd in open_fds { - let _ = sys_close(proc, host, fd); + let _ = sys_close_implicit(proc, host, fd); } // Close all directory streams @@ -6629,6 +6772,9 @@ pub fn sys_mmap( // must request exactly that length. let entry = proc.fd_table.get(fd)?; let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + if ofd.file_type == FileType::PcmPlayback { + return Err(Errno::ENODEV); + } if ofd.file_type == FileType::CharDevice && VirtualDevice::from_host_handle(ofd.host_handle) == Some(VirtualDevice::Fb0) { @@ -10246,6 +10392,22 @@ fn poll_check(proc: &mut Process, host: &mut dyn HostIO, fds: &mut [WasmPollFd]) let mut revents: i16 = 0; match ofd.file_type { + FileType::PcmPlayback => { + match crate::audio::has_fatal_error(ofd.host_handle) { + // Error readiness is reported regardless of the requested + // event mask, and a failed sink is never simultaneously + // advertised as writable. + Ok(true) => revents |= POLLERR, + Ok(false) + if pollfd.events & POLLOUT != 0 + && crate::audio::poll_writable(ofd.host_handle) + .unwrap_or(false) => + { + revents |= POLLOUT; + } + _ => {} + } + } FileType::EventFd => { let efd_idx = (-(ofd.host_handle + 1)) as usize; if let Some((counter, semaphore_room)) = @@ -10300,15 +10462,6 @@ fn poll_check(proc: &mut Process, host: &mut dyn HostIO, fds: &mut [WasmPollFd]) revents |= POLLIN; } // Mice doesn't accept writes — never report POLLOUT. - } else if ofd.file_type == FileType::CharDevice - && VirtualDevice::from_host_handle(ofd.host_handle) == Some(VirtualDevice::Dsp) - { - // /dev/dsp is write-only. POLLOUT is always ready — - // the ring drops oldest frames on overflow rather - // than blocking — and POLLIN never fires. - if pollfd.events & POLLOUT != 0 { - revents |= POLLOUT; - } } else { // Regular files and char devices are always ready if pollfd.events & POLLIN != 0 { @@ -10638,8 +10791,13 @@ pub fn sys_openat( let ofd_ref = entry.ofd_ref; proc.ofd_table.inc_ref(ofd_ref.0); let fd_flags = oflags_to_fd_flags(oflags); - let fd = proc.fd_table.alloc(ofd_ref, fd_flags)?; - return Ok(fd); + return match proc.fd_table.alloc(ofd_ref, fd_flags) { + Ok(fd) => Ok(fd), + Err(err) => { + proc.ofd_table.dec_ref(ofd_ref.0); + Err(err) + } + }; } // Virtual device nodes — handle in-kernel, no host call @@ -10650,10 +10808,29 @@ pub fn sys_openat( if dev == VirtualDevice::Mice { acquire_mice_or_busy(proc.pid)?; } + let status_flags = oflags & !CREATION_FLAGS; if dev == VirtualDevice::Dsp { - acquire_dsp_or_busy(proc.pid)?; + if status_flags & O_ACCMODE != O_WRONLY { + return Err(Errno::EOPNOTSUPP); + } + let pcm_handle = crate::audio::open_stream()?; + crate::audio::set_nonblock(pcm_handle, status_flags & O_NONBLOCK != 0)?; + let ofd_idx = proc.ofd_table.create( + FileType::PcmPlayback, + status_flags, + pcm_handle, + resolved, + ); + let fd_flags = oflags_to_fd_flags(oflags); + return match proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags) { + Ok(fd) => Ok(fd), + Err(error) => { + proc.ofd_table.dec_ref(ofd_idx); + crate::audio::rollback_open(pcm_handle); + Err(error) + } + }; } - let status_flags = oflags & !CREATION_FLAGS; let ofd_idx = proc.ofd_table.create( FileType::CharDevice, status_flags, @@ -11070,11 +11247,15 @@ pub fn sys_ioctl( } let val = i32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); let ofd = proc.ofd_table.get_mut(ofd_idx).ok_or(Errno::EBADF)?; + let pcm_handle = (ofd.file_type == FileType::PcmPlayback).then_some(ofd.host_handle); if val != 0 { ofd.status_flags |= wasm_posix_shared::flags::O_NONBLOCK; } else { ofd.status_flags &= !wasm_posix_shared::flags::O_NONBLOCK; } + if let Some(handle) = pcm_handle { + crate::audio::set_nonblock(handle, val != 0)?; + } return Ok(()); } @@ -11217,10 +11398,8 @@ pub fn sys_ioctl( // --- /dev/dsp ioctls — OSS surface --- { let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; - if ofd.file_type == FileType::CharDevice - && VirtualDevice::from_host_handle(ofd.host_handle) == Some(VirtualDevice::Dsp) - { - return handle_dsp_ioctl(request, buf); + if ofd.file_type == FileType::PcmPlayback { + return handle_dsp_ioctl(proc, ofd_idx, request, buf); } } @@ -12608,7 +12787,11 @@ pub fn sys_fpathconf( virtual_filesystem_pathconf_value(name) } } - FileType::EventFd | FileType::Epoll | FileType::TimerFd | FileType::SignalFd => { + FileType::EventFd + | FileType::Epoll + | FileType::TimerFd + | FileType::SignalFd + | FileType::PcmPlayback => { Err(Errno::EINVAL) } } @@ -28987,22 +29170,10 @@ mod tests { } // ----------------------------------------------------------------- - // /dev/dsp tests — mirror the fb0 / mice surface for OSS audio. + // /dev/dsp tests — OSS frontend over the OFD-owned PCM core. // ----------------------------------------------------------------- - /// Serializes tests that touch DSP_OWNER + the audio ring + the - /// audio config atomics. Shares the lock with `audio::tests` so - /// concurrent runs across the two modules don't race on the global - /// ring (single mutex, two test modules). - use crate::audio::TEST_RING_LOCK as DSP_OWNER_LOCK; - - fn reset_dsp_state() { - use core::sync::atomic::Ordering; - crate::audio::DSP_OWNER.store(-1, Ordering::SeqCst); - crate::audio::reset(); - crate::audio::SAMPLE_RATE.store(11025, Ordering::Relaxed); - crate::audio::CHANNELS.store(2, Ordering::Relaxed); - } + use crate::audio::TEST_AUDIO_LOCK; #[test] fn match_virtual_device_recognizes_dsp() { @@ -29021,196 +29192,590 @@ mod tests { } #[test] - fn open_dsp_acquires_ownership_and_second_open_from_other_pid_is_ebusy() { - use core::sync::atomic::Ordering; - let _g = DSP_OWNER_LOCK.lock().unwrap(); - reset_dsp_state(); + fn opened_dsp_fstat_is_chr() { + let _g = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::audio::reset_for_test(); + + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open( + &mut proc, + &mut host, + b"/dev/dsp", + O_WRONLY | O_NONBLOCK, + 0, + ) + .unwrap(); + let st = sys_fstat(&mut proc, &mut host, fd).unwrap(); + assert_eq!( + st.st_mode & wasm_posix_shared::mode::S_IFMT, + wasm_posix_shared::mode::S_IFCHR + ); + assert_eq!(st.st_ino, VirtualDevice::Dsp.ino()); + sys_close(&mut proc, &mut host, fd).unwrap(); + } + + #[test] + fn open_dsp_is_exclusive_per_open_description_and_rejects_capture() { + let _g = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::audio::reset_for_test(); let mut proc1 = Process::new(1); let mut proc2 = Process::new(2); let mut host = MockHostIO::new(); let fd1 = sys_open(&mut proc1, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + let err = sys_open(&mut proc2, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap_err(); + assert_eq!(err, Errno::EBUSY); assert_eq!( - crate::audio::DSP_OWNER.load(Ordering::SeqCst), - proc1.pid as i32 + sys_open(&mut proc1, &mut host, b"/dev/dsp", O_WRONLY, 0), + Err(Errno::EBUSY) + ); + assert_eq!( + sys_open(&mut proc2, &mut host, b"/dev/dsp", O_RDONLY, 0), + Err(Errno::EOPNOTSUPP) ); + assert_eq!( + sys_open(&mut proc2, &mut host, b"/dev/dsp", O_RDWR, 0), + Err(Errno::EOPNOTSUPP) + ); + sys_close(&mut proc1, &mut host, fd1).unwrap(); + } - let err = sys_open(&mut proc2, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap_err(); - assert_eq!(err, Errno::EBUSY); + #[test] + fn inherited_dsp_descriptor_shares_ownership_and_nonblock_state() { + use crate::process_table::ProcessTable; - // Re-open by SAME process is allowed. - let fd1b = sys_open(&mut proc1, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); - assert_ne!(fd1, fd1b); - sys_close(&mut proc1, &mut host, fd1).unwrap(); - sys_close(&mut proc1, &mut host, fd1b).unwrap(); + const PARENT: u32 = 980_100; + const CHILD: u32 = 980_101; + let _g = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::audio::reset_for_test(); + let mut table = ProcessTable::new(); + let mut host = MockHostIO::new(); + table.create_process(PARENT).unwrap(); + let fd = sys_open( + table.get_mut(PARENT).unwrap(), + &mut host, + b"/dev/dsp", + O_WRONLY, + 0, + ) + .unwrap(); + table.fork_process(PARENT, CHILD).unwrap(); + + sys_fcntl(table.get_mut(PARENT).unwrap(), fd, F_SETFL, O_NONBLOCK).unwrap(); + assert_ne!( + sys_fcntl(table.get_mut(CHILD).unwrap(), fd, F_GETFL, 0).unwrap() as u32 + & O_NONBLOCK, + 0 + ); + sys_fcntl(table.get_mut(CHILD).unwrap(), fd, F_SETFL, 0).unwrap(); + assert_eq!( + sys_fcntl(table.get_mut(PARENT).unwrap(), fd, F_GETFL, 0).unwrap() as u32 + & O_NONBLOCK, + 0 + ); + + sys_close(table.get_mut(CHILD).unwrap(), &mut host, fd).unwrap(); + let mut contender = Process::new(980_102); + assert_eq!( + sys_open(&mut contender, &mut host, b"/dev/dsp", O_WRONLY, 0), + Err(Errno::EBUSY) + ); + sys_close(table.get_mut(PARENT).unwrap(), &mut host, fd).unwrap(); + let contender_fd = + sys_open(&mut contender, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + sys_close(&mut contender, &mut host, contender_fd).unwrap(); } #[test] - fn write_dsp_buffers_pcm_into_ring() { - let _g = DSP_OWNER_LOCK.lock().unwrap(); - reset_dsp_state(); + fn duplicated_dsp_descriptor_keeps_one_ofd_and_exclusive_owner() { + let _g = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::audio::reset_for_test(); + let mut owner = Process::new(1); + let mut contender = Process::new(2); + let mut host = MockHostIO::new(); - let mut proc = Process::new(1); + let fd = sys_open(&mut owner, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + let alias = sys_dup(&mut owner, fd).unwrap(); + sys_close(&mut owner, &mut host, fd).unwrap(); + assert_eq!( + sys_open(&mut contender, &mut host, b"/dev/dsp", O_WRONLY, 0), + Err(Errno::EBUSY) + ); + sys_close(&mut owner, &mut host, alias).unwrap(); + + let contender_fd = + sys_open(&mut contender, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + sys_close(&mut contender, &mut host, contender_fd).unwrap(); + } + + #[test] + fn dsp_dup2_replacement_keeps_owner_until_the_final_alias_closes() { + let _g = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::audio::reset_for_test(); + let mut owner = Process::new(1); + let mut contender = Process::new(2); let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); - let pcm: [u8; 8] = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]; - let n = sys_write(&mut proc, &mut host, fd, &pcm).unwrap(); - assert_eq!(n, 8); - assert_eq!(crate::audio::pending_bytes(), 8); + let dsp_fd = sys_open(&mut owner, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + let replaced_fd = sys_open(&mut owner, &mut host, b"/dev/null", O_WRONLY, 0).unwrap(); + assert_eq!(sys_dup2(&mut owner, &mut host, dsp_fd, replaced_fd), Ok(replaced_fd)); + assert_eq!(sys_dup2(&mut owner, &mut host, replaced_fd, replaced_fd), Ok(replaced_fd)); + + sys_close(&mut owner, &mut host, dsp_fd).unwrap(); + assert_eq!( + sys_open(&mut contender, &mut host, b"/dev/dsp", O_WRONLY, 0), + Err(Errno::EBUSY) + ); + sys_close(&mut owner, &mut host, replaced_fd).unwrap(); - let mut out = [0u8; 8]; - let drained = crate::audio::drain_into(&mut out); - assert_eq!(drained, 8); - assert_eq!(out, pcm); + let contender_fd = + sys_open(&mut contender, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + sys_close(&mut contender, &mut host, contender_fd).unwrap(); + } - sys_close(&mut proc, &mut host, fd).unwrap(); + #[test] + fn non_cloexec_dsp_descriptor_survives_exec_with_the_same_owner() { + let _g = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::audio::reset_for_test(); + let mut owner = Process::new(1); + let mut contender = Process::new(2); + let mut host = MockHostIO::new(); + + let fd = sys_open(&mut owner, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + let pid = owner.pid; + commit_exec_state(&mut owner, &mut host, pid).unwrap(); + assert!(owner.fd_table.get(fd).is_ok()); + assert_eq!(sys_write(&mut owner, &mut host, fd, &[1; 4]), Ok(4)); + assert_eq!( + sys_open(&mut contender, &mut host, b"/dev/dsp", O_WRONLY, 0), + Err(Errno::EBUSY) + ); + + crate::audio::reset_stream( + owner + .ofd_table + .get(owner.fd_table.get(fd).unwrap().ofd_ref.0) + .unwrap() + .host_handle, + ) + .unwrap(); + sys_close(&mut owner, &mut host, fd).unwrap(); + let contender_fd = + sys_open(&mut contender, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + sys_close(&mut contender, &mut host, contender_fd).unwrap(); } #[test] - fn read_dsp_returns_zero() { - // OSS write-only: read returns 0 (EOF-like), not EAGAIN — same - // policy as /dev/null. Stops fbDOOM from spinning if it ever - // tries to read back the ring. - let _g = DSP_OWNER_LOCK.lock().unwrap(); - reset_dsp_state(); + fn inherited_dsp_owner_survives_child_exit_until_parent_closes() { + use crate::process_table::ProcessTable; - let mut proc = Process::new(1); + const PARENT: u32 = 980_200; + const CHILD: u32 = 980_201; + let _g = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::audio::reset_for_test(); + let mut table = ProcessTable::new(); let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/dev/dsp", O_RDONLY, 0).unwrap(); + table.create_process(PARENT).unwrap(); + let fd = sys_open( + table.get_mut(PARENT).unwrap(), + &mut host, + b"/dev/dsp", + O_WRONLY, + 0, + ) + .unwrap(); + table.fork_process(PARENT, CHILD).unwrap(); - let mut buf = [0u8; 8]; - let n = sys_read(&mut proc, &mut host, fd, &mut buf).unwrap(); - assert_eq!(n, 0); - sys_close(&mut proc, &mut host, fd).unwrap(); + sys_exit(table.get_mut(CHILD).unwrap(), &mut host, 0); + let mut contender = Process::new(980_202); + assert_eq!( + sys_open(&mut contender, &mut host, b"/dev/dsp", O_WRONLY, 0), + Err(Errno::EBUSY) + ); + assert_eq!( + sys_write(table.get_mut(PARENT).unwrap(), &mut host, fd, &[7; 4]), + Ok(4) + ); + crate::audio::reset_stream( + table + .get(PARENT) + .unwrap() + .ofd_table + .get(table.get(PARENT).unwrap().fd_table.get(fd).unwrap().ofd_ref.0) + .unwrap() + .host_handle, + ) + .unwrap(); + sys_close(table.get_mut(PARENT).unwrap(), &mut host, fd).unwrap(); + + let contender_fd = + sys_open(&mut contender, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + sys_close(&mut contender, &mut host, contender_fd).unwrap(); } #[test] - fn ioctl_dsp_speed_roundtrips_through_ring_config() { - use wasm_posix_shared::oss::*; - let _g = DSP_OWNER_LOCK.lock().unwrap(); - reset_dsp_state(); + fn caught_signal_interrupts_only_unprogressed_dsp_waits_and_keeps_close_live() { + use wasm_posix_shared::oss::{SNDCTL_DSP_RESET, SNDCTL_DSP_SETFRAGMENT, SNDCTL_DSP_SYNC}; + use wasm_posix_shared::signal::{SA_RESTART, SIGUSR1}; + let _g = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::audio::reset_for_test(); let mut proc = Process::new(1); let mut host = MockHostIO::new(); let fd = sys_open(&mut proc, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + let mut fragments = ((2i32 << 16) | 4).to_le_bytes(); + sys_ioctl( + &mut proc, + &mut host, + fd, + SNDCTL_DSP_SETFRAGMENT, + &mut fragments, + ) + .unwrap(); - let mut arg = 44100i32.to_le_bytes(); - sys_ioctl(&mut proc, &mut host,fd, SNDCTL_DSP_SPEED, &mut arg).unwrap(); - // The kernel echoes back the rate it actually configured. - assert_eq!(i32::from_le_bytes(arg), 44100); - assert_eq!(crate::audio::current_config().0, 44100); + assert_eq!(sys_write(&mut proc, &mut host, fd, &[1; 32]), Ok(32)); + sys_sigaction(&mut proc, SIGUSR1, 42, SA_RESTART, 0).unwrap(); + proc.signals.raise(SIGUSR1); + + // A nonblocking no-progress write reports buffer pressure, not an + // interruption: it never slept. The caught signal stays pending for + // the next syscall boundary. + sys_fcntl(&mut proc, fd, F_SETFL, O_NONBLOCK).unwrap(); + assert_eq!(sys_write(&mut proc, &mut host, fd, &[2; 1]), Err(Errno::EAGAIN)); + assert!(proc.signals.is_pending(SIGUSR1)); + sys_fcntl(&mut proc, fd, F_SETFL, 0).unwrap(); + + // No bytes fit, so write, drain, and final close are interruptible. + // The kernel leaves the caught record queued for the host/glue to + // deliver, and close has not removed the descriptor. + assert_eq!(sys_write(&mut proc, &mut host, fd, &[2; 1]), Err(Errno::EINTR)); + assert!(proc.signals.is_pending(SIGUSR1)); + assert_eq!( + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_SYNC, &mut []), + Err(Errno::EINTR) + ); + assert_eq!(sys_close(&mut proc, &mut host, fd), Err(Errno::EINTR)); + assert!(proc.fd_table.get(fd).is_ok()); + + // A write larger than the configured capacity may make partial + // progress. That progress wins over EINTR and the signal stays queued + // for delivery at the successful syscall boundary. + assert_eq!(crate::audio::drain_into(&mut [0; 16]), 16); + assert_eq!(sys_write(&mut proc, &mut host, fd, &[3; 33]), Ok(16)); + assert!(proc.signals.is_pending(SIGUSR1)); + // Changing the disposition to ignore discards the pending instance, + // allowing deterministic reset and close cleanup. + sys_sigaction(&mut proc, SIGUSR1, SIG_IGN, 0, 0).unwrap(); + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_RESET, &mut []).unwrap(); sys_close(&mut proc, &mut host, fd).unwrap(); } #[test] - fn ioctl_dsp_setfmt_rejects_non_s16_le() { + fn exec_cloexec_and_process_exit_leave_pcm_tail_to_the_audio_clock() { + let _g = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let mut host = MockHostIO::new(); + + crate::audio::reset_for_test(); + let mut exec_owner = Process::new(10); + let exec_fd = sys_open( + &mut exec_owner, + &mut host, + b"/dev/dsp", + O_WRONLY | O_CLOEXEC, + 0, + ) + .unwrap(); + sys_write(&mut exec_owner, &mut host, exec_fd, &[1; 8]).unwrap(); + let exec_pid = exec_owner.pid; + commit_exec_state(&mut exec_owner, &mut host, exec_pid).unwrap(); + assert!(exec_owner.fd_table.get(exec_fd).is_err()); + + let mut contender = Process::new(11); + assert_eq!( + sys_open(&mut contender, &mut host, b"/dev/dsp", O_WRONLY, 0), + Err(Errno::EBUSY) + ); + assert_eq!(crate::audio::drain_into(&mut [0; 8]), 8); + let reopened = + sys_open(&mut contender, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + sys_close(&mut contender, &mut host, reopened).unwrap(); + + crate::audio::reset_for_test(); + let mut exit_owner = Process::new(20); + let exit_fd = + sys_open(&mut exit_owner, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + sys_write(&mut exit_owner, &mut host, exit_fd, &[2; 8]).unwrap(); + sys_exit(&mut exit_owner, &mut host, 0); + assert!(exit_owner.fd_table.get(exit_fd).is_err()); + + let mut exit_contender = Process::new(21); + assert_eq!( + sys_open( + &mut exit_contender, + &mut host, + b"/dev/dsp", + O_WRONLY, + 0, + ), + Err(Errno::EBUSY) + ); + assert_eq!(crate::audio::drain_into(&mut [0; 8]), 8); + let reopened = sys_open( + &mut exit_contender, + &mut host, + b"/dev/dsp", + O_WRONLY, + 0, + ) + .unwrap(); + sys_close(&mut exit_contender, &mut host, reopened).unwrap(); + } + + #[test] + fn dsp_reconfiguration_and_unsupported_operations_fail_truthfully() { use wasm_posix_shared::oss::*; - let _g = DSP_OWNER_LOCK.lock().unwrap(); - reset_dsp_state(); + let _g = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::audio::reset_for_test(); let mut proc = Process::new(1); let mut host = MockHostIO::new(); let fd = sys_open(&mut proc, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); - let mut arg = 0x08u32.to_le_bytes(); // AFMT_U8 — unsupported - let err = sys_ioctl(&mut proc, &mut host,fd, SNDCTL_DSP_SETFMT, &mut arg).unwrap_err(); - assert_eq!(err, Errno::EINVAL); + let mut query = (AFMT_QUERY as i32).to_le_bytes(); + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_SETFMT, &mut query).unwrap(); + assert_eq!(u32::from_le_bytes(query), AFMT_S16_LE); + + let mut unsupported_format = (AFMT_S32_LE as i32).to_le_bytes(); + assert_eq!( + sys_ioctl( + &mut proc, + &mut host, + fd, + SNDCTL_DSP_SETFMT, + &mut unsupported_format, + ), + Err(Errno::EINVAL) + ); + + let mut low_rate = 1i32.to_le_bytes(); + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_SPEED, &mut low_rate).unwrap(); + assert_eq!(i32::from_le_bytes(low_rate), 8_000); + let mut channels = 9i32.to_le_bytes(); + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_CHANNELS, &mut channels).unwrap(); + assert_eq!(i32::from_le_bytes(channels), 2); + + sys_write(&mut proc, &mut host, fd, &[0; 4]).unwrap(); + let mut rate = 44_100i32.to_le_bytes(); + assert_eq!( + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_SPEED, &mut rate), + Err(Errno::EBUSY) + ); + assert_eq!( + sys_mmap(&mut proc, &mut host, 0, 4096, 3, 0x02, fd, 0), + Err(Errno::ENODEV) + ); + + for (request, size) in [ + (SNDCTL_DSP_SETBLKSIZE, 4), + (SOUND_PCM_WRITE_FILTER, 4), + (SOUND_PCM_READ_FILTER, 4), + (SNDCTL_DSP_SUBDIVIDE, 4), + (SNDCTL_DSP_GETISPACE, 16), + (SNDCTL_DSP_SETTRIGGER, 4), + (SNDCTL_DSP_GETTRIGGER, 4), + (SNDCTL_DSP_GETIPTR, 12), + (SNDCTL_DSP_MAPINBUF, 8), + (SNDCTL_DSP_MAPOUTBUF, 8), + (SNDCTL_DSP_SETSYNCRO, 0), + (SNDCTL_DSP_SETDUPLEX, 0), + ] { + let mut argument = alloc::vec![0; size]; + assert_eq!( + sys_ioctl(&mut proc, &mut host, fd, request, &mut argument), + Err(Errno::ENOTTY), + "request {request:#x}" + ); + } - let mut arg = AFMT_S16_LE.to_le_bytes(); - sys_ioctl(&mut proc, &mut host,fd, SNDCTL_DSP_SETFMT, &mut arg).unwrap(); + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_RESET, &mut []).unwrap(); sys_close(&mut proc, &mut host, fd).unwrap(); } #[test] - fn ioctl_dsp_getfmts_reports_s16_le_only() { + fn dsp_ioctl_negotiation_geometry_and_queries_are_truthful() { use wasm_posix_shared::oss::*; - let _g = DSP_OWNER_LOCK.lock().unwrap(); - reset_dsp_state(); + let _g = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::audio::reset_for_test(); let mut proc = Process::new(1); let mut host = MockHostIO::new(); let fd = sys_open(&mut proc, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); - let mut arg = [0u8; 4]; - sys_ioctl(&mut proc, &mut host,fd, SNDCTL_DSP_GETFMTS, &mut arg).unwrap(); - assert_eq!(u32::from_le_bytes(arg), AFMT_S16_LE); + let mut arg = 44100i32.to_le_bytes(); + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_SPEED, &mut arg).unwrap(); + assert_eq!(i32::from_le_bytes(arg), 44100); + let mut format = (AFMT_U8 as i32).to_le_bytes(); + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_SETFMT, &mut format).unwrap(); + assert_eq!(i32::from_le_bytes(format), AFMT_U8 as i32); + let mut fragments = ((2i32 << 16) | 4).to_le_bytes(); + sys_ioctl( + &mut proc, + &mut host, + fd, + SNDCTL_DSP_SETFRAGMENT, + &mut fragments, + ) + .unwrap(); + assert_eq!(i32::from_le_bytes(fragments), (2 << 16) | 4); + + let mut formats = [0; 4]; + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_GETFMTS, &mut formats).unwrap(); + assert_eq!(u32::from_le_bytes(formats), SUPPORTED_FORMATS); + let mut caps = [0; 4]; + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_GETCAPS, &mut caps).unwrap(); + assert_eq!(u32::from_le_bytes(caps), SUPPORTED_CAPS); + let mut space = [0; 16]; + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_GETOSPACE, &mut space).unwrap(); + assert_eq!(i32::from_le_bytes(space[0..4].try_into().unwrap()), 2); + assert_eq!(i32::from_le_bytes(space[8..12].try_into().unwrap()), 16); + assert_eq!(i32::from_le_bytes(space[12..16].try_into().unwrap()), 32); sys_close(&mut proc, &mut host, fd).unwrap(); } #[test] - fn ioctl_dsp_reset_drains_ring() { + fn dsp_backpressure_poll_reset_and_final_close_follow_audio_clock() { use wasm_posix_shared::oss::*; - let _g = DSP_OWNER_LOCK.lock().unwrap(); - reset_dsp_state(); - + let _g = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::audio::reset_for_test(); let mut proc = Process::new(1); let mut host = MockHostIO::new(); let fd = sys_open(&mut proc, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + let mut fragments = ((2i32 << 16) | 4).to_le_bytes(); + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_SETFRAGMENT, &mut fragments).unwrap(); - sys_write(&mut proc, &mut host, fd, &[1, 2, 3, 4]).unwrap(); - assert_eq!(crate::audio::pending_bytes(), 4); - - let mut arg = [0u8; 0]; - sys_ioctl(&mut proc, &mut host,fd, SNDCTL_DSP_RESET, &mut arg).unwrap(); - assert_eq!(crate::audio::pending_bytes(), 0); + assert_eq!(sys_write(&mut proc, &mut host, fd, &[1; 32]), Ok(32)); + assert_eq!(sys_write(&mut proc, &mut host, fd, &[2; 1]), Err(Errno::EAGAIN)); + let mut pollfd = WasmPollFd { fd, events: POLLOUT, revents: 0 }; + assert_eq!( + sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0), + Ok(0) + ); + let mut drained = [0; 16]; + assert_eq!(crate::audio::drain_into(&mut drained), 16); + assert_eq!( + sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0), + Ok(1) + ); + assert_eq!(pollfd.revents, POLLOUT); + assert_eq!(sys_close(&mut proc, &mut host, fd), Err(Errno::EAGAIN)); + assert!(proc.fd_table.get(fd).is_ok(), "blocking close keeps fd live"); + let mut tail = [0; 16]; + assert_eq!(crate::audio::drain_into(&mut tail), 16); sys_close(&mut proc, &mut host, fd).unwrap(); + + let fd2 = sys_open(&mut proc, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + sys_write(&mut proc, &mut host, fd2, &[3; 7]).unwrap(); + sys_ioctl(&mut proc, &mut host, fd2, SNDCTL_DSP_RESET, &mut []).unwrap(); + assert_eq!(crate::audio::pending_bytes(), 0); + sys_close(&mut proc, &mut host, fd2).unwrap(); } #[test] - fn close_dsp_releases_owner_and_clears_ring() { - use core::sync::atomic::Ordering; - let _g = DSP_OWNER_LOCK.lock().unwrap(); - reset_dsp_state(); + fn dsp_fatal_sink_polls_error_and_final_close_releases_ownership() { + use wasm_posix_shared::oss::SNDCTL_DSP_GETOSPACE; + use wasm_posix_shared::poll::{POLLERR, POLLOUT}; - let mut proc = Process::new(1); + let _g = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::audio::reset_for_test(); + let mut owner = Process::new(1); + let mut contender = Process::new(2); let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + let fd = sys_open(&mut owner, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + sys_write(&mut owner, &mut host, fd, &[1; 8]).unwrap(); + crate::audio::mark_fatal_error_for_test(); + + let mut pollfd = WasmPollFd { + fd, + events: POLLOUT, + revents: 0, + }; assert_eq!( - crate::audio::DSP_OWNER.load(Ordering::SeqCst), - proc.pid as i32 + sys_poll( + &mut owner, + &mut host, + core::slice::from_mut(&mut pollfd), + 0, + ), + Ok(1) ); + assert_eq!(pollfd.revents, POLLERR); - sys_write(&mut proc, &mut host, fd, &[1, 2, 3, 4]).unwrap(); - sys_close(&mut proc, &mut host, fd).unwrap(); - assert_eq!(crate::audio::DSP_OWNER.load(Ordering::SeqCst), -1); + let mut output_space = + [0; core::mem::size_of::()]; assert_eq!( - crate::audio::pending_bytes(), - 0, - "close should drain the ring when releasing ownership" + sys_ioctl( + &mut owner, + &mut host, + fd, + SNDCTL_DSP_GETOSPACE, + &mut output_space, + ), + Err(Errno::EIO) ); - } - #[test] - fn exec_keeps_dsp_owner_and_ring_for_open_fd() { - use core::sync::atomic::Ordering; - let _g = DSP_OWNER_LOCK.lock().unwrap(); - reset_dsp_state(); + assert_eq!(sys_close(&mut owner, &mut host, fd), Err(Errno::EIO)); + assert!( + owner.fd_table.get(fd).is_err(), + "fatal close must remove the descriptor even while returning EIO" + ); + assert_eq!(crate::audio::pending_bytes(), 0); - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let _fd = sys_open(&mut proc, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + let contender_fd = + sys_open(&mut contender, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); assert_eq!( - crate::audio::DSP_OWNER.load(Ordering::SeqCst), - proc.pid as i32 + sys_close(&mut contender, &mut host, contender_fd), + Err(Errno::EIO), + "a persistent host sink failure is reported without retaining ownership" ); + } + + #[test] + fn failed_dev_fd_alias_opens_do_not_leak_dsp_ofd_owner() { + let _g = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::audio::reset_for_test(); + let mut owner = Process::new(1); + let mut contender = Process::new(2); + let mut host = MockHostIO::new(); + + let fd = sys_open(&mut owner, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + assert_eq!(fd, 3); + sys_setrlimit(&mut owner, 7, 4, 4096).unwrap(); - sys_write(&mut proc, &mut host, _fd, &[5, 5, 5, 5]).unwrap(); - sys_execve(&mut proc, &mut host, b"/bin/sh").unwrap(); assert_eq!( - crate::audio::DSP_OWNER.load(Ordering::SeqCst), - proc.pid as i32 + sys_open(&mut owner, &mut host, b"/dev/fd/3", O_WRONLY, 0), + Err(Errno::EMFILE) ); assert_eq!( - crate::audio::pending_bytes(), - 4, - "exec should retain samples behind a surviving open fd" + sys_openat( + &mut owner, + &mut host, + AT_FDCWD, + b"/dev/fd/3", + O_WRONLY, + 0, + ), + Err(Errno::EMFILE) ); - sys_close(&mut proc, &mut host, _fd).unwrap(); + + sys_close(&mut owner, &mut host, fd).unwrap(); + let contender_fd = + sys_open(&mut contender, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + sys_close(&mut contender, &mut host, contender_fd).unwrap(); } // ----------------------------------------------------------------- diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index c2f5ddfe82..ed91737164 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -1566,10 +1566,6 @@ fn finish_removed_process(pid: u32, result: crate::process_table::RemoveProcessR // successor open starts clean. No host-side unbind — the device is // host→kernel only. crate::syscalls::maybe_release_mice(pid); - // /dev/dsp cleanup: drop ownership and flush the PCM ring. The host-side - // AudioContext keeps playing whatever is already scheduled; we just stop - // feeding it new samples from this dead pid. - crate::syscalls::maybe_release_dsp(pid); } fn remove_process_and_cleanup(pid: u32) -> i32 { @@ -2369,7 +2365,7 @@ fn prepare_exec_state(pid: u32, caller_tid: u32) -> Result<(), Errno> { syscalls::sys_dup2(proc, &mut host, old_fd, new_fd)?; } FdAction::Close { fd } => { - syscalls::sys_close(proc, &mut host, fd)?; + syscalls::sys_close_implicit(proc, &mut host, fd)?; } FdAction::Open { fd, @@ -2381,7 +2377,7 @@ fn prepare_exec_state(pid: u32, caller_tid: u32) -> Result<(), Errno> { syscalls::sys_open(proc, &mut host, path, flags as u32, mode as u32)?; if opened_fd != fd { syscalls::sys_dup2(proc, &mut host, opened_fd, fd)?; - let _ = syscalls::sys_close(proc, &mut host, opened_fd); + let _ = syscalls::sys_close_implicit(proc, &mut host, opened_fd); } } } @@ -3083,7 +3079,12 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { // Terminal 70 => kernel_tcgetattr(a1, a2 as *mut u8, 256), // SYS_TCGETATTR 71 => kernel_tcsetattr(a1, a2 as u32, a3 as *const u8, 256), // SYS_TCSETATTR - 72 => kernel_ioctl(a1, a2 as u32, a3 as *mut u8, 256), // SYS_IOCTL + 72 => kernel_ioctl( + a1, + a2 as u32, + a3 as *mut u8, + wasm_posix_shared::host_abi::ioctl_arg_size(a2 as u32), + ), // SYS_IOCTL // File system 79 => kernel_ftruncate(a1, args[1]), // SYS_FTRUNCATE: (fd, length) @@ -4721,7 +4722,7 @@ fn kernel_mknod(path_ptr: *const u8, path_len: u32, mode: u32) -> i32 { let flags = O_CREAT | O_EXCL | O_WRONLY; match syscalls::sys_open(proc, &mut host, path, flags, mode) { Ok(fd) => { - let _ = syscalls::sys_close(proc, &mut host, fd); + let _ = syscalls::sys_close_implicit(proc, &mut host, fd); 0 } Err(e) => -(e as i32), @@ -4737,7 +4738,7 @@ fn kernel_mknodat(dirfd: i32, path_ptr: *const u8, path_len: u32, mode: u32) -> let flags = O_CREAT | O_EXCL | O_WRONLY; match syscalls::sys_openat(proc, &mut host, dirfd, path, flags, mode) { Ok(fd) => { - let _ = syscalls::sys_close(proc, &mut host, fd); + let _ = syscalls::sys_close_implicit(proc, &mut host, fd); 0 } Err(e) => -(e as i32), @@ -8599,7 +8600,23 @@ pub extern "C" fn kernel_tcsetattr(fd: i32, action: u32, buf_ptr: *const u8, buf #[unsafe(no_mangle)] pub extern "C" fn kernel_ioctl(fd: i32, request: u32, buf_ptr: *mut u8, buf_len: u32) -> i32 { let (_gkl, proc) = unsafe { get_process() }; - let buf = unsafe { core::slice::from_raw_parts_mut(buf_ptr, buf_len as usize) }; + let required = wasm_posix_shared::host_abi::ioctl_arg_size(request); + if required != 0 && buf_ptr.is_null() { + return -(Errno::EFAULT as i32); + } + if buf_len < required { + return -(Errno::EINVAL as i32); + } + // TCFLSH predates pointer-encoded ioctl conventions: its third argument + // is the immediate TCIFLUSH/TCOFLUSH/TCIOFLUSH selector. + let mut immediate = (buf_ptr as usize as u32).to_le_bytes(); + let buf: &mut [u8] = if request == 0x540b { + &mut immediate + } else if required == 0 { + &mut [] + } else { + unsafe { core::slice::from_raw_parts_mut(buf_ptr, required as usize) } + }; let mut host = WasmHostIO; let result = match syscalls::sys_ioctl(proc, &mut host, fd, request, buf) { Ok(()) => 0, @@ -9565,7 +9582,7 @@ pub extern "C" fn kernel_apply_fork_fd_actions() -> i32 { } } crate::process::FdAction::Close { fd } => { - if let Err(e) = syscalls::sys_close(proc, &mut host, fd) { + if let Err(e) = syscalls::sys_close_implicit(proc, &mut host, fd) { return -(e as i32); } } @@ -9580,7 +9597,7 @@ pub extern "C" fn kernel_apply_fork_fd_actions() -> i32 { if let Err(e) = syscalls::sys_dup2(proc, &mut host, opened_fd, fd) { return -(e as i32); } - let _ = syscalls::sys_close(proc, &mut host, opened_fd); + let _ = syscalls::sys_close_implicit(proc, &mut host, opened_fd); } } Err(e) => return -(e as i32), @@ -10814,6 +10831,13 @@ pub extern "C" fn kernel_is_fd_nonblock(pid: u32, fd: i32) -> i32 { Some(o) => o, None => return -1, }; + if ofd.file_type == crate::ofd::FileType::PcmPlayback { + return match crate::audio::is_nonblock(ofd.host_handle) { + Ok(true) => 1, + Ok(false) => 0, + Err(_) => -1, + }; + } if ofd.status_flags & wasm_posix_shared::flags::O_NONBLOCK != 0 { 1 } else { @@ -10864,8 +10888,9 @@ pub extern "C" fn kernel_get_socket_timeout_ms(pid: u32, fd: i32, is_recv: i32) /// Look up the send pipe/buffer index for a fd (for writing). /// For pipe fds: returns the pipe index. -/// For socket fds: returns send_buf_idx. -/// Returns -1 if the fd is not a pipe or connected socket. +/// For socket fds: returns send_buf_idx. For PCM playback: returns the +/// stream's writable-capacity wake token. +/// Returns -1 if the fd has no targeted writable wake token. #[unsafe(no_mangle)] pub extern "C" fn kernel_get_fd_send_pipe_idx(pid: u32, fd: i32) -> i32 { use crate::ofd::FileType; @@ -10895,6 +10920,9 @@ pub extern "C" fn kernel_get_fd_send_pipe_idx(pid: u32, fd: i32) -> i32 { None => -1, } } + FileType::PcmPlayback => crate::audio::wake_token_for_handle(ofd.host_handle) + .map(|token| token as i32) + .unwrap_or(-1), _ => -1, } } @@ -11105,10 +11133,11 @@ pub extern "C" fn kernel_inject_mouse_event(dx: i32, dy: i32, buttons: u32) { /// Drain up to `out_len` bytes of PCM audio from the kernel-side ring /// into the host-provided buffer. Returns the number of bytes copied. /// -/// The host calls this from its audio scheduler (typically once per -/// audio block at ~11–48 ms cadence) and feeds the result to a Web -/// Audio AudioContext. Reads stop on whole-frame boundaries (2 bytes -/// for mono, 4 for stereo) so the host never receives a torn L/R pair. +/// This compatibility pull path is retained for hosts that do not claim the +/// shared-clock transport. Browser AudioWorklets and the Node clocked sink +/// consume the shared ring directly, and an exclusive transport claim prevents +/// this export from racing them. Reads stop on whole-frame boundaries so a +/// caller never receives a torn PCM frame. /// /// `out_ptr` points into kernel-wasm memory — same pattern as /// `kernel_drain_wakeup_events`. The host's scratch allocation is the @@ -11120,7 +11149,7 @@ pub extern "C" fn kernel_drain_audio(out_ptr: *mut u8, out_len: u32) -> u32 { } /// Read the currently configured `/dev/dsp` sample rate (Hz). Defaults -/// to 11025 Hz before the user program calls `SNDCTL_DSP_SPEED`. +/// to 48000 Hz before the user program calls `SNDCTL_DSP_SPEED`. #[unsafe(no_mangle)] pub extern "C" fn kernel_audio_sample_rate() -> u32 { crate::audio::current_config().0 @@ -11141,6 +11170,38 @@ pub extern "C" fn kernel_audio_pending() -> u32 { crate::audio::pending_bytes() as u32 } +/// Base pointer and length of the versioned PCM shared transport. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_pcm_transport_ptr() -> u32 { + crate::audio::transport_ptr() as usize as u32 +} + +#[unsafe(no_mangle)] +pub extern "C" fn kernel_pcm_transport_len() -> u32 { + crate::audio::transport_len() +} + +/// Claim the single PCM consumer mode (legacy pull or shared audio clock). +#[unsafe(no_mangle)] +pub extern "C" fn kernel_pcm_claim_transport(mode: u32) -> i32 { + match crate::audio::claim_transport(mode) { + Ok(()) => 0, + Err(error) => -(error as i32), + } +} + +/// Reconcile a host-written consumer cursor and publish writer wakeups. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_pcm_reconcile() -> i32 { + crate::audio::reconcile() +} + +/// Advance the Node/headless sink by an audio-clock frame budget. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_pcm_clock_update(frames: u32) -> u32 { + crate::audio::clock_update(frames) +} + // --------------------------------------------------------------------------- // Wakeup event drain // --------------------------------------------------------------------------- diff --git a/crates/shared/src/host_abi.rs b/crates/shared/src/host_abi.rs index 9801751bb9..ea96df102a 100644 --- a/crates/shared/src/host_abi.rs +++ b/crates/shared/src/host_abi.rs @@ -37,6 +37,44 @@ pub enum SyscallArgSize { Deref { arg_index: u8 }, /// Fixed byte length. Fixed { size: u32 }, + /// Length encoded in an ioctl request argument. Requests from older Unix + /// ioctl families that do not encode a size use [`ioctl_arg_size`]'s + /// explicit compatibility table instead of a blanket scratch-buffer size. + Ioctl { request_arg_index: u8 }, +} + +/// Decode Kandelo's owned ioctl argument size. `_IOC` requests carry a +/// 14-bit size in bits 16..29. Older terminal/fbdev/custom requests do not; +/// keep those exact sizes here so host and direct-kernel paths agree. +pub const fn ioctl_arg_size(request: u32) -> u32 { + let encoded = (request >> 16) & 0x3fff; + if encoded != 0 { + return encoded; + } + match request { + // Generic fd/terminal requests with legacy Linux encodings. + 0x5421 | 0x5452 | 0x541b | 0x8905 => 4, + 0x5401 | 0x5402 | 0x5403 | 0x5404 => 60, + 0x540f | 0x5410 | 0x5429 => 4, + 0x5413 | 0x5414 => 8, + 0x4b33 => 1, + 0x4b44 | 0x4b45 => 4, + + // Linux fbdev request numbers predate `_IOC` size encoding. + 0x4600 | 0x4601 | 0x4606 => 160, + 0x4602 => 80, + + // Kandelo GL bridge custom requests. + 0x40 => 4, + 0x42 => 16, + 0x44 => 32, + 0x47 => 8, + 0x49 => 24, + + // Commands with no argument, including OSS RESET/SYNC/POST/NONBLOCK, + // descriptor close-on-exec toggles, and terminal no-op controls. + _ => 0, + } } /// One pointer argument descriptor for host-side marshalling. @@ -106,6 +144,14 @@ macro_rules! fixed { }; } +macro_rules! ioctl_size { + ($request_arg_index:expr) => { + SyscallArgSize::Ioctl { + request_arg_index: $request_arg_index, + } + }; +} + macro_rules! desc { ($arg_index:expr, $direction:ident, $size:expr) => { SyscallArgDesc { @@ -279,7 +325,7 @@ pub const SYSCALL_ARG_DESCRIPTORS: &[SyscallArgDescriptor] = &[ entry!(Syscall::Openat as u32, [desc!(1, In, cstring!())]), entry!(Syscall::Tcgetattr as u32, [desc!(1, Out, fixed!(256))]), entry!(Syscall::Tcsetattr as u32, [desc!(2, In, fixed!(256))]), - entry!(Syscall::Ioctl as u32, [desc!(2, InOut, fixed!(256))]), + entry!(Syscall::Ioctl as u32, [desc!(2, InOut, ioctl_size!(1), nullable)]), entry!(Syscall::Uname as u32, [desc!(0, Out, fixed!(390))]), entry!(Syscall::Pipe2 as u32, [desc!(0, Out, fixed!(8))]), entry!( @@ -654,6 +700,54 @@ mod tests { assert!(maybe_find(Syscall::Readv as u32).is_none()); } + #[test] + fn ioctl_descriptor_and_oss_argument_sizes_are_pinned() { + use crate::oss::*; + + let ioctl = find(Syscall::Ioctl as u32).args[0]; + assert_eq!(ioctl.arg_index, 2); + assert_eq!(ioctl.direction, SyscallArgDirection::InOut); + assert_eq!( + ioctl.size, + SyscallArgSize::Ioctl { + request_arg_index: 1, + } + ); + assert!(ioctl.nullable, "no-argument ioctls accept a null arg"); + + for request in [ + SNDCTL_DSP_RESET, + SNDCTL_DSP_SYNC, + SNDCTL_DSP_POST, + SNDCTL_DSP_NONBLOCK, + ] { + assert_eq!(ioctl_arg_size(request), 0); + } + for request in [ + SNDCTL_DSP_SPEED, + SNDCTL_DSP_STEREO, + SNDCTL_DSP_GETBLKSIZE, + SNDCTL_DSP_SETBLKSIZE, + SNDCTL_DSP_SETFMT, + SNDCTL_DSP_CHANNELS, + SOUND_PCM_WRITE_FILTER, + SNDCTL_DSP_SUBDIVIDE, + SNDCTL_DSP_SETFRAGMENT, + SNDCTL_DSP_GETFMTS, + SNDCTL_DSP_GETCAPS, + SNDCTL_DSP_GETODELAY, + SOUND_PCM_READ_RATE, + SOUND_PCM_READ_BITS, + SOUND_PCM_READ_CHANNELS, + SOUND_PCM_READ_FILTER, + ] { + assert_eq!(ioctl_arg_size(request), 4, "request {request:#x}"); + } + assert_eq!(ioctl_arg_size(SNDCTL_DSP_GETOSPACE), 16); + assert_eq!(ioctl_arg_size(SNDCTL_DSP_GETOPTR), 12); + assert_eq!(ioctl_arg_size(0x540b), 0, "TCFLSH takes an immediate selector"); + } + fn find(syscall_number: u32) -> &'static SyscallArgDescriptor { maybe_find(syscall_number).expect("missing syscall arg descriptor") } diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index dadca0dde2..c68a4204bb 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -73,7 +73,10 @@ pub mod host_abi; /// 39: kernel-owned POSIX timer expiration preserves exact thread targets, /// SI_TIMER metadata, overruns, and finite signal-wait deadlines through /// the required host timer-fire export. -pub const ABI_VERSION: u32 = 39; +/// 40: OSS PCM ioctl marshalling uses request-sized arguments, `/dev/dsp` +/// descriptors share a refcounted PCM stream across fork/exec, and the +/// host consumes a versioned bounded PCM transport at audio-clock pace. +pub const ABI_VERSION: u32 = 40; /// Byte width of Kandelo's Linux-compatible kernel CPU-affinity mask. /// @@ -1370,6 +1373,11 @@ pub mod abi { "kernel_host_adapter_manifest_len", "kernel_host_adapter_manifest_ptr", "kernel_mark_process_signaled", + "kernel_pcm_claim_transport", + "kernel_pcm_clock_update", + "kernel_pcm_reconcile", + "kernel_pcm_transport_len", + "kernel_pcm_transport_ptr", "kernel_pipe_has_readers", "kernel_posix_timer_fire", "kernel_prepare_write_operation", @@ -2050,20 +2058,12 @@ mod fbdev_tests { /// OSS (Open Sound System) ABI constants. /// -/// These mirror what glibc / musl expose via `` to -/// programs that talk to `/dev/dsp`. We accept the subset fbDOOM (and -/// most real OSS clients) actually emit during init: speed, channel -/// count, format, and a couple of accept-and-acknowledge ops. -/// -/// The numeric values are the standard OSS encoding — the same numbers -/// real Linux kernels return — so user-space programs that hard-code -/// these constants (rather than `#include`-ing the header) work -/// unchanged. +/// This is Kandelo's owned wasm32 source ABI, informed by canonical OSS and +/// FreeBSD's PCM frontend. Every supported command has real state semantics; +/// unsupported capture, duplex, trigger, and mmap operations remain errors. pub mod oss { - // The values below come from the Linux `` IOC - // encoding — the same ones glibc, musl, and any OSS-targeted DOS - // port hard-code. Matching them exactly lets user programs that - // skip the header still talk to us. + // Pin canonical OSS ioctl encodings explicitly so the SDK header and Rust + // frontend cannot inherit or drift with a host operating system's ABI. /// `SNDCTL_DSP_RESET` — flush + stop. No argument. pub const SNDCTL_DSP_RESET: u32 = 0x00005000; @@ -2075,17 +2075,299 @@ pub mod oss { pub const SNDCTL_DSP_STEREO: u32 = 0xc0045003; /// `SNDCTL_DSP_GETBLKSIZE` — preferred fragment size. out: i32 bytes. pub const SNDCTL_DSP_GETBLKSIZE: u32 = 0xc0045004; + /// FreeBSD's distinct block-size setter; pinned for source ABI but unsupported. + pub const SNDCTL_DSP_SETBLKSIZE: u32 = 0x40045004; /// `SNDCTL_DSP_SETFMT` — get/set sample format. inout: i32 AFMT_*. pub const SNDCTL_DSP_SETFMT: u32 = 0xc0045005; /// `SNDCTL_DSP_CHANNELS` — get/set explicit channel count. inout: i32. pub const SNDCTL_DSP_CHANNELS: u32 = 0xc0045006; + /// Legacy PCM filter control; pinned for source ABI but unsupported. + pub const SOUND_PCM_WRITE_FILTER: u32 = 0xc0045007; + /// `SNDCTL_DSP_POST` — start playback of queued output. No argument. + pub const SNDCTL_DSP_POST: u32 = 0x00005008; + /// Legacy fragment subdivision control; pinned for source ABI but unsupported. + pub const SNDCTL_DSP_SUBDIVIDE: u32 = 0xc0045009; /// `SNDCTL_DSP_GETFMTS` — bitmask of supported formats. out: i32 AFMT_* mask. pub const SNDCTL_DSP_GETFMTS: u32 = 0x8004500b; /// `SNDCTL_DSP_SETFRAGMENT` — fragment-size hint. inout: i32. pub const SNDCTL_DSP_SETFRAGMENT: u32 = 0xc004500a; + /// `SNDCTL_DSP_GETOSPACE` — immediately writable output geometry. + pub const SNDCTL_DSP_GETOSPACE: u32 = 0x8010500c; + /// Canonical capture query; pinned for source ABI but unsupported. + pub const SNDCTL_DSP_GETISPACE: u32 = 0x8010500d; + /// `SNDCTL_DSP_NONBLOCK` — enable non-blocking mode on this OFD. + pub const SNDCTL_DSP_NONBLOCK: u32 = 0x0000500e; + /// `SNDCTL_DSP_GETCAPS` — query truthful PCM capabilities. + pub const SNDCTL_DSP_GETCAPS: u32 = 0x8004500f; + /// Canonical trigger controls; pinned for source ABI but unsupported. + pub const SNDCTL_DSP_SETTRIGGER: u32 = 0x40045010; + pub const SNDCTL_DSP_GETTRIGGER: u32 = 0x80045010; + /// Canonical capture position; pinned for source ABI but unsupported. + pub const SNDCTL_DSP_GETIPTR: u32 = 0x800c5011; + /// `SNDCTL_DSP_GETOPTR` — query monotonic output position. + pub const SNDCTL_DSP_GETOPTR: u32 = 0x800c5012; + /// Canonical mmap-buffer operations; pinned for source ABI but unsupported. + pub const SNDCTL_DSP_MAPINBUF: u32 = 0x80085013; + pub const SNDCTL_DSP_MAPOUTBUF: u32 = 0x80085014; + /// Canonical synchronization/duplex controls; pinned but unsupported. + pub const SNDCTL_DSP_SETSYNCRO: u32 = 0x00005015; + pub const SNDCTL_DSP_SETDUPLEX: u32 = 0x00005016; + /// `SNDCTL_DSP_GETODELAY` — queued, not-yet-played output bytes. + pub const SNDCTL_DSP_GETODELAY: u32 = 0x80045017; + /// Read-only aliases used by portable OSS clients. + pub const SOUND_PCM_READ_RATE: u32 = 0x80045002; + pub const SOUND_PCM_READ_BITS: u32 = 0x80045005; + pub const SOUND_PCM_READ_CHANNELS: u32 = 0x80045006; + pub const SOUND_PCM_READ_FILTER: u32 = 0x80045007; + + /// Values accepted by the canonical (currently unsupported) trigger ioctls. + pub const PCM_ENABLE_INPUT: u32 = 0x0000_0001; + pub const PCM_ENABLE_OUTPUT: u32 = 0x0000_0002; + + /// `AFMT_QUERY` — query the current format without changing it. + pub const AFMT_QUERY: u32 = 0; + // Keep the canonical OSS/FreeBSD format namespace available to source + // consumers even when the initial playback core does not implement a + // particular encoding. `GETFMTS` advertises only `SUPPORTED_FORMATS`, + // and `SETFMT` rejects every other value. + pub const AFMT_MU_LAW: u32 = 0x0000_0001; + pub const AFMT_A_LAW: u32 = 0x0000_0002; + pub const AFMT_IMA_ADPCM: u32 = 0x0000_0004; + /// `AFMT_U8` — unsigned 8-bit PCM. + pub const AFMT_U8: u32 = 0x0000_0008; + /// `AFMT_S16_LE` — signed 16-bit little-endian PCM. + pub const AFMT_S16_LE: u32 = 0x0000_0010; + /// `AFMT_S16_BE` — signed 16-bit big-endian PCM. + pub const AFMT_S16_BE: u32 = 0x0000_0020; + pub const AFMT_S8: u32 = 0x0000_0040; + pub const AFMT_U16_LE: u32 = 0x0000_0080; + pub const AFMT_U16_BE: u32 = 0x0000_0100; + pub const AFMT_MPEG: u32 = 0x0000_0200; + pub const AFMT_AC3: u32 = 0x0000_0400; + pub const AFMT_S32_LE: u32 = 0x0000_1000; + pub const AFMT_S32_BE: u32 = 0x0000_2000; + pub const AFMT_U32_LE: u32 = 0x0000_4000; + pub const AFMT_U32_BE: u32 = 0x0000_8000; + pub const AFMT_S24_LE: u32 = 0x0001_0000; + pub const AFMT_S24_BE: u32 = 0x0002_0000; + pub const AFMT_U24_LE: u32 = 0x0004_0000; + pub const AFMT_U24_BE: u32 = 0x0008_0000; + pub const AFMT_F32_LE: u32 = 0x1000_0000; + pub const AFMT_F32_BE: u32 = 0x2000_0000; + + pub const SUPPORTED_FORMATS: u32 = AFMT_U8 | AFMT_S16_LE | AFMT_S16_BE; + + // OSS4/FreeBSD core capability bits. The SDK exposes these names for + // source compatibility; only output/default/virtual are advertised. + pub const PCM_CAP_REVISION: u32 = 0x0000_00ff; + pub const PCM_CAP_DUPLEX: u32 = 0x0000_0100; + pub const PCM_CAP_REALTIME: u32 = 0x0000_0200; + pub const PCM_CAP_BATCH: u32 = 0x0000_0400; + pub const PCM_CAP_COPROC: u32 = 0x0000_0800; + pub const PCM_CAP_TRIGGER: u32 = 0x0000_1000; + pub const PCM_CAP_MMAP: u32 = 0x0000_2000; + pub const PCM_CAP_MULTI: u32 = 0x0000_4000; + pub const PCM_CAP_BIND: u32 = 0x0000_8000; + pub const PCM_CAP_INPUT: u32 = 0x0001_0000; + pub const PCM_CAP_OUTPUT: u32 = 0x0002_0000; + pub const PCM_CAP_VIRTUAL: u32 = 0x0004_0000; + pub const PCM_CAP_DEFAULT: u32 = 0x4000_0000; + pub const SUPPORTED_CAPS: u32 = PCM_CAP_OUTPUT | PCM_CAP_VIRTUAL | PCM_CAP_DEFAULT; + + /// Wasm32-owned layout of OSS `audio_buf_info`. + #[repr(C)] + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] + pub struct AudioBufInfo { + pub fragments: i32, + pub fragstotal: i32, + pub fragsize: i32, + pub bytes: i32, + } + + /// Wasm32-owned layout of OSS `count_info`. + #[repr(C)] + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] + pub struct CountInfo { + pub bytes: i32, + pub blocks: i32, + pub ptr: i32, + } + +} + +/// Implementation-neutral PCM host transport contract. +pub mod pcm { + pub const PCM_TRANSPORT_MAGIC: u32 = 0x314d_4350; // "PCM1" LE + pub const PCM_TRANSPORT_VERSION: u32 = 1; + pub const PCM_TRANSPORT_HEADER_BYTES: u32 = 128; + pub const PCM_TRANSPORT_RING_BYTES: u32 = 64 * 1024; + pub const PCM_TRANSPORT_BYTES: u32 = PCM_TRANSPORT_HEADER_BYTES + PCM_TRANSPORT_RING_BYTES; + + pub const PCM_STATE_CLOSED: u32 = 0; + pub const PCM_STATE_STOPPED: u32 = 1; + pub const PCM_STATE_RUNNING: u32 = 2; + pub const PCM_STATE_DRAINING: u32 = 3; + + pub const PCM_FORMAT_UNKNOWN: u32 = 0; + pub const PCM_FORMAT_U8: u32 = 1; + pub const PCM_FORMAT_S16_LE: u32 = 2; + pub const PCM_FORMAT_S16_BE: u32 = 3; + + pub const PCM_TRANSPORT_UNCLAIMED: u32 = 0; + pub const PCM_TRANSPORT_LEGACY_PULL: u32 = 1; + pub const PCM_TRANSPORT_SHARED_CLOCK: u32 = 2; + + /// Kernel is publishing a new multi-field stream configuration. Host + /// clocks must render silence and avoid cursor publication until clear. + pub const PCM_FLAG_CONFIGURING: u32 = 1 << 0; + /// Playback is currently in an underrun episode. The first transition + /// into an episode increments `PcmSharedControl::underruns`. + pub const PCM_FLAG_UNDERRUN_ACTIVE: u32 = 1 << 1; + /// The attached physical sink failed permanently. Suspension and browser + /// user-activation waits are recoverable and must not set this bit. + pub const PCM_FLAG_FATAL_ERROR: u32 = 1 << 2; + + /// Versioned PCM-only header shared with browser AudioWorklets and Node + /// sinks. Every field is a 32-bit word so JS can use `Atomics` directly. + /// The three u64 cursors use odd/even seqlocks around low/high words. + #[repr(C)] + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] + pub struct PcmSharedControl { + pub magic: u32, + pub version: u32, + pub header_bytes: u32, + pub physical_capacity_bytes: u32, + pub active_capacity_bytes: u32, + pub format: u32, + pub rate: u32, + pub channels: u32, + pub frame_bytes: u32, + pub fragment_bytes: u32, + pub fragment_count: u32, + pub state: u32, + pub generation: u32, + pub flags: u32, + pub transport_mode: u32, + pub producer_seq: u32, + pub producer_lo: u32, + pub producer_hi: u32, + pub consumer_seq: u32, + pub consumer_lo: u32, + pub consumer_hi: u32, + pub discard_seq: u32, + pub discard_lo: u32, + pub discard_hi: u32, + pub underruns: u32, + pub wake_seq: u32, + pub reserved: [u32; 6], + } +} + +#[cfg(test)] +mod oss_abi_tests { + use super::oss::*; + use super::pcm::*; + use core::mem::{align_of, offset_of, size_of}; - /// `AFMT_S16_LE` — signed 16-bit little-endian. The only format we accept. - pub const AFMT_S16_LE: u32 = 0x10; + #[test] + fn ioctl_values_and_struct_layouts_are_pinned() { + assert_eq!(SNDCTL_DSP_RESET, 0x0000_5000); + assert_eq!(SNDCTL_DSP_SYNC, 0x0000_5001); + assert_eq!(SNDCTL_DSP_SPEED, 0xc004_5002); + assert_eq!(SNDCTL_DSP_STEREO, 0xc004_5003); + assert_eq!(SNDCTL_DSP_GETBLKSIZE, 0xc004_5004); + assert_eq!(SNDCTL_DSP_SETBLKSIZE, 0x4004_5004); + assert_eq!(SNDCTL_DSP_SETFMT, 0xc004_5005); + assert_eq!(SNDCTL_DSP_CHANNELS, 0xc004_5006); + assert_eq!(SOUND_PCM_WRITE_FILTER, 0xc004_5007); + assert_eq!(SNDCTL_DSP_POST, 0x0000_5008); + assert_eq!(SNDCTL_DSP_SUBDIVIDE, 0xc004_5009); + assert_eq!(SNDCTL_DSP_SETFRAGMENT, 0xc004_500a); + assert_eq!(SNDCTL_DSP_GETFMTS, 0x8004_500b); + assert_eq!(SNDCTL_DSP_GETOSPACE, 0x8010_500c); + assert_eq!(SNDCTL_DSP_GETISPACE, 0x8010_500d); + assert_eq!(SNDCTL_DSP_NONBLOCK, 0x0000_500e); + assert_eq!(SNDCTL_DSP_GETCAPS, 0x8004_500f); + assert_eq!(SNDCTL_DSP_SETTRIGGER, 0x4004_5010); + assert_eq!(SNDCTL_DSP_GETTRIGGER, 0x8004_5010); + assert_eq!(SNDCTL_DSP_GETIPTR, 0x800c_5011); + assert_eq!(SNDCTL_DSP_GETOPTR, 0x800c_5012); + assert_eq!(SNDCTL_DSP_MAPINBUF, 0x8008_5013); + assert_eq!(SNDCTL_DSP_MAPOUTBUF, 0x8008_5014); + assert_eq!(SNDCTL_DSP_SETSYNCRO, 0x0000_5015); + assert_eq!(SNDCTL_DSP_SETDUPLEX, 0x0000_5016); + assert_eq!(SNDCTL_DSP_GETODELAY, 0x8004_5017); + assert_eq!(SOUND_PCM_READ_RATE, 0x8004_5002); + assert_eq!(SOUND_PCM_READ_BITS, 0x8004_5005); + assert_eq!(SOUND_PCM_READ_CHANNELS, 0x8004_5006); + assert_eq!(SOUND_PCM_READ_FILTER, 0x8004_5007); + assert_eq!(PCM_ENABLE_INPUT, 0x0000_0001); + assert_eq!(PCM_ENABLE_OUTPUT, 0x0000_0002); + assert_eq!(AFMT_QUERY, 0x0000_0000); + assert_eq!(AFMT_MU_LAW, 0x0000_0001); + assert_eq!(AFMT_A_LAW, 0x0000_0002); + assert_eq!(AFMT_IMA_ADPCM, 0x0000_0004); + assert_eq!(AFMT_U8, 0x0000_0008); + assert_eq!(AFMT_S16_LE, 0x0000_0010); + assert_eq!(AFMT_S16_BE, 0x0000_0020); + assert_eq!(AFMT_S8, 0x0000_0040); + assert_eq!(AFMT_U16_LE, 0x0000_0080); + assert_eq!(AFMT_U16_BE, 0x0000_0100); + assert_eq!(AFMT_MPEG, 0x0000_0200); + assert_eq!(AFMT_AC3, 0x0000_0400); + assert_eq!(AFMT_S32_LE, 0x0000_1000); + assert_eq!(AFMT_S32_BE, 0x0000_2000); + assert_eq!(AFMT_U32_LE, 0x0000_4000); + assert_eq!(AFMT_U32_BE, 0x0000_8000); + assert_eq!(AFMT_S24_LE, 0x0001_0000); + assert_eq!(AFMT_S24_BE, 0x0002_0000); + assert_eq!(AFMT_U24_LE, 0x0004_0000); + assert_eq!(AFMT_U24_BE, 0x0008_0000); + assert_eq!(AFMT_F32_LE, 0x1000_0000); + assert_eq!(AFMT_F32_BE, 0x2000_0000); + assert_eq!(SUPPORTED_FORMATS, 0x0000_0038); + assert_eq!(PCM_CAP_REVISION, 0x0000_00ff); + assert_eq!(PCM_CAP_DUPLEX, 0x0000_0100); + assert_eq!(PCM_CAP_REALTIME, 0x0000_0200); + assert_eq!(PCM_CAP_BATCH, 0x0000_0400); + assert_eq!(PCM_CAP_COPROC, 0x0000_0800); + assert_eq!(PCM_CAP_TRIGGER, 0x0000_1000); + assert_eq!(PCM_CAP_MMAP, 0x0000_2000); + assert_eq!(PCM_CAP_MULTI, 0x0000_4000); + assert_eq!(PCM_CAP_BIND, 0x0000_8000); + assert_eq!(PCM_CAP_INPUT, 0x0001_0000); + assert_eq!(PCM_CAP_OUTPUT, 0x0002_0000); + assert_eq!(PCM_CAP_VIRTUAL, 0x0004_0000); + assert_eq!(PCM_CAP_DEFAULT, 0x4000_0000); + assert_eq!(size_of::(), 16); + assert_eq!(align_of::(), 4); + assert_eq!(offset_of!(AudioBufInfo, fragments), 0); + assert_eq!(offset_of!(AudioBufInfo, fragstotal), 4); + assert_eq!(offset_of!(AudioBufInfo, fragsize), 8); + assert_eq!(offset_of!(AudioBufInfo, bytes), 12); + assert_eq!(size_of::(), 12); + assert_eq!(align_of::(), 4); + assert_eq!(offset_of!(CountInfo, bytes), 0); + assert_eq!(offset_of!(CountInfo, blocks), 4); + assert_eq!(offset_of!(CountInfo, ptr), 8); + } + + #[test] + fn pcm_transport_layout_is_fixed_for_js_atomics() { + assert_eq!(size_of::(), 128); + assert_eq!(align_of::(), 4); + assert_eq!(offset_of!(PcmSharedControl, active_capacity_bytes), 16); + assert_eq!(offset_of!(PcmSharedControl, state), 44); + assert_eq!(offset_of!(PcmSharedControl, producer_seq), 60); + assert_eq!(offset_of!(PcmSharedControl, consumer_seq), 72); + assert_eq!(offset_of!(PcmSharedControl, discard_seq), 84); + assert_eq!(offset_of!(PcmSharedControl, underruns), 96); + assert_eq!(offset_of!(PcmSharedControl, wake_seq), 100); + assert_eq!(PCM_FLAG_CONFIGURING, 1); + assert_eq!(PCM_FLAG_UNDERRUN_ACTIVE, 2); + assert_eq!(PCM_FLAG_FATAL_ERROR, 4); + assert_eq!(PCM_TRANSPORT_BYTES, 65_664); + } } diff --git a/docs/architecture.md b/docs/architecture.md index 328b6f4942..c9365d954f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -795,29 +795,149 @@ Single-open semantics match real Linux mousedev exclusive-grab. The host inverts ## Audio output (`/dev/dsp`) -The kernel exposes an OSS-style `/dev/dsp` character device so unmodified Linux audio software (fbDOOM, etc.) can play sound through a browser `AudioContext`. Direction is reversed vs. mouse: PCM samples flow **process → kernel → host**. - -``` - user process kernel-worker / kernel browser main thread - ──────────── ────────────────────── ─────────────────────── - open("/dev/dsp", O_WRONLY) - ioctl SNDCTL_DSP_SPEED ──► audio::set_sample_rate - ioctl SNDCTL_DSP_STEREO ──► audio::set_channels - ioctl SNDCTL_DSP_SETFMT ──► audio::set_format (must be S16_LE) - write(fd, pcm, len) ──► audio::write_pcm - push bytes to 256 KiB ring - (drop oldest whole frames on overflow) - ◄──────────── setInterval(50ms): drainAudio(maxBytes) - kernel_drain_audio(out_ptr, out_len) - drain whole-frame bytes from ring - ─────────────► decode S16 → Float32 - schedule AudioBufferSourceNode - on AudioContext clock +Kandelo separates the Unix compatibility API from its physical audio +transport. OSS is the first frontend; the state below it is an +implementation-neutral, playback-only PCM stream rather than an ALSA or Web +Audio model. + +```text + SDL2 / SDL3 / Unix application + open, ioctl, write, poll + | + OSS /dev/dsp frontend + | + Kandelo PCM stream core + format + geometry + monotonic cursors + | + shared bounded PCM transport + / \ + Browser AudioWorklet Node clocked sink ``` -The kernel does **not** mix or synthesize audio. The user program (DOOM's mixer in `i_kernel_sound.c` plus the OPL2 software synth in `i_oplmusic.c` + `opl/opl3.c` for music) does that work and writes interleaved S16_LE frames; the kernel ring is just transport. fbDOOM's mixer produces 1280 stereo frames per ~28 ms game tic — slightly more than the 1260 frames the AudioContext consumes per tic — so the ring stays full enough to hide drain jitter, and the drop-oldest-on-overflow policy keeps memory bounded. - -Single-open semantics match the typical OSS exclusive-grab model. Ownership is released on `close` of the last `/dev/dsp` fd or on process exit; a surviving non-CLOEXEC fd retains both ownership and queued samples across exec. The ring is flushed when ownership is released so a successor open hears silence rather than the previous owner's tail. ABI version bumped 7 → 8 to register the new `kernel_drain_audio(i64, i32) -> i32` export plus the three readouts `kernel_audio_sample_rate / channels / pending`. The OSS ioctl encodings live in `crates/shared/src/lib.rs::oss`. +The PCM core owns requested and actual format, sample rate and channel count; +fragment geometry; 64-bit monotonic producer, consumer, and discard positions; +started, stopped, and draining state; reset generation; underrun count; and +write/drain waiters. The OSS frontend translates fixed-width `soundcard.h` +arguments into that model. No Web Audio concept appears in the guest ABI. +Configuration fields are published under a configuring flag and become visible +as one generation. Finishing a drain also advances the generation, so host +resamplers reset their phase before the next logical playback stream. + +There is one physical/default playback device and, initially, one exclusive +stream. Ownership belongs to the open file description (OFD): `dup()` and +descriptors inherited through `fork()` share the stream, while a distinct +`open()` returns `EBUSY`. A surviving non-`CLOEXEC` descriptor keeps the same +stream across `exec`. There is no kernel mixer, routing policy, capture stream, +or concatenation of data from unrelated writers. + +The transport reserves one 64 KiB PCM ring plus a 128-byte fixed-width control +header in the kernel's shared Wasm memory: 65,664 bytes total. The active queue is latency-sized: +four 1024-byte fragments (4096 bytes) by default, and `SETFRAGMENT` may select +another geometry up to the 64 KiB physical bound. At the default 48 kHz, +stereo S16 configuration, 4096 bytes are about 21.3 ms of queued audio. The +host receives a descriptor for this same memory; it does not allocate a second +persistent PCM ring. Browser-engine Float32 render buffers are transient. + +Writes form one continuous byte stream and never discard previously queued +audio. Bytes from an incomplete PCM frame remain queued across later writes. +`SYNC` or final OFD close pads only a terminal incomplete frame with format +silence (`0x80` for U8 and zero for S16_LE/S16_BE) before draining; `POST` does +not fabricate padding. A frame-aligned blocking request no larger than the +active capacity waits until the entire request can be accepted, which covers +normal SDL periods. If an earlier unaligned write has left the ring ending in +an incomplete frame, a later blocking write may return the prefix that +completes that frame rather than deadlocking behind bytes the audio clock +cannot yet consume. Larger requests and nonblocking requests may likewise +report partial progress; a nonblocking write with no capacity returns +`EAGAIN`. +`poll(POLLOUT)` becomes ready only when at least one fragment is free. When the +host audio clock advances the consumer position, the kernel reconciles the +monotonic cursors and wakes writers, poll waiters, and drain waiters. Running +out of queued frames is an underrun and produces silence; it does not move the +producer or overwrite old frames. + +An explicit final `close()` drains to the audio clock before releasing the +exclusive device. `SNDCTL_DSP_RESET` is the explicit discard operation for an +application that does not want to drain. Exit, `CLOEXEC`, and forced teardown +cannot keep a syscall alive, so a queued tail becomes an orphan drain: the +device remains exclusive until the host consumes that tail, then releases +automatically. This prevents a final buffer from being truncated or joined to +the next opener's stream. Caught signals interrupt a blocked write, `SYNC`, or +explicit final close through the ordinary `EINTR` path. `SA_RESTART` restarts +write and `SYNC`; an interrupted close leaves the descriptor valid for an +explicit caller retry. + +In browsers an `AudioWorkletProcessor` consumes and converts PCM in render +quanta (normally 128 output frames), advances the shared consumer cursor, and +emits silence on underrun. The main thread only creates/resumes the +`AudioContext` and connects the node; it is not the audio clock and does not +drain through a timer. At 48 kHz one render quantum is about 2.7 ms; browser +device `baseLatency`/`outputLatency` is additional and platform-dependent. +After machine teardown drains the shared ring, a running browser context is +suspended to hand already-rendered blocks to the output device, then retained +for a bounded base/output-latency-plus-quantum settlement interval before it is +closed. Teardown never resumes a suspended or interrupted context, preserving +the browser's user-activation boundary. +Node uses the same cursor and wakeup contract with a wall-clock-paced null sink +for headless execution, so callbacks cannot run at CPU speed. Its running tick +follows the negotiated fragment duration, preserves fractional-frame drift, +and falls back to 10 ms polling while idle. + +A permanent sink failure is distinct from recoverable browser suspension. The +host latches a shared fatal flag and wakes the kernel: writes and drains fail +with `EIO`, polling reports `POLLERR`, and final close discards the unplayable +tail, releases ownership, and returns `EIO`. If the failure arrives after an +implicit close has orphaned a tail, reconciliation discards that unplayable +tail and releases ownership; no descriptor remains to receive `EIO`. A +suspended or interrupted `AudioContext` does not set that flag; it stops the +consumer clock and applies normal queue backpressure until resume. + +The AudioWorklet/shared-ring transport builds on the exploration in PR #698, +while deliberately omitting that experiment's ALSA state machine and +`/dev/snd` ABI. OSS command details are documented in +[POSIX status](posix-status.md#oss-playback-compatibility). + +### Measured PCM footprint + +These measurements compare the clean starting commit +`92d5940f7e0107514ea12ab813d395257678377e` with the ABI 40 implementation. +Compressed sizes use `zstd -19`; JavaScript totals cover the same ten existing +ESM entry files on both sides, with the new worklet shown separately. + +| Artifact | Before raw / compressed | After raw / compressed | Delta | +|---|---:|---:|---:| +| Kernel Wasm | 532,311 / 143,233 B | 612,569 / 147,554 B | +80,258 / +4,321 B | +| Host ESM entries | 3,711,675 / 634,586 B | 3,771,682 / 644,709 B | +60,007 / +10,123 B | +| PCM AudioWorklet | absent | 8,863 / 2,462 B | new | +| `audiotest.wasm` | 30,180 / 13,092 B | 30,365 / 13,183 B | +185 / +91 B | +| `dsp_signal_test.wasm` | absent | 32,355 / 13,890 B | new | + +The upstream integration artifacts measure as follows: + +| Artifact | Raw / compressed (`zstd -19`) | +|---|---:| +| SDL2 2.32.10 `libSDL2.a` | 1,192,694 / 322,705 B | +| SDL3 3.4.10 `libSDL3.a` | 1,970,446 / 513,937 B | +| SDL2 DSP fixture Wasm | 1,573,606 / 391,329 B | +| SDL3 DSP fixture Wasm | 2,470,559 / 521,937 B | +| SDL_mixer 2.8.2 `playwave` Wasm | 1,800,045 / 434,029 B | + +The installed regular-file totals are 3,864,113 bytes for the SDL2 package, +5,545,678 bytes for SDL3, and approximately 4.04 MB for the combined fixture +package. Deterministically staged package archives measured approximately +0.66 MB, 0.95 MB, and 0.70 MB respectively after `zstd -19`; exact published +archives vary slightly with provenance strings. The test-only `playwave` +package contains one 1,800,045-byte regular file; its compressed size is the +434,029-byte value above. + +Steady-state transport memory is the fixed 65,664-byte allocation described +above, versus a growable old queue whose maximum occupancy was 262,144 PCM +bytes. The default active queue is 21.333 ms. A normal 128-frame browser +quantum adds 2.667 ms at 48 kHz, for 24 ms of configured software buffering +before platform-specific `baseLatency` and `outputLatency`. Node advances in +the default 256-frame (5.333 ms) fragment cadence against the same 21.333 ms +bounded queue. These are footprint and configured-buffer measurements, not a +throughput or performance claim. ## Signal Subsystem diff --git a/docs/browser-support.md b/docs/browser-support.md index 057f5c4d68..1d39272f36 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -139,10 +139,67 @@ pipe pair. - Single-owner device (one process can hold `/dev/input/mice` open at a time; second open from another pid returns `EBUSY`). ### Audio output (`/dev/dsp`) -- The kernel exposes an OSS-style `/dev/dsp` character device. User programs `open(O_WRONLY)`, configure rate / channels / format via `SNDCTL_DSP_*` ioctls, and `write()` interleaved 16-bit-LE PCM. The kernel buffers samples in a 256 KiB ring (~1.5 s of stereo S16 @ 44.1 kHz). On overflow the *oldest* whole frame drops — same trade-off real OSS hardware makes under hardware overrun. -- Demo pages drive a `setInterval` loop (~50 ms cadence) that calls `BrowserKernel.drainAudio(maxBytes)`. The kernel-worker drains the ring via the `kernel_drain_audio` wasm export (which respects whole-frame boundaries so stereo L/R never tear) and posts the bytes back. Main thread converts S16 → Float32, builds an `AudioBuffer`, and schedules an `AudioBufferSourceNode` on the `AudioContext` clock with a small lookahead so brief drain hiccups don't underrun. -- Single-owner device. A non-CLOEXEC fd retains ownership and queued samples across `execve`; last close or process exit releases the owner and flushes the ring so a successor starts from silence. Format must be `AFMT_S16_LE`; other formats are `EINVAL`. -- **AudioContext gesture requirement.** `new AudioContext()` starts suspended in modern browsers and only resumes after a user gesture. The DOOM demo creates the context immediately after the user's "Start" click (which is itself a gesture), so `audioCtx.resume()` succeeds without a separate prompt. + +- The kernel exposes a playback-only OSS `/dev/dsp` frontend over its generic + PCM stream core. Applications may write U8, signed 16-bit little-endian, or + signed 16-bit big-endian mono/stereo PCM at 8–192 kHz. The worklet converts + those PCM concepts to the browser output format; Web Audio types are not + exposed to guests. +- The queue is bounded and backpressured. It defaults to four 1024-byte + fragments (4096 active bytes), within a fixed 65,664-byte transport + allocation (128-byte control header plus 65,536-byte ring). + The kernel never drops the oldest samples. Blocking writers sleep for + capacity, nonblocking writers receive partial progress or `EAGAIN`, and + `POLLOUT` requires at least one free fragment. Writes may end between PCM + frame boundaries: later writes continue the same byte stream, while a drain + pads only a terminal incomplete frame with format-appropriate silence. +- An `AudioWorkletProcessor`, running on the Web Audio render clock, reads the + shared PCM ring directly, advances the kernel-visible consumer cursor, and + outputs silence on underrun. There is no main-thread audio-drain timer and no + second persistent PCM queue; browser-provided Float32 output buffers are + transient. Consumption wakes blocked writes, `poll()`, and + `SNDCTL_DSP_SYNC`/close drain waiters in the kernel worker. +- The default 4096-byte queue is about 21.3 ms at 48 kHz stereo S16. A normal + 128-frame worklet render quantum adds about 2.7 ms at 48 kHz; the browser's + `AudioContext.baseLatency` and `outputLatency` are device-specific and must + be measured separately. Machine teardown first waits for the shared PCM ring + to drain. If the context is running, it then suspends the context so Web + Audio hands already-rendered blocks to the output device and waits a bounded + interval covering the reported base/output latency and final render quantum + before closing the context. A suspended or interrupted context is never + resumed implicitly during teardown. +- **A user gesture is required.** Preparing the PCM driver may leave its + `AudioContext` suspended. The application must call the session audio-resume + path from a click, keypress, or other browser-recognized activation. If the + context is suspended or interrupted, the consumer cursor intentionally + stops: the queue fills, writers apply backpressure, and drain/close stays + pending instead of pretending audio played. Resuming the context continues + from the queued position. +- Browser policy suspension and interruption are recoverable and do not poison + the stream. A permanent worklet, processor, or sink failure is latched into + the shared transport instead: blocked calls wake, `write()` and drain return + `EIO`, `poll()` reports `POLLERR`, and final close releases the exclusive + device after discarding only the tail that can no longer be played. A fatal + failure during an orphan drain likewise discards the unplayable tail and + releases ownership instead of wedging subsequent opens. +- The one physical device is exclusive by OFD. `dup()` and inherited + descriptors share it; another `open()` gets `EBUSY`. Explicit final close + drains. Exit or `CLOEXEC` leaves any queued tail as an orphan drain and keeps + the device busy until the worklet reaches it. `RESET` is the explicit way to + discard a tail. A caught signal can interrupt a blocked write, drain, or + explicit close; write and drain honor `SA_RESTART`, while interrupted close + leaves the fd valid for the caller to retry. +- Capture, duplex, `mmap`, OSS mixer devices, and kernel multi-client mixing + are not implemented. `open(O_RDONLY)` and `open(O_RDWR)` fail with + `ENOTSUP`, so browser software does not discover a fake recording device. + +Node uses the same transport and state transitions. Its default headless sink +advances the consumer position from elapsed wall-clock time at the configured +sample rate and emits consumed bytes to an optional observer; it never drains +the queue instantaneously or keeps a second PCM copy. Running ticks follow the +negotiated fragment duration, preserve fractional-frame drift, and use 10 ms +idle polling. Applications therefore see the same write and SDL callback +pacing even when no physical Node audio device is attached. ## Browser Demos @@ -178,6 +235,20 @@ The "Boot pattern" column reflects how the demo enters the kernel: Run the browser app: `cd apps/browser-demos && npm run dev`, then open `http://127.0.0.1:5401/`. +For a manual OSS playback check after changing the port, first run +`./run.sh clean fbdoom && ./run.sh build fbdoom` so an ignored local artifact +from an older package revision cannot be reused. Then run `./run.sh browser`, +select the fbDOOM demo, and click the framebuffer to satisfy the browser's +audio-activation requirement. The title-screen music checks the software OPL +path; starting a game and firing the pistol checks mixed sound effects. Quit +through DOOM's menu to exercise the normal `/dev/dsp` drain-and-close path. +This demo is a direct OSS consumer, not an SDL test; the `sdl-dsp-test` package +and host audio integration suite exercise the unmodified SDL2 and SDL3 `dsp` +backends. That suite also runs SDL_mixer 2.8.2's unmodified `playwave` example +against deterministic WAVs and compares the Node sink's consumed PCM exactly. +Browser output remains a manual audible check because the production +AudioWorklet intentionally exposes transport cursors, not rendered samples. + ### Kandelo session UI The Kandelo app at `/pages/kandelo/` keeps the running machine as the primary diff --git a/docs/porting-guide.md b/docs/porting-guide.md index e52c87c30a..d213400c26 100644 --- a/docs/porting-guide.md +++ b/docs/porting-guide.md @@ -63,6 +63,71 @@ headers from an arbitrary host LLVM install; the libcxx package generates and ships a version-matched header tree with its `libc++.a` and `libc++abi.a`. See `packages/registry/mariadb/build-mariadb.sh` for a complete example. +### OSS playback with `/dev/dsp` + +Kandelo's standard low-level audio API is OSS `/dev/dsp`, backed by the generic +PCM stream core. Do not enable ALSA or copy Linux `/dev/snd` headers into a +port. Direct users include ``, open `/dev/dsp` with +`O_WRONLY`, negotiate an advertised format/rate/channel count, and use +blocking `write()` or `poll(POLLOUT)` plus nonblocking writes. See the exact +[ioctl matrix](posix-status.md#oss-playback-compatibility). +Writes are a continuous byte stream, so an application may split a PCM frame +across calls. `SNDCTL_DSP_SYNC` and final close pad a terminal partial frame +with format silence and wait for it to reach the audio clock. + +Upstream SDL needs no DSP-backend source changes. The registry packages build +the official releases with only their OSS audio backend: + +```bash +cargo xtask build-deps resolve sdl2 +cargo xtask build-deps resolve sdl3 +``` + +For an external SDL2 build, retain the equivalent of: + +```bash +./configure --host=wasm32-unknown-none \ + --enable-static --disable-shared \ + --enable-audio --enable-oss \ + --disable-alsa --disable-pulseaudio --disable-pipewire \ + --disable-jack --disable-sndio +``` + +SDL2 2.32.x does not classify `wasm32-unknown-none` as a Unix target upstream, +so the Kandelo package carries a minimal platform-classification patch to its +generated configure logic. It does not change `src/audio/dsp/`. + +For SDL3, configure a truthful Kandelo CMake toolchain and retain: + +```bash +cmake -S SDL -B build \ + -DCMAKE_TOOLCHAIN_FILE=/path/to/kandelo-toolchain.cmake \ + -DSDL_STATIC=ON -DSDL_SHARED=OFF \ + -DSDL_UNIX_CONSOLE_BUILD=ON \ + -DSDL_AUDIO=ON -DSDL_OSS=ON \ + -DSDL_ALSA=OFF -DSDL_PULSEAUDIO=OFF -DSDL_PIPEWIRE=OFF \ + -DSDL_JACK=OFF -DSDL_SNDIO=OFF +``` + +The registry's SDL3 patch only teaches its platform detector that Kandelo is +Unix; its OSS backend is also unmodified. Do not claim Linux or Emscripten as +the target to make feature detection pass. + +At runtime the default path is already `/dev/dsp`. Set +`SDL_AUDIODRIVER=dsp` when a deterministic backend choice is needed (tests, +headless runners, or images that may gain another driver later). SDL also +honors its upstream device-path overrides (`AUDIODEV`, and `SDL_PATH_DSP` in +SDL2). Playback supports U8/S16_LE/S16_BE, mono/stereo, and 8–192 kHz. SDL2's +backend is paced primarily by blocking `write()`; SDL3 additionally waits on +truthful `SNDCTL_DSP_GETOSPACE` results. Capture/duplex opens fail with +`ENOTSUP`, so do not advertise recording support in application UI. + +The host audio integration suite also builds SDL_mixer 2.8.2's upstream +`playwave` example unchanged and runs it with `SDL_AUDIODRIVER=dsp`. It checks +44.1 kHz signed-16 stereo and 22.05 kHz unsigned-8 mono WAVs byte-for-byte at +the paced Node sink, including negotiated fragment geometry, the SDL2 OSS +ioctl sequence, complete drain, and zero discarded data. + ### Step 3: Test it ```bash diff --git a/docs/posix-status.md b/docs/posix-status.md index 1f31926b6f..94003dd797 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -360,11 +360,118 @@ shortcuts. | `/dev/pts/*` | Full | PTY slave devices. `posix_openpt()` + `grantpt()` + `unlockpt()` + `ptsname()`. Full line discipline, canonical/raw mode, OPOST/ONLCR, 16 terminal ioctls. | | `/dev/fb0` | Full | Linux fbdev framebuffer. Single-open (`EBUSY` for second opener). 640×400 BGRA32 packed-pixel. ioctls: `FBIOGET_VSCREENINFO`, `FBIOGET_FSCREENINFO`, `FBIOPAN_DISPLAY` (no-op success), `FBIOPUT_VSCREENINFO` (validates geometry). `mmap` returns a region in process memory and notifies the host (`bind_framebuffer` callback) so the browser canvas can mirror pixels. `munmap`/`exit`/`exec` discard the image mapping; a surviving fd retains device ownership across exec. Ownership is released after both the final fd and any live mapping are gone, since a mapping remains valid after `close()`. Linux-VT keyboard ioctls (`KDGKBTYPE`/`KDGKBMODE`/`KDSKBMODE`) accepted with sensible defaults so fbDOOM-style software works unmodified. | | `/dev/input/mice` | Full | Linux `mousedev` PS/2 mouse stream. Single-open (`EBUSY` for second pid). 3-byte packets: byte0 button bits + sign/overflow flags, bytes 1..2 signed dx/dy with positive-up dy. Host pushes events via `kernel_inject_mouse_event(dx, dy, buttons)`; the kernel buffers up to 4096 packets (whole-packet drop on overflow). `read()` drains queued bytes; returns `EAGAIN` when empty. `poll()` reports `POLLIN` only when bytes are queued. Ownership and queued packets survive exec with a non-CLOEXEC fd; last close or exit releases and clears them. No IMPS/2 wheel protocol, no `evdev`/`/dev/input/eventN`. | -| `/dev/dsp` | Full (write-only) | OSS-style PCM audio sink. Single-open (`EBUSY` for second pid). `write()` accepts interleaved 16-bit-LE PCM and buffers it in a 256 KiB ring; the host drains via the `kernel_drain_audio` wasm export and feeds a Web Audio `AudioContext`. ioctls: `SNDCTL_DSP_RESET`, `SNDCTL_DSP_SYNC`, `SNDCTL_DSP_SPEED` (clamp 4000–192000 Hz), `SNDCTL_DSP_STEREO` / `SNDCTL_DSP_CHANNELS` (1 or 2), `SNDCTL_DSP_SETFMT` (only `AFMT_S16_LE`), `SNDCTL_DSP_GETFMTS`, `SNDCTL_DSP_SETFRAGMENT` (accept-and-acknowledge). On overflow drops the *oldest whole frame* — never tears L/R alignment. Ownership and queued samples survive exec with a non-CLOEXEC fd; last close or exit releases and flushes them. `read()` returns 0 (EOF-like). `poll()` reports `POLLOUT` always, never `POLLIN`. No record path, no `mmap`-based zero-copy; DOOM's mixer is in user space. | +| `/dev/dsp` | Partial (playback only) | Source-compatible OSS PCM playback over the implementation-neutral Kandelo PCM core. U8/S16_LE/S16_BE, mono/stereo, 8–192 kHz; bounded fragment queue with blocking/nonblocking backpressure and audio-clock drain. Exclusive ownership is per OFD, not PID. See the matrix below. Capture, duplex, mmap, mixer controls, and multi-client mixing are unsupported. | | `/dev/shm/*` | Partial | POSIX shm objects are regular files used by `shm_open()`. Stable-identity backends support host-coordinated `MAP_SHARED` across processes at syscall boundaries; this is not immediate shared linear memory and does not make process-shared futexes work. | All virtual devices return synthetic `stat()` with `S_IFCHR | 0666`, deterministic inode numbers, and `st_dev=5`. Path interception in kernel before host delegation — no host filesystem changes needed. `access()` returns OK for all virtual devices. +## OSS playback compatibility + +Kandelo owns a fixed wasm32 OSS source ABI in ``; it does not +import a Linux host UAPI. `audio_buf_info` is four signed 32-bit fields in the +canonical `fragments`, `fragstotal`, `fragsize`, `bytes` order (16 bytes, +4-byte alignment). `count_info` is three signed 32-bit fields (`bytes`, +`blocks`, `ptr`; 12 bytes, 4-byte alignment). The C header, Rust constants, +and ABI snapshot assert every numeric value and layout. Canonical source aliases +are emitted by the same Rust-owned generator and carry C equality assertions. +The command semantics follow +the established [FreeBSD PCM/OSS frontend](https://github.com/freebsd/freebsd-src/blob/main/sys/dev/sound/pcm/dsp.c) +and [canonical OSS definitions](https://github.com/torvalds/linux/blob/master/include/uapi/linux/soundcard.h), +while the numeric encodings below are specifically Kandelo's wasm32 ABI. + +| Command | wasm32 value | Support and observable behavior | +|---|---:|---| +| `SNDCTL_DSP_RESET` (`SNDCTL_DSP_HALT`, `SOUND_PCM_RESET`) | `0x00005000` | Discards queued output, stops the stream, advances the reset generation, and wakes capacity/drain waiters. This is the explicit non-draining shutdown operation. | +| `SNDCTL_DSP_SYNC` (`SOUND_PCM_SYNC`) | `0x00005001` | Pads a terminal incomplete PCM frame with format silence, then blocks until the host audio clock has consumed all queued frames. Existing signal interruption rules apply; it is not an acknowledge-only no-op. | +| `SNDCTL_DSP_SPEED` (`SOUND_PCM_WRITE_RATE`) | `0xc0045002` | Signed 32-bit in/out rate. Zero queries the current rate; nonzero requests clamp to 8000–192000 Hz and return the actual value. Reconfiguration while running or draining returns `EBUSY`. | +| `SOUND_PCM_READ_RATE` | `0x80045002` | Returns the current actual rate as a signed 32-bit value without changing the configuration. | +| `SNDCTL_DSP_STEREO` | `0xc0045003` | Signed 32-bit in/out legacy mono/stereo selector (`0`/`1`), returning the actual selection. | +| `SNDCTL_DSP_GETBLKSIZE` | `0xc0045004` | Returns the actual fragment size in bytes. | +| `SNDCTL_DSP_SETFMT` (`SNDCTL_DSP_SAMPLESIZE`, `SOUND_PCM_SETFMT`, `SOUND_PCM_WRITE_BITS`) | `0xc0045005` | Signed 32-bit in/out format. Supports `AFMT_U8`, `AFMT_S16_LE`, and `AFMT_S16_BE`; `AFMT_QUERY` returns the current format. Reconfiguration while running or draining returns `EBUSY`. | +| `SOUND_PCM_READ_BITS` | `0x80045005` | Returns the current actual sample width (`8` or `16`) as a signed 32-bit value without changing the configuration. | +| `SNDCTL_DSP_CHANNELS` (`SOUND_PCM_WRITE_CHANNELS`) | `0xc0045006` | Signed 32-bit in/out channel count. Zero queries; requests negotiate to mono or stereo and return the actual count. | +| `SOUND_PCM_READ_CHANNELS` | `0x80045006` | Returns the current actual channel count as a signed 32-bit value without changing the configuration. | +| `SNDCTL_DSP_POST` (`SOUND_PCM_POST`) | `0x00005008` | Starts output without fabricating data. An empty stream underruns to silence. | +| `SNDCTL_DSP_SETFRAGMENT` (`SOUND_PCM_SETFRAGMENT`) | `0xc004500a` | Signed 32-bit in/out geometry: high 16 bits request fragment count, low 16 bits request log2 fragment bytes. The exponent clamps to 4–16. A zero count selects the maximum whole-fragment count fitting 65536 bytes; a nonzero count clamps to at least two fragments when two fit, then to that maximum. Thus exponent 16 truthfully selects the only possible geometry: one 64 KiB fragment. Returns the encoded actual geometry. Reconfiguration while running or draining returns `EBUSY`. | +| `SNDCTL_DSP_GETFMTS` (`SOUND_PCM_GETFMTS`) | `0x8004500b` | Returns exactly the supported playback format mask: U8, S16_LE, and S16_BE. | +| `SNDCTL_DSP_GETOSPACE` (`SOUND_PCM_GETOSPACE`) | `0x8010500c` | Returns truthful `audio_buf_info`: whole immediately available fragments, total fragments, fragment bytes, and all immediately writable bytes (including a partial fragment). SDL3 uses `bytes` to pace its wait path. | +| `SNDCTL_DSP_NONBLOCK` (`SOUND_PCM_NONBLOCK`) | `0x0000500e` | Sets `O_NONBLOCK` on the shared OFD. `fcntl()`/`FIONBIO` may subsequently manage that status flag through the normal descriptor path. | +| `SNDCTL_DSP_GETCAPS` (`SOUND_PCM_GETCAPS`) | `0x8004500f` | Returns only `PCM_CAP_OUTPUT | PCM_CAP_VIRTUAL | PCM_CAP_DEFAULT`. It does not advertise capture, duplex, mmap, trigger, or multi-open capabilities. | +| `SNDCTL_DSP_GETOPTR` (`SOUND_PCM_GETOPTR`) | `0x800c5012` | Returns `count_info`: low 32 bits of audio-clock-consumed bytes, fragment transitions since the previous query on this stream, and the consumer byte offset modulo active capacity. `RESET` discards do not advance it; drain padding counts when played. | +| `SNDCTL_DSP_GETODELAY` | `0x80045017` | Returns all queued, not-yet-consumed output bytes, including terminal-frame padding once a drain begins. | + +The SDK header also defines the canonical FreeBSD OSS format identifiers for +mu-law, A-law, IMA ADPCM, signed 8-bit, unsigned 16/24/32-bit, signed +24/32-bit, MPEG, AC3, and 32-bit float formats, including native- and +opposite-endian aliases. This is source compatibility, not an advertisement: +`GETFMTS` contains only U8/S16_LE/S16_BE, and `SETFMT` returns `EINVAL` for the +other identifiers. + +The following canonical command numbers are pinned in the SDK header so source +and ioctl marshalling cannot drift, but the playback-only frontend rejects each +one with `ENOTTY`; none is accepted as a no-op or advertised by `GETCAPS`. + +| Unsupported command | wasm32 value | Boundary | +|---|---:|---| +| `SNDCTL_DSP_SETBLKSIZE` | `0x40045004` | FreeBSD's distinct block-size setter is source-visible, but direct block-size setting is not implemented. It does not collide with the supported `GETBLKSIZE` request. | +| `SOUND_PCM_WRITE_FILTER` | `0xc0045007` | Legacy PCM filter control is not implemented. | +| `SOUND_PCM_READ_FILTER` | `0x80045007` | Legacy PCM filter query is not implemented. | +| `SNDCTL_DSP_SUBDIVIDE` (`SOUND_PCM_SUBDIVIDE`) | `0xc0045009` | Legacy fragment subdivision is not implemented; use `SETFRAGMENT`. | +| `SNDCTL_DSP_GETISPACE` (`SOUND_PCM_GETISPACE`) | `0x8010500d` | Capture-space query; capture is not implemented. | +| `SNDCTL_DSP_SETTRIGGER` (`SOUND_PCM_SETTRIGGER`) | `0x40045010` | Trigger control is not implemented. The header defines canonical values `PCM_ENABLE_INPUT=1` and `PCM_ENABLE_OUTPUT=2` without advertising trigger capability. | +| `SNDCTL_DSP_GETTRIGGER` (`SOUND_PCM_GETTRIGGER`) | `0x80045010` | Trigger control is not implemented. | +| `SNDCTL_DSP_GETIPTR` (`SOUND_PCM_GETIPTR`) | `0x800c5011` | Capture-position accounting is not implemented. | +| `SNDCTL_DSP_MAPINBUF` (`SOUND_PCM_MAPINBUF`) | `0x80085013` | Direct mapped capture buffers are not implemented. | +| `SNDCTL_DSP_MAPOUTBUF` (`SOUND_PCM_MAPOUTBUF`) | `0x80085014` | Direct mapped playback buffers are not implemented. | +| `SNDCTL_DSP_SETSYNCRO` (`SOUND_PCM_SETSYNCRO`) | `0x00005015` | Synchronized input/output start is not implemented. | +| `SNDCTL_DSP_SETDUPLEX` | `0x00005016` | Duplex operation is not implemented. | + +The initial stream is 48 kHz stereo S16_LE with four 1024-byte fragments. +Configuration calls record both requested and returned actual values. Format, +rate, channel, and fragment changes return `EBUSY` while running or draining; +they are accepted again after a successful `SYNC` leaves the stream stopped, +or after `RESET`. Writes +accept arbitrary byte lengths and form one continuous PCM byte stream; a +partial frame remains queued across subsequent writes. `SYNC` and final OFD +close pad a terminal partial frame with `0x80` for U8 or `0x00` for either S16 +format before draining. `POST` does not pad. A frame-aligned blocking write no +larger than active capacity waits for the full request, including normal SDL +period writes. If a prior unaligned write has left an incomplete frame at a +full queue boundary, a later blocking call may return the prefix that completes +that frame so playback can resume; callers must handle that ordinary Unix +short-write result. Requests larger than capacity may also advance partially. +With `O_NONBLOCK`, available bytes are accepted immediately, and no capacity +returns `EAGAIN`. `poll(POLLOUT)` is reported only when at least one fragment +is free. Consumer progress wakes writers and poll/drain waiters. Underruns +output silence; queue overflow never discards older audio. +Underruns are counted once per continuous starvation episode, and a short, +successfully draining tail is not classified as an underrun. + +The one physical/default device is exclusively owned by its OFD. `dup()` and +fork inheritance share it; a separate `open()` returns `EBUSY`, including one +from the same PID. Explicit final `close()` drains before releasing ownership. +Exit, `CLOEXEC`, or forced teardown leaves a queued tail draining and keeps the +device busy until the audio clock reaches the producer. A non-`CLOEXEC` +descriptor and its queued state survive `exec`. Caught signals interrupt a +blocked write, `SYNC`, or final close with `EINTR`; `SA_RESTART` applies to +write and `SYNC`, while an interrupted close keeps the descriptor open for a +caller-directed retry. + +A permanent host sink failure is latched and wakes every affected waiter. +Further writes and drains fail with `EIO`, `poll()` exposes `POLLERR`, and final +close discards the now-unplayable tail, releases the fd/OFD and exclusive +ownership, and returns `EIO`. If the failure arrives during an orphan drain +after exit or `CLOEXEC`, reconciliation discards the unplayable orphan tail and +releases exclusive ownership; there is no fd left to report `EIO` through. +Browser user-activation suspension is recoverable backpressure and does not set +this fatal state. + +Capture opens (`O_RDONLY` and `O_RDWR`) fail with `ENOTSUP`; they do not expose +a device that returns EOF. Unsupported and unknown ioctls fail with `ENOTTY`, +`mmap()` fails with `ENODEV`, and seek-style operations fail with `ESPIPE`. `/dev/mixer`, +recording, duplex, trigger control, direct mapped playback, and kernel mixing +remain explicit gaps. + ## Environment | Function | Status | Notes | diff --git a/docs/sdk-guide.md b/docs/sdk-guide.md index f17eaac35f..c7a3833672 100644 --- a/docs/sdk-guide.md +++ b/docs/sdk-guide.md @@ -148,6 +148,7 @@ wasm32posix-cc -shared -fPIC plugin.c -o plugin.so ``` --target=wasm32-unknown-unknown # Wasm target triple +-D__unix__=1 -D__unix=1 # Kandelo Unix source-environment identity -matomics # Enable atomics (SharedArrayBuffer) -mbulk-memory # Enable bulk memory operations -mexception-handling # Enable Wasm exception handling @@ -159,6 +160,12 @@ wasm32posix-cc -shared -fPIC plugin.c -o plugin.so --sysroot= # musl sysroot ``` +The generic LLVM Wasm triple does not imply an operating system. The SDK +defines the conventional reserved Unix macros because Kandelo supplies a Unix +and POSIX userspace; it deliberately does not define Linux, FreeBSD, WASI, or +Emscripten platform macros. This lets upstream source choose generic Unix +interfaces such as OSS without misrepresenting the kernel it will run on. + The musl objects in the SDK sysroot are compiled with the same Wasm exception handling and SjLj lowering flags, so libc calls to `setjmp`/`longjmp` do not leave unresolved host imports in linked programs. diff --git a/examples/dsp_signal_test.c b/examples/dsp_signal_test.c new file mode 100644 index 0000000000..8c402cf28a --- /dev/null +++ b/examples/dsp_signal_test.c @@ -0,0 +1,215 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum { PCM_BYTES = 8192 }; + +static volatile sig_atomic_t alarm_count; +static unsigned char pcm[PCM_BYTES]; + +static void on_alarm(int signum) +{ + (void)signum; + alarm_count++; +} + +static int arm_alarm(long usec) +{ + const struct itimerval timer = { + .it_value = { .tv_sec = 0, .tv_usec = usec }, + }; + return setitimer(ITIMER_REAL, &timer, NULL); +} + +static long elapsed_ms(struct timespec start, struct timespec end) +{ + return (end.tv_sec - start.tv_sec) * 1000L + + (end.tv_nsec - start.tv_nsec) / 1000000L; +} + +static int configure_dsp(int fd) +{ + int format = AFMT_U8; + int channels = 1; + int rate = 8000; + int fragments = (2 << 16) | 12; + + if (ioctl(fd, SNDCTL_DSP_SETFMT, &format) != 0 || format != AFMT_U8) + return -1; + if (ioctl(fd, SNDCTL_DSP_CHANNELS, &channels) != 0 || channels != 1) + return -1; + if (ioctl(fd, SNDCTL_DSP_SPEED, &rate) != 0 || rate != 8000) + return -1; + if (ioctl(fd, SNDCTL_DSP_SETFRAGMENT, &fragments) != 0 || + fragments != ((2 << 16) | 12)) + return -1; + return 0; +} + +static int fill_dsp(int fd) +{ + ssize_t written = write(fd, pcm, sizeof(pcm)); + if (written != (ssize_t)sizeof(pcm)) { + fprintf(stderr, "fill write: result=%ld errno=%d\n", (long)written, errno); + return -1; + } + return 0; +} + +static int expect_interrupted_write(int fd) +{ + const int before = alarm_count; + if (fill_dsp(fd) != 0 || arm_alarm(20 * 1000) != 0) + return -1; + errno = 0; + ssize_t result = write(fd, pcm, sizeof(pcm)); + if (result != -1 || errno != EINTR || alarm_count != before + 1) { + fprintf(stderr, "write interruption: result=%ld errno=%d alarms=%d\n", + (long)result, errno, (int)alarm_count); + return -1; + } + return ioctl(fd, SNDCTL_DSP_RESET, 0); +} + +static int expect_interrupted_sync(int fd) +{ + const int before = alarm_count; + if (fill_dsp(fd) != 0 || arm_alarm(20 * 1000) != 0) + return -1; + errno = 0; + int result = ioctl(fd, SNDCTL_DSP_SYNC, 0); + if (result != -1 || errno != EINTR || alarm_count != before + 1) { + fprintf(stderr, "sync interruption: result=%d errno=%d alarms=%d\n", + result, errno, (int)alarm_count); + return -1; + } + return ioctl(fd, SNDCTL_DSP_RESET, 0); +} + +static int expect_interrupted_close(int fd) +{ + const int before = alarm_count; + if (fill_dsp(fd) != 0 || arm_alarm(20 * 1000) != 0) + return -1; + errno = 0; + int result = close(fd); + if (result != -1 || errno != EINTR || alarm_count != before + 1) { + fprintf(stderr, "close interruption: result=%d errno=%d alarms=%d\n", + result, errno, (int)alarm_count); + return -1; + } + if (fcntl(fd, F_GETFL) < 0) { + perror("interrupted close consumed fd"); + return -1; + } + return 0; +} + +static int expect_restarted_write(int fd) +{ + const int before = alarm_count; + struct timespec start; + struct timespec end; + if (fill_dsp(fd) != 0 || arm_alarm(20 * 1000) != 0 || + clock_gettime(CLOCK_MONOTONIC, &start) != 0) + return -1; + errno = 0; + ssize_t result = write(fd, pcm, sizeof(pcm)); + if (clock_gettime(CLOCK_MONOTONIC, &end) != 0) + return -1; + long duration = elapsed_ms(start, end); + if (result != (ssize_t)sizeof(pcm) || alarm_count != before + 1 || + duration < 500 || duration > 5000) { + fprintf(stderr, + "restarted write: result=%ld errno=%d alarms=%d elapsed=%ld\n", + (long)result, errno, (int)alarm_count, duration); + return -1; + } + return ioctl(fd, SNDCTL_DSP_RESET, 0); +} + +static int expect_restarted_sync(int fd) +{ + const int before = alarm_count; + struct timespec start; + struct timespec end; + if (fill_dsp(fd) != 0 || arm_alarm(20 * 1000) != 0 || + clock_gettime(CLOCK_MONOTONIC, &start) != 0) + return -1; + errno = 0; + int result = ioctl(fd, SNDCTL_DSP_SYNC, 0); + if (clock_gettime(CLOCK_MONOTONIC, &end) != 0) + return -1; + long duration = elapsed_ms(start, end); + if (result != 0 || alarm_count != before + 1 || + duration < 500 || duration > 5000) { + fprintf(stderr, + "restarted sync: result=%d errno=%d alarms=%d elapsed=%ld\n", + result, errno, (int)alarm_count, duration); + return -1; + } + return 0; +} + +int main(void) +{ + for (size_t i = 0; i < sizeof(pcm); i++) + pcm[i] = (unsigned char)i; + + struct sigaction action = { .sa_handler = on_alarm }; + sigemptyset(&action.sa_mask); + if (sigaction(SIGALRM, &action, NULL) != 0) { + perror("sigaction"); + return 2; + } + + int fd = open("/dev/dsp", O_WRONLY); + if (fd < 0 || configure_dsp(fd) != 0) { + perror("open/configure dsp"); + return 3; + } + if (expect_interrupted_write(fd) != 0 || + expect_interrupted_sync(fd) != 0 || + expect_interrupted_close(fd) != 0) { + return 4; + } + if (ioctl(fd, SNDCTL_DSP_RESET, 0) != 0 || close(fd) != 0) { + perror("cleanup after interrupted close"); + return 5; + } + + action.sa_flags = SA_RESTART; + if (sigaction(SIGALRM, &action, NULL) != 0) { + perror("sigaction(SA_RESTART)"); + return 6; + } + fd = open("/dev/dsp", O_WRONLY); + if (fd < 0 || configure_dsp(fd) != 0) { + perror("reopen/configure dsp"); + return 7; + } + if (expect_restarted_write(fd) != 0 || expect_restarted_sync(fd) != 0) + return 8; + + /* close is intentionally outside the SA_RESTART whitelist: an EINTR + * leaves the descriptor live, so policy remains with the caller. */ + if (expect_interrupted_close(fd) != 0) + return 9; + if (ioctl(fd, SNDCTL_DSP_RESET, 0) != 0 || close(fd) != 0) { + perror("final cleanup"); + return 10; + } + + printf("PASS dsp signal interruption alarms=%d\n", (int)alarm_count); + return 0; +} diff --git a/host/package.json b/host/package.json index 13f0602d7d..86b600ae16 100644 --- a/host/package.json +++ b/host/package.json @@ -61,6 +61,7 @@ "default": "./dist/framebuffer/index.cjs" } }, + "./audio/pcm-audio-worklet.js": "./dist/audio/pcm-audio-worklet.js", "./worker-entry": { "browser": { "types": "./dist/worker-entry-browser.d.ts", diff --git a/host/src/audio/browser-pcm-driver.ts b/host/src/audio/browser-pcm-driver.ts new file mode 100644 index 0000000000..f907536183 --- /dev/null +++ b/host/src/audio/browser-pcm-driver.ts @@ -0,0 +1,380 @@ +import type { PcmOutputDriver, PcmOutputState } from "./pcm-driver.js"; +import { + PCM_CONTROL, + PcmSampleFormat, + PcmStreamState, + PcmTransportFlag, + hasPcmFatalError, + markPcmFatalError, + pcmControlWords, + validatePcmTransport, + type PcmTransportDescriptor, +} from "./pcm-transport.js"; + +export interface BrowserPcmDriverOptions { + workletUrl: string | URL; + createContext?: () => AudioContext; + createNode?: ( + context: AudioContext, + name: string, + options: AudioWorkletNodeOptions, + ) => AudioWorkletNode; +} + +const DEFAULT_RENDER_QUANTUM_FRAMES = 128; +const UNREPORTED_OUTPUT_LATENCY_FALLBACK_MS = 100; +const MAX_OUTPUT_PIPELINE_SETTLE_MS = 1000; + +/** AudioWorklet-backed physical/default PCM sink for browser machines. */ +export class BrowserPcmDriver implements PcmOutputDriver { + private readonly listeners = new Set<(state: PcmOutputState) => void>(); + private context: AudioContext | null = null; + private node: AudioWorkletNode | null = null; + private transport: PcmTransportDescriptor | null = null; + private state: PcmOutputState = "unprepared"; + private preparing: Promise | null = null; + private readonly onContextError = () => this.fail(); + + constructor(private readonly options: BrowserPcmDriverOptions) {} + + prepare(transport: PcmTransportDescriptor): Promise { + if (this.state === "closed") { + return Promise.reject(new Error("PCM output is closed")); + } + validatePcmTransport(transport); + if (this.transport) { + if (!sameTransport(this.transport, transport)) { + return Promise.reject( + new Error("PCM output is already attached to another transport"), + ); + } + return this.preparing ?? Promise.resolve(); + } + this.transport = transport; + this.preparing = this.prepareInner(transport).finally(() => { + this.preparing = null; + }); + return this.preparing; + } + + async resume(): Promise { + if (this.preparing) await this.preparing; + if (this.state === "error") throw new Error("PCM output has failed"); + if (this.transport && hasPcmFatalError(pcmControlWords(this.transport))) { + this.setState("error"); + throw new Error("PCM output has failed"); + } + const context = this.context; + if (!context) throw new Error("PCM output has not been prepared"); + try { + await context.resume(); + this.syncContextState(); + if (context.state !== "running") { + throw new Error(`AudioContext remained ${context.state}`); + } + } catch (error) { + // A suspended context and a rejected resume before a user gesture are + // recoverable. The caller can retry resume from the next activation. + this.syncContextState(); + throw error; + } + } + + async suspend(): Promise { + if (this.preparing) await this.preparing; + if (this.transport && hasPcmFatalError(pcmControlWords(this.transport))) { + this.setState("error"); + } + if (!this.context || this.context.state === "closed") return; + await this.context.suspend(); + this.syncContextState(); + } + + /** + * Let samples already rendered by the worklet reach the physical output + * before machine teardown closes the AudioContext. + * + * The shared consumer cursor advances when the worklet fills a render + * quantum, before Web Audio's downstream buffers and the output device have + * necessarily emitted that quantum. BrowserKernel calls this only after the + * kernel worker has drained the shared PCM ring. Per Web Audio 1.1's + * AudioContext.suspend() contract, suspension lets already-processed blocks + * play to the destination and resolves once its frame buffer has been handed + * to the hardware; the latency wait then covers physical emission of the + * reported processing/device queues and the final quantum. + * + * This method never resumes a context, so it does not bypass browser user- + * activation policy. Both suspension and the final wait share a bounded + * teardown budget so a broken AudioContext cannot wedge machine destroy. + */ + async settleOutputPipeline(): Promise { + if (this.preparing) await this.preparing.catch(() => {}); + const context = this.context; + if (!context || !this.node || context.state !== "running") return; + + const startedAt = performance.now(); + await waitForPromiseWithin( + context.suspend(), + MAX_OUTPUT_PIPELINE_SETTLE_MS, + ); + this.syncContextState(); + + if ((context.state as string) === "closed" || context !== this.context) { + return; + } + const elapsedMs = Math.max(0, performance.now() - startedAt); + const remainingBudgetMs = Math.max( + 0, + MAX_OUTPUT_PIPELINE_SETTLE_MS - elapsedMs, + ); + const settleMs = Math.min( + outputPipelineSettleMs(context), + remainingBudgetMs, + ); + if (settleMs > 0) await delay(settleMs); + } + + async close(): Promise { + if (this.state === "closed") return; + if (this.preparing) await this.preparing.catch(() => {}); + const node = this.node; + const context = this.context; + this.node = null; + this.context = null; + this.transport = null; + if (node) { + node.port.onmessage = null; + node.onprocessorerror = null; + node.disconnect(); + node.port.close(); + } + if (context) { + context.onstatechange = null; + context.removeEventListener?.("error", this.onContextError); + } + if (context && context.state !== "closed") await context.close(); + this.setState("closed"); + this.listeners.clear(); + } + + getState(): PcmOutputState { + return this.state; + } + + subscribe(listener: (state: PcmOutputState) => void): () => void { + this.listeners.add(listener); + listener(this.state); + return () => this.listeners.delete(listener); + } + + private async prepareInner(transport: PcmTransportDescriptor): Promise { + const AudioContextCtor = + globalThis.AudioContext ?? + ( + globalThis as typeof globalThis & { + webkitAudioContext?: typeof AudioContext; + } + ).webkitAudioContext; + if (!this.options.createContext && !AudioContextCtor) { + this.markFatalError(); + this.setState("unavailable"); + this.transport = null; + throw new Error("Web Audio is unavailable"); + } + + let context: AudioContext; + try { + context = this.options.createContext + ? this.options.createContext() + : new (AudioContextCtor as typeof AudioContext)({ + latencyHint: "interactive", + }); + } catch (error) { + this.markFatalError(); + this.transport = null; + this.setState("error"); + throw error; + } + this.context = context; + context.onstatechange = () => this.syncContextState(); + // Web Audio 1.1 defines AudioContext's `error` event for audio-system + // resource failures. TypeScript's current DOM declarations do not yet + // include the event in AudioContextEventMap, so use the string overload. + context.addEventListener?.("error", this.onContextError); + + try { + await context.audioWorklet.addModule(String(this.options.workletUrl)); + const options: AudioWorkletNodeOptions = { + numberOfInputs: 0, + numberOfOutputs: 1, + outputChannelCount: [2], + processorOptions: { + ...transport, + layout: PCM_CONTROL, + formats: { + u8: PcmSampleFormat.U8, + s16le: PcmSampleFormat.S16Le, + s16be: PcmSampleFormat.S16Be, + }, + states: { + running: PcmStreamState.Running, + draining: PcmStreamState.Draining, + }, + flags: { + configuring: PcmTransportFlag.Configuring, + underrunActive: PcmTransportFlag.UnderrunActive, + fatalError: PcmTransportFlag.FatalError, + }, + outputSampleRate: context.sampleRate, + }, + }; + const node = this.options.createNode + ? this.options.createNode(context, "kandelo-pcm-output", options) + : new AudioWorkletNode(context, "kandelo-pcm-output", options); + node.port.onmessage = (event: MessageEvent) => { + const message = event.data as { type?: unknown; message?: unknown }; + if (message?.type !== "error") return; + this.fail(); + console.error( + `[BrowserPcmDriver] ${ + typeof message.message === "string" + ? message.message + : "AudioWorklet failed" + }`, + ); + }; + node.onprocessorerror = () => { + this.fail(); + }; + node.connect(context.destination); + this.node = node; + this.syncContextState(); + } catch (error) { + this.markFatalError(); + this.node = null; + this.context = null; + this.transport = null; + context.onstatechange = null; + context.removeEventListener?.("error", this.onContextError); + if (context.state !== "closed") await context.close().catch(() => {}); + this.setState("error"); + throw error; + } + } + + private syncContextState(): void { + if ( + this.state === "error" || + (this.transport && hasPcmFatalError(pcmControlWords(this.transport))) + ) { + this.setState("error"); + return; + } + const contextState = this.context?.state as string | undefined; + switch (contextState) { + case "running": + this.setState("running"); + break; + case "interrupted": + this.setState("interrupted"); + break; + case "closed": + this.fail(); + break; + default: + this.setState("suspended"); + break; + } + } + + private setState(state: PcmOutputState): void { + if (this.state === "error" && state !== "closed") return; + if (this.state === state) return; + this.state = state; + for (const listener of this.listeners) listener(state); + } + + private markFatalError(): void { + if (!this.transport) return; + markPcmFatalError(pcmControlWords(this.transport)); + } + + private fail(): void { + this.markFatalError(); + this.setState("error"); + } +} + +function sameTransport( + a: PcmTransportDescriptor, + b: PcmTransportDescriptor, +): boolean { + return ( + a.buffer === b.buffer && + a.controlOffset === b.controlOffset && + a.controlBytes === b.controlBytes && + a.dataOffset === b.dataOffset && + a.dataBytes === b.dataBytes + ); +} + +function outputPipelineSettleMs(context: AudioContext): number { + const extendedContext = context as AudioContext & { + outputLatency?: number; + renderQuantumSize?: number; + }; + const sampleRate = positiveFinite(context.sampleRate) ?? 48_000; + const renderFrames = + positiveFinite(extendedContext.renderQuantumSize) ?? + DEFAULT_RENDER_QUANTUM_FRAMES; + const quantumMs = (renderFrames * 1000) / sampleRate; + const baseLatencyMs = (nonnegativeFinite(context.baseLatency) ?? 0) * 1000; + const outputLatencySeconds = positiveFinite(extendedContext.outputLatency); + const outputLatencyMs = (outputLatencySeconds ?? 0) * 1000; + const unreportedFallbackMs = + outputLatencySeconds === undefined + ? UNREPORTED_OUTPUT_LATENCY_FALLBACK_MS + : 0; + return Math.ceil( + Math.min( + MAX_OUTPUT_PIPELINE_SETTLE_MS, + Math.max( + unreportedFallbackMs, + baseLatencyMs + outputLatencyMs + quantumMs, + ), + ), + ); +} + +function positiveFinite(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? value + : undefined; +} + +function nonnegativeFinite(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? value + : undefined; +} + +function delay(delayMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, delayMs)); +} + +function waitForPromiseWithin( + promise: Promise, + timeoutMs: number, +): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve(); + }; + const timeout = setTimeout(finish, timeoutMs); + void promise.then(finish, finish); + }); +} diff --git a/host/src/audio/node-pcm-driver.ts b/host/src/audio/node-pcm-driver.ts new file mode 100644 index 0000000000..bb947f7f3a --- /dev/null +++ b/host/src/audio/node-pcm-driver.ts @@ -0,0 +1,318 @@ +import type { PcmOutputDriver, PcmOutputState } from "./pcm-driver.js"; +import { + PcmSampleFormat, + PcmStreamState, + PcmTransportFlag, + hasPcmFatalError, + isPcmGenerationCurrent, + markPcmFatalError, + pcmControlWords, + pcmDataBytes, + readEffectiveConsumerPosition, + readPcmConfig, + readProducerPosition, + readRingBytes, + validatePcmTransport, + type PcmTransportConfig, + type PcmTransportDescriptor, +} from "./pcm-transport.js"; + +export interface PcmDriverClock { + now(): number; + setTimeout(callback: () => void, delayMs: number): unknown; + clearTimeout(handle: unknown): void; +} + +export interface NodePcmDriverOptions { + clockUpdate(requestedFrames: number): number; + /** Wake/retry guest syscalls after a fatal sink transition. */ + onFatal?: () => void; + clock?: PcmDriverClock; + onConsume?: (event: { + bytes: Uint8Array; + frames: number; + requestedFrames: number; + config: PcmTransportConfig; + }) => void; + idlePollMs?: number; +} + +const defaultClock: PcmDriverClock = { + now: () => performance.now(), + setTimeout: (callback, delayMs) => { + const handle = setTimeout(callback, delayMs); + if (typeof handle === "object" && "unref" in handle) handle.unref(); + return handle; + }, + clearTimeout: (handle) => + clearTimeout(handle as ReturnType), +}; + +/** Wall-clock-paced null sink for Node and headless hosts. */ +export class NodePcmDriver implements PcmOutputDriver { + private readonly clock: PcmDriverClock; + private readonly listeners = new Set<(state: PcmOutputState) => void>(); + private transport: PcmTransportDescriptor | null = null; + private timer: unknown = null; + private state: PcmOutputState = "unprepared"; + private enabled = true; + private generation = -1; + private fatalNotified = false; + private lastNow = 0; + private frameRemainder = 0; + + constructor(private readonly options: NodePcmDriverOptions) { + this.clock = options.clock ?? defaultClock; + } + + async prepare(transport: PcmTransportDescriptor): Promise { + if (this.state === "closed") throw new Error("PCM driver is closed"); + validatePcmTransport(transport); + if (this.transport) { + if (!sameTransport(this.transport, transport)) { + throw new Error("PCM driver is already attached to another transport"); + } + return; + } + this.transport = transport; + this.lastNow = this.clock.now(); + this.setState("running"); + this.schedule(0); + } + + async resume(): Promise { + if (this.state === "closed") throw new Error("PCM driver is closed"); + if (this.state === "error") throw new Error("PCM output has failed"); + if (this.transport && hasPcmFatalError(pcmControlWords(this.transport))) { + this.fail(); + throw new Error("PCM output has failed"); + } + this.enabled = true; + this.lastNow = this.clock.now(); + this.frameRemainder = 0; + if (this.transport) { + this.setState("running"); + this.schedule(0); + } + } + + async suspend(): Promise { + if (this.transport && hasPcmFatalError(pcmControlWords(this.transport))) { + this.fail(); + return; + } + this.enabled = false; + this.cancelTimer(); + if (this.state !== "closed" && this.state !== "error") { + this.setState("suspended"); + } + } + + async close(): Promise { + if (this.state === "closed") return; + this.enabled = false; + this.cancelTimer(); + this.transport = null; + this.setState("closed"); + this.listeners.clear(); + } + + getState(): PcmOutputState { + return this.state; + } + + subscribe(listener: (state: PcmOutputState) => void): () => void { + this.listeners.add(listener); + listener(this.state); + return () => this.listeners.delete(listener); + } + + /** Visible for deterministic tests and worker-owned scheduling. */ + tick(): void { + this.timer = null; + const transport = this.transport; + if (!transport || !this.enabled || this.state === "closed") return; + + const words = pcmControlWords(transport); + const config = readPcmConfig(words); + const now = this.clock.now(); + if ((config.flags & PcmTransportFlag.FatalError) !== 0) { + this.fail(); + return; + } + const sampleBytes = bytesPerSample(config.format); + if ( + config.sampleRate === 0 || + (config.channels !== 1 && config.channels !== 2) || + sampleBytes === 0 || + config.frameBytes !== sampleBytes * config.channels || + config.activeCapacityBytes < config.frameBytes || + config.activeCapacityBytes > transport.dataBytes || + config.activeCapacityBytes % config.frameBytes !== 0 + ) { + this.fail(true); + return; + } + if (config.generation !== this.generation) { + this.generation = config.generation; + this.frameRemainder = 0; + this.lastNow = now; + } + + if ( + (config.state !== PcmStreamState.Running && + config.state !== PcmStreamState.Draining) || + config.sampleRate === 0 || + config.frameBytes === 0 + ) { + this.lastNow = now; + this.frameRemainder = 0; + this.schedule(this.options.idlePollMs ?? 10); + return; + } + + const elapsedMs = Math.max(0, now - this.lastNow); + this.lastNow = now; + const due = this.frameRemainder + (elapsedMs * config.sampleRate) / 1000; + const requestedFrames = Math.floor(due); + this.frameRemainder = due - requestedFrames; + + if (requestedFrames > 0) { + const consumer = readEffectiveConsumerPosition(words); + const producer = readProducerPosition(words); + const queuedFrames = Number( + (producer > consumer ? producer - consumer : 0n) / + BigInt(config.frameBytes), + ); + const candidateFrames = Math.min(requestedFrames, queuedFrames); + const candidateBytes = + this.options.onConsume && candidateFrames > 0 + ? readRingBytes( + pcmDataBytes(transport).subarray( + 0, + Math.min( + transport.dataBytes, + Math.max(config.frameBytes, config.activeCapacityBytes), + ), + ), + consumer, + candidateFrames * config.frameBytes, + ) + : new Uint8Array(0); + if (!isPcmGenerationCurrent(words, config.generation)) { + this.lastNow = now; + this.frameRemainder = 0; + this.schedule(0); + return; + } + let consumedFrames: number; + try { + consumedFrames = Math.max( + 0, + Math.min(candidateFrames, this.options.clockUpdate(requestedFrames)), + ); + } catch { + this.fail(true); + return; + } + const completedDrain = + config.state === PcmStreamState.Draining && + consumedFrames > 0 && + consumedFrames === queuedFrames; + if ( + !isPcmGenerationCurrent(words, config.generation) && + !completedDrain + ) { + this.lastNow = now; + this.frameRemainder = 0; + this.schedule(0); + return; + } + if (consumedFrames > 0 && this.options.onConsume) { + try { + this.options.onConsume({ + bytes: candidateBytes.subarray( + 0, + consumedFrames * config.frameBytes, + ), + frames: consumedFrames, + requestedFrames, + config, + }); + } catch { + this.fail(true); + return; + } + } + } + + const periodFrames = + config.fragmentBytes > 0 + ? Math.max(1, Math.floor(config.fragmentBytes / config.frameBytes)) + : 128; + const periodMs = (periodFrames * 1000) / config.sampleRate; + const untilNextFrame = + ((1 - this.frameRemainder) * 1000) / config.sampleRate; + this.schedule(Math.max(1, Math.min(periodMs, untilNextFrame + periodMs))); + } + + private schedule(delayMs: number): void { + if (this.timer !== null || !this.enabled || !this.transport) return; + this.timer = this.clock.setTimeout(() => this.tick(), delayMs); + } + + private cancelTimer(): void { + if (this.timer === null) return; + this.clock.clearTimeout(this.timer); + this.timer = null; + } + + private fail(markFatal = false): void { + if (markFatal && this.transport) { + markPcmFatalError(pcmControlWords(this.transport)); + } + if (!this.fatalNotified) { + this.fatalNotified = true; + try { + this.options.onFatal?.(); + } catch (error) { + console.error("[NodePcmDriver] fatal wake callback failed", error); + } + } + this.enabled = false; + this.cancelTimer(); + this.setState("error"); + } + + private setState(state: PcmOutputState): void { + if (this.state === "error" && state !== "closed") return; + if (this.state === state) return; + this.state = state; + for (const listener of this.listeners) listener(state); + } +} + +function sameTransport( + a: PcmTransportDescriptor, + b: PcmTransportDescriptor, +): boolean { + return ( + a.buffer === b.buffer && + a.controlOffset === b.controlOffset && + a.controlBytes === b.controlBytes && + a.dataOffset === b.dataOffset && + a.dataBytes === b.dataBytes + ); +} + +function bytesPerSample(format: PcmSampleFormat): number { + switch (format) { + case PcmSampleFormat.U8: + return 1; + case PcmSampleFormat.S16Le: + case PcmSampleFormat.S16Be: + return 2; + default: + return 0; + } +} diff --git a/host/src/audio/pcm-audio-worklet.js b/host/src/audio/pcm-audio-worklet.js new file mode 100644 index 0000000000..c207df57aa --- /dev/null +++ b/host/src/audio/pcm-audio-worklet.js @@ -0,0 +1,263 @@ +/* + * Kandelo PCM AudioWorklet. The processor is deliberately self-contained so + * it can be emitted as a real browser asset and tested directly in Vitest. + * All layout offsets and enum values arrive in processorOptions; the worklet + * never imports OSS, WebAssembly, or kernel implementation details. + */ + +const WorkletBase = + globalThis.AudioWorkletProcessor ?? + class { + constructor() { + this.port = { onmessage: null, postMessage() {} }; + } + }; + +function loadU32(words, index) { + return Atomics.load(words, index) >>> 0; +} + +function readU64(words, seqIndex, loIndex, hiIndex) { + for (;;) { + const before = loadU32(words, seqIndex); + if (before & 1) continue; + const lo = loadU32(words, loIndex); + const hi = loadU32(words, hiIndex); + const after = loadU32(words, seqIndex); + if (before === after && !(after & 1)) { + return (BigInt(hi) << 32n) | BigInt(lo); + } + } +} + +function writeU64(words, seqIndex, loIndex, hiIndex, value) { + Atomics.add(words, seqIndex, 1); + Atomics.store(words, loIndex, Number(value & 0xffff_ffffn) | 0); + Atomics.store(words, hiIndex, Number((value >> 32n) & 0xffff_ffffn) | 0); + Atomics.add(words, seqIndex, 1); +} + +export class KandeloPcmProcessor extends WorkletBase { + constructor(options = {}) { + super(options); + const config = options.processorOptions ?? options; + this.layout = config.layout; + this.formats = config.formats; + this.states = config.states; + this.flags = config.flags; + this.outputRate = + config.outputSampleRate ?? globalThis.sampleRate ?? 48_000; + this.words = new Int32Array( + config.buffer, + config.controlOffset, + config.controlBytes / 4, + ); + this.ring = new Uint8Array( + config.buffer, + config.dataOffset, + config.dataBytes, + ); + this.generation = -1; + this.sourcePhase = 0; + this.lastError = ""; + } + + process(_inputs, outputs) { + const output = outputs[0] ?? []; + const outputFrames = output[0]?.length ?? 0; + for (const channel of output) channel.fill(0); + if (outputFrames === 0) return true; + + const l = this.layout; + const transportFlags = loadU32(this.words, l.flags); + if ( + (transportFlags & (this.flags.configuring | this.flags.fatalError)) !== + 0 + ) { + return true; + } + const generation = loadU32(this.words, l.generation); + if (generation !== this.generation) { + this.generation = generation; + this.sourcePhase = 0; + } + + const state = loadU32(this.words, l.state); + if (state !== this.states.running && state !== this.states.draining) { + this.clearUnderrun(); + return true; + } + + const format = loadU32(this.words, l.format); + const sourceRate = loadU32(this.words, l.sampleRate); + const channels = loadU32(this.words, l.channels); + const frameBytes = loadU32(this.words, l.frameBytes); + const activeCapacityBytes = loadU32(this.words, l.activeCapacityBytes); + const bytesPerSample = this.bytesPerSample(format); + const configGeneration = loadU32(this.words, l.generation); + const configFlags = loadU32(this.words, l.flags); + const confirmedConfigGeneration = loadU32(this.words, l.generation); + if ( + configGeneration !== generation || + confirmedConfigGeneration !== generation || + (configFlags & (this.flags.configuring | this.flags.fatalError)) !== 0 + ) { + this.generation = confirmedConfigGeneration; + this.sourcePhase = 0; + return true; + } + if ( + sourceRate === 0 || + (channels !== 1 && channels !== 2) || + bytesPerSample === 0 || + frameBytes !== bytesPerSample * channels || + activeCapacityBytes < frameBytes || + activeCapacityBytes > this.ring.byteLength || + activeCapacityBytes % frameBytes !== 0 + ) { + this.reportError("unsupported PCM configuration"); + return true; + } + + const producer = readU64( + this.words, + l.producerSeq, + l.producerLo, + l.producerHi, + ); + let consumer = readU64( + this.words, + l.consumerSeq, + l.consumerLo, + l.consumerHi, + ); + const discard = readU64(this.words, l.discardSeq, l.discardLo, l.discardHi); + if (discard > consumer) consumer = discard > producer ? producer : discard; + const initialConsumer = consumer; + this.activeCapacityBytes = activeCapacityBytes; + const step = sourceRate / this.outputRate; + let underrun = false; + let renderedPcm = false; + + for (let outFrame = 0; outFrame < outputFrames; outFrame++) { + const queuedBytes = producer > consumer ? producer - consumer : 0n; + const queuedFrames = Number(queuedBytes / BigInt(frameBytes)); + const sourceIndex = Math.floor(this.sourcePhase); + if (sourceIndex >= queuedFrames) { + underrun = true; + this.sourcePhase = 0; + break; + } + + renderedPcm = true; + const nextIndex = Math.min(sourceIndex + 1, queuedFrames - 1); + const fraction = this.sourcePhase - sourceIndex; + for (let outChannel = 0; outChannel < output.length; outChannel++) { + const sourceChannel = + channels === 1 ? 0 : Math.min(outChannel, channels - 1); + const a = this.readSample( + consumer + + BigInt(sourceIndex * frameBytes + sourceChannel * bytesPerSample), + format, + ); + const b = this.readSample( + consumer + + BigInt(nextIndex * frameBytes + sourceChannel * bytesPerSample), + format, + ); + output[outChannel][outFrame] = a + (b - a) * fraction; + } + + this.sourcePhase += step; + const advance = Math.min(Math.floor(this.sourcePhase), queuedFrames); + if (advance > 0) { + consumer += BigInt(advance * frameBytes); + this.sourcePhase -= advance; + } + } + + // RESET and reopen run in the kernel worker concurrently with this render + // quantum. Never publish an old generation's cursor or samples into the + // new stream. Transport cursors remain monotonic across generations, which + // also makes a generation change in the tiny interval after this check + // harmless to the new stream's queued bytes. + const finalGeneration = loadU32(this.words, l.generation); + const finalFlags = loadU32(this.words, l.flags); + const confirmedGeneration = loadU32(this.words, l.generation); + if ( + finalGeneration !== generation || + confirmedGeneration !== generation || + (finalFlags & (this.flags.configuring | this.flags.fatalError)) !== 0 + ) { + for (const channel of output) channel.fill(0); + this.generation = confirmedGeneration; + this.sourcePhase = 0; + return true; + } + + const finalState = loadU32(this.words, l.state); + if (renderedPcm || finalState !== this.states.running) { + this.clearUnderrun(); + } + if (underrun && finalState === this.states.running) this.noteUnderrun(); + if (consumer !== initialConsumer) { + writeU64(this.words, l.consumerSeq, l.consumerLo, l.consumerHi, consumer); + this.signalProgress(); + } + return true; + } + + bytesPerSample(format) { + if (format === this.formats.u8) return 1; + if (format === this.formats.s16le || format === this.formats.s16be) + return 2; + return 0; + } + + readSample(absoluteByte, format) { + const capacity = this.activeCapacityBytes; + const at = Number(absoluteByte % BigInt(capacity)); + if (format === this.formats.u8) return (this.ring[at] - 128) / 128; + const next = (at + 1) % capacity; + const lo = format === this.formats.s16le ? this.ring[at] : this.ring[next]; + const hi = format === this.formats.s16le ? this.ring[next] : this.ring[at]; + let sample = lo | (hi << 8); + if (sample & 0x8000) sample -= 0x1_0000; + return sample / 32768; + } + + noteUnderrun() { + const previous = Atomics.or( + this.words, + this.layout.flags, + this.flags.underrunActive, + ); + if ((previous & this.flags.underrunActive) !== 0) return; + Atomics.add(this.words, this.layout.underruns, 1); + this.signalProgress(); + } + + clearUnderrun() { + Atomics.and(this.words, this.layout.flags, ~this.flags.underrunActive); + } + + signalProgress() { + Atomics.add(this.words, this.layout.wakeSeq, 1); + // A kernel observer and teardown drain can wait concurrently. Final-tail + // consumption is a one-shot transition, so every waiter must see it. + Atomics.notify(this.words, this.layout.wakeSeq); + } + + reportError(message) { + if (message === this.lastError) return; + this.lastError = message; + this.clearUnderrun(); + Atomics.or(this.words, this.layout.flags, this.flags.fatalError); + this.signalProgress(); + this.port.postMessage({ type: "error", message }); + } +} + +if (typeof globalThis.registerProcessor === "function") { + globalThis.registerProcessor("kandelo-pcm-output", KandeloPcmProcessor); +} diff --git a/host/src/audio/pcm-driver.ts b/host/src/audio/pcm-driver.ts new file mode 100644 index 0000000000..eedefbcf2b --- /dev/null +++ b/host/src/audio/pcm-driver.ts @@ -0,0 +1,19 @@ +import type { PcmTransportDescriptor } from "./pcm-transport.js"; + +export type PcmOutputState = + | "unavailable" + | "unprepared" + | "suspended" + | "running" + | "interrupted" + | "closed" + | "error"; + +export interface PcmOutputDriver { + prepare(transport: PcmTransportDescriptor): Promise; + resume(): Promise; + suspend(): Promise; + close(): Promise; + getState(): PcmOutputState; + subscribe(listener: (state: PcmOutputState) => void): () => void; +} diff --git a/host/src/audio/pcm-transport.ts b/host/src/audio/pcm-transport.ts new file mode 100644 index 0000000000..bf9914ecce --- /dev/null +++ b/host/src/audio/pcm-transport.ts @@ -0,0 +1,370 @@ +import { + PCM_FORMAT_S16_BE, + PCM_FORMAT_S16_LE, + PCM_FORMAT_U8, + PCM_FORMAT_UNKNOWN, + PCM_FLAG_CONFIGURING, + PCM_FLAG_FATAL_ERROR, + PCM_FLAG_UNDERRUN_ACTIVE, + PCM_SHARED_CONTROL_FIELDS, + PCM_STATE_CLOSED, + PCM_STATE_DRAINING, + PCM_STATE_RUNNING, + PCM_STATE_STOPPED, + PCM_TRANSPORT_HEADER_BYTES, + PCM_TRANSPORT_LEGACY_PULL, + PCM_TRANSPORT_MAGIC, + PCM_TRANSPORT_RING_BYTES, + PCM_TRANSPORT_SHARED_CLOCK, + PCM_TRANSPORT_UNCLAIMED, + PCM_TRANSPORT_VERSION, +} from "../generated/abi.js"; + +/** + * Shared kernel/host PCM transport. + * + * The control header lives in the kernel's shared WebAssembly memory. Rust is + * the sole writer for stream configuration and the producer cursor; the host + * sink is the sole writer for the consumer cursor. Cursor halves are guarded + * by 32-bit sequence counters so AudioWorklet code never depends on 64-bit JS + * atomics. + */ + +export const PCM_CONTROL_MAGIC = PCM_TRANSPORT_MAGIC; +export const PCM_CONTROL_VERSION = PCM_TRANSPORT_VERSION; +export const PCM_CONTROL_BYTES = PCM_TRANSPORT_HEADER_BYTES; +export const PCM_PHYSICAL_CAPACITY_BYTES = PCM_TRANSPORT_RING_BYTES; + +/** Word indices within the fixed-width control header. */ +export const PCM_CONTROL = { + magic: PCM_SHARED_CONTROL_FIELDS.magic.offset / 4, + version: PCM_SHARED_CONTROL_FIELDS.version.offset / 4, + headerBytes: PCM_SHARED_CONTROL_FIELDS.headerBytes.offset / 4, + physicalCapacityBytes: + PCM_SHARED_CONTROL_FIELDS.physicalCapacityBytes.offset / 4, + activeCapacityBytes: PCM_SHARED_CONTROL_FIELDS.activeCapacityBytes.offset / 4, + format: PCM_SHARED_CONTROL_FIELDS.format.offset / 4, + sampleRate: PCM_SHARED_CONTROL_FIELDS.sampleRate.offset / 4, + channels: PCM_SHARED_CONTROL_FIELDS.channels.offset / 4, + frameBytes: PCM_SHARED_CONTROL_FIELDS.frameBytes.offset / 4, + fragmentBytes: PCM_SHARED_CONTROL_FIELDS.fragmentBytes.offset / 4, + fragments: PCM_SHARED_CONTROL_FIELDS.fragments.offset / 4, + state: PCM_SHARED_CONTROL_FIELDS.state.offset / 4, + generation: PCM_SHARED_CONTROL_FIELDS.generation.offset / 4, + flags: PCM_SHARED_CONTROL_FIELDS.flags.offset / 4, + transportMode: PCM_SHARED_CONTROL_FIELDS.transportMode.offset / 4, + producerSeq: PCM_SHARED_CONTROL_FIELDS.producerSeq.offset / 4, + producerLo: PCM_SHARED_CONTROL_FIELDS.producerLo.offset / 4, + producerHi: PCM_SHARED_CONTROL_FIELDS.producerHi.offset / 4, + consumerSeq: PCM_SHARED_CONTROL_FIELDS.consumerSeq.offset / 4, + consumerLo: PCM_SHARED_CONTROL_FIELDS.consumerLo.offset / 4, + consumerHi: PCM_SHARED_CONTROL_FIELDS.consumerHi.offset / 4, + discardSeq: PCM_SHARED_CONTROL_FIELDS.discardSeq.offset / 4, + discardLo: PCM_SHARED_CONTROL_FIELDS.discardLo.offset / 4, + discardHi: PCM_SHARED_CONTROL_FIELDS.discardHi.offset / 4, + underruns: PCM_SHARED_CONTROL_FIELDS.underruns.offset / 4, + wakeSeq: PCM_SHARED_CONTROL_FIELDS.wakeSeq.offset / 4, +} as const; + +/** Implementation-neutral formats presented to host sinks. */ +export const PcmSampleFormat = { + Unknown: PCM_FORMAT_UNKNOWN, + U8: PCM_FORMAT_U8, + S16Le: PCM_FORMAT_S16_LE, + S16Be: PCM_FORMAT_S16_BE, +} as const; +export type PcmSampleFormat = + (typeof PcmSampleFormat)[keyof typeof PcmSampleFormat]; + +export const PcmStreamState = { + Closed: PCM_STATE_CLOSED, + Stopped: PCM_STATE_STOPPED, + Running: PCM_STATE_RUNNING, + Draining: PCM_STATE_DRAINING, +} as const; +export type PcmStreamState = + (typeof PcmStreamState)[keyof typeof PcmStreamState]; + +export const PcmTransportMode = { + Unclaimed: PCM_TRANSPORT_UNCLAIMED, + LegacyPull: PCM_TRANSPORT_LEGACY_PULL, + SharedClock: PCM_TRANSPORT_SHARED_CLOCK, +} as const; +export type PcmTransportMode = + (typeof PcmTransportMode)[keyof typeof PcmTransportMode]; + +export const PcmTransportFlag = { + Configuring: PCM_FLAG_CONFIGURING, + UnderrunActive: PCM_FLAG_UNDERRUN_ACTIVE, + FatalError: PCM_FLAG_FATAL_ERROR, +} as const; + +export interface PcmTransportDescriptor { + buffer: SharedArrayBuffer; + controlOffset: number; + controlBytes: number; + dataOffset: number; + dataBytes: number; +} + +export interface PcmTransportConfig { + activeCapacityBytes: number; + format: PcmSampleFormat; + sampleRate: number; + channels: number; + frameBytes: number; + fragmentBytes: number; + fragments: number; + state: PcmStreamState; + generation: number; + flags: number; +} + +export function pcmControlWords( + descriptor: PcmTransportDescriptor, +): Int32Array { + validateDescriptorBounds(descriptor); + return new Int32Array( + descriptor.buffer, + descriptor.controlOffset, + descriptor.controlBytes / Int32Array.BYTES_PER_ELEMENT, + ); +} + +export function pcmDataBytes( + descriptor: PcmTransportDescriptor, +): Uint8Array { + validateDescriptorBounds(descriptor); + return new Uint8Array( + descriptor.buffer, + descriptor.dataOffset, + descriptor.dataBytes, + ); +} + +export function validatePcmTransport(descriptor: PcmTransportDescriptor): void { + const words = pcmControlWords(descriptor); + const magic = loadU32(words, PCM_CONTROL.magic); + const version = loadU32(words, PCM_CONTROL.version); + const headerBytes = loadU32(words, PCM_CONTROL.headerBytes); + const capacityBytes = loadU32(words, PCM_CONTROL.physicalCapacityBytes); + if (magic !== PCM_CONTROL_MAGIC) { + throw new Error(`PCM transport has invalid magic 0x${magic.toString(16)}`); + } + if (version !== PCM_CONTROL_VERSION) { + throw new Error( + `PCM transport version ${version} is not supported (expected ${PCM_CONTROL_VERSION})`, + ); + } + if ( + headerBytes !== PCM_CONTROL_BYTES || + headerBytes > descriptor.controlBytes + ) { + throw new Error( + `PCM transport header is ${headerBytes} bytes (expected ${PCM_CONTROL_BYTES})`, + ); + } + if (capacityBytes !== descriptor.dataBytes) { + throw new Error( + `PCM transport capacity ${capacityBytes} does not match descriptor ${descriptor.dataBytes}`, + ); + } +} + +export function readPcmConfig(words: Int32Array): PcmTransportConfig { + for (;;) { + const flagsBefore = loadU32(words, PCM_CONTROL.flags); + if ((flagsBefore & PcmTransportFlag.Configuring) !== 0) continue; + const generation = loadU32(words, PCM_CONTROL.generation); + const config = { + activeCapacityBytes: loadU32(words, PCM_CONTROL.activeCapacityBytes), + format: loadU32(words, PCM_CONTROL.format) as PcmSampleFormat, + sampleRate: loadU32(words, PCM_CONTROL.sampleRate), + channels: loadU32(words, PCM_CONTROL.channels), + frameBytes: loadU32(words, PCM_CONTROL.frameBytes), + fragmentBytes: loadU32(words, PCM_CONTROL.fragmentBytes), + fragments: loadU32(words, PCM_CONTROL.fragments), + state: loadU32(words, PCM_CONTROL.state) as PcmStreamState, + generation, + flags: flagsBefore, + }; + const generationAfter = loadU32(words, PCM_CONTROL.generation); + const flagsAfter = loadU32(words, PCM_CONTROL.flags); + if ( + generationAfter === generation && + (flagsAfter & PcmTransportFlag.Configuring) === 0 + ) { + config.flags = flagsAfter; + return config; + } + } +} + +export function isPcmGenerationCurrent( + words: Int32Array, + generation: number, +): boolean { + const before = loadU32(words, PCM_CONTROL.generation); + const flags = loadU32(words, PCM_CONTROL.flags); + const after = loadU32(words, PCM_CONTROL.generation); + return ( + before === generation && + after === generation && + (flags & PcmTransportFlag.Configuring) === 0 + ); +} + +export function markPcmFatalError(words: Int32Array): void { + Atomics.and(words, PCM_CONTROL.flags, ~PcmTransportFlag.UnderrunActive); + Atomics.or(words, PCM_CONTROL.flags, PcmTransportFlag.FatalError); + signalPcmConsumerProgress(words); +} + +export function hasPcmFatalError(words: Int32Array): boolean { + return ( + (loadU32(words, PCM_CONTROL.flags) & PcmTransportFlag.FatalError) !== 0 + ); +} + +export function readProducerPosition(words: Int32Array): bigint { + return readSeqlockedU64( + words, + PCM_CONTROL.producerSeq, + PCM_CONTROL.producerLo, + PCM_CONTROL.producerHi, + ); +} + +export function readConsumerPosition(words: Int32Array): bigint { + return readSeqlockedU64( + words, + PCM_CONTROL.consumerSeq, + PCM_CONTROL.consumerLo, + PCM_CONTROL.consumerHi, + ); +} + +export function readDiscardPosition(words: Int32Array): bigint { + return readSeqlockedU64( + words, + PCM_CONTROL.discardSeq, + PCM_CONTROL.discardLo, + PCM_CONTROL.discardHi, + ); +} + +export function readEffectiveConsumerPosition(words: Int32Array): bigint { + const consumer = readConsumerPosition(words); + const discard = readDiscardPosition(words); + const producer = readProducerPosition(words); + const effective = consumer > discard ? consumer : discard; + return effective > producer ? producer : effective; +} + +export function writeConsumerPosition(words: Int32Array, value: bigint): void { + writeSeqlockedU64( + words, + PCM_CONTROL.consumerSeq, + PCM_CONTROL.consumerLo, + PCM_CONTROL.consumerHi, + value, + ); +} + +export function signalPcmConsumerProgress(words: Int32Array): void { + Atomics.add(words, PCM_CONTROL.wakeSeq, 1); + // The persistent kernel observer and a bounded teardown drain may both be + // waiting on the same one-shot cursor transition. Wake every waiter: the + // sequence check below each wait decides which work remains relevant. + Atomics.notify(words, PCM_CONTROL.wakeSeq); +} + +export function readRingBytes( + ring: Uint8Array, + absoluteOffset: bigint, + length: number, +): Uint8Array { + const out = new Uint8Array(length); + if (length === 0 || ring.byteLength === 0) return out; + let offset = Number(absoluteOffset % BigInt(ring.byteLength)); + let copied = 0; + while (copied < length) { + const chunk = Math.min(length - copied, ring.byteLength - offset); + out.set(ring.subarray(offset, offset + chunk), copied); + copied += chunk; + offset = 0; + } + return out; +} + +export function loadU32(words: Int32Array, index: number): number { + return Atomics.load(words, index) >>> 0; +} + +export function storeU32( + words: Int32Array, + index: number, + value: number, +): void { + Atomics.store(words, index, value | 0); +} + +export function readSeqlockedU64( + words: Int32Array, + seqIndex: number, + loIndex: number, + hiIndex: number, +): bigint { + for (;;) { + const before = loadU32(words, seqIndex); + if ((before & 1) !== 0) continue; + const lo = loadU32(words, loIndex); + const hi = loadU32(words, hiIndex); + const after = loadU32(words, seqIndex); + if (before === after && (after & 1) === 0) { + return (BigInt(hi) << 32n) | BigInt(lo); + } + } +} + +export function writeSeqlockedU64( + words: Int32Array, + seqIndex: number, + loIndex: number, + hiIndex: number, + value: bigint, +): void { + Atomics.add(words, seqIndex, 1); + storeU32(words, loIndex, Number(value & 0xffff_ffffn)); + storeU32(words, hiIndex, Number((value >> 32n) & 0xffff_ffffn)); + Atomics.add(words, seqIndex, 1); +} + +function validateDescriptorBounds(descriptor: PcmTransportDescriptor): void { + if (!(descriptor.buffer instanceof SharedArrayBuffer)) { + throw new TypeError("PCM transport requires SharedArrayBuffer"); + } + for (const [name, value] of Object.entries({ + controlOffset: descriptor.controlOffset, + controlBytes: descriptor.controlBytes, + dataOffset: descriptor.dataOffset, + dataBytes: descriptor.dataBytes, + })) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError(`PCM transport ${name} is invalid: ${value}`); + } + } + if ( + (descriptor.controlOffset & 3) !== 0 || + (descriptor.controlBytes & 3) !== 0 + ) { + throw new RangeError("PCM control header must be 32-bit aligned"); + } + if ( + descriptor.controlOffset + descriptor.controlBytes > + descriptor.buffer.byteLength || + descriptor.dataOffset + descriptor.dataBytes > descriptor.buffer.byteLength + ) { + throw new RangeError("PCM transport lies outside shared kernel memory"); + } +} diff --git a/host/src/browser-kernel-host.ts b/host/src/browser-kernel-host.ts index 2e774ee68a..b384d82661 100644 --- a/host/src/browser-kernel-host.ts +++ b/host/src/browser-kernel-host.ts @@ -27,6 +27,14 @@ import rootfsVfsUrl from "@rootfs-vfs?url"; import workerEntryUrl from "./worker-entry-browser.ts?worker&url"; import kernelWorkerEntryUrl from "./browser-kernel-worker-entry.ts?worker&url"; import { DEFAULT_MAX_PAGES } from "./constants"; +import { BrowserPcmDriver } from "./audio/browser-pcm-driver"; +import type { PcmOutputState } from "./audio/pcm-driver"; +import type { PcmTransportDescriptor } from "./audio/pcm-transport"; + +const defaultPcmWorkletUrl = new URL( + "./audio/pcm-audio-worklet.js", + import.meta.url, +); export interface BrowserKernelOptions { /** Maximum concurrent workers (default: 4) */ @@ -83,6 +91,8 @@ export interface BrowserKernelOptions { * are not controlled by Kandelo's service worker can use this to route * guest outbound HTTP(S) through a same-origin proxy. */ corsProxyUrl?: string; + /** Override the packaged PCM AudioWorklet asset URL. */ + audioWorkletUrl?: string | URL; } /** Options for {@link BrowserKernel.boot}. */ @@ -154,6 +164,8 @@ export class BrowserKernel { * PtyTerminal calls onPtyOutput. Drained when a callback registers. */ private pendingPtyOutput = new Map(); private lazyDownloadListeners = new Set<(event: LazyDownloadEvent) => void>(); + private pcmTransport: PcmTransportDescriptor | null = null; + private pcmDriver: BrowserPcmDriver | null = null; constructor(options: BrowserKernelOptions = {}) { this.maxPages = options.maxMemoryPages ?? DEFAULT_MAX_PAGES; @@ -793,10 +805,9 @@ export class BrowserKernel { * rate / channel count so the caller can build a correctly-sized * `AudioBuffer`. Empty `Uint8Array` if the ring is empty. * - * The audio scheduler in `apps/browser-demos/pages/doom/main.ts` calls - * this every ~50 ms via setInterval, decodes S16 → Float32, and - * schedules the result on a chained `AudioBufferSourceNode` so DOOM - * SFX play continuously while the game is running. + * @deprecated BrowserKernel now claims the shared-clock PCM transport for + * its machine-level AudioWorklet. This compatibility method returns an + * empty buffer while that transport is active. */ async drainAudio(maxBytes: number): Promise<{ bytes: Uint8Array; @@ -811,6 +822,45 @@ export class BrowserKernel { }) as Promise<{ bytes: Uint8Array; sampleRate: number; channels: number }>; } + /** Preload the machine-level PCM sink without attempting user activation. */ + async prepareAudio(): Promise { + const transport = this.pcmTransport; + if (!transport) throw new Error("PCM transport is not available"); + const driver = this.pcmDriver ??= new BrowserPcmDriver({ + workletUrl: this.options.audioWorkletUrl ?? defaultPcmWorkletUrl, + }); + await driver.prepare(transport); + } + + /** Resume audible PCM output. Call directly from a trusted user gesture. */ + async resumeAudio(): Promise { + await this.prepareAudio(); + await this.pcmDriver!.resume(); + } + + /** Suspend the browser audio clock without discarding queued PCM. */ + async suspendAudio(): Promise { + await this.pcmDriver?.suspend(); + } + + getAudioState(): PcmOutputState { + return this.pcmDriver?.getState() ?? + (this.pcmTransport ? "unprepared" : "unavailable"); + } + + onAudioStateChange(listener: (state: PcmOutputState) => void): () => void { + if (!this.pcmDriver && this.pcmTransport) { + this.pcmDriver = new BrowserPcmDriver({ + workletUrl: this.options.audioWorkletUrl ?? defaultPcmWorkletUrl, + }); + } + if (!this.pcmDriver) { + listener("unavailable"); + return () => {}; + } + return this.pcmDriver.subscribe(listener); + } + // ── PTY methods ── /** Write data to the PTY master for a process. */ @@ -931,6 +981,10 @@ export class BrowserKernel { type: "destroy", requestId, }); + await this.pcmDriver?.settleOutputPipeline().catch(() => {}); + await this.pcmDriver?.close().catch(() => {}); + this.pcmDriver = null; + this.pcmTransport = null; this.kernelWorkerHandle.terminate(); this.exitResolvers.clear(); this.unclaimedExitStatuses.clear(); @@ -1014,11 +1068,27 @@ export class BrowserKernel { private handleWorkerMessage(msg: KernelToMainMessage): void { switch (msg.type) { - case "ready": + case "ready": { + if (msg.pcmTransport) { + this.pcmTransport = msg.pcmTransport; + if ( + typeof globalThis.AudioContext === "function" || + "webkitAudioContext" in globalThis + ) { + void this.prepareAudio().catch((error) => { + this.options.onHostDiagnostic?.({ + pid: 0, + source: "browser PCM output", + message: error instanceof Error ? error.message : String(error), + }); + }); + } + } + break; + } case "init_error": // The temporary boot listener resolves or rejects initialization. The - // permanent listener also receives these messages, so account for - // them explicitly rather than relying on an implicit fall-through. + // permanent listener also receives this message, so account for it. break; case "response": { const pending = this.pendingRequests.get(msg.requestId); diff --git a/host/src/browser-kernel-protocol.ts b/host/src/browser-kernel-protocol.ts index adf373a107..90750090a8 100644 --- a/host/src/browser-kernel-protocol.ts +++ b/host/src/browser-kernel-protocol.ts @@ -12,6 +12,7 @@ import type { LazyDownloadEvent } from "./vfs/memory-fs"; import type { HostDiagnosticMessage, } from "./host-diagnostic"; +import type { PcmTransportDescriptor } from "./audio/pcm-transport"; export type { HttpRequest, HttpResponse }; export type { HostDiagnostic } from "./host-diagnostic"; @@ -385,6 +386,8 @@ export type MainToKernelMessage = export interface ReadyMessage { type: "ready"; + /** Versioned PCM-only shared transport claimed by the kernel worker. */ + pcmTransport?: PcmTransportDescriptor; } export interface InitErrorMessage { diff --git a/host/src/browser-kernel-worker-entry.ts b/host/src/browser-kernel-worker-entry.ts index 3567df1039..30a24fc870 100644 --- a/host/src/browser-kernel-worker-entry.ts +++ b/host/src/browser-kernel-worker-entry.ts @@ -177,6 +177,7 @@ const NODE_PROCESS_WORKER_TERMINATION_SETTLE_MS = 2000; // their exit path and drain. See docs/jsc-terminate-atomics-wait-workaround.md. const DESTROY_KILL_DRAIN_TIMEOUT_MS = 1500; const DESTROY_KILL_DRAIN_POLL_MS = 15; +const PCM_DESTROY_DRAIN_TIMEOUT_MS = 2000; /** * Workers we deliberately terminated — exec, exit, top-level destroy. The @@ -825,7 +826,8 @@ async function handleInit(msg: Extract) { initReady = true; flushPendingLazyRegistrations(); - post({ type: "ready" }); + const pcmTransport = kernelWorker.claimPcmTransport(true); + post({ type: "ready", pcmTransport }); } // ── Spawn ── @@ -2046,6 +2048,16 @@ async function handleDestroy(msg: Extract `/dev/input/mice` PS/2 packets. - * - `/dev/dsp` PCM ring drains -> Web Audio playback. + * + * The legacy PCM scheduler types remain exported below for source + * compatibility. Machine-level AudioWorklet playback supersedes that + * main-thread drain path. */ export interface MouseEventSink { @@ -80,6 +83,7 @@ export interface ScalePointerLockMouseDeltaOptions { clientHeight?: number; } +/** @deprecated Use the machine-level shared PCM transport. */ export interface AudioDrainSource { drainAudio(maxBytes: number): Promise<{ bytes: Uint8Array; @@ -88,6 +92,7 @@ export interface AudioDrainSource { }>; } +/** @deprecated The main-thread PCM scheduler has been retired. */ export interface PcmAudioSchedulerOptions { pollMs?: number; drainBytes?: number; @@ -95,6 +100,7 @@ export interface PcmAudioSchedulerOptions { maxLookaheadSeconds?: number; } +/** @deprecated Use the machine-level browser PCM driver lifecycle. */ export interface AudioOutputHandle { resume(): Promise; close(): void; @@ -103,8 +109,6 @@ export interface AudioOutputHandle { export const DEFAULT_POINTER_LOCK_MOUSE_SENSITIVITY = 4; -const AUDIO_POLL_MS = 50; -const AUDIO_DRAIN_BYTES = 32 * 1024; const MIN_MOUSE_DELTA = -128; const MAX_MOUSE_DELTA = 127; @@ -675,89 +679,28 @@ export function attachPointerLockMouse( }; } +/** + * Retained only so existing imports fail truthfully at runtime instead of + * silently reviving the retired timer-driven drain path. + * + * @deprecated Browser PCM playback now belongs to the machine-level + * AudioWorklet transport. Use `BrowserKernel.resumeAudio()` (or the matching + * `KernelHost` method) from a trusted user gesture. + */ export function createPcmAudioScheduler( source: AudioDrainSource, opts: PcmAudioSchedulerOptions = {}, ): AudioOutputHandle { - const AudioContextCtor = - globalThis.AudioContext ?? - (globalThis as typeof globalThis & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; - if (!AudioContextCtor) { - return { - resume: async () => {}, - close: () => {}, - getState: () => "unavailable", - }; - } - - const audioCtx = new AudioContextCtor(); - const pollMs = opts.pollMs ?? AUDIO_POLL_MS; - const drainBytes = opts.drainBytes ?? AUDIO_DRAIN_BYTES; - const lookaheadSeconds = opts.lookaheadSeconds ?? 0.04; - const maxLookaheadSeconds = opts.maxLookaheadSeconds ?? 0.15; - - let cursor = audioCtx.currentTime; - let sampleRate = 44100; - let channels = 2; - let stopped = false; - - const timer = globalThis.setInterval(async () => { - if (stopped || audioCtx.state !== "running") return; - - let drain; - try { - drain = await source.drainAudio(drainBytes); - } catch { - return; - } - - const bytes = drain.bytes; - if (bytes.byteLength === 0) return; - if (drain.sampleRate > 0) sampleRate = drain.sampleRate; - if (drain.channels > 0) channels = drain.channels; - - const bytesPerFrame = 2 * channels; - const frames = Math.floor(bytes.byteLength / bytesPerFrame); - if (frames === 0) return; - - const buffer = audioCtx.createBuffer(channels, frames, sampleRate); - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - for (let ch = 0; ch < channels; ch++) { - const dst = buffer.getChannelData(ch); - for (let i = 0; i < frames; i++) { - const sample = view.getInt16((i * channels + ch) * 2, true); - dst[i] = sample / 32768; - } - } - - const now = audioCtx.currentTime; - if (cursor < now + lookaheadSeconds) { - cursor = now + lookaheadSeconds; - } else if (cursor > now + maxLookaheadSeconds) { - cursor = now + lookaheadSeconds; - return; - } - - const node = audioCtx.createBufferSource(); - node.buffer = buffer; - node.connect(audioCtx.destination); - node.start(cursor); - cursor += frames / sampleRate; - }, pollMs); - + void source; + void opts; return { resume: async () => { - if (audioCtx.state === "suspended") { - await audioCtx.resume().catch(() => {}); - } - }, - close: () => { - if (stopped) return; - stopped = true; - globalThis.clearInterval(timer); - void audioCtx.close().catch(() => {}); + throw new Error( + "createPcmAudioScheduler is unavailable; use the machine-level AudioWorklet PCM driver", + ); }, - getState: () => audioCtx.state, + close: () => {}, + getState: () => "unavailable", }; } diff --git a/host/src/generated/abi.ts b/host/src/generated/abi.ts index 4e15853748..c9191543e9 100644 --- a/host/src/generated/abi.ts +++ b/host/src/generated/abi.ts @@ -1,7 +1,7 @@ /* GENERATED by `cargo xtask dump-abi`. Do not edit by hand. */ /* Regenerated by scripts/check-abi-version.sh; drift is a CI failure. */ -export const ABI_VERSION = 39 as const; +export const ABI_VERSION = 40 as const; export const ABI_CUSTOM_SECTION = "wasm-posix-abi" as const; export const ABI_KERNEL_EXPORT = "__abi_version" as const; @@ -33,6 +33,11 @@ export const HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS = [ "kernel_host_adapter_manifest_len", "kernel_host_adapter_manifest_ptr", "kernel_mark_process_signaled", + "kernel_pcm_claim_transport", + "kernel_pcm_clock_update", + "kernel_pcm_reconcile", + "kernel_pcm_transport_len", + "kernel_pcm_transport_ptr", "kernel_pipe_has_readers", "kernel_posix_timer_fire", "kernel_prepare_write_operation", @@ -50,6 +55,55 @@ export const HOST_ADAPTER_OPTIONAL_KERNEL_EXPORTS = [ "kernel_set_process_argv", ] as const; +export const PCM_TRANSPORT_MAGIC = 827147088 as const; +export const PCM_TRANSPORT_VERSION = 1 as const; +export const PCM_TRANSPORT_HEADER_BYTES = 128 as const; +export const PCM_TRANSPORT_RING_BYTES = 65536 as const; +export const PCM_TRANSPORT_BYTES = 65664 as const; +export const PCM_STATE_CLOSED = 0 as const; +export const PCM_STATE_STOPPED = 1 as const; +export const PCM_STATE_RUNNING = 2 as const; +export const PCM_STATE_DRAINING = 3 as const; +export const PCM_FORMAT_UNKNOWN = 0 as const; +export const PCM_FORMAT_U8 = 1 as const; +export const PCM_FORMAT_S16_LE = 2 as const; +export const PCM_FORMAT_S16_BE = 3 as const; +export const PCM_TRANSPORT_UNCLAIMED = 0 as const; +export const PCM_TRANSPORT_LEGACY_PULL = 1 as const; +export const PCM_TRANSPORT_SHARED_CLOCK = 2 as const; +export const PCM_FLAG_CONFIGURING = 1 as const; +export const PCM_FLAG_UNDERRUN_ACTIVE = 2 as const; +export const PCM_FLAG_FATAL_ERROR = 4 as const; + +export const PCM_SHARED_CONTROL_FIELDS = { + magic: { offset: 0, size: 4 }, + version: { offset: 4, size: 4 }, + headerBytes: { offset: 8, size: 4 }, + physicalCapacityBytes: { offset: 12, size: 4 }, + activeCapacityBytes: { offset: 16, size: 4 }, + format: { offset: 20, size: 4 }, + sampleRate: { offset: 24, size: 4 }, + channels: { offset: 28, size: 4 }, + frameBytes: { offset: 32, size: 4 }, + fragmentBytes: { offset: 36, size: 4 }, + fragments: { offset: 40, size: 4 }, + state: { offset: 44, size: 4 }, + generation: { offset: 48, size: 4 }, + flags: { offset: 52, size: 4 }, + transportMode: { offset: 56, size: 4 }, + producerSeq: { offset: 60, size: 4 }, + producerLo: { offset: 64, size: 4 }, + producerHi: { offset: 68, size: 4 }, + consumerSeq: { offset: 72, size: 4 }, + consumerLo: { offset: 76, size: 4 }, + consumerHi: { offset: 80, size: 4 }, + discardSeq: { offset: 84, size: 4 }, + discardLo: { offset: 88, size: 4 }, + discardHi: { offset: 92, size: 4 }, + underruns: { offset: 96, size: 4 }, + wakeSeq: { offset: 100, size: 4 }, +} as const; + export const HOST_ADAPTER_MANIFEST_FIELDS = { magic: { offset: 0, size: 4 }, manifestVersion: { offset: 4, size: 2 }, @@ -611,6 +665,7 @@ export type SyscallArgSizeSpec = | { type: "cstring" } | { type: "arg"; argIndex: number; multiplier?: number; add?: number } | { type: "deref"; argIndex: number } + | { type: "ioctl"; requestArgIndex: number } | { type: "fixed"; size: number }; export interface SyscallArgDesc { @@ -772,7 +827,7 @@ export const SYSCALL_ARGS: Record = { { argIndex: 2, direction: "in", size: { type: "fixed", size: 256 } }, ], 72: [ - { argIndex: 2, direction: "inout", size: { type: "fixed", size: 256 } }, + { argIndex: 2, direction: "inout", size: { type: "ioctl", requestArgIndex: 1 }, nullable: true }, ], 75: [ { argIndex: 0, direction: "out", size: { type: "fixed", size: 390 } }, diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index 31709f3363..8fc4b4e0d4 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -87,6 +87,15 @@ import { PROCESS_MMAP_BASE, growMemoryToCover, } from "./process-memory"; +import { + PCM_CONTROL, + PcmTransportMode, + pcmControlWords, + readEffectiveConsumerPosition, + readProducerPosition, + validatePcmTransport, + type PcmTransportDescriptor, +} from "./audio/pcm-transport"; import type { KernelConfig, NetworkAddress, PlatformIO, TcpConnectionPeer, UdpDatagram } from "./types"; @@ -156,6 +165,41 @@ const EALREADY = 114; const EINPROGRESS = 115; const EINTR_ERRNO = 4; +/** Decode Kandelo's pinned ioctl argument size (mirrors host_abi.rs). */ +export function ioctlArgSize(request: number): number { + const unsigned = request >>> 0; + const encoded = (unsigned >>> 16) & 0x3fff; + if (encoded !== 0) return encoded; + switch (unsigned) { + case 0x5421: case 0x5452: case 0x541b: case 0x8905: + case 0x540f: case 0x5410: case 0x5429: + case 0x4b44: case 0x4b45: case 0x40: + return 4; + case 0x5401: case 0x5402: case 0x5403: case 0x5404: + return 60; + case 0x5413: case 0x5414: case 0x47: + return 8; + case 0x4b33: + return 1; + case 0x4600: case 0x4601: case 0x4606: + return 160; + case 0x4602: + return 80; + case 0x42: + return 16; + case 0x44: + return 32; + case 0x49: + return 24; + default: + return 0; + } +} + +export function ioctlPointerRequired(request: number): boolean { + return ioctlArgSize(request) > 0; +} + function cstringCopySize( memory: Uint8Array, ptr: number, @@ -361,6 +405,7 @@ const SYS_MQ_TIMEDRECEIVE = ABI_SYSCALLS.MqTimedreceive; const SYS_OPEN = ABI_SYSCALLS.Open; const SYS_OPENAT = ABI_SYSCALLS.Openat; const SYS_CLOSE = ABI_SYSCALLS.Close; +const SNDCTL_DSP_SYNC = 0x00005001; /** IPC constants (must match musl) */ const IPC_64 = 0x100; @@ -971,6 +1016,8 @@ export class CentralizedKernelWorker { private scratchOffset = 0; private initialized = false; private nextChildPid = 100; + private pcmTransportDescriptor: PcmTransportDescriptor | null = null; + private pcmWakeObserverGeneration = 0; /** * Allocate a fresh pid for a top-level spawn from a host. Skips any pids @@ -3642,13 +3689,18 @@ export class CentralizedKernelWorker { for (const desc of argDescs) { const ptr = origArgs[desc.argIndex]; + const decodedIoctlSize = desc.size.type === "ioctl" + ? ioctlArgSize(origArgs[desc.size.requestArgIndex]) + : undefined; const deferSchedGetaffinityOutputError = syscallNr === SYS_SCHED_GETAFFINITY && desc.argIndex === 2 && desc.direction === "out"; if (ptr === 0 && !deferSchedGetaffinityOutputError) { const required = desc.required === true - || (desc.size.type === "cstring" && desc.nullable !== true); + || (desc.size.type === "cstring" && desc.nullable !== true) + || (desc.size.type === "ioctl" + && ioctlPointerRequired(origArgs[desc.size.requestArgIndex])); if (required) { this.completeChannel( channel, @@ -3704,6 +3756,8 @@ export class CentralizedKernelWorker { } size = processMem[derefPtr] | (processMem[derefPtr + 1] << 8) | (processMem[derefPtr + 2] << 16) | (processMem[derefPtr + 3] << 24); + } else if (desc.size.type === "ioctl") { + size = decodedIoctlSize!; } else { size = desc.size.size; } @@ -4257,7 +4311,10 @@ export class CentralizedKernelWorker { // different process, which resets the kernel's ambient TID to the shared // signal context. Rebind only on that uncommon path; ordinary syscall // completion stays free of another host-to-kernel call. - this.dequeueSignalForDelivery(channel, routedMqNotification); + const deliveredSignal = this.dequeueSignalForDelivery( + channel, + routedMqNotification, + ); if (routedMqNotification && this.finishSignalTermination(channel)) return; // --- Blocking syscall handling --- @@ -4273,6 +4330,24 @@ export class CentralizedKernelWorker { // 1. EAGAIN: kernel returned EAGAIN for a blocking syscall. // Schedule async retry — the process stays blocked on Atomics.wait. if (retVal === -1 && errVal === EAGAIN) { + // A caught signal which raced the kernel's last PCM wait check now + // belongs to this channel. Complete the interruptible operation with + // EINTR instead of parking it and risking a later retry clearing that + // undelivered record. Successful/short writes never enter this path. + if ( + deliveredSignal > 0 && + this.caughtSignalInterruptsEagain(channel, syscallNr, origArgs) + ) { + this.completeChannel( + channel, + syscallNr, + origArgs, + argDescs, + -1, + EINTR_ERRNO, + ); + return; + } if (logging) { console.error(logEntry + " = -1 (EAGAIN, will retry)"); } @@ -4516,6 +4591,8 @@ export class CentralizedKernelWorker { (processMem[derefPtr + 1] << 8) | (processMem[derefPtr + 2] << 16) | (processMem[derefPtr + 3] << 24); + } else if (desc.size.type === "ioctl") { + size = ioctlArgSize(origArgs[desc.size.requestArgIndex]); } else { size = desc.size.size; } @@ -5936,6 +6013,42 @@ export class CentralizedKernelWorker { } } + /** + * Retry the exact write/writev mailbox selected for a caught signal. + * + * PCM backpressure uses the generic writable-token registry, but the signal + * path historically retried only poll-timer entries. Leaving a PCM writer + * here meant its handler could not run until the audio clock happened to + * make the original write succeed. Re-entering the kernel lets it return + * EINTR before progress, or preserve a short successful write if capacity + * became available concurrently. + */ + private retryPendingWriterForCaughtSignal(pid: number, tid: number): boolean { + for (const writers of this.pendingPipeWriters.values()) { + const writer = writers.find(({ channel }) => { + if ( + channel.pid !== pid || + this.guestTidForChannel(channel) !== tid + ) { + return false; + } + const syscallNr = new DataView( + channel.memory.buffer, + channel.channelOffset, + ).getUint32(CH_SYSCALL, true); + return syscallNr === SYS_WRITE || syscallNr === SYS_WRITEV; + }); + if (!writer) continue; + + this.removePendingPipeWriter(writer.channel); + if (this.isRegisteredChannel(writer.channel)) { + this.retrySyscall(writer.channel); + } + return true; + } + return false; + } + /** * SYS_THREAD_CANCEL — wake a thread that is blocked in a cancellation-point * syscall so its glue (__syscall_cp) can observe the pending cancel flag @@ -6618,6 +6731,27 @@ export class CentralizedKernelWorker { }); } + /** + * Blocking PCM operations use EAGAIN only as an internal host-parking + * sentinel. If a caught signal was already copied into the channel, these + * operations must publish EINTR immediately so guest libc can run the + * handler (and apply its narrowly-scoped SA_RESTART policy). + */ + private caughtSignalInterruptsEagain( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + ): boolean { + if (syscallNr === SYS_WRITE || syscallNr === SYS_WRITEV) { + const isFdNonblock = this.kernelInstance!.exports + .kernel_is_fd_nonblock as + ((pid: number, fd: number) => number) | undefined; + return isFdNonblock?.(channel.pid, origArgs[0]) === 0; + } + return syscallNr === SYS_CLOSE || + (syscallNr === SYS_IOCTL && (origArgs[1] >>> 0) === SNDCTL_DSP_SYNC); + } + /** * Retry a syscall by re-invoking handleSyscall with the original * args still in the process channel. @@ -7953,13 +8087,21 @@ export class CentralizedKernelWorker { this.currentHandlePid = 0; } - this.dequeueSignalForDelivery(channel); + const deliveredSignal = this.dequeueSignalForDelivery(channel); if (this.finishSignalTermination(channel)) return; const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); const errVal = kernelView.getUint32(CH_ERRNO, true); if (retVal === -1 && errVal === EAGAIN) { + if ( + deliveredSignal > 0 && + this.caughtSignalInterruptsEagain(channel, syscallNr, origArgs) + ) { + this.completeChannelRaw(channel, -1, EINTR_ERRNO); + this.relistenChannel(channel); + return; + } this.handleBlockingRetry(channel, syscallNr, origArgs); return; } @@ -8060,8 +8202,16 @@ export class CentralizedKernelWorker { } if (gotEagain) { - this.dequeueSignalForDelivery(channel); + const deliveredSignal = this.dequeueSignalForDelivery(channel); if (this.finishSignalTermination(channel)) return; + if ( + deliveredSignal > 0 && + this.caughtSignalInterruptsEagain(channel, syscallNr, origArgs) + ) { + this.completeChannelRaw(channel, -1, EINTR_ERRNO); + this.relistenChannel(channel); + return; + } this.handleBlockingRetry(channel, syscallNr, origArgs); return; } @@ -8178,8 +8328,16 @@ export class CentralizedKernelWorker { this.relistenChannel(channel); return; } - this.dequeueSignalForDelivery(channel); + const deliveredSignal = this.dequeueSignalForDelivery(channel); if (this.finishSignalTermination(channel)) return; + if ( + deliveredSignal > 0 && + this.caughtSignalInterruptsEagain(channel, syscallNr, origArgs) + ) { + this.completeChannelRaw(channel, -1, EINTR_ERRNO); + this.relistenChannel(channel); + return; + } this.handleBlockingRetry(channel, syscallNr, origArgs); return; } @@ -10802,6 +10960,10 @@ export class CentralizedKernelWorker { // Signal is deliverable — wake any blocking syscall for this process + // PCM and pipe write/writev waits live in the targeted writable registry, + // not the poll-timer map below. Retry only the signal-selected thread. + if (this.retryPendingWriterForCaughtSignal(targetPid, targetTid)) return; + // 1. Pending sleep (nanosleep, usleep, clock_nanosleep) const pendingSleepMatch = Array.from(this.pendingSleeps.entries()).find( ([channel]) => channel.pid === targetPid @@ -13313,11 +13475,9 @@ export class CentralizedKernelWorker { * a multiple of the active frame size (2 bytes mono / 4 bytes * stereo). * - * The host typically drives this from an `AudioWorkletNode` or - * `AudioBufferSourceNode` scheduler that pulls samples at the rate - * an `AudioContext` reports. The kernel ring drops oldest frames on - * overflow rather than blocking, so falling behind a few RAFs costs - * audio but never wedges DOOM. + * Retained only for compatibility with older hosts. The shared-clock + * transport is exclusive, so this pull path returns no data once an + * AudioWorklet or Node clock owns the sink. */ drainAudio(out: Uint8Array): number { return this.kernel.drainAudio(out); @@ -13338,6 +13498,142 @@ export class CentralizedKernelWorker { return this.kernel.audioPending(); } + /** + * Claim the single physical PCM sink and return its versioned shared ring. + * Browser workers observe AudioWorklet cursor updates; Node's clock export + * reconciles synchronously and therefore does not need a separate observer. + */ + claimPcmTransport(observeConsumerWake: boolean): PcmTransportDescriptor { + if (this.pcmTransportDescriptor) return this.pcmTransportDescriptor; + const descriptor = this.kernel.pcmTransport(); + if (!descriptor) { + throw new Error("kernel does not expose a shared PCM transport"); + } + validatePcmTransport(descriptor); + const result = this.kernel.pcmClaimTransport(PcmTransportMode.SharedClock); + if (result < 0) { + throw new Error(`failed to claim PCM transport: errno ${-result}`); + } + this.pcmTransportDescriptor = descriptor; + if (observeConsumerWake) this.startPcmWakeObserver(descriptor); + return descriptor; + } + + /** Advance Node's paced null sink and wake affected write/poll/drain calls. */ + pcmClockUpdate(requestedFrames: number): number { + const consumed = this.kernel.pcmClockUpdate(requestedFrames); + // kernel_pcm_clock_update advances wakeSeq inside Wasm, but changing an + // atomic value does not by itself wake a JS Atomics.waitAsync waiter. + // Browser consumption calls Atomics.notify from the AudioWorklet; mirror + // that notification for Node so destroy-time orphan drains settle as soon + // as the paced null sink reaches their tail instead of sleeping until the + // bounded teardown timeout. + const descriptor = this.pcmTransportDescriptor; + if (descriptor) { + Atomics.notify(pcmControlWords(descriptor), PCM_CONTROL.wakeSeq); + } + this.drainAndProcessWakeupEvents(); + this.scheduleWakeBlockedRetries(); + return consumed; + } + + /** Stop host-side observation before terminating a kernel worker. */ + shutdownPcmTransport(): void { + this.pcmWakeObserverGeneration++; + const descriptor = this.pcmTransportDescriptor; + this.pcmTransportDescriptor = null; + if (descriptor) { + const words = pcmControlWords(descriptor); + // Pair the notification with a sequence change so an observer racing + // between its lifecycle check and waitAsync cannot miss shutdown. + Atomics.add(words, PCM_CONTROL.wakeSeq, 1); + Atomics.notify(words, PCM_CONTROL.wakeSeq); + } + } + + /** + * Give the physical audio clock a bounded chance to consume an orphaned + * close tail before machine teardown. A suspended browser context times out + * truthfully; ordinary descriptor close/SYNC remains unbounded in the guest. + */ + async waitForPcmDrain(timeoutMs: number): Promise { + const descriptor = this.pcmTransportDescriptor; + if (!descriptor) return true; + const words = pcmControlWords(descriptor); + const deadline = performance.now() + Math.max(0, timeoutMs); + let observed = Atomics.load(words, PCM_CONTROL.wakeSeq); + while (true) { + // Reconcile before testing the cursor or arming a wait. In particular, + // the final AudioWorklet quantum may already have published its cursor + // and one-shot notification before this continuation gets to run. + this.kernel.pcmReconcile(); + this.drainAndProcessWakeupEvents(); + this.scheduleWakeBlockedRetries(); + + if ( + readProducerPosition(words) <= readEffectiveConsumerPosition(words) + ) { + return true; + } + const remaining = deadline - performance.now(); + if (remaining <= 0) return false; + + const current = Atomics.load(words, PCM_CONTROL.wakeSeq); + if (current !== observed) { + observed = current; + continue; + } + + // waitAsync compares and arms atomically. A sequence change after the + // load therefore either returns "not-equal" or wakes this waiter. + const wait = Atomics.waitAsync( + words, + PCM_CONTROL.wakeSeq, + observed, + remaining, + ); + if (wait.async) await wait.value; + observed = Atomics.load(words, PCM_CONTROL.wakeSeq); + } + } + + private startPcmWakeObserver(descriptor: PcmTransportDescriptor): void { + const observerGeneration = ++this.pcmWakeObserverGeneration; + const words = pcmControlWords(descriptor); + void (async () => { + let observed = Atomics.load(words, PCM_CONTROL.wakeSeq); + while ( + observerGeneration === this.pcmWakeObserverGeneration && + descriptor === this.pcmTransportDescriptor + ) { + // Reconcile first: the sequence may already reflect a final quantum + // whose notification ran before this observer continuation. + this.kernel.pcmReconcile(); + this.drainAndProcessWakeupEvents(); + this.scheduleWakeBlockedRetries(); + + if ( + observerGeneration !== this.pcmWakeObserverGeneration || + descriptor !== this.pcmTransportDescriptor + ) { + return; + } + + const current = Atomics.load(words, PCM_CONTROL.wakeSeq); + if (current !== observed) { + observed = current; + continue; + } + + const wait = Atomics.waitAsync(words, PCM_CONTROL.wakeSeq, observed); + if (wait.async) await wait.value; + observed = Atomics.load(words, PCM_CONTROL.wakeSeq); + } + })().catch((error) => { + console.error("[kernel-worker] PCM wake observer failed", error); + }); + } + /** * ABI version the kernel advertised at startup via its * `__abi_version` export. Worker processes compare against this diff --git a/host/src/kernel.ts b/host/src/kernel.ts index fcc2cbaac6..41752bd4a6 100644 --- a/host/src/kernel.ts +++ b/host/src/kernel.ts @@ -28,6 +28,11 @@ import { GlMuxer } from "./webgl/muxer"; import { drainSubmitQueue } from "./webgl/submit-drain"; import { STRUCT_SIZE_WASM_DIRENT, STRUCT_SIZE_WASM_STAT } from "./generated/abi"; import { detectPtrWidth } from "./constants"; +import { + PCM_CONTROL_BYTES, + PcmTransportMode, + type PcmTransportDescriptor, +} from "./audio/pcm-transport"; export type KernelPointer = number | bigint; @@ -521,6 +526,56 @@ export class WasmPosixKernel { return fn ? fn() : 0; } + /** Locate the versioned PCM control header and bounded ring in shared memory. */ + pcmTransport(): PcmTransportDescriptor | null { + const exports = this.instance?.exports as Record | undefined; + const ptrFn = exports?.kernel_pcm_transport_ptr as + | (() => number | bigint) + | undefined; + const lenFn = exports?.kernel_pcm_transport_len as + | (() => number) + | undefined; + if (!ptrFn || !lenFn || !this.memory) return null; + const controlOffset = Number(ptrFn()); + const totalBytes = lenFn() >>> 0; + if ( + controlOffset <= 0 || + totalBytes <= PCM_CONTROL_BYTES || + controlOffset + totalBytes > this.memory.buffer.byteLength || + !(this.memory.buffer instanceof SharedArrayBuffer) + ) { + return null; + } + return { + buffer: this.memory.buffer, + controlOffset, + controlBytes: PCM_CONTROL_BYTES, + dataOffset: controlOffset + PCM_CONTROL_BYTES, + dataBytes: totalBytes - PCM_CONTROL_BYTES, + }; + } + + pcmClaimTransport(mode: PcmTransportMode): number { + const fn = this.instance?.exports?.kernel_pcm_claim_transport as + | ((mode: number) => number) + | undefined; + return fn ? fn(mode) : -38; + } + + pcmReconcile(): number { + const fn = this.instance?.exports?.kernel_pcm_reconcile as + | (() => number) + | undefined; + return fn ? fn() : -38; + } + + pcmClockUpdate(requestedFrames: number): number { + const fn = this.instance?.exports?.kernel_pcm_clock_update as + | ((frames: number) => number) + | undefined; + return fn ? fn(requestedFrames >>> 0) : 0; + } + registerSharedPipe(handle: number, sab: SharedArrayBuffer, end: "read" | "write"): void { this.sharedPipes.set(handle, { pipe: SharedPipeBuffer.fromSharedBuffer(sab), end }); } diff --git a/host/src/node-kernel-host.ts b/host/src/node-kernel-host.ts index f68df90990..d4b50988b1 100644 --- a/host/src/node-kernel-host.ts +++ b/host/src/node-kernel-host.ts @@ -36,7 +36,9 @@ function currentModuleDir(): string { } const MODULE_DIR = currentModuleDir(); -const DESTROY_REQUEST_TIMEOUT_MS = 2_000; +// Worker teardown may spend 1.5s waking blocked guests and then give a +// suspended/slow PCM clock 2s to finish an orphaned close tail. +const DESTROY_REQUEST_TIMEOUT_MS = 5_000; const DEFAULT_SSL_ENV = [ "SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt", "SSL_CERT_DIR=/etc/ssl/certs", diff --git a/host/src/node-kernel-worker-entry.ts b/host/src/node-kernel-worker-entry.ts index 046fa4e21f..ae96e6a186 100644 --- a/host/src/node-kernel-worker-entry.ts +++ b/host/src/node-kernel-worker-entry.ts @@ -83,6 +83,7 @@ import type { TerminateProcessMessage, HttpRequestMessage, } from "./node-kernel-protocol"; +import { NodePcmDriver } from "./audio/node-pcm-driver"; if (!parentPort) { throw new Error("node-kernel-worker-entry must run in a worker_thread"); @@ -93,6 +94,7 @@ const port = parentPort; // --- State --- let kernelWorker: CentralizedKernelWorker; +let pcmDriver: NodePcmDriver | null = null; let workerAdapter: NodeWorkerAdapter; let maxPages: number = DEFAULT_MAX_PAGES; let defaultThreadSlots: number = DEFAULT_PROCESS_THREAD_SLOTS; @@ -106,6 +108,7 @@ const ENOEXEC = 8; // [JSC-TERMINATE-ATOMICS-WAIT-LEAK] destroy-time drain bounds; see handleDestroy. const DESTROY_KILL_DRAIN_TIMEOUT_MS = 1500; const DESTROY_KILL_DRAIN_POLL_MS = 15; +const PCM_DESTROY_DRAIN_TIMEOUT_MS = 2000; // Process tracking interface ForkReplayContext { @@ -723,6 +726,18 @@ async function handleInit(msg: InitMessage) { await kernelWorker.init(msg.kernelWasmBytes); + const pcmTransport = kernelWorker.claimPcmTransport(false); + pcmDriver = new NodePcmDriver({ + clockUpdate: (frames) => kernelWorker.pcmClockUpdate(frames), + // Node does not run the browser's shared-wake observer. Force one kernel + // reconciliation/retry pass so blocked write, poll, drain, and close calls + // observe EIO immediately when the null/physical sink fails. + onFatal: () => { + kernelWorker.pcmClockUpdate(0); + }, + }); + await pcmDriver.prepare(pcmTransport); + post({ type: "ready" }); } @@ -1649,6 +1664,18 @@ async function handleDestroy(msg: { requestId: number }) { threadModuleCache.clear(); threadWorkers.clear(); ptyByPid.clear(); + if (!(await kernelWorker.waitForPcmDrain(PCM_DESTROY_DRAIN_TIMEOUT_MS))) { + post({ + type: "host_diagnostic", + pid: 0, + source: "Node PCM output", + message: + "Audio clock did not consume the queued close tail before machine teardown; the remaining tail was discarded.", + }); + } + await pcmDriver?.close(); + pcmDriver = null; + kernelWorker.shutdownPcmTransport(); cleanupSessionDir(); respond(msg.requestId, true); } diff --git a/host/test/audio-integration.test.ts b/host/test/audio-integration.test.ts index 6ca5342206..1bbfa70b84 100644 --- a/host/test/audio-integration.test.ts +++ b/host/test/audio-integration.test.ts @@ -1,175 +1,501 @@ /** - * Integration test for /dev/dsp. - * - * Spawns programs/audiotest.c which: - * - opens /dev/dsp with O_WRONLY - * - configures sample rate (44100), stereo, AFMT_S16_LE via OSS ioctls - * - prints "ready \n" - * - writes a 256-byte PCM frame sequence (byte i = i & 0xff) - * - prints "wrote \n" - * - exits 0 - * - * The test then asserts: - * - the program exited cleanly - * - audioSampleRate() / audioChannels() report what audiotest configured - * - drainAudio() returns the same 256 bytes the program wrote - * - * Runs with the shared kernel and one worker per process — same harness as - * mouse-integration.test.ts. + * End-to-end `/dev/dsp` playback through the same paced Node sink used by the + * worker-thread host. No legacy pull drain participates in these tests: the + * shared-clock transport is claimed before the guest starts, and descriptor + * close cannot complete until the null sink's wall clock consumes the tail. */ -import { describe, it, expect } from "vitest"; -import { existsSync, readFileSync } from "node:fs"; -import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { CAPTURED_STDIO, CentralizedKernelWorker } from "../src/kernel-worker"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { beforeAll, describe, expect, it } from "vitest"; +import { NodePcmDriver } from "../src/audio/node-pcm-driver"; +import { + pcmControlWords, + readConsumerPosition, + readDiscardPosition, + readPcmConfig, + readProducerPosition, + type PcmTransportDescriptor, +} from "../src/audio/pcm-transport"; import { NodePlatformIO } from "../src/platform/node"; -import { NodeWorkerAdapter } from "../src/worker-adapter"; -import { detectPtrWidth } from "../src/constants"; -import type { CentralizedWorkerInitMessage } from "../src/worker-protocol"; +import { resolveBinary } from "../src/binary-resolver"; +import type { CentralizedKernelWorker } from "../src/kernel-worker"; +import { ABI_SYSCALLS } from "../src/generated/abi"; +import { runCentralizedProgram } from "./centralized-test-helper"; +import { ensureSdlDspFixtures } from "./sdl-dsp-fixtures"; -const __dirname = dirname(fileURLToPath(import.meta.url)); +const SNDCTL_DSP_SPEED = 0xc004_5002; +const SNDCTL_DSP_SETFMT = 0xc004_5005; +const SNDCTL_DSP_CHANNELS = 0xc004_5006; +const SNDCTL_DSP_SETFRAGMENT = 0xc004_500a; +const SNDCTL_DSP_GETFMTS = 0x8004_500b; +const SNDCTL_DSP_GETOSPACE = 0x8010_500c; -const audiotestBinary = join(__dirname, "../wasm/audiotest.wasm"); -const kernelBinary = join(__dirname, "../wasm/kandelo-kernel.wasm"); - -const MAX_PAGES = 16384; -const CH_TOTAL_SIZE = 72 + 65536; +interface AudioProgramResult { + exitCode: number; + stdout: string; + stderr: string; + elapsedMs: number; + consumed: Uint8Array; + sampleRate: number; + channels: number; + frameBytes: number; + fragmentBytes: number; + fragments: number; + activeCapacityBytes: number; + drainedAtExit: boolean; + cleanTail: boolean; + producerBytes: number; + consumerBytes: number; + discardedBytes: number; + ioctlRequests: number[]; +} -function loadProgramWasm(path: string): ArrayBuffer { - const buf = readFileSync(path); - return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); +async function runAudioProgram( + relativePath: string, + argv: string[], + timeoutMs = 15_000, +): Promise { + const consumedChunks: Uint8Array[] = []; + let kernel: CentralizedKernelWorker | null = null; + let pcmDriver: NodePcmDriver | null = null; + let transport: PcmTransportDescriptor | null = null; + let initialProducer = 0n; + let initialConsumer = 0n; + let initialDiscard = 0n; + const start = performance.now(); + try { + const result = await runCentralizedProgram({ + programPath: resolveBinary(relativePath), + argv, + env: ["SDL_AUDIODRIVER=dsp"], + timeout: timeoutMs, + io: new NodePlatformIO(), + onKernelReady: async (readyKernel) => { + kernel = readyKernel; + // This opt-in trace is scoped to the fixture process and is drained + // after exit. It proves which unmodified upstream backend path ran + // without adding a production-only ioctl counter or weakening the ABI. + readyKernel.enableSyscallTrace(); + transport = readyKernel.claimPcmTransport(false); + const words = pcmControlWords(transport); + initialProducer = readProducerPosition(words); + initialConsumer = readConsumerPosition(words); + initialDiscard = readDiscardPosition(words); + pcmDriver = new NodePcmDriver({ + clockUpdate: (frames) => readyKernel.pcmClockUpdate(frames), + onConsume: ({ bytes }) => consumedChunks.push(bytes.slice()), + }); + await pcmDriver.prepare(transport); + }, + }); + const elapsedMs = performance.now() - start; + const activeKernel = kernel as CentralizedKernelWorker | null; + if (!activeKernel) throw new Error("PCM kernel hook did not run"); + const activeTransport = transport as PcmTransportDescriptor | null; + if (!activeTransport) throw new Error("PCM transport hook did not run"); + const words = pcmControlWords(activeTransport); + const config = readPcmConfig(words); + // Snapshot before the teardown helper gets any opportunity to finish an + // orphan drain. The fixture's explicit SDL device close must itself have + // reached the audio clock and released the OFD before process exit. + const drainedAtExit = + readProducerPosition(words) === readConsumerPosition(words) && + readDiscardPosition(words) === initialDiscard; + const cleanTail = await activeKernel.waitForPcmDrain(1000); + const producerBytes = Number(readProducerPosition(words) - initialProducer); + const consumerBytes = Number(readConsumerPosition(words) - initialConsumer); + const discardedBytes = Number(readDiscardPosition(words) - initialDiscard); + const ioctlRequests = activeKernel + .drainSyscallTrace() + .filter((event) => event.nr === ABI_SYSCALLS.Ioctl) + .map((event) => event.args[1] >>> 0); + const total = consumedChunks.reduce( + (sum, chunk) => sum + chunk.byteLength, + 0, + ); + const consumed = new Uint8Array(total); + let offset = 0; + for (const chunk of consumedChunks) { + consumed.set(chunk, offset); + offset += chunk.byteLength; + } + return { + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + elapsedMs, + consumed, + sampleRate: activeKernel.audioSampleRate(), + channels: activeKernel.audioChannels(), + frameBytes: config.frameBytes, + fragmentBytes: config.fragmentBytes, + fragments: config.fragments, + activeCapacityBytes: config.activeCapacityBytes, + drainedAtExit, + cleanTail, + producerBytes, + consumerBytes, + discardedBytes, + ioctlRequests, + }; + } finally { + await pcmDriver?.close().catch(() => {}); + kernel?.shutdownPcmTransport(); + } } -function createProcessMemory(initialPages: number): WebAssembly.Memory { - return new WebAssembly.Memory({ - initial: initialPages, - maximum: MAX_PAGES, - shared: true, - }); +interface WaveFixture { + bytes: Uint8Array; + pcm: Uint8Array; + sampleRate: number; + channels: number; + bitsPerSample: 8 | 16; + frameBytes: number; + periodFrames: number; + periodBytes: number; + durationMs: number; + silenceByte: number; } -describe.skipIf(!existsSync(audiotestBinary))("audio integration", () => { - it("/dev/dsp buffers PCM written by a program; host drains it back verbatim", async () => { - const programBytes = loadProgramWasm(audiotestBinary); - const kernelWasmBytes = loadProgramWasm(kernelBinary); - const ptrWidth = detectPtrWidth(programBytes); - expect(ptrWidth).toBe(4); +function deterministicWave( + sampleRate: number, + channels: 1 | 2, + bitsPerSample: 8 | 16, + periods: number, +): WaveFixture { + const periodFrames = 4096; + const sampleBytes = bitsPerSample / 8; + const frameBytes = channels * sampleBytes; + const frames = periodFrames * periods; + const pcm = new Uint8Array(frames * frameBytes); + const pcmView = new DataView(pcm.buffer); - const io = new NodePlatformIO(); - const workerAdapter = new NodeWorkerAdapter(); - const workers = new Map>(); + for (let frame = 0; frame < frames; frame++) { + if (bitsPerSample === 8) { + // Stay away from unsigned 8-bit silence (0x80), making the exact + // beginning and end of playwave's sample observable in the sink. + for (let channel = 0; channel < channels; channel++) { + pcm[frame * frameBytes + channel] = + 32 + ((frame + channel * 17) % 64); + } + } else { + const left = ((frame % 257) - 128) * 123; + const offset = frame * frameBytes; + pcmView.setInt16(offset, left, true); + if (channels === 2) pcmView.setInt16(offset + 2, -left, true); + } + } - const pid = 100; + const bytes = new Uint8Array(44 + pcm.byteLength); + const view = new DataView(bytes.buffer); + const ascii = (offset: number, text: string) => { + for (let i = 0; i < text.length; i++) bytes[offset + i] = text.charCodeAt(i); + }; + ascii(0, "RIFF"); + view.setUint32(4, 36 + pcm.byteLength, true); + ascii(8, "WAVE"); + ascii(12, "fmt "); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, channels, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * frameBytes, true); + view.setUint16(32, frameBytes, true); + view.setUint16(34, bitsPerSample, true); + ascii(36, "data"); + view.setUint32(40, pcm.byteLength, true); + bytes.set(pcm, 44); - let stdout = ""; - let resolveExit: (status: number) => void; - const exitPromise = new Promise((resolve) => { - resolveExit = resolve; - }); + return { + bytes, + pcm, + sampleRate, + channels, + bitsPerSample, + frameBytes, + periodFrames, + periodBytes: periodFrames * frameBytes, + durationMs: (frames * 1000) / sampleRate, + silenceByte: bitsPerSample === 8 ? 0x80 : 0, + }; +} - const kernel = new CentralizedKernelWorker( - { maxWorkers: 4, dataBufferSize: 65536, useSharedMemory: true, enableSyscallLog: false }, - io, - { - onExit: (exitPid, exitStatus) => { - if (exitPid === pid) { - kernel.unregisterProcess(exitPid); - const w = workers.get(exitPid); - if (w) { - w.terminate().catch(() => {}); - workers.delete(exitPid); - } - resolveExit(exitStatus); - } - }, - }, - ); +function expectExactPlaywavePcm( + consumed: Uint8Array, + fixture: WaveFixture, +): void { + const start = consumed.findIndex((byte) => byte !== fixture.silenceByte); + expect( + start, + "playwave never produced non-silent sample data", + ).toBeGreaterThanOrEqual(0); + expect(start % fixture.periodBytes).toBe(0); + expect(consumed.byteLength % fixture.periodBytes).toBe(0); + expect( + (consumed.byteLength - fixture.pcm.byteLength) % fixture.periodBytes, + ).toBe(0); + expect(start + fixture.pcm.byteLength).toBeLessThanOrEqual( + consumed.byteLength, + ); - let stderr = ""; - kernel.setOutputCallbacks({ - onStdout: (data: Uint8Array) => { - stdout += new TextDecoder().decode(data); - }, - onStderr: (data: Uint8Array) => { - stderr += new TextDecoder().decode(data); - }, - }); + expect(consumed.slice(0, start)).toEqual( + new Uint8Array(start).fill(fixture.silenceByte), + ); + expect(consumed.slice(start, start + fixture.pcm.byteLength)).toEqual( + fixture.pcm, + ); + expect(consumed.slice(start + fixture.pcm.byteLength)).toEqual( + new Uint8Array(consumed.byteLength - start - fixture.pcm.byteLength).fill( + fixture.silenceByte, + ), + ); - await kernel.init(kernelWasmBytes); - - const memory = createProcessMemory(17); - const channelOffset = (MAX_PAGES - 2) * 65536; - memory.grow(MAX_PAGES - 17); - new Uint8Array(memory.buffer, channelOffset, CH_TOTAL_SIZE).fill(0); - - kernel.registerProcess(pid, memory, [channelOffset], { ptrWidth, stdio: CAPTURED_STDIO }); - - const initData: CentralizedWorkerInitMessage = { - type: "centralized_init", - pid, - ppid: 0, - programBytes, - memory, - channelOffset, - argv: ["audiotest"], - env: [], - ptrWidth, - }; + // SDL2 may prime its device or race one final callback while playwave polls + // Mix_Playing(). Bound that behavior while accepting only whole periods. + expect(consumed.byteLength - fixture.pcm.byteLength).toBeLessThanOrEqual( + fixture.periodBytes * 8, + ); +} + +describe("audio integration", () => { + beforeAll(() => ensureSdlDspFixtures(), 20 * 60_000); + + it("paces and consumes the deterministic OSS PCM fixture verbatim", async () => { + const result = await runAudioProgram("programs/audiotest.wasm", [ + "audiotest", + ]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout.trim().split("\n")).toEqual([ + "ready 44100 2", + "wrote 256", + ]); + expect(result.sampleRate).toBe(44_100); + expect(result.channels).toBe(2); + expect(result.drainedAtExit).toBe(true); + expect(result.cleanTail).toBe(true); + expect(result.consumed.byteLength).toBe(256); + expect(result.producerBytes).toBe(256); + expect(result.consumerBytes).toBe(256); + expect(result.discardedBytes).toBe(0); + for (let i = 0; i < result.consumed.byteLength; i++) { + expect(result.consumed[i]).toBe(i & 0xff); + } + }, 30_000); - const mainWorker = workerAdapter.createWorker(initData); - workers.set(pid, mainWorker); - - try { - const exitCode = await Promise.race([ - exitPromise, - new Promise((_, reject) => - setTimeout(() => reject(new Error("audiotest didn't exit in 10s")), 10_000), - ), - ]); - expect(exitCode).toBe(0); - void stderr; - - // Stdout: "ready 44100 2\nwrote 256\n" - const lines = stdout.split("\n").filter((l) => l.length > 0); - expect(lines).toHaveLength(2); - const ready = lines[0].split(" "); - expect(ready[0]).toBe("ready"); - expect(parseInt(ready[1], 10)).toBe(44100); - expect(parseInt(ready[2], 10)).toBe(2); - const wrote = lines[1].split(" "); - expect(wrote[0]).toBe("wrote"); - expect(parseInt(wrote[1], 10)).toBe(256); - - // Kernel-side config readouts agree with what the program asked for. - expect(kernel.audioSampleRate()).toBe(44100); - expect(kernel.audioChannels()).toBe(2); - - // Drain the ring. audiotest writes 256 bytes that the kernel's - // 4-byte stereo frame alignment rounds down to 256 (256 / 4 * - // 4 = 256). The first call should pull all 256 bytes; we loop - // defensively in case the host returns fewer per call. - const drained = new Uint8Array(256); - let total = 0; - for (let attempt = 0; attempt < 8 && total < drained.length; attempt++) { - const chunk = new Uint8Array(drained.length - total); - const n = kernel.drainAudio(chunk); - if (n === 0) break; - drained.set(chunk.subarray(0, n), total); - total += n; + for (const fixture of [ + { + relativePath: "programs/sdl-dsp-test/sdl2-dsp-test.wasm", + argv0: "sdl2-dsp-test", + sdlMajor: 2, + rate: 22_050, + format: "U8", + channels: 1, + }, + { + relativePath: "programs/sdl-dsp-test/sdl3-dsp-test.wasm", + argv0: "sdl3-dsp-test", + sdlMajor: 3, + rate: 48_000, + format: "S16LE", + channels: 2, + }, + ]) { + it(`runs upstream SDL${fixture.sdlMajor}'s dsp backend at real-time pace`, async () => { + const result = await runAudioProgram( + fixture.relativePath, + [fixture.argv0], + 20_000, + ); + expect(result.exitCode, result.stderr).toBe(0); + const resultLines = result.stdout + .split("\n") + .filter((line) => line.startsWith("SDL_DSP_RESULT ")); + expect(resultLines, result.stdout).toHaveLength(1); + const report = JSON.parse( + resultLines[0]!.slice("SDL_DSP_RESULT ".length), + ) as { + sdl_major: number; + requested_rate: number; + requested_format: string; + requested_channels: number; + actual_rate: number; + actual_format: string; + actual_channels: number; + callbacks: number; + frames: number; + pcm_bytes: number; + period_frames?: number; + elapsed_ms: number; + close_ms: number; + paced: boolean; + }; + + expect(report).toMatchObject({ + sdl_major: fixture.sdlMajor, + requested_rate: fixture.rate, + requested_format: fixture.format, + requested_channels: fixture.channels, + actual_rate: fixture.rate, + actual_format: fixture.format, + actual_channels: fixture.channels, + paced: true, + }); + expect(report.callbacks).toBeGreaterThanOrEqual(2); + expect(report.frames).toBeGreaterThan(0); + expect(report.elapsed_ms).toBeGreaterThanOrEqual(750); + expect(report.elapsed_ms).toBeLessThan(2500); + expect(report.close_ms).toBeLessThan(2000); + expect(result.elapsedMs).toBeGreaterThanOrEqual(750); + expect(result.elapsedMs).toBeLessThan(5000); + expect(report.pcm_bytes).toBe( + report.frames * (fixture.sdlMajor === 2 ? 1 : 4), + ); + expect(result.producerBytes).toBeGreaterThan(0); + const startupSilenceBytes = + fixture.sdlMajor === 2 ? result.producerBytes - report.pcm_bytes : 0; + if (fixture.sdlMajor === 2) { + // SDL2 primes OSS playback with callback-sized silent periods before + // it starts delivering application callback data. The count depends + // on how many device periods elapse while its audio thread starts, + // but each period and every following pattern byte remain exact. + expect(report.period_frames).toBeGreaterThan(0); + expect(startupSilenceBytes).toBeGreaterThanOrEqual( + report.period_frames!, + ); + expect(startupSilenceBytes % report.period_frames!).toBe(0); + expect(startupSilenceBytes).toBeLessThanOrEqual( + report.period_frames! * 4, + ); + expect(result.producerBytes).toBe( + report.pcm_bytes + startupSilenceBytes, + ); + } else { + // SDL3 may retain a callback-produced suffix in its AudioStream when + // it destroys the stream; every byte that reached /dev/dsp is still + // verified below and drained according to the transport cursors. + expect(result.producerBytes).toBeLessThanOrEqual(report.pcm_bytes); + } + expect(result.consumerBytes).toBe(result.producerBytes); + expect(result.discardedBytes).toBe(0); + expect(result.consumed.byteLength).toBe(result.producerBytes); + const expectedPcm = new Uint8Array(result.consumed.byteLength); + for (let offset = 0; offset < result.consumed.byteLength; offset++) { + if (fixture.sdlMajor === 2) { + expectedPcm[offset] = + offset < startupSilenceBytes + ? 0x80 + : 32 + ((offset - startupSilenceBytes) % 192); + } else { + const phase = Math.floor(offset / 4) % 200; + const sample = (phase - 100) * 240; + const right = -sample; + expectedPcm[offset] = [ + sample & 0xff, + (sample >> 8) & 0xff, + right & 0xff, + (right >> 8) & 0xff, + ][offset % 4]!; + } } - expect(total).toBe(256); - // audiotest wrote pcm[i] = i & 0xff - for (let i = 0; i < 256; i++) { - expect(drained[i]).toBe(i & 0xff); + expect(result.consumed).toEqual(expectedPcm); + expect(result.drainedAtExit).toBe(true); + expect(result.cleanTail).toBe(true); + if (fixture.sdlMajor === 3) { + expect(result.ioctlRequests).toContain(SNDCTL_DSP_GETOSPACE); } + }, 30_000); + } - // Ring is empty after the drain. - expect(kernel.audioPending()).toBe(0); - const after = new Uint8Array(64); - expect(kernel.drainAudio(after)).toBe(0); - } finally { - for (const [, w] of workers) await w.terminate().catch(() => {}); - void exitPromise.catch(() => {}); - } + for (const playwave of [ + { + name: "default S16 stereo", + fixture: deterministicWave(44_100, 2, 16, 5), + args: [] as string[], + opened: "Opened audio at 44100 Hz 16 bit stereo", + }, + { + name: "requested U8 mono", + fixture: deterministicWave(22_050, 1, 8, 3), + args: ["-8", "-m", "-r", "22050"], + opened: "Opened audio at 22050 Hz 8 bit mono", + }, + ]) { + it( + `plays upstream SDL_mixer playwave's ${playwave.name} WAV exactly`, + async () => { + const tempDir = mkdtempSync(join(tmpdir(), "kandelo-playwave-")); + const wavePath = join(tempDir, "deterministic.wav"); + writeFileSync(wavePath, playwave.fixture.bytes); + + let result: AudioProgramResult; + try { + result = await runAudioProgram( + "programs/playwave.wasm", + ["playwave", ...playwave.args, wavePath], + 20_000, + ); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + + expect(result.exitCode, result.stderr).toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain(playwave.opened); + expect(result.sampleRate).toBe(playwave.fixture.sampleRate); + expect(result.channels).toBe(playwave.fixture.channels); + expect(result.frameBytes).toBe(playwave.fixture.frameBytes); + expect(result.fragmentBytes).toBe(playwave.fixture.periodBytes); + expect(result.fragments).toBe(2); + expect(result.activeCapacityBytes).toBe( + playwave.fixture.periodBytes * 2, + ); + + // The sample itself must take approximately this much audio-clock + // time; startup periods and process setup may make the total longer. + expect(result.elapsedMs).toBeGreaterThanOrEqual( + playwave.fixture.durationMs * 0.75, + ); + expect(result.elapsedMs).toBeLessThan(5000); + expect(result.producerBytes).toBeGreaterThanOrEqual( + playwave.fixture.pcm.byteLength, + ); + expect(result.consumerBytes).toBe(result.producerBytes); + expect(result.consumed.byteLength).toBe(result.producerBytes); + expect(result.discardedBytes).toBe(0); + expect(result.drainedAtExit).toBe(true); + expect(result.cleanTail).toBe(true); + expectExactPlaywavePcm(result.consumed, playwave.fixture); + + for (const request of [ + SNDCTL_DSP_GETFMTS, + SNDCTL_DSP_SETFMT, + SNDCTL_DSP_CHANNELS, + SNDCTL_DSP_SPEED, + SNDCTL_DSP_SETFRAGMENT, + ]) { + expect(result.ioctlRequests).toContain(request); + } + }, + 30_000, + ); + } + + it("paces SDL through the production dedicated Node kernel worker", async () => { + const start = performance.now(); + const result = await runCentralizedProgram({ + programPath: resolveBinary("programs/sdl-dsp-test/sdl2-dsp-test.wasm"), + argv: ["sdl2-dsp-test"], + env: ["SDL_AUDIODRIVER=dsp"], + timeout: 20_000, + useDefaultRootfs: false, + }); + const elapsedMs = performance.now() - start; + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain('\"sdl_major\":2'); + expect(result.stdout).toContain('\"paced\":true'); + expect(elapsedMs).toBeGreaterThanOrEqual(750); + expect(elapsedMs).toBeLessThan(5000); + expect(result.hostDiagnostics).toEqual([]); }, 30_000); }); diff --git a/host/test/audio-signal-interruption.test.ts b/host/test/audio-signal-interruption.test.ts new file mode 100644 index 0000000000..810e7925c4 --- /dev/null +++ b/host/test/audio-signal-interruption.test.ts @@ -0,0 +1,48 @@ +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { NodePcmDriver } from "../src/audio/node-pcm-driver"; +import { NodePlatformIO } from "../src/platform/node"; +import type { CentralizedKernelWorker } from "../src/kernel-worker"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const program = join(repoRoot, "examples/dsp_signal_test.wasm"); + +describe.skipIf(!existsSync(program))("/dev/dsp signal interruption", () => { + it("delivers caught signals, applies narrow SA_RESTART, and preserves an interrupted close fd", async () => { + let kernel: CentralizedKernelWorker | null = null; + let driver: NodePcmDriver | null = null; + let consumedBytes = 0; + try { + const result = await runCentralizedProgram({ + programPath: program, + argv: ["dsp_signal_test"], + useDefaultRootfs: false, + timeout: 15_000, + io: new NodePlatformIO(), + onKernelReady: async (readyKernel) => { + kernel = readyKernel; + driver = new NodePcmDriver({ + clockUpdate: (frames) => readyKernel.pcmClockUpdate(frames), + onConsume: ({ bytes }) => { + consumedBytes += bytes.byteLength; + }, + }); + await driver.prepare(readyKernel.claimPcmTransport(false)); + }, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("PASS dsp signal interruption alarms=6"); + expect(consumedBytes).toBeGreaterThan(0); + expect(kernel).not.toBeNull(); + expect(await kernel!.waitForPcmDrain(1000)).toBe(true); + } finally { + await driver?.close().catch(() => {}); + kernel?.shutdownPcmTransport(); + } + }, 20_000); +}); diff --git a/host/test/browser-kernel.test.ts b/host/test/browser-kernel.test.ts index 439b0fa4d1..9965121ff3 100644 --- a/host/test/browser-kernel.test.ts +++ b/host/test/browser-kernel.test.ts @@ -14,6 +14,7 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import type { HttpResponse } from "../src/networking"; +import { createPcmTransport } from "./pcm-test-helpers"; // --------------------------------------------------------------------------- // Mock Worker @@ -79,6 +80,64 @@ async function loadBrowserKernel() { return mod.BrowserKernel as typeof import("../src/browser-kernel-host").BrowserKernel; } +function browserAudioGlobals() { + const addModule = vi.fn(async () => {}); + const context = { + state: "suspended" as AudioContextState, + sampleRate: 48_000, + baseLatency: 0.01, + outputLatency: 0.02, + renderQuantumSize: 128, + destination: {}, + audioWorklet: { addModule }, + onstatechange: null as (() => void) | null, + resume: vi.fn(async function (this: typeof context) { + this.state = "running"; + this.onstatechange?.(); + }), + suspend: vi.fn(async function (this: typeof context) { + this.state = "suspended"; + this.onstatechange?.(); + }), + close: vi.fn(async function (this: typeof context) { + this.state = "closed"; + this.onstatechange?.(); + }), + }; + const port = { + onmessage: null as ((event: MessageEvent) => void) | null, + close: vi.fn(), + }; + const node = { + port, + onprocessorerror: null as (() => void) | null, + connect: vi.fn(), + disconnect: vi.fn(), + }; + let nodeOptions: AudioWorkletNodeOptions | undefined; + const AudioContextCtor = vi.fn(function () { + return context; + }); + const AudioWorkletNodeCtor = vi.fn(function ( + _context: AudioContext, + _name: string, + options: AudioWorkletNodeOptions, + ) { + nodeOptions = options; + return node; + }); + vi.stubGlobal("AudioContext", AudioContextCtor); + vi.stubGlobal("AudioWorkletNode", AudioWorkletNodeCtor); + return { + addModule, + context, + node, + AudioContextCtor, + AudioWorkletNodeCtor, + get nodeOptions() { return nodeOptions; }, + }; +} + describe("BrowserKernel", () => { beforeEach(() => { @@ -536,4 +595,107 @@ describe("BrowserKernel", () => { await destroyPromise; expect(w.terminated).toBe(true); }); + + it("prepares the browser PCM sink from the worker ready transport", async () => { + const audio = browserAudioGlobals(); + const BrowserKernel = await loadBrowserKernel(); + const kernel = new BrowserKernel({ + kernelOwnedFs: true, + audioWorkletUrl: "/assets/kandelo-pcm-output.js", + }); + const initPromise = kernel.initFromImage({ + kernelWasm: new ArrayBuffer(8), + vfsImage: new Uint8Array(0), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const worker = MockWorker.instances[0]!; + const transport = createPcmTransport(); + + worker.simulateMessage({ type: "ready", pcmTransport: transport }); + await initPromise; + await vi.waitFor(() => { + expect(kernel.getAudioState()).toBe("suspended"); + }); + + expect(audio.AudioContextCtor).toHaveBeenCalledOnce(); + expect(audio.addModule).toHaveBeenCalledWith( + "/assets/kandelo-pcm-output.js", + ); + expect(audio.AudioWorkletNodeCtor).toHaveBeenCalledWith( + audio.context, + "kandelo-pcm-output", + expect.any(Object), + ); + expect(audio.nodeOptions?.processorOptions).toMatchObject(transport); + expect(audio.node.connect).toHaveBeenCalledWith(audio.context.destination); + }); + + it("settles and closes browser PCM before terminating the kernel worker", async () => { + const audio = browserAudioGlobals(); + const BrowserKernel = await loadBrowserKernel(); + const kernel = new BrowserKernel({ kernelOwnedFs: true }); + const initPromise = kernel.initFromImage({ + kernelWasm: new ArrayBuffer(8), + vfsImage: new Uint8Array(0), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const worker = MockWorker.instances[0]!; + worker.simulateMessage({ + type: "ready", + pcmTransport: createPcmTransport(), + }); + await initPromise; + await vi.waitFor(() => { + expect(kernel.getAudioState()).toBe("suspended"); + }); + await kernel.resumeAudio(); + + const teardownOrder: string[] = []; + let finishPcmSettlement!: () => void; + audio.context.suspend.mockImplementationOnce( + () => + new Promise((resolve) => { + teardownOrder.push("pcm-pipeline-settle"); + finishPcmSettlement = () => { + audio.context.state = "suspended"; + audio.context.onstatechange?.(); + resolve(); + }; + }), + ); + audio.context.close.mockImplementationOnce(async () => { + teardownOrder.push("pcm-context-close"); + audio.context.state = "closed"; + audio.context.onstatechange?.(); + }); + vi.spyOn(worker, "terminate").mockImplementationOnce(() => { + teardownOrder.push("kernel-worker-terminate"); + worker.terminated = true; + }); + + const destroyPromise = kernel.destroy(); + await new Promise((resolve) => setTimeout(resolve, 0)); + const destroy = worker.lastMessage("destroy"); + worker.simulateMessage({ + type: "response", + requestId: destroy.requestId, + result: true, + }); + await vi.waitFor(() => { + expect(teardownOrder).toEqual(["pcm-pipeline-settle"]); + }); + expect(audio.context.close).not.toHaveBeenCalled(); + expect(worker.terminated).toBe(false); + finishPcmSettlement(); + await destroyPromise; + + expect(teardownOrder).toEqual([ + "pcm-pipeline-settle", + "pcm-context-close", + "kernel-worker-terminate", + ]); + expect(audio.node.disconnect).toHaveBeenCalledOnce(); + expect(audio.node.port.close).toHaveBeenCalledOnce(); + expect(kernel.getAudioState()).toBe("unavailable"); + }); }); diff --git a/host/test/browser-pcm-driver.test.ts b/host/test/browser-pcm-driver.test.ts new file mode 100644 index 0000000000..df8c2616e7 --- /dev/null +++ b/host/test/browser-pcm-driver.test.ts @@ -0,0 +1,417 @@ +import { describe, expect, it, vi } from "vitest"; +import { BrowserPcmDriver } from "../src/audio/browser-pcm-driver"; +import { + PCM_CONTROL, + PcmSampleFormat, + PcmStreamState, + PcmTransportFlag, + pcmControlWords, + readEffectiveConsumerPosition, + readProducerPosition, +} from "../src/audio/pcm-transport"; +import { createPcmTransport, writeProducer } from "./pcm-test-helpers"; + +function browserAudioMocks() { + const addModule = vi.fn(async () => {}); + const eventListeners = new Map< + string, + Set + >(); + const context = { + state: "suspended" as AudioContextState, + sampleRate: 48_000, + baseLatency: 0.01, + outputLatency: 0.02, + renderQuantumSize: 128, + destination: {}, + audioWorklet: { addModule }, + onstatechange: null as (() => void) | null, + addEventListener: vi.fn( + (type: string, listener: EventListenerOrEventListenerObject | null) => { + if (!listener) return; + const listeners = eventListeners.get(type) ?? new Set(); + listeners.add(listener); + eventListeners.set(type, listeners); + }, + ), + removeEventListener: vi.fn( + (type: string, listener: EventListenerOrEventListenerObject | null) => { + if (listener) eventListeners.get(type)?.delete(listener); + }, + ), + dispatchEvent: vi.fn((event: Event) => { + for (const listener of eventListeners.get(event.type) ?? []) { + if (typeof listener === "function") listener.call(context, event); + else listener.handleEvent(event); + } + return true; + }), + resume: vi.fn(async function (this: typeof context) { + this.state = "running"; + this.onstatechange?.(); + }), + suspend: vi.fn(async function (this: typeof context) { + this.state = "suspended"; + this.onstatechange?.(); + }), + close: vi.fn(async function (this: typeof context) { + this.state = "closed"; + this.onstatechange?.(); + }), + }; + const port = { + onmessage: null as ((event: MessageEvent) => void) | null, + close: vi.fn(), + }; + const node = { + port, + onprocessorerror: null as (() => void) | null, + connect: vi.fn(), + disconnect: vi.fn(), + }; + let nodeOptions: AudioWorkletNodeOptions | undefined; + const createNode = vi.fn( + ( + _context: AudioContext, + _name: string, + options: AudioWorkletNodeOptions, + ) => { + nodeOptions = options; + return node as unknown as AudioWorkletNode; + }, + ); + return { + addModule, + context, + node, + createNode, + get nodeOptions() { + return nodeOptions; + }, + }; +} + +describe("BrowserPcmDriver", () => { + it("loads the packaged worklet with the generated PCM-only transport contract", async () => { + const mocks = browserAudioMocks(); + const driver = new BrowserPcmDriver({ + workletUrl: "/assets/kandelo-pcm.js", + createContext: () => mocks.context as unknown as AudioContext, + createNode: mocks.createNode, + }); + const states: string[] = []; + driver.subscribe((state) => states.push(state)); + + await driver.prepare(createPcmTransport()); + expect(mocks.addModule).toHaveBeenCalledWith("/assets/kandelo-pcm.js"); + expect(mocks.createNode).toHaveBeenCalledWith( + mocks.context, + "kandelo-pcm-output", + expect.any(Object), + ); + expect(mocks.nodeOptions?.processorOptions).toMatchObject({ + layout: PCM_CONTROL, + formats: { + u8: PcmSampleFormat.U8, + s16le: PcmSampleFormat.S16Le, + s16be: PcmSampleFormat.S16Be, + }, + states: { + running: PcmStreamState.Running, + draining: PcmStreamState.Draining, + }, + flags: { + configuring: PcmTransportFlag.Configuring, + underrunActive: PcmTransportFlag.UnderrunActive, + fatalError: PcmTransportFlag.FatalError, + }, + outputSampleRate: 48_000, + }); + expect(driver.getState()).toBe("suspended"); + + await driver.resume(); + expect(driver.getState()).toBe("running"); + await driver.suspend(); + expect(driver.getState()).toBe("suspended"); + await driver.close(); + expect(mocks.node.disconnect).toHaveBeenCalledOnce(); + expect(mocks.node.port.close).toHaveBeenCalledOnce(); + expect(mocks.context.close).toHaveBeenCalledOnce(); + expect(states).toContain("running"); + expect(states.at(-1)).toBe("closed"); + }); + + it("surfaces AudioContext activation failure instead of pretending to run", async () => { + const mocks = browserAudioMocks(); + mocks.context.resume.mockImplementationOnce(async () => { + throw new Error("user activation required"); + }); + const driver = new BrowserPcmDriver({ + workletUrl: "/worklet.js", + createContext: () => mocks.context as unknown as AudioContext, + createNode: mocks.createNode, + }); + const descriptor = createPcmTransport(); + await driver.prepare(descriptor); + + await expect(driver.resume()).rejects.toThrow("user activation required"); + expect(driver.getState()).toBe("suspended"); + expect( + Atomics.load(pcmControlWords(descriptor), PCM_CONTROL.flags) & + PcmTransportFlag.FatalError, + ).toBe(0); + await driver.close(); + }); + + it("settles the rendered Web Audio tail before context close", async () => { + vi.useFakeTimers(); + try { + const mocks = browserAudioMocks(); + const driver = new BrowserPcmDriver({ + workletUrl: "/worklet.js", + createContext: () => mocks.context as unknown as AudioContext, + createNode: mocks.createNode, + }); + await driver.prepare(createPcmTransport()); + await driver.resume(); + + let settled = false; + const settlement = driver.settleOutputPipeline().then(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(0); + + expect(mocks.context.suspend).toHaveBeenCalledOnce(); + expect(mocks.context.resume).toHaveBeenCalledOnce(); + expect(driver.getState()).toBe("suspended"); + expect(settled).toBe(false); + + // 10 ms base latency + 20 ms device latency + one 128-frame + // render quantum at 48 kHz rounds up to a 33 ms settlement wait. + await vi.advanceTimersByTimeAsync(32); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await settlement; + expect(settled).toBe(true); + + await driver.close(); + expect(mocks.context.close).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + it("does not resume suspended audio while settling teardown", async () => { + const mocks = browserAudioMocks(); + const driver = new BrowserPcmDriver({ + workletUrl: "/worklet.js", + createContext: () => mocks.context as unknown as AudioContext, + createNode: mocks.createNode, + }); + await driver.prepare(createPcmTransport()); + + await driver.settleOutputPipeline(); + + expect(mocks.context.resume).not.toHaveBeenCalled(); + expect(mocks.context.suspend).not.toHaveBeenCalled(); + expect(driver.getState()).toBe("suspended"); + await driver.close(); + }); + + it("bounds settlement when AudioContext suspension never resolves", async () => { + vi.useFakeTimers(); + try { + const mocks = browserAudioMocks(); + mocks.context.suspend.mockImplementationOnce( + () => new Promise(() => {}), + ); + const driver = new BrowserPcmDriver({ + workletUrl: "/worklet.js", + createContext: () => mocks.context as unknown as AudioContext, + createNode: mocks.createNode, + }); + await driver.prepare(createPcmTransport()); + await driver.resume(); + + let settled = false; + const settlement = driver.settleOutputPipeline().then(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(999); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await settlement; + expect(settled).toBe(true); + + await driver.close(); + } finally { + vi.useRealTimers(); + } + }); + + it("surfaces an explicit AudioWorklet error message", async () => { + const mocks = browserAudioMocks(); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const driver = new BrowserPcmDriver({ + workletUrl: "/worklet.js", + createContext: () => mocks.context as unknown as AudioContext, + createNode: mocks.createNode, + }); + const states: string[] = []; + driver.subscribe((state) => states.push(state)); + const descriptor = createPcmTransport(); + await driver.prepare(descriptor); + + mocks.node.port.onmessage?.({ + data: { type: "error", message: "PCM generation changed mid-quantum" }, + } as MessageEvent); + + expect(driver.getState()).toBe("error"); + expect(states.at(-1)).toBe("error"); + expect(consoleError).toHaveBeenCalledWith( + "[BrowserPcmDriver] PCM generation changed mid-quantum", + ); + expect( + Atomics.load(pcmControlWords(descriptor), PCM_CONTROL.flags) & + PcmTransportFlag.FatalError, + ).toBe(PcmTransportFlag.FatalError); + await expect(driver.resume()).rejects.toThrow("PCM output has failed"); + await driver.close(); + }); + + it("latches the standard AudioContext output-error event across its suspended statechange", async () => { + const mocks = browserAudioMocks(); + const driver = new BrowserPcmDriver({ + workletUrl: "/worklet.js", + createContext: () => mocks.context as unknown as AudioContext, + createNode: mocks.createNode, + }); + const descriptor = createPcmTransport(); + const words = pcmControlWords(descriptor); + await driver.prepare(descriptor); + await driver.resume(); + + mocks.context.dispatchEvent(new Event("error")); + expect(driver.getState()).toBe("error"); + expect( + Atomics.load(words, PCM_CONTROL.flags) & PcmTransportFlag.FatalError, + ).toBe(PcmTransportFlag.FatalError); + expect(Atomics.load(words, PCM_CONTROL.wakeSeq)).toBe(1); + + // Web Audio 1.1 dispatches `error` before moving a resource-failed + // context to suspended and dispatching statechange. That follow-up state + // must not make a permanently failed sink look recoverable. + mocks.context.state = "suspended"; + mocks.context.onstatechange?.(); + await driver.suspend(); + expect(driver.getState()).toBe("error"); + await expect(driver.resume()).rejects.toThrow("PCM output has failed"); + + await driver.close(); + expect(mocks.context.removeEventListener).toHaveBeenCalledWith( + "error", + expect.any(Function), + ); + }); + + it("keeps ordinary interruption and suspension recoverable", async () => { + const mocks = browserAudioMocks(); + const driver = new BrowserPcmDriver({ + workletUrl: "/worklet.js", + createContext: () => mocks.context as unknown as AudioContext, + createNode: mocks.createNode, + }); + const descriptor = createPcmTransport(); + const words = pcmControlWords(descriptor); + await driver.prepare(descriptor); + + mocks.context.state = "interrupted" as AudioContextState; + mocks.context.onstatechange?.(); + expect(driver.getState()).toBe("interrupted"); + expect( + Atomics.load(words, PCM_CONTROL.flags) & PcmTransportFlag.FatalError, + ).toBe(0); + + mocks.context.state = "suspended"; + mocks.context.onstatechange?.(); + expect(driver.getState()).toBe("suspended"); + await driver.resume(); + expect(driver.getState()).toBe("running"); + await driver.close(); + }); + + it("treats an unexpectedly closed AudioContext as a latched sink failure", async () => { + const mocks = browserAudioMocks(); + const driver = new BrowserPcmDriver({ + workletUrl: "/worklet.js", + createContext: () => mocks.context as unknown as AudioContext, + createNode: mocks.createNode, + }); + const descriptor = createPcmTransport(); + const words = pcmControlWords(descriptor); + await driver.prepare(descriptor); + + mocks.context.state = "closed"; + mocks.context.onstatechange?.(); + expect(driver.getState()).toBe("error"); + expect( + Atomics.load(words, PCM_CONTROL.flags) & PcmTransportFlag.FatalError, + ).toBe(PcmTransportFlag.FatalError); + expect(Atomics.load(words, PCM_CONTROL.wakeSeq)).toBe(1); + + mocks.context.state = "suspended"; + mocks.context.onstatechange?.(); + expect(driver.getState()).toBe("error"); + await driver.close(); + }); + + it("wakes orphan-drain reconciliation when the AudioWorklet processor fails", async () => { + const mocks = browserAudioMocks(); + const driver = new BrowserPcmDriver({ + workletUrl: "/worklet.js", + createContext: () => mocks.context as unknown as AudioContext, + createNode: mocks.createNode, + }); + const states: string[] = []; + driver.subscribe((state) => states.push(state)); + const descriptor = createPcmTransport({ + state: PcmStreamState.Draining, + }); + writeProducer(descriptor, 64n); + const words = pcmControlWords(descriptor); + await driver.prepare(descriptor); + + mocks.node.onprocessorerror?.(); + + expect(driver.getState()).toBe("error"); + expect(states.at(-1)).toBe("error"); + expect( + Atomics.load(words, PCM_CONTROL.flags) & PcmTransportFlag.FatalError, + ).toBe(PcmTransportFlag.FatalError); + expect(Atomics.load(words, PCM_CONTROL.wakeSeq)).toBe(1); + expect(readEffectiveConsumerPosition(words)).toBe(0n); + expect(readProducerPosition(words)).toBe(64n); + await driver.close(); + }); + + it("closes a partially-created context when worklet loading fails", async () => { + const mocks = browserAudioMocks(); + mocks.addModule.mockRejectedValueOnce(new Error("asset missing")); + const driver = new BrowserPcmDriver({ + workletUrl: "/missing.js", + createContext: () => mocks.context as unknown as AudioContext, + createNode: mocks.createNode, + }); + + const descriptor = createPcmTransport(); + await expect(driver.prepare(descriptor)).rejects.toThrow("asset missing"); + expect(driver.getState()).toBe("error"); + expect(mocks.context.close).toHaveBeenCalledOnce(); + expect( + Atomics.load(pcmControlWords(descriptor), PCM_CONTROL.flags) & + PcmTransportFlag.FatalError, + ).toBe(PcmTransportFlag.FatalError); + }); +}); diff --git a/host/test/centralized-test-helper.ts b/host/test/centralized-test-helper.ts index 205d76ba81..3de7361bc0 100644 --- a/host/test/centralized-test-helper.ts +++ b/host/test/centralized-test-helper.ts @@ -132,6 +132,16 @@ export interface RunProgramOptions { /** Callback invoked after the process starts. * Use this to call appendStdinData() for interactive stdin testing. */ onStarted?: (kernelProxy: KernelStdinProxy, pid: number) => void | Promise; + /** + * Main-thread harness hook invoked after process registration but before its + * Worker starts. Supplying this forces main-thread mode so tests can attach + * host devices that need direct access to `CentralizedKernelWorker` while + * retaining the production-equivalent pthread/fork/exec worker wiring. + */ + onKernelReady?: ( + kernelWorker: CentralizedKernelWorker, + pid: number, + ) => void | Promise; /** If `true`, the helper queries `kernel_get_fork_count(pid)` after the * program exits and surfaces the value on `RunProgramResult.forkCount`. * Used by the non-forking-spawn regression tests. Worker-thread mode @@ -176,7 +186,7 @@ export interface RunProgramResult { export async function runCentralizedProgram( options: RunProgramOptions, ): Promise { - if (options.io) { + if (options.io || options.onKernelReady) { return runOnMainThread(options); } return runInWorkerThread(options); @@ -662,6 +672,10 @@ async function runOnMainThread(options: RunProgramOptions): Promise { { dx: 46, dy: -4, buttons: 0b101 }, ]); }); + + it("keeps the retired PCM scheduler export explicitly unavailable", async () => { + let drainCalls = 0; + const output = createPcmAudioScheduler({ + drainAudio: async () => { + drainCalls++; + return { bytes: new Uint8Array(), sampleRate: 48_000, channels: 2 }; + }, + }); + + expect(output.getState()).toBe("unavailable"); + await expect(output.resume()).rejects.toThrow("machine-level AudioWorklet PCM driver"); + expect(drainCalls).toBe(0); + expect(() => output.close()).not.toThrow(); + }); }); diff --git a/host/test/global-setup.ts b/host/test/global-setup.ts index 300adea434..c66d9a9f97 100644 --- a/host/test/global-setup.ts +++ b/host/test/global-setup.ts @@ -24,6 +24,7 @@ const TEST_PROGRAMS = [ "clock_getcpuclockid_test.c", "syscall_cp_offset_test.c", "select_signal_test.c", + "dsp_signal_test.c", "lseek_invalid_test.c", "environment_lifecycle_test.c", "chown_sentinel_test.c", diff --git a/host/test/ioctl-arg-size.test.ts b/host/test/ioctl-arg-size.test.ts new file mode 100644 index 0000000000..b12452d8f0 --- /dev/null +++ b/host/test/ioctl-arg-size.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { ioctlArgSize, ioctlPointerRequired } from "../src/kernel-worker"; + +describe("ioctl argument sizing", () => { + it("matches Kandelo's supported OSS wasm32 ABI", () => { + for (const request of [0x5000, 0x5001, 0x5008, 0x500e]) { + expect(ioctlArgSize(request), request.toString(16)).toBe(0); + } + for (const request of [ + 0xc0045002, // SPEED + 0xc0045003, // STEREO + 0xc0045004, // GETBLKSIZE + 0xc0045005, // SETFMT + 0xc0045006, // CHANNELS + 0xc004500a, // SETFRAGMENT + 0x8004500b, // GETFMTS + 0x8004500f, // GETCAPS + 0x80045017, // GETODELAY + 0x80045002, // READ_RATE + 0x80045005, // READ_BITS + 0x80045006, // READ_CHANNELS + ]) { + expect(ioctlArgSize(request), request.toString(16)).toBe(4); + } + expect(ioctlArgSize(0x8010500c)).toBe(16); // GETOSPACE/audio_buf_info + expect(ioctlArgSize(0x800c5012)).toBe(12); // GETOPTR/count_info + expect(ioctlArgSize(0x540b)).toBe(0); // TCFLSH has an immediate selector + expect(ioctlPointerRequired(0x5000)).toBe(false); + expect(ioctlPointerRequired(0x8010500c)).toBe(true); + }); +}); diff --git a/host/test/node-pcm-driver.test.ts b/host/test/node-pcm-driver.test.ts new file mode 100644 index 0000000000..3ae3886820 --- /dev/null +++ b/host/test/node-pcm-driver.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it, vi } from "vitest"; +import { + NodePcmDriver, + type PcmDriverClock, +} from "../src/audio/node-pcm-driver"; +import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { + PCM_CONTROL, + PcmStreamState, + PcmTransportFlag, + pcmControlWords, + readConsumerPosition, + readEffectiveConsumerPosition, + readPcmConfig, + readProducerPosition, + storeU32, + writeConsumerPosition, +} from "../src/audio/pcm-transport"; +import { + createPcmTransport, + writeProducer, + writeRing, +} from "./pcm-test-helpers"; + +class FakeClock implements PcmDriverClock { + time = 0; + private nextId = 1; + private callbacks = new Map void>(); + + now(): number { + return this.time; + } + + setTimeout(callback: () => void, _delayMs: number): number { + const id = this.nextId++; + this.callbacks.set(id, callback); + return id; + } + + clearTimeout(handle: unknown): void { + this.callbacks.delete(handle as number); + } + + runNext(advanceMs = 0): void { + this.time += advanceMs; + const entry = this.callbacks.entries().next().value as + [number, () => void] | undefined; + if (!entry) throw new Error("no timer scheduled"); + this.callbacks.delete(entry[0]); + entry[1](); + } + + get pending(): number { + return this.callbacks.size; + } +} + +function kernelClockUpdate( + descriptor: ReturnType, + requests: number[], +) { + return (requestedFrames: number): number => { + requests.push(requestedFrames); + const words = pcmControlWords(descriptor); + const config = readPcmConfig(words); + const consumer = readEffectiveConsumerPosition(words); + const producer = readProducerPosition(words); + const queued = Number((producer - consumer) / BigInt(config.frameBytes)); + const actual = Math.min(requestedFrames, queued); + writeConsumerPosition(words, consumer + BigInt(actual * config.frameBytes)); + return actual; + }; +} + +describe("NodePcmDriver", () => { + it("advances playback from elapsed wall-clock time, not CPU speed", async () => { + const descriptor = createPcmTransport({ fragmentBytes: 1920 }); + const bytes = new Uint8Array(480 * 4).map((_, i) => i & 0xff); + writeRing(descriptor, 0n, bytes, 4096); + writeProducer(descriptor, BigInt(bytes.byteLength)); + const clock = new FakeClock(); + const requests: number[] = []; + const events: Array<{ frames: number; bytes: Uint8Array }> = []; + const driver = new NodePcmDriver({ + clock, + clockUpdate: kernelClockUpdate(descriptor, requests), + onConsume: (event) => + events.push({ frames: event.frames, bytes: event.bytes }), + }); + + await driver.prepare(descriptor); + clock.runNext(0); + expect(requests).toEqual([]); + clock.runNext(10); + expect(requests).toEqual([480]); + expect(events[0]?.frames).toBe(480); + expect([...events[0]!.bytes]).toEqual([...bytes]); + expect(readConsumerPosition(pcmControlWords(descriptor))).toBe(1920n); + await driver.close(); + }); + + it("asks the kernel to account for underrun time but reports only queued data", async () => { + const descriptor = createPcmTransport(); + writeProducer(descriptor, 100n * 4n); + const clock = new FakeClock(); + const requests: number[] = []; + const consumed: number[] = []; + const driver = new NodePcmDriver({ + clock, + clockUpdate: kernelClockUpdate(descriptor, requests), + onConsume: (event) => consumed.push(event.frames), + }); + + await driver.prepare(descriptor); + clock.runNext(); + clock.runNext(10); + expect(requests).toEqual([480]); + expect(consumed).toEqual([100]); + expect(readConsumerPosition(pcmControlWords(descriptor))).toBe(400n); + await driver.close(); + }); + + it("preserves fractional frames so repeated short ticks do not drift", async () => { + const descriptor = createPcmTransport({ sampleRate: 44_100 }); + writeProducer(descriptor, 10_000n * 4n); + const clock = new FakeClock(); + const requests: number[] = []; + const driver = new NodePcmDriver({ + clock, + clockUpdate: kernelClockUpdate(descriptor, requests), + }); + + await driver.prepare(descriptor); + clock.runNext(); + for (let i = 0; i < 10; i++) clock.runNext(1); + expect(requests.reduce((sum, value) => sum + value, 0)).toBe(441); + await driver.close(); + }); + + it("does not report bytes from a tick whose generation changed in flight", async () => { + const descriptor = createPcmTransport(); + writeProducer(descriptor, 480n * 4n); + const words = pcmControlWords(descriptor); + const clock = new FakeClock(); + const consumed: number[] = []; + const driver = new NodePcmDriver({ + clock, + clockUpdate: (requestedFrames) => { + writeConsumerPosition(words, BigInt(requestedFrames * 4)); + storeU32(words, PCM_CONTROL.generation, 2); + return requestedFrames; + }, + onConsume: (event) => consumed.push(event.frames), + }); + + await driver.prepare(descriptor); + clock.runNext(); + clock.runNext(10); + expect(consumed).toEqual([]); + expect(driver.getState()).toBe("running"); + await driver.close(); + }); + + it("paces a draining tail without turning the expected silence into an error", async () => { + const descriptor = createPcmTransport({ state: PcmStreamState.Draining }); + writeProducer(descriptor, 100n * 4n); + const clock = new FakeClock(); + const requests: number[] = []; + const consumed: number[] = []; + const words = pcmControlWords(descriptor); + const driver = new NodePcmDriver({ + clock, + clockUpdate: (requestedFrames) => { + requests.push(requestedFrames); + writeConsumerPosition(words, 100n * 4n); + storeU32(words, PCM_CONTROL.state, PcmStreamState.Stopped); + storeU32(words, PCM_CONTROL.generation, 2); + return 100; + }, + onConsume: (event) => consumed.push(event.frames), + }); + + await driver.prepare(descriptor); + clock.runNext(); + clock.runNext(10); + expect(requests).toEqual([480]); + expect(consumed).toEqual([100]); + expect(driver.getState()).toBe("running"); + expect( + Atomics.load(words, PCM_CONTROL.flags) & PcmTransportFlag.FatalError, + ).toBe(0); + await driver.close(); + }); + + it("wakes orphan-drain reconciliation when the Node sink fails with a queued tail", async () => { + const descriptor = createPcmTransport({ state: PcmStreamState.Draining }); + writeProducer(descriptor, 960n * 4n); + const words = pcmControlWords(descriptor); + const clock = new FakeClock(); + let queuedAtFatal = 0n; + const onFatal = vi.fn(() => { + queuedAtFatal = + readProducerPosition(words) - readEffectiveConsumerPosition(words); + }); + const driver = new NodePcmDriver({ + clock, + clockUpdate: kernelClockUpdate(descriptor, []), + onFatal, + onConsume: () => { + throw new Error("sink failed"); + }, + }); + + await driver.prepare(descriptor); + clock.runNext(); + clock.runNext(10); + expect(driver.getState()).toBe("error"); + expect(clock.pending).toBe(0); + expect( + Atomics.load(words, PCM_CONTROL.flags) & PcmTransportFlag.FatalError, + ).toBe(PcmTransportFlag.FatalError); + expect(Atomics.load(words, PCM_CONTROL.wakeSeq)).toBe(1); + expect(onFatal).toHaveBeenCalledOnce(); + expect(queuedAtFatal).toBe(480n * 4n); + await driver.suspend(); + expect(driver.getState()).toBe("error"); + await expect(driver.resume()).rejects.toThrow("PCM output has failed"); + expect(onFatal).toHaveBeenCalledOnce(); + expect(clock.pending).toBe(0); + await driver.close(); + }); + + it("suspends and tears down its worker-owned timer deterministically", async () => { + const descriptor = createPcmTransport(); + const clock = new FakeClock(); + const driver = new NodePcmDriver({ + clock, + clockUpdate: () => 0, + }); + + await driver.prepare(descriptor); + expect(clock.pending).toBe(1); + await driver.suspend(); + expect(driver.getState()).toBe("suspended"); + expect(clock.pending).toBe(0); + await driver.resume(); + expect(clock.pending).toBe(1); + await driver.close(); + expect(driver.getState()).toBe("closed"); + expect(clock.pending).toBe(0); + }); + + it("wakes a pending machine-teardown drain as soon as the Node clock consumes its tail", async () => { + const descriptor = createPcmTransport({ state: PcmStreamState.Draining }); + const words = pcmControlWords(descriptor); + writeProducer(descriptor, 4n); + + // Model the Wasm clock export precisely: it advances the shared cursor and + // wake sequence, but only the host can notify a JS Atomics.waitAsync waiter. + const worker = Object.assign( + Object.create(CentralizedKernelWorker.prototype), + { + pcmTransportDescriptor: descriptor, + kernel: { + pcmClockUpdate: (requestedFrames: number) => { + const frames = Math.min(requestedFrames, 1); + writeConsumerPosition(words, BigInt(frames * 4)); + Atomics.add(words, PCM_CONTROL.wakeSeq, 1); + return frames; + }, + pcmReconcile: () => 0, + }, + drainAndProcessWakeupEvents: vi.fn(), + scheduleWakeBlockedRetries: vi.fn(), + }, + ) as CentralizedKernelWorker; + + const drain = worker.waitForPcmDrain(2_000); + worker.pcmClockUpdate(1); + + await expect( + Promise.race([ + drain.then((drained) => ({ kind: "drain", drained })), + new Promise<{ kind: "timeout" }>((resolve) => + setTimeout(() => resolve({ kind: "timeout" }), 250), + ), + ]), + ).resolves.toEqual({ kind: "drain", drained: true }); + expect(readConsumerPosition(words)).toBe(4n); + }); +}); diff --git a/host/test/pcm-audio-worklet.test.ts b/host/test/pcm-audio-worklet.test.ts new file mode 100644 index 0000000000..6c3be1f975 --- /dev/null +++ b/host/test/pcm-audio-worklet.test.ts @@ -0,0 +1,356 @@ +import { describe, expect, it } from "vitest"; +import { + PCM_CONTROL, + PcmSampleFormat, + PcmStreamState, + PcmTransportFlag, + pcmControlWords, + readConsumerPosition, + readEffectiveConsumerPosition, + storeU32, +} from "../src/audio/pcm-transport"; +// The production worklet is deliberately a self-contained JavaScript asset. +// @ts-expect-error JavaScript worklet asset has no declaration file. +import { KandeloPcmProcessor } from "../src/audio/pcm-audio-worklet.js"; +import { + createPcmTransport, + writeConsumer, + writeDiscard, + writeProducer, + writeRing, +} from "./pcm-test-helpers"; + +function processorOptions(descriptor: ReturnType) { + return { + ...descriptor, + layout: PCM_CONTROL, + formats: { + u8: PcmSampleFormat.U8, + s16le: PcmSampleFormat.S16Le, + s16be: PcmSampleFormat.S16Be, + }, + states: { + running: PcmStreamState.Running, + draining: PcmStreamState.Draining, + }, + flags: { + configuring: PcmTransportFlag.Configuring, + underrunActive: PcmTransportFlag.UnderrunActive, + fatalError: PcmTransportFlag.FatalError, + }, + outputSampleRate: 48_000, + }; +} + +function render( + processor: InstanceType, + frames: number, + channels = 2, +): Float32Array[] { + const output = Array.from( + { length: channels }, + () => new Float32Array(frames), + ); + expect(processor.process([], [output])).toBe(true); + return output; +} + +describe("Kandelo PCM AudioWorklet", () => { + it("renders idle silence without recording false underruns", () => { + const descriptor = createPcmTransport({ state: PcmStreamState.Stopped }); + const processor = new KandeloPcmProcessor({ + processorOptions: processorOptions(descriptor), + }); + expect([...render(processor, 128)[0]]).toEqual(new Array(128).fill(0)); + expect( + Atomics.load(pcmControlWords(descriptor), PCM_CONTROL.underruns), + ).toBe(0); + }); + + it("converts unsigned 8-bit mono and advances the byte cursor", () => { + const descriptor = createPcmTransport({ + activeCapacityBytes: 16, + format: PcmSampleFormat.U8, + channels: 1, + frameBytes: 1, + }); + writeRing(descriptor, 0n, Uint8Array.from([0, 64, 128, 255]), 16); + writeProducer(descriptor, 4n); + const processor = new KandeloPcmProcessor({ + processorOptions: processorOptions(descriptor), + }); + + const [left, right] = render(processor, 4); + expect([...left]).toEqual([-1, -0.5, 0, 127 / 128]); + expect([...right]).toEqual([...left]); + expect(readConsumerPosition(pcmControlWords(descriptor))).toBe(4n); + }); + + it("decodes S16LE stereo across ring wrap", () => { + const descriptor = createPcmTransport({ activeCapacityBytes: 16 }); + writeConsumer(descriptor, 12n); + writeRing( + descriptor, + 12n, + Uint8Array.from([0x00, 0x40, 0x00, 0xc0, 0xff, 0x7f, 0x00, 0x80]), + 16, + ); + writeProducer(descriptor, 20n); + const processor = new KandeloPcmProcessor({ + processorOptions: processorOptions(descriptor), + }); + + const [left, right] = render(processor, 2); + expect([...left]).toEqual([0.5, 32767 / 32768]); + expect([...right]).toEqual([-0.5, -1]); + expect(readConsumerPosition(pcmControlWords(descriptor))).toBe(20n); + }); + + it("decodes S16BE mono across ring wrap and duplicates it to stereo", () => { + const descriptor = createPcmTransport({ + activeCapacityBytes: 8, + format: PcmSampleFormat.S16Be, + channels: 1, + frameBytes: 2, + }); + writeConsumer(descriptor, 6n); + writeRing(descriptor, 6n, Uint8Array.from([0x40, 0x00, 0xc0, 0x00]), 8); + writeProducer(descriptor, 10n); + const processor = new KandeloPcmProcessor({ + processorOptions: processorOptions(descriptor), + }); + + const [left, right] = render(processor, 2); + expect([...left]).toEqual([0.5, -0.5]); + expect([...right]).toEqual([...left]); + expect(readConsumerPosition(pcmControlWords(descriptor))).toBe(10n); + }); + + it("honors reset discard positions without replaying stale bytes", () => { + const descriptor = createPcmTransport({ + activeCapacityBytes: 8, + format: PcmSampleFormat.U8, + channels: 1, + frameBytes: 1, + }); + writeRing(descriptor, 4n, Uint8Array.from([128, 255]), 8); + writeConsumer(descriptor, 0n); + writeDiscard(descriptor, 4n); + writeProducer(descriptor, 6n); + const processor = new KandeloPcmProcessor({ + processorOptions: processorOptions(descriptor), + }); + + const [left] = render(processor, 1); + expect(left[0]).toBe(0); + expect(readConsumerPosition(pcmControlWords(descriptor))).toBe(5n); + }); + + it("emits silence on underrun and counts one transition, not one quantum", () => { + const descriptor = createPcmTransport({ + activeCapacityBytes: 8, + format: PcmSampleFormat.U8, + channels: 1, + frameBytes: 1, + }); + const processor = new KandeloPcmProcessor({ + processorOptions: processorOptions(descriptor), + }); + const words = pcmControlWords(descriptor); + + expect([...render(processor, 4)[0]]).toEqual([0, 0, 0, 0]); + render(processor, 4); + expect(Atomics.load(words, PCM_CONTROL.underruns)).toBe(1); + + writeRing(descriptor, 0n, Uint8Array.from([128]), 8); + writeProducer(descriptor, 1n); + render(processor, 2); + expect(Atomics.load(words, PCM_CONTROL.underruns)).toBe(2); + }); + + it("drains a short queued tail into silence without counting an underrun", () => { + const descriptor = createPcmTransport({ + activeCapacityBytes: 8, + format: PcmSampleFormat.U8, + channels: 1, + frameBytes: 1, + state: PcmStreamState.Draining, + }); + writeRing(descriptor, 0n, Uint8Array.from([128, 255]), 8); + writeProducer(descriptor, 2n); + const processor = new KandeloPcmProcessor({ + processorOptions: processorOptions(descriptor), + }); + const words = pcmControlWords(descriptor); + + const [left] = render(processor, 8); + expect([...left.slice(0, 2)]).toEqual([0, 127 / 128]); + expect([...left.slice(2)]).toEqual(new Array(6).fill(0)); + expect(readConsumerPosition(words)).toBe(2n); + expect(Atomics.load(words, PCM_CONTROL.underruns)).toBe(0); + expect( + Atomics.load(words, PCM_CONTROL.flags) & PcmTransportFlag.UnderrunActive, + ).toBe(0); + }); + + it("resamples from the guest rate to the audio clock", () => { + const descriptor = createPcmTransport({ + activeCapacityBytes: 8, + format: PcmSampleFormat.U8, + sampleRate: 24_000, + channels: 1, + frameBytes: 1, + }); + writeRing(descriptor, 0n, Uint8Array.from([128, 255]), 8); + writeProducer(descriptor, 2n); + const processor = new KandeloPcmProcessor({ + processorOptions: processorOptions(descriptor), + }); + + const [left] = render(processor, 4); + expect(left[0]).toBe(0); + expect(left[1]).toBeCloseTo(127 / 256); + expect(left[2]).toBeCloseTo(127 / 128); + expect(readConsumerPosition(pcmControlWords(descriptor))).toBe(2n); + }); + + it("drops an in-flight quantum when reset and reopen changes generation", () => { + const descriptor = createPcmTransport({ + activeCapacityBytes: 8, + format: PcmSampleFormat.U8, + channels: 1, + frameBytes: 1, + generation: 7, + }); + writeConsumer(descriptor, 8n); + writeRing(descriptor, 8n, Uint8Array.from([0, 255]), 8); + writeProducer(descriptor, 10n); + const processor = new KandeloPcmProcessor({ + processorOptions: processorOptions(descriptor), + }); + const words = pcmControlWords(descriptor); + + // Inject RESET + final close + reopen after the old quantum has begun + // reading. The new generation keeps absolute cursors monotonic, advances + // discard to the old producer, then queues one new byte at that base. + const originalReadSample = processor.readSample.bind(processor); + let reopened = false; + processor.readSample = (absoluteByte: bigint, format: number) => { + const sample = originalReadSample(absoluteByte, format); + if (!reopened) { + reopened = true; + storeU32(words, PCM_CONTROL.state, PcmStreamState.Closed); + writeDiscard(descriptor, 10n); + storeU32(words, PCM_CONTROL.generation, 8); + writeRing(descriptor, 10n, Uint8Array.from([255]), 8); + writeProducer(descriptor, 11n); + storeU32(words, PCM_CONTROL.state, PcmStreamState.Running); + } + return sample; + }; + + const [staleLeft, staleRight] = render(processor, 4); + expect([...staleLeft]).toEqual([0, 0, 0, 0]); + expect([...staleRight]).toEqual([0, 0, 0, 0]); + expect(readConsumerPosition(words)).toBe(8n); + expect(readEffectiveConsumerPosition(words)).toBe(10n); + expect(Atomics.load(words, PCM_CONTROL.underruns)).toBe(0); + + const [freshLeft] = render(processor, 1); + expect(freshLeft[0]).toBe(127 / 128); + expect(readConsumerPosition(words)).toBe(11n); + }); + + it("rejects a quantum that overlaps a multi-field configuration update", () => { + const descriptor = createPcmTransport({ + activeCapacityBytes: 8, + format: PcmSampleFormat.U8, + channels: 1, + frameBytes: 1, + generation: 11, + }); + writeRing(descriptor, 0n, Uint8Array.from([255, 255]), 8); + writeProducer(descriptor, 2n); + const processor = new KandeloPcmProcessor({ + processorOptions: processorOptions(descriptor), + }); + const words = pcmControlWords(descriptor); + + const originalReadSample = processor.readSample.bind(processor); + let beganUpdate = false; + processor.readSample = (absoluteByte: bigint, format: number) => { + const sample = originalReadSample(absoluteByte, format); + if (!beganUpdate) { + beganUpdate = true; + Atomics.or(words, PCM_CONTROL.flags, PcmTransportFlag.Configuring); + // A torn snapshot is temporarily invalid. It must be rejected as a + // generation race, not latched as a physical-sink failure. + storeU32(words, PCM_CONTROL.channels, 3); + } + return sample; + }; + + expect([...render(processor, 2)[0]]).toEqual([0, 0]); + expect(readConsumerPosition(words)).toBe(0n); + expect( + Atomics.load(words, PCM_CONTROL.flags) & PcmTransportFlag.FatalError, + ).toBe(0); + + storeU32(words, PCM_CONTROL.channels, 1); + storeU32(words, PCM_CONTROL.generation, 12); + Atomics.and(words, PCM_CONTROL.flags, ~PcmTransportFlag.Configuring); + processor.readSample = originalReadSample; + expect(render(processor, 1)[0][0]).toBe(127 / 128); + }); + + it("latches and wakes a fatal transport error", () => { + const descriptor = createPcmTransport({ channels: 3 }); + const processor = new KandeloPcmProcessor({ + processorOptions: processorOptions(descriptor), + }); + const words = pcmControlWords(descriptor); + + render(processor, 1); + expect( + Atomics.load(words, PCM_CONTROL.flags) & PcmTransportFlag.FatalError, + ).toBe(PcmTransportFlag.FatalError); + expect(Atomics.load(words, PCM_CONTROL.wakeSeq)).toBe(1); + }); + + it("wakes both the kernel observer and teardown drain on final progress", async () => { + const descriptor = createPcmTransport({ + activeCapacityBytes: 8, + format: PcmSampleFormat.U8, + channels: 1, + frameBytes: 1, + state: PcmStreamState.Draining, + }); + writeRing(descriptor, 0n, Uint8Array.from([128]), 8); + writeProducer(descriptor, 1n); + const processor = new KandeloPcmProcessor({ + processorOptions: processorOptions(descriptor), + }); + const words = pcmControlWords(descriptor); + const observed = Atomics.load(words, PCM_CONTROL.wakeSeq); + const waiters = [ + Atomics.waitAsync(words, PCM_CONTROL.wakeSeq, observed), + Atomics.waitAsync(words, PCM_CONTROL.wakeSeq, observed), + ]; + expect(waiters.every((waiter) => waiter.async)).toBe(true); + + render(processor, 1); + let timer: ReturnType | undefined; + const wokeAll = await Promise.race([ + Promise.all(waiters.map((waiter) => Promise.resolve(waiter.value))).then( + () => true, + ), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), 100); + }), + ]); + if (timer !== undefined) clearTimeout(timer); + Atomics.notify(words, PCM_CONTROL.wakeSeq); + + expect(wokeAll).toBe(true); + }); +}); diff --git a/host/test/pcm-test-helpers.ts b/host/test/pcm-test-helpers.ts new file mode 100644 index 0000000000..fa862d2a70 --- /dev/null +++ b/host/test/pcm-test-helpers.ts @@ -0,0 +1,114 @@ +import { + PCM_CONTROL, + PCM_CONTROL_BYTES, + PCM_CONTROL_MAGIC, + PCM_CONTROL_VERSION, + PCM_PHYSICAL_CAPACITY_BYTES, + PcmSampleFormat, + PcmStreamState, + pcmControlWords, + pcmDataBytes, + storeU32, + writeConsumerPosition, + writeSeqlockedU64, + type PcmTransportDescriptor, +} from "../src/audio/pcm-transport"; + +export function createPcmTransport( + options: { + activeCapacityBytes?: number; + format?: PcmSampleFormat; + sampleRate?: number; + channels?: number; + frameBytes?: number; + fragmentBytes?: number; + fragments?: number; + state?: PcmStreamState; + generation?: number; + flags?: number; + } = {}, +): PcmTransportDescriptor { + const buffer = new SharedArrayBuffer( + PCM_CONTROL_BYTES + PCM_PHYSICAL_CAPACITY_BYTES, + ); + const descriptor: PcmTransportDescriptor = { + buffer, + controlOffset: 0, + controlBytes: PCM_CONTROL_BYTES, + dataOffset: PCM_CONTROL_BYTES, + dataBytes: PCM_PHYSICAL_CAPACITY_BYTES, + }; + const words = pcmControlWords(descriptor); + storeU32(words, PCM_CONTROL.magic, PCM_CONTROL_MAGIC); + storeU32(words, PCM_CONTROL.version, PCM_CONTROL_VERSION); + storeU32(words, PCM_CONTROL.headerBytes, PCM_CONTROL_BYTES); + storeU32( + words, + PCM_CONTROL.physicalCapacityBytes, + PCM_PHYSICAL_CAPACITY_BYTES, + ); + storeU32( + words, + PCM_CONTROL.activeCapacityBytes, + options.activeCapacityBytes ?? 4096, + ); + storeU32(words, PCM_CONTROL.format, options.format ?? PcmSampleFormat.S16Le); + storeU32(words, PCM_CONTROL.sampleRate, options.sampleRate ?? 48_000); + storeU32(words, PCM_CONTROL.channels, options.channels ?? 2); + storeU32(words, PCM_CONTROL.frameBytes, options.frameBytes ?? 4); + storeU32(words, PCM_CONTROL.fragmentBytes, options.fragmentBytes ?? 512); + storeU32(words, PCM_CONTROL.fragments, options.fragments ?? 8); + storeU32(words, PCM_CONTROL.state, options.state ?? PcmStreamState.Running); + storeU32(words, PCM_CONTROL.generation, options.generation ?? 1); + storeU32(words, PCM_CONTROL.flags, options.flags ?? 0); + return descriptor; +} + +export function writeProducer( + descriptor: PcmTransportDescriptor, + value: bigint, +): void { + const words = pcmControlWords(descriptor); + writeSeqlockedU64( + words, + PCM_CONTROL.producerSeq, + PCM_CONTROL.producerLo, + PCM_CONTROL.producerHi, + value, + ); +} + +export function writeConsumer( + descriptor: PcmTransportDescriptor, + value: bigint, +): void { + writeConsumerPosition(pcmControlWords(descriptor), value); +} + +export function writeDiscard( + descriptor: PcmTransportDescriptor, + value: bigint, +): void { + const words = pcmControlWords(descriptor); + writeSeqlockedU64( + words, + PCM_CONTROL.discardSeq, + PCM_CONTROL.discardLo, + PCM_CONTROL.discardHi, + value, + ); +} + +export function writeRing( + descriptor: PcmTransportDescriptor, + absoluteOffset: bigint, + bytes: Uint8Array, + activeCapacityBytes: number, +): void { + const ring = pcmDataBytes(descriptor); + let at = Number(absoluteOffset % BigInt(activeCapacityBytes)); + for (const byte of bytes) { + ring[at] = byte; + at = (at + 1) % activeCapacityBytes; + } +} diff --git a/host/test/pcm-transport.test.ts b/host/test/pcm-transport.test.ts new file mode 100644 index 0000000000..52b16cdc3d --- /dev/null +++ b/host/test/pcm-transport.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { PCM_SHARED_CONTROL_FIELDS } from "../src/generated/abi"; +import { + PCM_CONTROL, + readRingBytes, + readSeqlockedU64, + signalPcmConsumerProgress, + writeSeqlockedU64, +} from "../src/audio/pcm-transport"; + +describe("PCM shared transport", () => { + it("derives every AudioWorklet word index from generated ABI offsets", () => { + for (const key of Object.keys(PCM_CONTROL) as Array) { + expect(PCM_CONTROL[key]).toBe(PCM_SHARED_CONTROL_FIELDS[key].offset / 4); + expect(PCM_SHARED_CONTROL_FIELDS[key].size).toBe(4); + } + }); + + it("reads and writes 64-bit cursors through 32-bit seqlocks", () => { + const words = new Int32Array(new SharedArrayBuffer(16)); + const value = 0x1234_5678_abcd_ef01n; + writeSeqlockedU64(words, 0, 1, 2, value); + expect(readSeqlockedU64(words, 0, 1, 2)).toBe(value); + expect(Atomics.load(words, 0) & 1).toBe(0); + }); + + it("copies bytes across a bounded ring wrap", () => { + const ring = Uint8Array.from([0, 1, 2, 3, 4, 5, 6, 7]); + expect([...readRingBytes(ring, 6n, 5)]).toEqual([6, 7, 0, 1, 2]); + }); + + it("wakes every waiter on a one-shot consumer transition", async () => { + const words = new Int32Array(new SharedArrayBuffer(256)); + const observed = Atomics.load(words, PCM_CONTROL.wakeSeq); + const waiters = [ + Atomics.waitAsync(words, PCM_CONTROL.wakeSeq, observed), + Atomics.waitAsync(words, PCM_CONTROL.wakeSeq, observed), + ]; + expect(waiters.every((waiter) => waiter.async)).toBe(true); + + signalPcmConsumerProgress(words); + let timer: ReturnType | undefined; + const wokeAll = await Promise.race([ + Promise.all(waiters.map((waiter) => Promise.resolve(waiter.value))).then( + () => true, + ), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), 100); + }), + ]); + if (timer !== undefined) clearTimeout(timer); + // Clean up the remaining waiter if this assertion ever regresses. + Atomics.notify(words, PCM_CONTROL.wakeSeq); + + expect(wokeAll).toBe(true); + }); +}); diff --git a/host/test/pcm-wake-observer.test.ts b/host/test/pcm-wake-observer.test.ts new file mode 100644 index 0000000000..e1807a8686 --- /dev/null +++ b/host/test/pcm-wake-observer.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from "vitest"; +import { + PCM_CONTROL, + PcmStreamState, + pcmControlWords, + signalPcmConsumerProgress, + writeConsumerPosition, + type PcmTransportDescriptor, +} from "../src/audio/pcm-transport"; +import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { createPcmTransport, writeProducer } from "./pcm-test-helpers"; + +type WakeObserverAccess = { + startPcmWakeObserver(descriptor: PcmTransportDescriptor): void; +}; + +function testWorker( + descriptor: PcmTransportDescriptor, + pcmReconcile: () => number, +): CentralizedKernelWorker { + return Object.assign(Object.create(CentralizedKernelWorker.prototype), { + pcmTransportDescriptor: descriptor, + pcmWakeObserverGeneration: 0, + kernel: { pcmReconcile }, + drainAndProcessWakeupEvents: vi.fn(), + scheduleWakeBlockedRetries: vi.fn(), + }) as CentralizedKernelWorker; +} + +describe("PCM wake observation", () => { + it("reconciles final-quantum progress without requiring a later wake", async () => { + const descriptor = createPcmTransport({ state: PcmStreamState.Draining }); + const words = pcmControlWords(descriptor); + writeProducer(descriptor, 4n); + const reconcile = vi.fn(() => { + if (reconcile.mock.calls.length === 1) { + // Model the worklet publishing the final cursor while the preceding + // reconciliation is in flight. Its notification may run before the + // observer arms again, so the remembered sequence must cause a retry. + writeConsumerPosition(words, 4n); + signalPcmConsumerProgress(words); + } + return 0; + }); + const worker = testWorker(descriptor, reconcile); + + const observer = worker as unknown as WakeObserverAccess; + observer.startPcmWakeObserver(descriptor); + + // The first pass observes the progress race; the sequence recheck drives + // the second reconciliation synchronously without another notification. + expect(reconcile).toHaveBeenCalledTimes(2); + expect(Atomics.load(words, PCM_CONTROL.wakeSeq)).toBe(1); + + worker.shutdownPcmTransport(); + await Promise.resolve(); + }); + + it("does not sleep when progress lands between the drain check and wait", async () => { + const descriptor = createPcmTransport({ state: PcmStreamState.Draining }); + const words = pcmControlWords(descriptor); + writeProducer(descriptor, 4n); + const worker = testWorker(descriptor, () => 0); + const originalLoad = Atomics.load.bind(Atomics); + let wakeSequenceLoads = 0; + const loadSpy = vi.spyOn(Atomics, "load").mockImplementation( + ((array: Int32Array, index: number) => { + if ( + array.buffer === words.buffer && + array.byteOffset === words.byteOffset && + index === PCM_CONTROL.wakeSeq + ) { + wakeSequenceLoads++; + if (wakeSequenceLoads === 2) { + // The first load remembers the prior sequence. Inject the final + // cursor at the explicit pre-wait recheck to exercise the window + // that used to lose this one-shot notification. + writeConsumerPosition(words, 4n); + signalPcmConsumerProgress(words); + } + } + return originalLoad(array, index); + }) as typeof Atomics.load, + ); + + let settledWithoutAnotherWake: boolean | undefined; + try { + let settled: boolean | undefined; + const drain = worker.waitForPcmDrain(1_000).then((value) => { + settled = value; + return value; + }); + await Promise.resolve(); + settledWithoutAnotherWake = settled; + + // Keep a failing implementation from leaving a live waiter behind. + if (settled === undefined) { + writeConsumerPosition(words, 4n); + signalPcmConsumerProgress(words); + await drain; + } + } finally { + loadSpy.mockRestore(); + } + + expect(wakeSequenceLoads).toBeGreaterThanOrEqual(2); + expect(settledWithoutAnotherWake).toBe(true); + }); +}); diff --git a/host/test/sdl-dsp-fixtures.ts b/host/test/sdl-dsp-fixtures.ts new file mode 100644 index 0000000000..be13d22475 --- /dev/null +++ b/host/test/sdl-dsp-fixtures.ts @@ -0,0 +1,83 @@ +import { execFileSync } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const testDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(testDir, "../.."); +const resolveBinaryScript = join(repoRoot, "scripts/resolve-binary.sh"); + +const SDL_DSP_FIXTURES = [ + "programs/sdl-dsp-test/sdl2-dsp-test.wasm", + "programs/sdl-dsp-test/sdl3-dsp-test.wasm", + "programs/playwave.wasm", +] as const; + +const SDL_DSP_PACKAGES = [ + "sdl-dsp-test", + "sdl2-mixer-playwave", +] as const; + +function binaryResolves(relativePath: string): boolean { + try { + execFileSync("bash", [resolveBinaryScript, relativePath], { + cwd: repoRoot, + stdio: "ignore", + }); + return true; + } catch { + return false; + } +} + +/** + * Materialize the upstream-SDL integration fixtures through the normal + * package resolver. This helper is imported only by audio-integration.test.ts + * so unrelated focused Vitest runs do not build SDL. + */ +export function ensureSdlDspFixtures(): void { + const rustcVersion = execFileSync("rustc", ["-vV"], { + cwd: repoRoot, + encoding: "utf8", + }); + const hostTarget = /^host:\s*(\S+)$/m.exec(rustcVersion)?.[1]; + if (!hostTarget) { + throw new Error( + "[audio-integration] rustc -vV did not report a host target", + ); + } + + // Always enter through the resolver. It owns dependency and source/cache + // invalidation; merely accepting an old output symlink could run stale SDL + // fixtures after package metadata, patches, or the cache key changes. + console.log("[audio-integration] Resolving SDL2/SDL3 /dev/dsp fixtures..."); + for (const packageName of SDL_DSP_PACKAGES) { + execFileSync( + "cargo", + [ + "run", + "-p", + "xtask", + "--target", + hostTarget, + "--quiet", + "--", + "build-deps", + "resolve", + packageName, + "--arch", + "wasm32", + "--binaries-dir", + join(repoRoot, "binaries"), + ], + { cwd: repoRoot, stdio: "inherit" }, + ); + } + + for (const fixture of SDL_DSP_FIXTURES) { + if (!binaryResolves(fixture)) { + throw new Error( + `[audio-integration] package resolver did not materialize ${fixture}`, + ); + } + } +} diff --git a/host/test/signal-accept-livelock.test.ts b/host/test/signal-accept-livelock.test.ts index 7ab7822647..fce1b3cbc2 100644 --- a/host/test/signal-accept-livelock.test.ts +++ b/host/test/signal-accept-livelock.test.ts @@ -54,6 +54,8 @@ function createWorkerHarness(): any { signalWaitDeadlines: new Map(), pendingPollRetries: new Map(), pendingSelectRetries: new Map(), + pendingPipeReaders: new Map(), + pendingPipeWriters: new Map(), }); return worker; } diff --git a/host/tsup.config.ts b/host/tsup.config.ts index 6c3fc25250..95c884ee80 100644 --- a/host/tsup.config.ts +++ b/host/tsup.config.ts @@ -1,4 +1,9 @@ import { defineConfig } from "tsup"; +import { copyFileSync, mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); export default defineConfig({ entry: [ @@ -19,4 +24,12 @@ export default defineConfig({ clean: true, target: "es2022", splitting: false, + onSuccess: async () => { + const outputDir = resolve(here, "dist/audio"); + mkdirSync(outputDir, { recursive: true }); + copyFileSync( + resolve(here, "src/audio/pcm-audio-worklet.js"), + resolve(outputDir, "pcm-audio-worklet.js"), + ); + }, }); diff --git a/libc/glue/abi_constants.h b/libc/glue/abi_constants.h index b2d5bd8826..7b0b4c2c94 100644 --- a/libc/glue/abi_constants.h +++ b/libc/glue/abi_constants.h @@ -4,7 +4,7 @@ #define WASM_POSIX_ABI_CONSTANTS_H /* Mirrors wasm_posix_shared::ABI_VERSION. */ -#define WASM_POSIX_ABI_VERSION 39u +#define WASM_POSIX_ABI_VERSION 40u /* Default process-wasm pthread slot declaration. */ #define WASM_POSIX_THREAD_SLOT_DECL_DEFAULT -1 diff --git a/libc/glue/channel_syscall.c b/libc/glue/channel_syscall.c index b0bff7f8af..5b66e8d397 100644 --- a/libc/glue/channel_syscall.c +++ b/libc/glue/channel_syscall.c @@ -106,12 +106,16 @@ int *__errno_location(void); #define EFAULT 14 #define EINTR 4 #define EINVAL 22 +#define SYS_WRITE 4 #define SYS_SIGACTION 36 +#define SYS_IOCTL 72 +#define SYS_WRITEV 81 #define SYS_WAIT4 139 #define SYS_WAITID 288 #define SYS_SIGPROCMASK 37 #define SYS_RT_SIGRETURN 208 #define SIG_SETMASK 2 +#define SNDCTL_DSP_SYNC 0x00005001u /* The kernel ABI deliberately keeps sigaction's transport record fixed at * 16 bytes: u32 table index, u32 flags, u64 mask. musl's internal @@ -522,14 +526,17 @@ static long __do_syscall_impl(long n, long long a1, long long a2, long long a3, * semantics (raise() doesn't return until signal handler completes). */ delivered_flags = __deliver_pending_signal(get_channel_base()); - /* wait4()/waitid() are host-deferred, so a caught signal completes the - * channel with EINTR in order to run its handler on this guest thread. - * SA_RESTART makes that interruption transparent: after the handler and - * mask restoration finish, submit the same wait operation again. Keep the - * retry list deliberately narrow; several other EINTR-returning calls have - * timeout/cancellation rules that forbid this generic treatment. */ + /* Host-deferred waits and slow PCM output complete the channel with EINTR + * so the caught handler can run on this guest thread. SA_RESTART makes the + * interruption transparent for wait4()/waitid(), write()/writev(), and the + * one blocking OSS ioctl (SNDCTL_DSP_SYNC). Keep the retry list deliberately + * narrow: timed waits have different restart rules, and an interrupted + * final /dev/dsp close deliberately leaves its fd valid for an explicit + * caller retry. */ if (err == EINTR && (delivered_flags & SA_RESTART) != 0 && - (n == SYS_WAIT4 || n == SYS_WAITID)) { + (n == SYS_WAIT4 || n == SYS_WAITID || + n == SYS_WRITE || n == SYS_WRITEV || + (n == SYS_IOCTL && (uint32_t)a2 == SNDCTL_DSP_SYNC))) { /* __syscall_cp's outer cancellation check has not run yet. A signal * handler may have enabled a cancellation that was already pending, * or the host may have used this EINTR completion to wake a canceled diff --git a/libc/glue/syscall_glue.c b/libc/glue/syscall_glue.c index fbc5dd7213..5c8ed46a6d 100644 --- a/libc/glue/syscall_glue.c +++ b/libc/glue/syscall_glue.c @@ -320,8 +320,8 @@ #define FCNTL_F_SETLK 13 #define FCNTL_F_SETLKW 14 -/* Buffer size hints for ioctl/termios where kernel needs a length */ -#define IOCTL_BUF_SIZE 256 +/* Buffer size hint for the dedicated termios calls. ioctl sizes are decoded + * from the request below so no-argument commands never form a null slice. */ #define TERMIOS_BUF_SIZE 256 /* mmap2 page unit — musl divides the byte offset by this before syscall */ @@ -337,6 +337,40 @@ static inline unsigned int slen(const char *s) return (unsigned int)__builtin_strlen(s); } +static inline unsigned int ioctl_arg_size(uint32_t request) +{ + unsigned int encoded = (request >> 16) & 0x3fffU; + if (encoded) return encoded; + switch (request) { + case 0x5421: case 0x5452: case 0x541b: case 0x8905: + case 0x540f: case 0x5410: case 0x5429: + case 0x4b44: case 0x4b45: + return 4; + case 0x5401: case 0x5402: case 0x5403: case 0x5404: + return 60; + case 0x5413: case 0x5414: + return 8; + case 0x4b33: + return 1; + case 0x4600: case 0x4601: case 0x4606: + return 160; + case 0x4602: + return 80; + case 0x40: + return 4; + case 0x42: + return 16; + case 0x44: + return 32; + case 0x47: + return 8; + case 0x49: + return 24; + default: + return 0; + } +} + /* ================================================================== */ /* Central dispatch — all __syscallN functions delegate here */ /* */ @@ -848,7 +882,7 @@ static long __do_syscall(long n, long a1, long a2, long a3, case SYS_IOCTL: return (long)kernel_ioctl((int32_t)a1, (uint32_t)a2, (uint8_t *)(uintptr_t)a3, - IOCTL_BUF_SIZE); + ioctl_arg_size((uint32_t)a2)); /* ============================================================== */ /* Environment */ diff --git a/libc/musl-overlay/include/sys/soundcard.h b/libc/musl-overlay/include/sys/soundcard.h new file mode 100644 index 0000000000..efc66e5b94 --- /dev/null +++ b/libc/musl-overlay/include/sys/soundcard.h @@ -0,0 +1,269 @@ +/* GENERATED by `cargo xtask dump-abi`. Do not edit by hand. */ +/* Kandelo-owned wasm32 OSS source ABI; independent of the host UAPI. */ +#ifndef KANDELO_SYS_SOUNDCARD_H +#define KANDELO_SYS_SOUNDCARD_H + +#include +#include + +#define AFMT_QUERY 0x00000000u +#define AFMT_MU_LAW 0x00000001u +#define AFMT_A_LAW 0x00000002u +#define AFMT_IMA_ADPCM 0x00000004u +#define AFMT_U8 0x00000008u +#define AFMT_S16_LE 0x00000010u +#define AFMT_S16_BE 0x00000020u +#define AFMT_S8 0x00000040u +#define AFMT_U16_LE 0x00000080u +#define AFMT_U16_BE 0x00000100u +#define AFMT_MPEG 0x00000200u +#define AFMT_AC3 0x00000400u +#define AFMT_S32_LE 0x00001000u +#define AFMT_S32_BE 0x00002000u +#define AFMT_U32_LE 0x00004000u +#define AFMT_U32_BE 0x00008000u +#define AFMT_S24_LE 0x00010000u +#define AFMT_S24_BE 0x00020000u +#define AFMT_U24_LE 0x00040000u +#define AFMT_U24_BE 0x00080000u +#define AFMT_F32_LE 0x10000000u +#define AFMT_F32_BE 0x20000000u +#define AFMT_S16_NE AFMT_S16_LE +#define AFMT_S16_OE AFMT_S16_BE +#define AFMT_S24_NE AFMT_S24_LE +#define AFMT_S24_OE AFMT_S24_BE +#define AFMT_S32_NE AFMT_S32_LE +#define AFMT_S32_OE AFMT_S32_BE +#define AFMT_U16_NE AFMT_U16_LE +#define AFMT_U16_OE AFMT_U16_BE +#define AFMT_U24_NE AFMT_U24_LE +#define AFMT_U24_OE AFMT_U24_BE +#define AFMT_U32_NE AFMT_U32_LE +#define AFMT_U32_OE AFMT_U32_BE +#define AFMT_F32_NE AFMT_F32_LE +#define AFMT_F32_OE AFMT_F32_BE +#define AFMT_FLOAT AFMT_F32_NE + +typedef struct audio_buf_info { + int32_t fragments; + int32_t fragstotal; + int32_t fragsize; + int32_t bytes; +} audio_buf_info; + +typedef struct count_info { + int32_t bytes; + int32_t blocks; + int32_t ptr; +} count_info; + +/* Declared for source compatibility; Kandelo does not support DSP mmap. */ +typedef struct buffmem_desc { + void *buffer; + int32_t size; +} buffmem_desc; + +#define SNDCTL_DSP_RESET 0x00005000u +#define SNDCTL_DSP_SYNC 0x00005001u +#define SNDCTL_DSP_SPEED 0xc0045002u +#define SNDCTL_DSP_STEREO 0xc0045003u +#define SNDCTL_DSP_GETBLKSIZE 0xc0045004u +#define SNDCTL_DSP_SETBLKSIZE 0x40045004u +#define SNDCTL_DSP_SETFMT 0xc0045005u +#define SNDCTL_DSP_CHANNELS 0xc0045006u +#define SOUND_PCM_WRITE_FILTER 0xc0045007u +#define SNDCTL_DSP_POST 0x00005008u +#define SNDCTL_DSP_SUBDIVIDE 0xc0045009u +#define SNDCTL_DSP_SETFRAGMENT 0xc004500au +#define SNDCTL_DSP_GETFMTS 0x8004500bu +#define SNDCTL_DSP_GETOSPACE 0x8010500cu +#define SNDCTL_DSP_GETISPACE 0x8010500du +#define SNDCTL_DSP_NONBLOCK 0x0000500eu +#define SNDCTL_DSP_GETCAPS 0x8004500fu +#define SNDCTL_DSP_SETTRIGGER 0x40045010u +#define SNDCTL_DSP_GETTRIGGER 0x80045010u +#define SNDCTL_DSP_GETIPTR 0x800c5011u +#define SNDCTL_DSP_GETOPTR 0x800c5012u +#define SNDCTL_DSP_MAPINBUF 0x80085013u +#define SNDCTL_DSP_MAPOUTBUF 0x80085014u +#define SNDCTL_DSP_SETSYNCRO 0x00005015u +#define SNDCTL_DSP_SETDUPLEX 0x00005016u +#define SNDCTL_DSP_GETODELAY 0x80045017u +#define SOUND_PCM_READ_RATE 0x80045002u +#define SOUND_PCM_READ_BITS 0x80045005u +#define SOUND_PCM_READ_CHANNELS 0x80045006u +#define SOUND_PCM_READ_FILTER 0x80045007u +#define SNDCTL_DSP_HALT SNDCTL_DSP_RESET +#define SNDCTL_DSP_SAMPLESIZE SNDCTL_DSP_SETFMT +#define SOUND_PCM_WRITE_RATE SNDCTL_DSP_SPEED +#define SOUND_PCM_WRITE_CHANNELS SNDCTL_DSP_CHANNELS +#define SOUND_PCM_WRITE_BITS SNDCTL_DSP_SETFMT +#define SOUND_PCM_SETFMT SNDCTL_DSP_SETFMT +#define SOUND_PCM_POST SNDCTL_DSP_POST +#define SOUND_PCM_RESET SNDCTL_DSP_RESET +#define SOUND_PCM_SYNC SNDCTL_DSP_SYNC +#define SOUND_PCM_SUBDIVIDE SNDCTL_DSP_SUBDIVIDE +#define SOUND_PCM_SETFRAGMENT SNDCTL_DSP_SETFRAGMENT +#define SOUND_PCM_GETFMTS SNDCTL_DSP_GETFMTS +#define SOUND_PCM_GETOSPACE SNDCTL_DSP_GETOSPACE +#define SOUND_PCM_GETISPACE SNDCTL_DSP_GETISPACE +#define SOUND_PCM_NONBLOCK SNDCTL_DSP_NONBLOCK +#define SOUND_PCM_GETCAPS SNDCTL_DSP_GETCAPS +#define SOUND_PCM_GETTRIGGER SNDCTL_DSP_GETTRIGGER +#define SOUND_PCM_SETTRIGGER SNDCTL_DSP_SETTRIGGER +#define SOUND_PCM_SETSYNCRO SNDCTL_DSP_SETSYNCRO +#define SOUND_PCM_GETIPTR SNDCTL_DSP_GETIPTR +#define SOUND_PCM_GETOPTR SNDCTL_DSP_GETOPTR +#define SOUND_PCM_MAPINBUF SNDCTL_DSP_MAPINBUF +#define SOUND_PCM_MAPOUTBUF SNDCTL_DSP_MAPOUTBUF + +#define PCM_ENABLE_INPUT 0x00000001u +#define PCM_ENABLE_OUTPUT 0x00000002u + +#define PCM_CAP_REVISION 0x000000ffu +#define PCM_CAP_DUPLEX 0x00000100u +#define PCM_CAP_REALTIME 0x00000200u +#define PCM_CAP_BATCH 0x00000400u +#define PCM_CAP_COPROC 0x00000800u +#define PCM_CAP_TRIGGER 0x00001000u +#define PCM_CAP_MMAP 0x00002000u +#define PCM_CAP_MULTI 0x00004000u +#define PCM_CAP_BIND 0x00008000u +#define PCM_CAP_INPUT 0x00010000u +#define PCM_CAP_OUTPUT 0x00020000u +#define PCM_CAP_VIRTUAL 0x00040000u +#define PCM_CAP_DEFAULT 0x40000000u +#define DSP_CAP_REVISION PCM_CAP_REVISION +#define DSP_CAP_DUPLEX PCM_CAP_DUPLEX +#define DSP_CAP_REALTIME PCM_CAP_REALTIME +#define DSP_CAP_BATCH PCM_CAP_BATCH +#define DSP_CAP_COPROC PCM_CAP_COPROC +#define DSP_CAP_TRIGGER PCM_CAP_TRIGGER +#define DSP_CAP_MMAP PCM_CAP_MMAP +#define DSP_CAP_MULTI PCM_CAP_MULTI +#define DSP_CAP_BIND PCM_CAP_BIND +#define DSP_CAP_INPUT PCM_CAP_INPUT +#define DSP_CAP_OUTPUT PCM_CAP_OUTPUT +#define DSP_CAP_VIRTUAL PCM_CAP_VIRTUAL +#define DSP_CAP_DEFAULT PCM_CAP_DEFAULT + +#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L +_Static_assert(AFMT_QUERY == 0x00000000u, "AFMT_QUERY ABI"); +_Static_assert(AFMT_MU_LAW == 0x00000001u, "AFMT_MU_LAW ABI"); +_Static_assert(AFMT_A_LAW == 0x00000002u, "AFMT_A_LAW ABI"); +_Static_assert(AFMT_IMA_ADPCM == 0x00000004u, "AFMT_IMA_ADPCM ABI"); +_Static_assert(AFMT_U8 == 0x00000008u, "AFMT_U8 ABI"); +_Static_assert(AFMT_S16_LE == 0x00000010u, "AFMT_S16_LE ABI"); +_Static_assert(AFMT_S16_BE == 0x00000020u, "AFMT_S16_BE ABI"); +_Static_assert(AFMT_S8 == 0x00000040u, "AFMT_S8 ABI"); +_Static_assert(AFMT_U16_LE == 0x00000080u, "AFMT_U16_LE ABI"); +_Static_assert(AFMT_U16_BE == 0x00000100u, "AFMT_U16_BE ABI"); +_Static_assert(AFMT_MPEG == 0x00000200u, "AFMT_MPEG ABI"); +_Static_assert(AFMT_AC3 == 0x00000400u, "AFMT_AC3 ABI"); +_Static_assert(AFMT_S32_LE == 0x00001000u, "AFMT_S32_LE ABI"); +_Static_assert(AFMT_S32_BE == 0x00002000u, "AFMT_S32_BE ABI"); +_Static_assert(AFMT_U32_LE == 0x00004000u, "AFMT_U32_LE ABI"); +_Static_assert(AFMT_U32_BE == 0x00008000u, "AFMT_U32_BE ABI"); +_Static_assert(AFMT_S24_LE == 0x00010000u, "AFMT_S24_LE ABI"); +_Static_assert(AFMT_S24_BE == 0x00020000u, "AFMT_S24_BE ABI"); +_Static_assert(AFMT_U24_LE == 0x00040000u, "AFMT_U24_LE ABI"); +_Static_assert(AFMT_U24_BE == 0x00080000u, "AFMT_U24_BE ABI"); +_Static_assert(AFMT_F32_LE == 0x10000000u, "AFMT_F32_LE ABI"); +_Static_assert(AFMT_F32_BE == 0x20000000u, "AFMT_F32_BE ABI"); +_Static_assert(SNDCTL_DSP_RESET == 0x00005000u, "SNDCTL_DSP_RESET ABI"); +_Static_assert(SNDCTL_DSP_SYNC == 0x00005001u, "SNDCTL_DSP_SYNC ABI"); +_Static_assert(SNDCTL_DSP_SPEED == 0xc0045002u, "SNDCTL_DSP_SPEED ABI"); +_Static_assert(SNDCTL_DSP_STEREO == 0xc0045003u, "SNDCTL_DSP_STEREO ABI"); +_Static_assert(SNDCTL_DSP_GETBLKSIZE == 0xc0045004u, "SNDCTL_DSP_GETBLKSIZE ABI"); +_Static_assert(SNDCTL_DSP_SETBLKSIZE == 0x40045004u, "SNDCTL_DSP_SETBLKSIZE ABI"); +_Static_assert(SNDCTL_DSP_SETFMT == 0xc0045005u, "SNDCTL_DSP_SETFMT ABI"); +_Static_assert(SNDCTL_DSP_CHANNELS == 0xc0045006u, "SNDCTL_DSP_CHANNELS ABI"); +_Static_assert(SOUND_PCM_WRITE_FILTER == 0xc0045007u, "SOUND_PCM_WRITE_FILTER ABI"); +_Static_assert(SNDCTL_DSP_POST == 0x00005008u, "SNDCTL_DSP_POST ABI"); +_Static_assert(SNDCTL_DSP_SUBDIVIDE == 0xc0045009u, "SNDCTL_DSP_SUBDIVIDE ABI"); +_Static_assert(SNDCTL_DSP_SETFRAGMENT == 0xc004500au, "SNDCTL_DSP_SETFRAGMENT ABI"); +_Static_assert(SNDCTL_DSP_GETFMTS == 0x8004500bu, "SNDCTL_DSP_GETFMTS ABI"); +_Static_assert(SNDCTL_DSP_GETOSPACE == 0x8010500cu, "SNDCTL_DSP_GETOSPACE ABI"); +_Static_assert(SNDCTL_DSP_GETISPACE == 0x8010500du, "SNDCTL_DSP_GETISPACE ABI"); +_Static_assert(SNDCTL_DSP_NONBLOCK == 0x0000500eu, "SNDCTL_DSP_NONBLOCK ABI"); +_Static_assert(SNDCTL_DSP_GETCAPS == 0x8004500fu, "SNDCTL_DSP_GETCAPS ABI"); +_Static_assert(SNDCTL_DSP_SETTRIGGER == 0x40045010u, "SNDCTL_DSP_SETTRIGGER ABI"); +_Static_assert(SNDCTL_DSP_GETTRIGGER == 0x80045010u, "SNDCTL_DSP_GETTRIGGER ABI"); +_Static_assert(SNDCTL_DSP_GETIPTR == 0x800c5011u, "SNDCTL_DSP_GETIPTR ABI"); +_Static_assert(SNDCTL_DSP_GETOPTR == 0x800c5012u, "SNDCTL_DSP_GETOPTR ABI"); +_Static_assert(SNDCTL_DSP_MAPINBUF == 0x80085013u, "SNDCTL_DSP_MAPINBUF ABI"); +_Static_assert(SNDCTL_DSP_MAPOUTBUF == 0x80085014u, "SNDCTL_DSP_MAPOUTBUF ABI"); +_Static_assert(SNDCTL_DSP_SETSYNCRO == 0x00005015u, "SNDCTL_DSP_SETSYNCRO ABI"); +_Static_assert(SNDCTL_DSP_SETDUPLEX == 0x00005016u, "SNDCTL_DSP_SETDUPLEX ABI"); +_Static_assert(SNDCTL_DSP_GETODELAY == 0x80045017u, "SNDCTL_DSP_GETODELAY ABI"); +_Static_assert(SOUND_PCM_READ_RATE == 0x80045002u, "SOUND_PCM_READ_RATE ABI"); +_Static_assert(SOUND_PCM_READ_BITS == 0x80045005u, "SOUND_PCM_READ_BITS ABI"); +_Static_assert(SOUND_PCM_READ_CHANNELS == 0x80045006u, "SOUND_PCM_READ_CHANNELS ABI"); +_Static_assert(SOUND_PCM_READ_FILTER == 0x80045007u, "SOUND_PCM_READ_FILTER ABI"); +_Static_assert(PCM_ENABLE_INPUT == 0x00000001u, "PCM_ENABLE_INPUT ABI"); +_Static_assert(PCM_ENABLE_OUTPUT == 0x00000002u, "PCM_ENABLE_OUTPUT ABI"); +_Static_assert(PCM_CAP_REVISION == 0x000000ffu, "PCM_CAP_REVISION ABI"); +_Static_assert(PCM_CAP_DUPLEX == 0x00000100u, "PCM_CAP_DUPLEX ABI"); +_Static_assert(PCM_CAP_REALTIME == 0x00000200u, "PCM_CAP_REALTIME ABI"); +_Static_assert(PCM_CAP_BATCH == 0x00000400u, "PCM_CAP_BATCH ABI"); +_Static_assert(PCM_CAP_COPROC == 0x00000800u, "PCM_CAP_COPROC ABI"); +_Static_assert(PCM_CAP_TRIGGER == 0x00001000u, "PCM_CAP_TRIGGER ABI"); +_Static_assert(PCM_CAP_MMAP == 0x00002000u, "PCM_CAP_MMAP ABI"); +_Static_assert(PCM_CAP_MULTI == 0x00004000u, "PCM_CAP_MULTI ABI"); +_Static_assert(PCM_CAP_BIND == 0x00008000u, "PCM_CAP_BIND ABI"); +_Static_assert(PCM_CAP_INPUT == 0x00010000u, "PCM_CAP_INPUT ABI"); +_Static_assert(PCM_CAP_OUTPUT == 0x00020000u, "PCM_CAP_OUTPUT ABI"); +_Static_assert(PCM_CAP_VIRTUAL == 0x00040000u, "PCM_CAP_VIRTUAL ABI"); +_Static_assert(PCM_CAP_DEFAULT == 0x40000000u, "PCM_CAP_DEFAULT ABI"); +_Static_assert(SNDCTL_DSP_HALT == SNDCTL_DSP_RESET, "SNDCTL_DSP_HALT source alias"); +_Static_assert(SNDCTL_DSP_SAMPLESIZE == SNDCTL_DSP_SETFMT, "SNDCTL_DSP_SAMPLESIZE source alias"); +_Static_assert(SOUND_PCM_WRITE_RATE == SNDCTL_DSP_SPEED, "SOUND_PCM_WRITE_RATE source alias"); +_Static_assert(SOUND_PCM_WRITE_CHANNELS == SNDCTL_DSP_CHANNELS, "SOUND_PCM_WRITE_CHANNELS source alias"); +_Static_assert(SOUND_PCM_WRITE_BITS == SNDCTL_DSP_SETFMT, "SOUND_PCM_WRITE_BITS source alias"); +_Static_assert(SOUND_PCM_SETFMT == SNDCTL_DSP_SETFMT, "SOUND_PCM_SETFMT source alias"); +_Static_assert(SOUND_PCM_POST == SNDCTL_DSP_POST, "SOUND_PCM_POST source alias"); +_Static_assert(SOUND_PCM_RESET == SNDCTL_DSP_RESET, "SOUND_PCM_RESET source alias"); +_Static_assert(SOUND_PCM_SYNC == SNDCTL_DSP_SYNC, "SOUND_PCM_SYNC source alias"); +_Static_assert(SOUND_PCM_SUBDIVIDE == SNDCTL_DSP_SUBDIVIDE, "SOUND_PCM_SUBDIVIDE source alias"); +_Static_assert(SOUND_PCM_SETFRAGMENT == SNDCTL_DSP_SETFRAGMENT, "SOUND_PCM_SETFRAGMENT source alias"); +_Static_assert(SOUND_PCM_GETFMTS == SNDCTL_DSP_GETFMTS, "SOUND_PCM_GETFMTS source alias"); +_Static_assert(SOUND_PCM_GETOSPACE == SNDCTL_DSP_GETOSPACE, "SOUND_PCM_GETOSPACE source alias"); +_Static_assert(SOUND_PCM_GETISPACE == SNDCTL_DSP_GETISPACE, "SOUND_PCM_GETISPACE source alias"); +_Static_assert(SOUND_PCM_NONBLOCK == SNDCTL_DSP_NONBLOCK, "SOUND_PCM_NONBLOCK source alias"); +_Static_assert(SOUND_PCM_GETCAPS == SNDCTL_DSP_GETCAPS, "SOUND_PCM_GETCAPS source alias"); +_Static_assert(SOUND_PCM_GETTRIGGER == SNDCTL_DSP_GETTRIGGER, "SOUND_PCM_GETTRIGGER source alias"); +_Static_assert(SOUND_PCM_SETTRIGGER == SNDCTL_DSP_SETTRIGGER, "SOUND_PCM_SETTRIGGER source alias"); +_Static_assert(SOUND_PCM_SETSYNCRO == SNDCTL_DSP_SETSYNCRO, "SOUND_PCM_SETSYNCRO source alias"); +_Static_assert(SOUND_PCM_GETIPTR == SNDCTL_DSP_GETIPTR, "SOUND_PCM_GETIPTR source alias"); +_Static_assert(SOUND_PCM_GETOPTR == SNDCTL_DSP_GETOPTR, "SOUND_PCM_GETOPTR source alias"); +_Static_assert(SOUND_PCM_MAPINBUF == SNDCTL_DSP_MAPINBUF, "SOUND_PCM_MAPINBUF source alias"); +_Static_assert(SOUND_PCM_MAPOUTBUF == SNDCTL_DSP_MAPOUTBUF, "SOUND_PCM_MAPOUTBUF source alias"); +_Static_assert(AFMT_S16_NE == AFMT_S16_LE, "AFMT_S16_NE source alias"); +_Static_assert(AFMT_S16_OE == AFMT_S16_BE, "AFMT_S16_OE source alias"); +_Static_assert(AFMT_S24_NE == AFMT_S24_LE, "AFMT_S24_NE source alias"); +_Static_assert(AFMT_S24_OE == AFMT_S24_BE, "AFMT_S24_OE source alias"); +_Static_assert(AFMT_S32_NE == AFMT_S32_LE, "AFMT_S32_NE source alias"); +_Static_assert(AFMT_S32_OE == AFMT_S32_BE, "AFMT_S32_OE source alias"); +_Static_assert(AFMT_U16_NE == AFMT_U16_LE, "AFMT_U16_NE source alias"); +_Static_assert(AFMT_U16_OE == AFMT_U16_BE, "AFMT_U16_OE source alias"); +_Static_assert(AFMT_U24_NE == AFMT_U24_LE, "AFMT_U24_NE source alias"); +_Static_assert(AFMT_U24_OE == AFMT_U24_BE, "AFMT_U24_OE source alias"); +_Static_assert(AFMT_U32_NE == AFMT_U32_LE, "AFMT_U32_NE source alias"); +_Static_assert(AFMT_U32_OE == AFMT_U32_BE, "AFMT_U32_OE source alias"); +_Static_assert(AFMT_F32_NE == AFMT_F32_LE, "AFMT_F32_NE source alias"); +_Static_assert(AFMT_F32_OE == AFMT_F32_BE, "AFMT_F32_OE source alias"); +_Static_assert(AFMT_FLOAT == AFMT_F32_NE, "AFMT_FLOAT source alias"); +_Static_assert(sizeof(audio_buf_info) == 16, "audio_buf_info ABI size"); +_Static_assert(_Alignof(audio_buf_info) == 4, "audio_buf_info ABI align"); +_Static_assert(offsetof(audio_buf_info, fragments) == 0, "audio_buf_info.fragments ABI offset"); +_Static_assert(offsetof(audio_buf_info, fragstotal) == 4, "audio_buf_info.fragstotal ABI offset"); +_Static_assert(offsetof(audio_buf_info, fragsize) == 8, "audio_buf_info.fragsize ABI offset"); +_Static_assert(offsetof(audio_buf_info, bytes) == 12, "audio_buf_info.bytes ABI offset"); +_Static_assert(sizeof(count_info) == 12, "count_info ABI size"); +_Static_assert(_Alignof(count_info) == 4, "count_info ABI align"); +_Static_assert(offsetof(count_info, bytes) == 0, "count_info.bytes ABI offset"); +_Static_assert(offsetof(count_info, blocks) == 4, "count_info.blocks ABI offset"); +_Static_assert(offsetof(count_info, ptr) == 8, "count_info.ptr ABI offset"); +#endif + +#endif /* KANDELO_SYS_SOUNDCARD_H */ diff --git a/libc/musl-overlay/src/unistd/close.c b/libc/musl-overlay/src/unistd/close.c new file mode 100644 index 0000000000..04503d60ec --- /dev/null +++ b/libc/musl-overlay/src/unistd/close.c @@ -0,0 +1,23 @@ +#include +#include "aio_impl.h" +#include "syscall.h" + +/* + * Upstream musl converts close(2)'s EINTR result into success for Linux, + * where the descriptor has already been consumed even when close reports an + * interruption. Kandelo's blocking /dev/dsp final close preflights its drain + * before mutating the descriptor table: EINTR therefore means the fd is still + * valid and the caller can retry or reset it. Preserve that truthful result. + */ +static int dummy(int fd) +{ + return fd; +} + +weak_alias(dummy, __aio_close); + +int close(int fd) +{ + fd = __aio_close(fd); + return __syscall_ret(__syscall_cp(SYS_close, fd)); +} diff --git a/packages/registry/fbdoom/build-fbdoom.sh b/packages/registry/fbdoom/build-fbdoom.sh index 7186c5575c..4262595b0b 100755 --- a/packages/registry/fbdoom/build-fbdoom.sh +++ b/packages/registry/fbdoom/build-fbdoom.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Cross-compile maximevince/fbDOOM for the kandelo using +# Cross-compile maximevince/fbDOOM for Kandelo using # wasm32posix-cc. The fbdev frontend writes BGRA32 pixels into the # framebuffer mmap; the canvas renderer (host/src/framebuffer/canvas-renderer.ts) # consumes them. @@ -11,9 +11,15 @@ set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$HERE/../../.." && pwd)" -SRC="$HERE/fbdoom-src" +UPSTREAM_SRC="$HERE/fbdoom-src" +SRC="$HERE/fbdoom-build" CDOOM_SRC="$HERE/chocolate-doom-src" +# Pin the upstream tree that the patch series targets. fbDOOM has no release +# tags, so building whatever happens to be at the remote default branch would +# make a package revision non-reproducible. +FBDOOM_COMMIT="17280163bc95e5d954d2efaa0633489b763b4cd1" + # Pin to chocolate-doom 3.1.0. fbDOOM stripped its OPL/MIDI/MUS sources # when removing SDL; we vendor them back so the music path compiles. CDOOM_COMMIT="35fb1372d10756ca27eca05665bd8a7cebc71c05" # chocolate-doom-3.1.0 @@ -23,63 +29,77 @@ CDOOM_COMMIT="35fb1372d10756ca27eca05665bd8a7cebc71c05" # chocolate-doom-3.1.0 source "$REPO_ROOT/sdk/activate.sh" export WASM_POSIX_SYSROOT="$REPO_ROOT/sysroot" -if [ ! -d "$SRC" ]; then - echo "==> Cloning maximevince/fbDOOM..." - git clone --depth 1 https://github.com/maximevince/fbDOOM "$SRC" +if [ ! -d "$UPSTREAM_SRC/.git" ]; then + if [ -e "$UPSTREAM_SRC" ]; then + echo "ERROR: $UPSTREAM_SRC exists but is not a git checkout" >&2 + exit 1 + fi + echo "==> Cloning maximevince/fbDOOM source cache..." + git clone --filter=blob:none --no-checkout \ + https://github.com/maximevince/fbDOOM "$UPSTREAM_SRC" fi -# Sentinel — last file added by patches/0005-add-music-support.patch. If -# it's present, the source tree is already fully vendored + patched and -# we skip both steps. Re-vendoring would clobber 0004's edits to -# opl.c/opl_internal.h/midifile.c. To force a re-apply (e.g. when -# iterating on patches), `rm -rf packages/registry/fbdoom/fbdoom-src`. -SENTINEL="$SRC/fbdoom/opl/opl_kernel.c" +if ! git -C "$UPSTREAM_SRC" cat-file -e "$FBDOOM_COMMIT^{commit}" 2>/dev/null; then + echo "==> Fetching pinned fbDOOM commit $FBDOOM_COMMIT..." + git -C "$UPSTREAM_SRC" fetch --depth 1 origin "$FBDOOM_COMMIT" +fi apply_patches() { - local mode="${1:-strict}" echo "==> Applying patches..." for p in "$HERE/patches/"*.patch; do [ -f "$p" ] || continue name="$(basename "$p")" - if (cd "$SRC" && git apply --check "$p") >/dev/null 2>&1; then - echo " $name" - (cd "$SRC" && git apply "$p") - elif [ "$mode" = "lenient" ]; then - echo " $name (already applied or superseded)" - else + if ! (cd "$SRC" && git --git-dir="$UPSTREAM_SRC/.git" \ + --work-tree="$SRC" apply --check "$p"); then echo "ERROR: patch $name does not apply cleanly" >&2 exit 1 fi + echo " $name" + (cd "$SRC" && git --git-dir="$UPSTREAM_SRC/.git" \ + --work-tree="$SRC" apply "$p") done } -if [ -e "$SENTINEL" ]; then - echo "==> Source tree already vendored (sentinel present); checking patches." - apply_patches lenient -else - if [ ! -d "$CDOOM_SRC" ]; then - echo "==> Cloning chocolate-doom @ $CDOOM_COMMIT for music sources..." - # Shallow-by-commit needs `--filter=blob:none` since github - # archives don't carry refs/tags reachability for arbitrary SHAs. - git clone --filter=blob:none --no-checkout \ - https://github.com/chocolate-doom/chocolate-doom "$CDOOM_SRC" - (cd "$CDOOM_SRC" && git checkout "$CDOOM_COMMIT") - elif [ "$(cd "$CDOOM_SRC" && git rev-parse HEAD)" != "$CDOOM_COMMIT" ]; then - echo "==> Re-pinning chocolate-doom to $CDOOM_COMMIT..." - (cd "$CDOOM_SRC" && git fetch && git checkout "$CDOOM_COMMIT") +if [ ! -d "$CDOOM_SRC/.git" ]; then + if [ -e "$CDOOM_SRC" ]; then + echo "ERROR: $CDOOM_SRC exists but is not a git checkout" >&2 + exit 1 fi - - echo "==> Vendoring OPL/MIDI/MUS sources from chocolate-doom..." - mkdir -p "$SRC/fbdoom/opl" - for f in opl.c opl.h opl3.c opl3.h opl_internal.h opl_queue.c opl_queue.h; do - cp "$CDOOM_SRC/opl/$f" "$SRC/fbdoom/opl/$f" - done - for f in mus2mid.c mus2mid.h midifile.c midifile.h; do - cp "$CDOOM_SRC/src/$f" "$SRC/fbdoom/$f" - done - - apply_patches strict + echo "==> Cloning chocolate-doom @ $CDOOM_COMMIT for music sources..." + git clone --filter=blob:none --no-checkout \ + https://github.com/chocolate-doom/chocolate-doom "$CDOOM_SRC" fi +if ! git -C "$CDOOM_SRC" cat-file -e "$CDOOM_COMMIT^{commit}" 2>/dev/null; then + echo "==> Fetching pinned chocolate-doom commit $CDOOM_COMMIT..." + git -C "$CDOOM_SRC" fetch --depth 1 origin "$CDOOM_COMMIT" +fi + +# Always construct the patched source from the pinned commit. The upstream +# checkout is only a content cache, so local edits there are neither destroyed +# nor accidentally incorporated into a package artifact. +echo "==> Creating pristine fbDOOM build tree..." +rm -rf "$SRC" +mkdir -p "$SRC" +git -C "$UPSTREAM_SRC" archive "$FBDOOM_COMMIT" | tar -x -C "$SRC" + +echo "==> Vendoring OPL/MIDI/MUS sources from chocolate-doom..." +VENDOR_SRC="$SRC/.chocolate-doom" +mkdir -p "$VENDOR_SRC" +git -C "$CDOOM_SRC" archive "$CDOOM_COMMIT" -- \ + opl/opl.c opl/opl.h opl/opl3.c opl/opl3.h opl/opl_internal.h \ + opl/opl_queue.c opl/opl_queue.h \ + src/mus2mid.c src/mus2mid.h src/midifile.c src/midifile.h \ + | tar -x -C "$VENDOR_SRC" +mkdir -p "$SRC/fbdoom/opl" +for f in opl.c opl.h opl3.c opl3.h opl_internal.h opl_queue.c opl_queue.h; do + cp "$VENDOR_SRC/opl/$f" "$SRC/fbdoom/opl/$f" +done +for f in mus2mid.c mus2mid.h midifile.c midifile.h; do + cp "$VENDOR_SRC/src/$f" "$SRC/fbdoom/$f" +done +rm -rf "$VENDOR_SRC" + +apply_patches cd "$SRC/fbdoom" @@ -87,8 +107,9 @@ echo "==> Cleaning previous build..." make clean || true echo "==> Cross-compiling fbdoom (wasm32, NOSDL=1)..." -# fbDOOM's own Makefile already wires NOSDL=1 to the framebuffer + null -# audio frontend; we just override the toolchain. +# fbDOOM's own Makefile wires NOSDL=1 to the framebuffer frontend; the +# patch series adds a conventional OSS PCM module alongside it. We only +# override the toolchain here. # # LIBS="-lm" — wasm32posix-cc auto-injects channel_syscall.c plus the # musl libc.a; passing -lc explicitly (the upstream Makefile default) @@ -113,11 +134,7 @@ echo "==> fbdoom.wasm built." # Install into local-binaries/ (resolver priority 1) and the resolver # scratch dir when invoked by xtask build-deps / archive-stage. # -# `cd "$REPO_ROOT"` is load-bearing: install_local_binary uses -# `git rev-parse --show-toplevel` to locate the repo root, and we're -# still inside `$SRC/fbdoom` (a nested git clone) from the make step. -# Without the cd, git would return fbdoom's clone as the toplevel and -# we'd write into packages/registry/fbdoom/fbdoom-src/local-binaries/. +# Return to the repository before invoking its artifact installer. # # No IWAD is bundled. The browser demo fetches the DOOM shareware # `doom1.wad` at page load (id Software, freely redistributable) and diff --git a/packages/registry/fbdoom/build.toml b/packages/registry/fbdoom/build.toml index 88f3d6d685..6ef7a939e9 100644 --- a/packages/registry/fbdoom/build.toml +++ b/packages/registry/fbdoom/build.toml @@ -1,7 +1,17 @@ script_path = "packages/registry/fbdoom/build-fbdoom.sh" -repo_url = "https://github.com/brandonpayton/kandelo.git" -commit = "8c53383229fab78f97b098c3207a655159c03041" -revision = 5 +inputs = [ + "packages/registry/fbdoom/build-fbdoom.sh", + "packages/registry/fbdoom/patches/0001-fix-I_InitInput-signature.patch", + "packages/registry/fbdoom/patches/0002-add-mice-input.patch", + "packages/registry/fbdoom/patches/0003-add-sound-support.patch", + "packages/registry/fbdoom/patches/0004-music-support-vendor-fixups.patch", + "packages/registry/fbdoom/patches/0005-add-music-support.patch", + "packages/registry/fbdoom/patches/0006-fix-G_CheckDemoStatus-atexit-signature.patch", + "packages/registry/fbdoom/patches/0007-use-home-for-save-directory.patch", +] +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "UNPUBLISHED" +revision = 6 [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/fbdoom/package.toml b/packages/registry/fbdoom/package.toml index 0bef67b922..bdffbc0189 100644 --- a/packages/registry/fbdoom/package.toml +++ b/packages/registry/fbdoom/package.toml @@ -1,18 +1,20 @@ kind = "program" name = "fbdoom" version = "0.1.0" -# rev5: coalesce queued PS/2 mouse packets into one Doom mouse event per -# input poll so pointer-lock motion is not lost inside a game tic. rev4 fixed +# rev6: use the SDK OSS API, negotiate a latency-sized buffer, and complete +# short writes so the generic /dev/dsp backend remains correctly paced. rev5 +# coalesced queued PS/2 mouse packets into one Doom mouse event per input poll +# so pointer-lock motion is not lost inside a game tic. rev4 fixed # fbDOOM's wasm exit path by avoiding an invalid G_CheckDemoStatus -> # atexit_func_t cast. rev3 dropped the bundled IWAD; the browser demo fetches # the DOOM shareware `doom1.wad` at page load from a Linux-distro mirror and # caches it via the Cache API. The archive carries only `fbdoom.wasm`. -kernel_abi = 7 +kernel_abi = 40 depends_on = [] -# Upstream is a depth-1 git clone of maximevince/fbDOOM at HEAD; no -# release-tag tarball exists. Source SHA is placeholder until either -# the upstream pins a release or we mirror a frozen tarball. +# The build script pins a maximevince/fbDOOM commit because upstream has no +# release-tag tarball. Source SHA remains a placeholder until we mirror that +# frozen tree as an archive. [source] url = "https://github.com/maximevince/fbDOOM" sha256 = "0000000000000000000000000000000000000000000000000000000000000000" diff --git a/packages/registry/fbdoom/patches/0003-add-sound-support.patch b/packages/registry/fbdoom/patches/0003-add-sound-support.patch index a89bcd93ca..9fdc6edc06 100644 --- a/packages/registry/fbdoom/patches/0003-add-sound-support.patch +++ b/packages/registry/fbdoom/patches/0003-add-sound-support.patch @@ -6,26 +6,24 @@ index fb32c7b..b566875 100644 #CFLAGS+=-fsanitize=address OBJS+=$(OBJDIR)/i_video_fbdev.o OBJS+=$(OBJDIR)/i_input_tty.o -+OBJS+=$(OBJDIR)/i_kernel_sound.o ++OBJS+=$(OBJDIR)/i_oss_sound.o CC=$(CROSS_COMPILE)gcc # gcc or g++ CFLAGS+=-ggdb3 -Os -diff --git a/fbdoom/i_kernel_sound.c b/fbdoom/i_kernel_sound.c +diff --git a/fbdoom/i_oss_sound.c b/fbdoom/i_oss_sound.c new file mode 100644 index 0000000..082b14b --- /dev/null -+++ b/fbdoom/i_kernel_sound.c -@@ -0,0 +1,285 @@ ++++ b/fbdoom/i_oss_sound.c +@@ -0,0 +1,410 @@ +/* -+ * i_kernel_sound.c — chocolate-doom sound module talking to /dev/dsp. ++ * i_oss_sound.c — chocolate-doom sound module using OSS PCM playback. + * -+ * The kandelo exposes an OSS-style /dev/dsp character -+ * device (kernel/audio.rs). This module fills the chocolate-doom -+ * `sound_module_t` shape, opens /dev/dsp once at init, mixes 8-bit -+ * mono SFX from WAD lumps into a 16-bit stereo @ 44.1 kHz mixbuffer, -+ * and `write()`s it to /dev/dsp every tic. The host (browser -+ * AudioContext) drains the kernel ring via the `kernel_drain_audio` -+ * export and feeds it back out as audio. ++ * This module fills the chocolate-doom `sound_module_t` shape, opens ++ * AUDIODEV (or /dev/dsp by default), mixes 8-bit mono SFX from WAD ++ * lumps into a 16-bit stereo 44.1 kHz buffer, and writes one buffer per ++ * game tic. The OSS device is only the PCM transport; mixing remains a ++ * userspace responsibility. + * + * No SDL / no ALSA / no resampler library — DOOM SFX sample rate + * (11025 Hz mono u8) is upmixed in-place with a 16.16 fixed-point @@ -33,12 +31,14 @@ index 0000000..082b14b + * SFX only, which is what the demo most needs. + */ + ++#include +#include +#include +#include +#include +#include +#include ++#include +#include + +#include "doomtype.h" @@ -48,31 +48,26 @@ index 0000000..082b14b +#include "w_wad.h" +#include "z_zone.h" + -+/* OSS ioctls — same numeric values the kernel and Linux use. We -+ * hard-code rather than #include so the build -+ * doesn't pull additional headers out of the wasm sysroot. */ -+#define SNDCTL_DSP_RESET 0x00005000u -+#define SNDCTL_DSP_SPEED 0xc0045002u -+#define SNDCTL_DSP_STEREO 0xc0045003u -+#define SNDCTL_DSP_SETFMT 0xc0045005u -+#define SNDCTL_DSP_GETFMTS 0x8004500bu -+#define AFMT_S16_LE 0x10 -+ -+/* Output config — must match what the kernel ring is configured for -+ * (kernel auto-rounds to whole frames, so the host AudioContext sees -+ * stereo S16 @ 44100 Hz). */ ++/* The mixer and OPL clock require the device to return this exact ++ * configuration. OSS negotiation is in/out, so init validates every ++ * returned value instead of assuming the request was accepted. */ +#define OUTPUT_RATE 44100 +#define OUTPUT_CHANNELS 2 +#define BYTES_PER_FRAME (2 * OUTPUT_CHANNELS) /* S16 * stereo */ + -+/* DOOM ticks run at 35 Hz. At 44100 Hz that's exactly 1260 frames -+ * per tic. Match exactly so the producer rate equals the consumer -+ * rate — any mismatch makes the AudioContext queue grow without -+ * bound, sounds desync, and the kernel ring drops fresh SFX on -+ * overflow (most visibly: the pistol shot vanishes after a few s). */ ++/* DOOM ticks run at 35 Hz. At 44100 Hz that is exactly 1260 frames ++ * per tic. Matching the device rate keeps SFX and music synchronized. */ +#define MIX_FRAMES 1260 +#define MIX_BYTES (MIX_FRAMES * BYTES_PER_FRAME) + ++/* Four 2048-byte fragments provide 8192 bytes (about 46 ms) of ++ * buffering, enough for one 5040-byte mix. Drivers may return a ++ * different geometry; the full-write loop below remains authoritative. */ ++#define OSS_FRAGMENT_SHIFT 11 ++#define OSS_FRAGMENT_COUNT 4 ++#define OSS_FRAGMENT_REQUEST \ ++ ((OSS_FRAGMENT_COUNT << 16) | OSS_FRAGMENT_SHIFT) ++ +#define NUM_CHANNELS 8 + +/* DOOM SFX in WAD lumps are 11025 Hz mono u8. The 8-byte header @@ -105,7 +100,57 @@ index 0000000..082b14b + * value back to I_StopSound / I_SoundIsPlaying. */ +static int handle_counter = 100; + -+static int I_KERNEL_GetSfxLumpNum(sfxinfo_t *sfx) ++static void I_OSS_ResetAndClose(void) ++{ ++ if (dsp_fd < 0) return; ++ ++ (void)ioctl(dsp_fd, SNDCTL_DSP_RESET, 0); ++ (void)close(dsp_fd); ++ dsp_fd = -1; ++} ++ ++static boolean I_OSS_SetFragmentGeometry(void) ++{ ++ int fragments = OSS_FRAGMENT_REQUEST; ++ unsigned int shift; ++ unsigned int count; ++ size_t fragment_bytes; ++ size_t capacity; ++ ++ if (ioctl(dsp_fd, SNDCTL_DSP_SETFRAGMENT, &fragments) < 0) { ++ fprintf(stderr, ++ "I_OSS_InitSound: SNDCTL_DSP_SETFRAGMENT failed: %s\n", ++ strerror(errno)); ++ return false; ++ } ++ ++ shift = (unsigned int)fragments & 0xffffu; ++ count = (unsigned int)fragments >> 16; ++ if (count == 0 || shift >= 31) { ++ fprintf(stderr, ++ "I_OSS_InitSound: invalid fragment geometry 0x%x\n", ++ (unsigned int)fragments); ++ return false; ++ } ++ ++ fragment_bytes = (size_t)1u << shift; ++ if (count > SIZE_MAX / fragment_bytes) { ++ fprintf(stderr, ++ "I_OSS_InitSound: fragment geometry overflows size_t\n"); ++ return false; ++ } ++ capacity = count * fragment_bytes; ++ if (capacity < MIX_BYTES) { ++ fprintf(stderr, ++ "I_OSS_InitSound: %zu-byte OSS buffer is smaller than " ++ "%u-byte mix; using blocking short writes\n", ++ capacity, (unsigned int)MIX_BYTES); ++ } ++ ++ return true; ++} ++ ++static int I_OSS_GetSfxLumpNum(sfxinfo_t *sfx) +{ + char namebuf[9]; + snprintf(namebuf, sizeof namebuf, "%s%s", @@ -113,57 +158,115 @@ index 0000000..082b14b + return W_GetNumForName(namebuf); +} + -+static boolean I_KERNEL_InitSound(boolean _use_sfx_prefix) ++static boolean I_OSS_InitSound(boolean _use_sfx_prefix) +{ ++ const char *device; ++ int formats; ++ int speed; ++ int stereo; ++ int fmt; ++ + use_sfx_prefix = _use_sfx_prefix; + -+ dsp_fd = open("/dev/dsp", O_WRONLY); ++ device = getenv("AUDIODEV"); ++ if (device == NULL || device[0] == '\0') device = "/dev/dsp"; ++ ++ dsp_fd = open(device, O_WRONLY); + if (dsp_fd < 0) { + fprintf(stderr, -+ "I_KERNEL_InitSound: /dev/dsp unavailable; sound disabled.\n"); ++ "I_OSS_InitSound: cannot open %s: %s; sound disabled.\n", ++ device, strerror(errno)); + return false; + } + -+ int speed = OUTPUT_RATE; -+ if (ioctl(dsp_fd, SNDCTL_DSP_SPEED, &speed) < 0) { ++ if (!I_OSS_SetFragmentGeometry()) goto fail; ++ ++ formats = 0; ++ if (ioctl(dsp_fd, SNDCTL_DSP_GETFMTS, &formats) < 0) { + fprintf(stderr, -+ "I_KERNEL_InitSound: SNDCTL_DSP_SPEED failed; sound disabled.\n"); -+ close(dsp_fd); -+ dsp_fd = -1; -+ return false; ++ "I_OSS_InitSound: SNDCTL_DSP_GETFMTS failed: %s\n", ++ strerror(errno)); ++ goto fail; ++ } ++ if ((formats & AFMT_S16_LE) == 0) { ++ fprintf(stderr, ++ "I_OSS_InitSound: signed 16-bit little-endian PCM unavailable\n"); ++ goto fail; + } + -+ int stereo = 1; /* 1 = stereo */ ++ fmt = AFMT_S16_LE; ++ if (ioctl(dsp_fd, SNDCTL_DSP_SETFMT, &fmt) < 0) { ++ fprintf(stderr, ++ "I_OSS_InitSound: SNDCTL_DSP_SETFMT failed: %s\n", ++ strerror(errno)); ++ goto fail; ++ } ++ if (fmt != AFMT_S16_LE) { ++ fprintf(stderr, ++ "I_OSS_InitSound: device returned PCM format 0x%x\n", ++ (unsigned int)fmt); ++ goto fail; ++ } ++ ++ stereo = 1; + if (ioctl(dsp_fd, SNDCTL_DSP_STEREO, &stereo) < 0) { + fprintf(stderr, -+ "I_KERNEL_InitSound: SNDCTL_DSP_STEREO failed; sound disabled.\n"); -+ close(dsp_fd); -+ dsp_fd = -1; -+ return false; ++ "I_OSS_InitSound: SNDCTL_DSP_STEREO failed: %s\n", ++ strerror(errno)); ++ goto fail; ++ } ++ if (stereo != 1) { ++ fprintf(stderr, ++ "I_OSS_InitSound: device did not accept stereo output\n"); ++ goto fail; + } + -+ int fmt = AFMT_S16_LE; -+ if (ioctl(dsp_fd, SNDCTL_DSP_SETFMT, &fmt) < 0) { ++ speed = OUTPUT_RATE; ++ if (ioctl(dsp_fd, SNDCTL_DSP_SPEED, &speed) < 0) { + fprintf(stderr, -+ "I_KERNEL_InitSound: SNDCTL_DSP_SETFMT(S16_LE) failed; sound disabled.\n"); -+ close(dsp_fd); -+ dsp_fd = -1; -+ return false; ++ "I_OSS_InitSound: SNDCTL_DSP_SPEED failed: %s\n", ++ strerror(errno)); ++ goto fail; ++ } ++ if (speed != OUTPUT_RATE) { ++ fprintf(stderr, ++ "I_OSS_InitSound: device returned %d Hz, need %d Hz\n", ++ speed, OUTPUT_RATE); ++ goto fail; + } + + memset(channels, 0, sizeof channels); + return true; ++ ++fail: ++ I_OSS_ResetAndClose(); ++ return false; +} + -+static void I_KERNEL_ShutdownSound(void) ++static void I_OSS_ShutdownSound(void) +{ -+ if (dsp_fd >= 0) { -+ close(dsp_fd); -+ dsp_fd = -1; ++ int result; ++ ++ if (dsp_fd < 0) return; ++ ++ do { ++ result = ioctl(dsp_fd, SNDCTL_DSP_SYNC, 0); ++ } while (result < 0 && errno == EINTR); ++ ++ if (result < 0) { ++ fprintf(stderr, "I_OSS_ShutdownSound: drain failed: %s\n", ++ strerror(errno)); ++ (void)ioctl(dsp_fd, SNDCTL_DSP_RESET, 0); ++ } ++ ++ if (close(dsp_fd) < 0) { ++ fprintf(stderr, "I_OSS_ShutdownSound: close failed: %s\n", ++ strerror(errno)); + } ++ dsp_fd = -1; +} + -+static void I_KERNEL_UpdateSoundParams(int handle, int vol, int sep) ++static void I_OSS_UpdateSoundParams(int handle, int vol, int sep) +{ + /* chocolate-doom passes the channel index here, not the handle — + * but the engine guards calls with SoundIsPlaying first, so a @@ -183,12 +286,12 @@ index 0000000..082b14b + } +} + -+static int I_KERNEL_StartSound(sfxinfo_t *sfxinfo, int channel, -+ int vol, int sep) ++static int I_OSS_StartSound(sfxinfo_t *sfxinfo, int channel, ++ int vol, int sep) +{ + if (dsp_fd < 0) return -1; + -+ int lump = I_KERNEL_GetSfxLumpNum(sfxinfo); ++ int lump = I_OSS_GetSfxLumpNum(sfxinfo); + if (lump < 0) return -1; + int size = W_LumpLength(lump); + if (size <= DOOM_SFX_HEADER_BYTES) return -1; @@ -216,11 +319,11 @@ index 0000000..082b14b + channels[slot].leftvol = vol > 127 ? 127 : (vol < 0 ? 0 : vol); + channels[slot].rightvol = channels[slot].leftvol; + -+ I_KERNEL_UpdateSoundParams(handle, vol, sep); ++ I_OSS_UpdateSoundParams(handle, vol, sep); + return handle; +} + -+static void I_KERNEL_StopSound(int handle) ++static void I_OSS_StopSound(int handle) +{ + for (int i = 0; i < NUM_CHANNELS; i++) { + if (channels[i].active && channels[i].handle == handle) { @@ -230,7 +333,7 @@ index 0000000..082b14b + } +} + -+static boolean I_KERNEL_SoundIsPlaying(int handle) ++static boolean I_OSS_SoundIsPlaying(int handle) +{ + for (int i = 0; i < NUM_CHANNELS; i++) { + if (channels[i].active && channels[i].handle == handle) return true; @@ -238,7 +341,27 @@ index 0000000..082b14b + return false; +} + -+static void I_KERNEL_PrecacheSounds(sfxinfo_t *sounds, int num_sounds) ++static boolean I_OSS_WriteAll(const void *data, size_t size) ++{ ++ const uint8_t *cursor = data; ++ ++ while (size != 0) { ++ ssize_t written = write(dsp_fd, cursor, size); ++ ++ if (written > 0) { ++ cursor += written; ++ size -= (size_t)written; ++ continue; ++ } ++ if (written < 0 && errno == EINTR) continue; ++ if (written == 0) errno = EIO; ++ return false; ++ } ++ ++ return true; ++} ++ ++static void I_OSS_PrecacheSounds(sfxinfo_t *sounds, int num_sounds) +{ + /* On-demand load via W_CacheLumpNum in StartSound — no-op here. */ + (void)sounds; (void)num_sounds; @@ -246,7 +369,7 @@ index 0000000..082b14b + +/* Mix MIX_FRAMES stereo S16 frames and write them to /dev/dsp. Called + * once per game tic from S_UpdateSounds → I_UpdateSound. */ -+static void I_KERNEL_UpdateSound(void) ++static void I_OSS_UpdateSound(void) +{ + if (dsp_fd < 0) return; + @@ -278,48 +401,49 @@ index 0000000..082b14b + mixbuffer[frame * 2 + 1] = (int16_t)dr; + } + -+ /* The kernel ring drops oldest frames on overflow rather than -+ * blocking, so a slow drain just costs audio — never wedges -+ * the game loop. We never short-write here in practice. */ -+ (void)write(dsp_fd, mixbuffer, sizeof mixbuffer); ++ if (!I_OSS_WriteAll(mixbuffer, sizeof mixbuffer)) { ++ int error = errno; ++ fprintf(stderr, "I_OSS_UpdateSound: PCM write failed: %s; " ++ "sound disabled.\n", strerror(error)); ++ I_OSS_ResetAndClose(); ++ } +} + -+static snddevice_t sound_kernel_devices[] = { ++static snddevice_t sound_oss_devices[] = { + SNDDEVICE_SB, +}; + -+sound_module_t sound_kernel_module = { -+ sound_kernel_devices, -+ sizeof(sound_kernel_devices) / sizeof(sound_kernel_devices[0]), -+ I_KERNEL_InitSound, -+ I_KERNEL_ShutdownSound, -+ I_KERNEL_GetSfxLumpNum, -+ I_KERNEL_UpdateSound, -+ I_KERNEL_UpdateSoundParams, -+ I_KERNEL_StartSound, -+ I_KERNEL_StopSound, -+ I_KERNEL_SoundIsPlaying, -+ I_KERNEL_PrecacheSounds, ++sound_module_t sound_oss_module = { ++ sound_oss_devices, ++ sizeof(sound_oss_devices) / sizeof(sound_oss_devices[0]), ++ I_OSS_InitSound, ++ I_OSS_ShutdownSound, ++ I_OSS_GetSfxLumpNum, ++ I_OSS_UpdateSound, ++ I_OSS_UpdateSoundParams, ++ I_OSS_StartSound, ++ I_OSS_StopSound, ++ I_OSS_SoundIsPlaying, ++ I_OSS_PrecacheSounds, +}; diff --git a/fbdoom/i_sound.c b/fbdoom/i_sound.c index 71947d2..d4b4017 100644 --- a/fbdoom/i_sound.c +++ b/fbdoom/i_sound.c -@@ -89,8 +89,16 @@ static int snd_mport = 0; +@@ -89,8 +89,15 @@ static int snd_mport = 0; // Compiled-in sound modules: -static sound_module_t *sound_modules[] = -+// /dev/dsp-backed sound module — defined in i_kernel_sound.c. We -+// register it unconditionally (no FEATURE_SOUND gate) because the -+// Kandelo always exposes /dev/dsp; if the open fails (no -+// AudioContext sink wired up host-side) the module's Init returns -+// false and the dispatcher falls through to silence. -+extern sound_module_t sound_kernel_module; ++// OSS sound module defined in i_oss_sound.c. Register it independently ++// of FEATURE_SOUND because fbDOOM's slim build omits the SDL and PC ++// speaker modules. If AUDIODEV or /dev/dsp cannot be opened, Init ++// returns false and the dispatcher falls through to silence. ++extern sound_module_t sound_oss_module; + +static sound_module_t *sound_modules[] = { -+ &sound_kernel_module, ++ &sound_oss_module, #ifdef FEATURE_SOUND &sound_sdl_module, &sound_pcsound_module, diff --git a/packages/registry/fbdoom/patches/0004-music-support-vendor-fixups.patch b/packages/registry/fbdoom/patches/0004-music-support-vendor-fixups.patch index 5163427d3e..fd648dbd87 100644 --- a/packages/registry/fbdoom/patches/0004-music-support-vendor-fixups.patch +++ b/packages/registry/fbdoom/patches/0004-music-support-vendor-fixups.patch @@ -5,9 +5,9 @@ // -#include "config.h" -+// kandelo build: no autotools config.h, no SDL. The only -+// driver shipped is opl_kernel.c, which is a pull-model driver suitable -+// for the single-threaded /dev/dsp mixing loop in i_kernel_sound.c. ++// fbDOOM's slim OSS build has no autotools config.h or SDL. Its only ++// driver is opl_pull.c, a pull model suitable for the single-threaded ++// /dev/dsp mixing loop in i_oss_sound.c. #include #include @@ -34,7 +34,7 @@ -#ifndef DISABLE_SDL2MIXER - &opl_sdl_driver, -#endif // DISABLE_SDL2MIXER -+ &opl_kernel_driver, ++ &opl_pull_driver, NULL }; @@ -142,10 +142,10 @@ -extern opl_driver_t opl_win32_driver; -#endif -extern opl_driver_t opl_sdl_driver; -+// The kandelo build only ships the kernel pull driver — all -+// SDL/Linux/Win32 hardware drivers are stripped (no SDL in the sysroot, -+// no real OPL chip behind a wasm sandbox). -+extern opl_driver_t opl_kernel_driver; ++// This slim OSS build ships only the pull driver. SDL and native ++// hardware drivers are omitted: neither exists in the wasm target, ++// and there is no physical OPL chip behind the sandbox. ++extern opl_driver_t opl_pull_driver; #endif /* #ifndef OPL_INTERNAL_H */ diff --git a/packages/registry/fbdoom/patches/0005-add-music-support.patch b/packages/registry/fbdoom/patches/0005-add-music-support.patch index ffd455588c..6b52b0b02e 100644 --- a/packages/registry/fbdoom/patches/0005-add-music-support.patch +++ b/packages/registry/fbdoom/patches/0005-add-music-support.patch @@ -1,5 +1,5 @@ ---- a/fbdoom/i_kernel_sound.c -+++ b/fbdoom/i_kernel_sound.c +--- a/fbdoom/i_oss_sound.c ++++ b/fbdoom/i_oss_sound.c @@ -11,8 +11,10 @@ * * No SDL / no ALSA / no resampler library — DOOM SFX sample rate @@ -7,12 +7,12 @@ - * step. Music (MUS / MIDI) is left to a future patch; this lands - * SFX only, which is what the demo most needs. + * step. Music (MUS/MIDI through GENMIDI -> OPL2 emulation) is mixed -+ * into the same per-tic mixbuffer below: OPL_Kernel_FillBuffer fills a ++ * into the same per-tic mixbuffer below: OPL_Pull_FillBuffer fills a + * stereo S16 buffer at OUTPUT_RATE which we accumulate alongside SFX + * before clamping. One write() per tic carries both. */ - #include + #include @@ -26,6 +28,7 @@ #include "doomtype.h" #include "deh_str.h" @@ -26,11 +26,11 @@ static boolean use_sfx_prefix; static int16_t mixbuffer[MIX_FRAMES * OUTPUT_CHANNELS]; +/* Music samples produced by the OPL emulator each tic (when active). -+ * Filled by OPL_Kernel_FillBuffer (opl/opl_kernel.c) and accumulated ++ * Filled by OPL_Pull_FillBuffer (opl/opl_pull.c) and accumulated + * into mixbuffer alongside SFX. When music is inactive the call + * returns silence so the mixer path is uniform. */ +static int16_t musicbuffer[MIX_FRAMES * OUTPUT_CHANNELS]; -+extern void OPL_Kernel_FillBuffer(int16_t *out, unsigned int frames); ++extern void OPL_Pull_FillBuffer(int16_t *out, unsigned int frames); static sound_channel_t channels[NUM_CHANNELS]; +/* S_UpdateSounds runs once per render frame, not once per tic. Gate on @@ -47,7 +47,7 @@ -/* Mix MIX_FRAMES stereo S16 frames and write them to /dev/dsp. Called - * once per game tic from S_UpdateSounds → I_UpdateSound. */ +/* Mix MIX_FRAMES stereo S16 frames and write them to /dev/dsp. */ - static void I_KERNEL_UpdateSound(void) + static void I_OSS_UpdateSound(void) { if (dsp_fd < 0) return; @@ -61,7 +61,7 @@ + * resampling — but it does fire MIDI register-write callbacks + * scheduled by i_oplmusic as time advances inside this call, + * which is how tempo / note events stay sample-precise. */ -+ OPL_Kernel_FillBuffer(musicbuffer, MIX_FRAMES); ++ OPL_Pull_FillBuffer(musicbuffer, MIX_FRAMES); + for (int frame = 0; frame < MIX_FRAMES; frame++) { - int dl = 0, dr = 0; @@ -79,7 +79,7 @@ + +// OPL music (i_oplmusic.c) — synthesizes MUS/MIDI through the +// chocolate-doom OPL2 emulator (opl/opl3.c) into PCM that -+// i_kernel_sound mixes into the same /dev/dsp write as SFX. We register ++// i_oss_sound mixes into the same /dev/dsp write as SFX. We register +// it unconditionally so the dispatcher picks it up for SNDDEVICE_SB / +// SNDDEVICE_ADLIB; if OPL_Init or GENMIDI-lump load fails the module's +// Init returns false and the dispatcher falls through to silence. @@ -98,7 +98,7 @@ +++ b/fbdoom/Makefile @@ -20,6 +20,15 @@ OBJS+=$(OBJDIR)/i_input_tty.o - OBJS+=$(OBJDIR)/i_kernel_sound.o + OBJS+=$(OBJDIR)/i_oss_sound.o +# Music: MUS->MIDI->OPL2 software synthesis, mixed into /dev/dsp. +OBJS+=$(OBJDIR)/i_oplmusic.o @@ -107,7 +107,7 @@ +OBJS+=$(OBJDIR)/opl/opl.o +OBJS+=$(OBJDIR)/opl/opl3.o +OBJS+=$(OBJDIR)/opl/opl_queue.o -+OBJS+=$(OBJDIR)/opl/opl_kernel.o ++OBJS+=$(OBJDIR)/opl/opl_pull.o + CC=$(CROSS_COMPILE)gcc # gcc or g++ CFLAGS+=-ggdb3 -Os @@ -120,12 +120,12 @@ @echo [Compiling $<] $(VB)$(CC) $(CFLAGS) -c $< -o $@ ---- a/fbdoom/opl/opl_kernel.c -+++ b/fbdoom/opl/opl_kernel.c -@@ -0,0 +1,354 @@ +--- a/fbdoom/opl/opl_pull.c ++++ b/fbdoom/opl/opl_pull.c +@@ -0,0 +1,353 @@ +// +// Copyright(C) 2005-2014 Simon Howard -+// Copyright(C) 2026 kandelo authors ++// Copyright(C) 2026 Kandelo authors +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License @@ -138,16 +138,16 @@ +// GNU General Public License for more details. +// +// DESCRIPTION: -+// OPL pull-model driver — kandelo /dev/dsp variant. ++// OPL pull-model driver for the OSS mixer. +// +// Chocolate-doom's OPL frontend (opl.c) drives an opl_driver_t +// impl. The stock SDL driver (opl_sdl.c) is push-based: SDL's audio +// thread invokes OPL_Mix_Callback, which advances time and calls +// OPL3_GenerateStream into SDL's mix buffer. We have no audio -+// thread — i_kernel_sound.c sits in the game-tic loop and pulls ++// thread — i_oss_sound.c sits in the game-tic loop and pulls +// audio synchronously every ~28 ms before write()-ing to /dev/dsp. +// -+// Hence "pull": OPL_Kernel_FillBuffer is the entry point. It ++// Hence "pull": OPL_Pull_FillBuffer is the entry point. It +// advances current_time as samples are produced, fires any due +// MIDI register-write callbacks queued by i_oplmusic, and writes +// stereo S16 frames into the caller-provided buffer at @@ -179,7 +179,7 @@ +static opl_callback_queue_t *callback_queue = NULL; + +// Current time in microseconds since OPL_Init. Advanced by AdvanceTime -+// as samples are produced (and by OPL_Kernel_Delay during OPL_Detect). ++// as samples are produced (and by OPL_Pull_Delay during OPL_Detect). +static uint64_t current_time; + +// If non-zero, time still advances but pause_offset accumulates so @@ -237,9 +237,8 @@ +// alongside, firing callbacks at sample-precise points so MIDI tempo +// stays exact regardless of how often the caller pulls. +// -+// Caller-facing entry point. Declared in opl_kernel.h-equivalent -+// extern in i_kernel_sound.c. -+void OPL_Kernel_FillBuffer(int16_t *out, unsigned int frames) ++// Caller-facing entry point. Declared in i_oss_sound.c. ++void OPL_Pull_FillBuffer(int16_t *out, unsigned int frames) +{ + unsigned int filled = 0; + @@ -297,7 +296,7 @@ + } +} + -+static int OPL_Kernel_Init(unsigned int port_base) ++static int OPL_Pull_Init(unsigned int port_base) +{ + paused = 0; + pause_offset = 0; @@ -309,7 +308,7 @@ + return 1; +} + -+static void OPL_Kernel_Shutdown(void) ++static void OPL_Pull_Shutdown(void) +{ + if (callback_queue != NULL) + { @@ -318,7 +317,7 @@ + } +} + -+static unsigned int OPL_Kernel_PortRead(opl_port_t port) ++static unsigned int OPL_Pull_PortRead(opl_port_t port) +{ + unsigned int result = 0; + @@ -384,7 +383,7 @@ + + if ((value & 0x20) == 0) + { -+ timer1.enabled = (value & 0x02) != 0; ++ timer2.enabled = (value & 0x02) != 0; + OPLTimer_CalculateEndTime(&timer2); + } + } @@ -396,7 +395,7 @@ + } +} + -+static void OPL_Kernel_PortWrite(opl_port_t port, unsigned int value) ++static void OPL_Pull_PortWrite(opl_port_t port, unsigned int value) +{ + if (port == OPL_REGISTER_PORT) + { @@ -412,7 +411,7 @@ + } +} + -+static void OPL_Kernel_SetCallback(uint64_t us, opl_callback_t callback, ++static void OPL_Pull_SetCallback(uint64_t us, opl_callback_t callback, + void *data) +{ + if (callback_queue != NULL) @@ -422,7 +421,7 @@ + } +} + -+static void OPL_Kernel_ClearCallbacks(void) ++static void OPL_Pull_ClearCallbacks(void) +{ + if (callback_queue != NULL) + { @@ -431,15 +430,15 @@ +} + +// Single-threaded — no concurrent producer to lock against. -+static void OPL_Kernel_Lock(void) { } -+static void OPL_Kernel_Unlock(void) { } ++static void OPL_Pull_Lock(void) { } ++static void OPL_Pull_Unlock(void) { } + -+static void OPL_Kernel_SetPaused(int p) ++static void OPL_Pull_SetPaused(int p) +{ + paused = p; +} + -+static void OPL_Kernel_AdjustCallbacks(float factor) ++static void OPL_Pull_AdjustCallbacks(float factor) +{ + if (callback_queue != NULL) + { @@ -450,7 +449,7 @@ +// Used only by OPL_Detect, which writes a timer value, "waits 1ms" and +// re-reads status to confirm the (emulated) chip is alive. We just step +// the clock; no audio output is needed for detection. -+static void OPL_Kernel_Delay(uint64_t us) ++static void OPL_Pull_Delay(uint64_t us) +{ + current_time += us; + @@ -462,18 +461,18 @@ + RunDueCallbacks(); +} + -+opl_driver_t opl_kernel_driver = ++opl_driver_t opl_pull_driver = +{ -+ "Kernel", -+ OPL_Kernel_Init, -+ OPL_Kernel_Shutdown, -+ OPL_Kernel_PortRead, -+ OPL_Kernel_PortWrite, -+ OPL_Kernel_SetCallback, -+ OPL_Kernel_ClearCallbacks, -+ OPL_Kernel_Lock, -+ OPL_Kernel_Unlock, -+ OPL_Kernel_SetPaused, -+ OPL_Kernel_AdjustCallbacks, -+ OPL_Kernel_Delay, ++ "Pull", ++ OPL_Pull_Init, ++ OPL_Pull_Shutdown, ++ OPL_Pull_PortRead, ++ OPL_Pull_PortWrite, ++ OPL_Pull_SetCallback, ++ OPL_Pull_ClearCallbacks, ++ OPL_Pull_Lock, ++ OPL_Pull_Unlock, ++ OPL_Pull_SetPaused, ++ OPL_Pull_AdjustCallbacks, ++ OPL_Pull_Delay, +}; diff --git a/packages/registry/sdl-dsp-test/build-sdl-dsp-test.sh b/packages/registry/sdl-dsp-test/build-sdl-dsp-test.sh new file mode 100644 index 0000000000..02f95fdd39 --- /dev/null +++ b/packages/registry/sdl-dsp-test/build-sdl-dsp-test.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Build deterministic SDL2 and SDL3 /dev/dsp pacing fixtures. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +# shellcheck source=/dev/null +source "$REPO_ROOT/sdk/activate.sh" + +INSTALL_DIR="${WASM_POSIX_DEP_OUT_DIR:?WASM_POSIX_DEP_OUT_DIR must name the resolver staging directory}" +TARGET_ARCH="${WASM_POSIX_DEP_TARGET_ARCH:-wasm32}" +SDL2_PREFIX="${WASM_POSIX_DEP_SDL2_DIR:?resolver did not provide the direct sdl2 dependency}" +SDL3_PREFIX="${WASM_POSIX_DEP_SDL3_DIR:?resolver did not provide the direct sdl3 dependency}" + +if [ "$TARGET_ARCH" != "wasm32" ]; then + echo "ERROR: SDL dsp fixtures currently support only wasm32, got $TARGET_ARCH" >&2 + exit 1 +fi + +export WASM_POSIX_SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}" +CC=wasm32posix-cc +command -v "$CC" >/dev/null || { + echo "ERROR: $CC not found after sourcing sdk/activate.sh" >&2 + exit 1 +} + +test -f "$SDL2_PREFIX/lib/libSDL2.a" +test -f "$SDL3_PREFIX/lib/libSDL3.a" +mkdir -p "$INSTALL_DIR" +REPRO_FLAGS=( + "-ffile-prefix-map=$REPO_ROOT=/usr/src/kandelo" + "-fdebug-prefix-map=$REPO_ROOT=/usr/src/kandelo" + "-fmacro-prefix-map=$REPO_ROOT=/usr/src/kandelo" +) + +echo "==> Building the SDL2 blocking-write pacing fixture..." +"$CC" -O2 "${REPRO_FLAGS[@]}" -DSDL_MAIN_HANDLED \ + -I"$SDL2_PREFIX/include/SDL2" \ + "$SCRIPT_DIR/src/sdl2-dsp-test.c" \ + "$SDL2_PREFIX/lib/libSDL2.a" -lm \ + -o "$INSTALL_DIR/sdl2-dsp-test.wasm" + +echo "==> Building the SDL3 GETOSPACE pacing fixture..." +"$CC" -O2 "${REPRO_FLAGS[@]}" -DSDL_MAIN_HANDLED \ + -I"$SDL3_PREFIX/include" \ + "$SCRIPT_DIR/src/sdl3-dsp-test.c" \ + "$SDL3_PREFIX/lib/libSDL3.a" -lm \ + -o "$INSTALL_DIR/sdl3-dsp-test.wasm" + +for output in sdl2-dsp-test.wasm sdl3-dsp-test.wasm; do + "$REPO_ROOT/scripts/run-wasm-fork-instrument.sh" \ + "$INSTALL_DIR/$output" -o "$INSTALL_DIR/$output.instrumented" + mv "$INSTALL_DIR/$output.instrumented" "$INSTALL_DIR/$output" +done + +test -f "$INSTALL_DIR/sdl2-dsp-test.wasm" +test -f "$INSTALL_DIR/sdl3-dsp-test.wasm" +echo "==> SDL /dev/dsp fixtures complete" diff --git a/packages/registry/sdl-dsp-test/build.toml b/packages/registry/sdl-dsp-test/build.toml new file mode 100644 index 0000000000..b2bcd8d174 --- /dev/null +++ b/packages/registry/sdl-dsp-test/build.toml @@ -0,0 +1,13 @@ +script_path = "packages/registry/sdl-dsp-test/build-sdl-dsp-test.sh" +inputs = [ + "packages/registry/sdl-dsp-test/build-sdl-dsp-test.sh", + "packages/registry/sdl-dsp-test/src/sdl2-dsp-test.c", + "packages/registry/sdl-dsp-test/src/sdl3-dsp-test.c", + "scripts/run-wasm-fork-instrument.sh", +] +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "UNPUBLISHED" +revision = 1 + +[binary] +index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/sdl-dsp-test/package.toml b/packages/registry/sdl-dsp-test/package.toml new file mode 100644 index 0000000000..594195f436 --- /dev/null +++ b/packages/registry/sdl-dsp-test/package.toml @@ -0,0 +1,25 @@ +kind = "program" +name = "sdl-dsp-test" +version = "0.1.0" +kernel_abi = 40 +depends_on = ["sdl2@2.32.10", "sdl3@3.4.10"] +arches = ["wasm32"] + +[source] +url = "https://github.com/Automattic/kandelo" +sha256 = "0000000000000000000000000000000000000000000000000000000000000000" + +[license] +spdx = "GPL-2.0-or-later" +url = "https://github.com/Automattic/kandelo/blob/main/COPYING" + +[build] +script_path = "packages/registry/sdl-dsp-test/build-sdl-dsp-test.sh" + +[[outputs]] +name = "sdl2-dsp-test" +wasm = "sdl2-dsp-test.wasm" + +[[outputs]] +name = "sdl3-dsp-test" +wasm = "sdl3-dsp-test.wasm" diff --git a/packages/registry/sdl-dsp-test/src/sdl2-dsp-test.c b/packages/registry/sdl-dsp-test/src/sdl2-dsp-test.c new file mode 100644 index 0000000000..c51ebeebbf --- /dev/null +++ b/packages/registry/sdl-dsp-test/src/sdl2-dsp-test.c @@ -0,0 +1,144 @@ +#include + +#include +#include +#include +#include +#include + +enum { RUN_MS = 900 }; + +struct playback_state { + uint64_t callbacks; + uint64_t frames; + uint32_t phase; +}; + +static uint64_t monotonic_ms(void) { + struct timespec now; + if (clock_gettime(CLOCK_MONOTONIC, &now) != 0) { + return 0; + } + return (uint64_t)now.tv_sec * 1000u + (uint64_t)now.tv_nsec / 1000000u; +} + +static void SDLCALL fill_audio(void *userdata, Uint8 *stream, int len) { + struct playback_state *state = userdata; + int i; + + /* Deterministic unsigned 8-bit mono sawtooth. */ + for (i = 0; i < len; ++i) { + stream[i] = (Uint8)(32u + (state->phase++ % 192u)); + } + state->callbacks++; + state->frames += (uint64_t)len; +} + +static const char *format_name(SDL_AudioFormat format) { + return format == AUDIO_U8 ? "U8" : "OTHER"; +} + +int main(void) { + SDL_AudioSpec requested; + SDL_AudioSpec actual; + SDL_AudioDeviceID device; + struct playback_state state; + const char *driver; + uint64_t start_ms; + uint64_t end_ms; + uint64_t close_start_ms; + uint64_t close_end_ms; + uint64_t elapsed_ms; + uint64_t close_ms; + uint64_t expected_frames; + int paced; + + memset(&requested, 0, sizeof(requested)); + memset(&actual, 0, sizeof(actual)); + memset(&state, 0, sizeof(state)); + + if (setenv("SDL_AUDIODRIVER", "dsp", 1) != 0) { + perror("setenv SDL_AUDIODRIVER"); + return 1; + } + if (SDL_Init(SDL_INIT_AUDIO | SDL_INIT_TIMER) != 0) { + fprintf(stderr, "SDL2 init failed: %s\n", SDL_GetError()); + return 1; + } + + driver = SDL_GetCurrentAudioDriver(); + if (driver == NULL || strcmp(driver, "dsp") != 0) { + fprintf(stderr, "SDL2 selected unexpected audio driver: %s\n", + driver == NULL ? "(null)" : driver); + SDL_Quit(); + return 1; + } + + requested.freq = 22050; + requested.format = AUDIO_U8; + requested.channels = 1; + requested.samples = 512; + requested.callback = fill_audio; + requested.userdata = &state; + + device = SDL_OpenAudioDevice(NULL, 0, &requested, &actual, 0); + if (device == 0) { + fprintf(stderr, "SDL2 open failed: %s\n", SDL_GetError()); + SDL_Quit(); + return 1; + } + if (actual.freq != requested.freq || actual.format != requested.format || + actual.channels != requested.channels) { + fprintf(stderr, + "SDL2 changed an exactly supported spec: %d/%#x/%u -> %d/%#x/%u\n", + requested.freq, requested.format, (unsigned)requested.channels, + actual.freq, actual.format, (unsigned)actual.channels); + SDL_CloseAudioDevice(device); + SDL_Quit(); + return 1; + } + + start_ms = monotonic_ms(); + SDL_PauseAudioDevice(device, 0); + SDL_Delay(RUN_MS); + end_ms = monotonic_ms(); + close_start_ms = end_ms; + SDL_CloseAudioDevice(device); + close_end_ms = monotonic_ms(); + + SDL_Quit(); + + elapsed_ms = end_ms >= start_ms ? end_ms - start_ms : 0; + close_ms = close_end_ms >= close_start_ms ? close_end_ms - close_start_ms : 0; + expected_frames = actual.freq > 0 ? + ((uint64_t)actual.freq * elapsed_ms) / 1000u : 0; + paced = state.callbacks >= 2 && expected_frames > 0 && + state.frames >= expected_frames / 2u && + state.frames <= expected_frames * 2u + (uint64_t)actual.samples * 2u; + + if (!paced) { + fprintf(stderr, + "SDL2 callback pacing failed: frames=%llu expected=%llu elapsed=%llu ms\n", + (unsigned long long)state.frames, + (unsigned long long)expected_frames, + (unsigned long long)elapsed_ms); + return 1; + } + + printf("SDL_DSP_RESULT {\"sdl_major\":2,\"requested_rate\":22050," + "\"requested_format\":\"U8\",\"requested_channels\":1," + "\"actual_rate\":%d,\"actual_format\":\"%s\"," + "\"actual_channels\":%u,\"callbacks\":%llu,\"frames\":%llu," + "\"pcm_bytes\":%llu,\"period_frames\":%u," + "\"elapsed_ms\":%llu,\"close_ms\":%llu,\"paced\":%s}\n", + actual.freq, format_name(actual.format), (unsigned)actual.channels, + (unsigned long long)state.callbacks, + (unsigned long long)state.frames, + (unsigned long long)state.frames, + (unsigned)actual.samples, + (unsigned long long)elapsed_ms, + (unsigned long long)close_ms, + "true"); + fflush(stdout); + return 0; +} diff --git a/packages/registry/sdl-dsp-test/src/sdl3-dsp-test.c b/packages/registry/sdl-dsp-test/src/sdl3-dsp-test.c new file mode 100644 index 0000000000..4a26f2cb1f --- /dev/null +++ b/packages/registry/sdl-dsp-test/src/sdl3-dsp-test.c @@ -0,0 +1,218 @@ +#include + +#ifndef SDL_PLATFORM_UNIX +#error "Kandelo's SDK must expose its Unix platform identity to SDL3 consumers" +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum { RUN_MS = 900, FRAME_BYTES = 4, CHUNK_BYTES = 8192 }; + +struct playback_state { + uint64_t callbacks; + uint64_t frames; + uint32_t phase; + int failed; + Uint8 chunk[CHUNK_BYTES]; +}; + +static uint64_t monotonic_ms(void) { + struct timespec now; + if (clock_gettime(CLOCK_MONOTONIC, &now) != 0) { + return 0; + } + return (uint64_t)now.tv_sec * 1000u + (uint64_t)now.tv_nsec / 1000000u; +} + +static void fill_s16le_stereo(struct playback_state *state, int bytes) { + int offset; + + for (offset = 0; offset < bytes; offset += FRAME_BYTES) { + int sample = ((int)(state->phase++ % 200u) - 100) * 240; + int right = -sample; + state->chunk[offset + 0] = (Uint8)(sample & 0xff); + state->chunk[offset + 1] = (Uint8)((sample >> 8) & 0xff); + state->chunk[offset + 2] = (Uint8)(right & 0xff); + state->chunk[offset + 3] = (Uint8)((right >> 8) & 0xff); + } +} + +static void SDLCALL provide_audio(void *userdata, SDL_AudioStream *stream, + int additional_amount, int total_amount) { + struct playback_state *state = userdata; + int remaining; + (void)total_amount; + + state->callbacks++; + remaining = additional_amount; + while (remaining > 0) { + int bytes = remaining < CHUNK_BYTES ? remaining : CHUNK_BYTES; + bytes = (bytes + FRAME_BYTES - 1) & ~(FRAME_BYTES - 1); + fill_s16le_stereo(state, bytes); + if (!SDL_PutAudioStreamData(stream, state->chunk, bytes)) { + state->failed = 1; + return; + } + state->frames += (uint64_t)bytes / FRAME_BYTES; + remaining -= bytes; + } +} + +static const char *format_name(SDL_AudioFormat format) { + return format == SDL_AUDIO_S16LE ? "S16LE" : "OTHER"; +} + +static void report_direct_dsp_probe(void) { + struct stat status; + int fd; + + errno = 0; + fd = open("/dev/dsp", O_WRONLY | O_NONBLOCK | O_CLOEXEC, 0); + if (fd < 0) { + fprintf(stderr, "SDL3 direct /dev/dsp probe: open failed: %s (%d)\n", + strerror(errno), errno); + return; + } + errno = 0; + if (fstat(fd, &status) != 0) { + fprintf(stderr, "SDL3 direct /dev/dsp probe: fstat failed: %s (%d)\n", + strerror(errno), errno); + } else { + fprintf(stderr, + "SDL3 direct /dev/dsp probe: mode=%#o character_device=%d\n", + (unsigned)status.st_mode, S_ISCHR(status.st_mode) ? 1 : 0); + } + if (close(fd) != 0) { + fprintf(stderr, "SDL3 direct /dev/dsp probe: close failed: %s (%d)\n", + strerror(errno), errno); + } +} + +int main(void) { + SDL_AudioSpec requested; + SDL_AudioSpec source_spec; + SDL_AudioSpec device_spec; + SDL_AudioStream *stream; + struct playback_state state; + const char *driver; + uint64_t start_ms; + uint64_t end_ms; + uint64_t close_start_ms; + uint64_t close_end_ms; + uint64_t elapsed_ms; + uint64_t close_ms; + uint64_t expected_frames; + int paced; + + SDL_zero(requested); + SDL_zero(source_spec); + SDL_zero(device_spec); + SDL_zero(state); + + if (setenv("SDL_AUDIODRIVER", "dsp", 1) != 0) { + perror("setenv SDL_AUDIODRIVER"); + return 1; + } + if (!SDL_Init(SDL_INIT_AUDIO)) { + fprintf(stderr, "SDL3 init failed: %s\n", SDL_GetError()); + report_direct_dsp_probe(); + return 1; + } + + driver = SDL_GetCurrentAudioDriver(); + if (driver == NULL || strcmp(driver, "dsp") != 0) { + fprintf(stderr, "SDL3 selected unexpected audio driver: %s\n", + driver == NULL ? "(null)" : driver); + SDL_Quit(); + return 1; + } + + requested.freq = 48000; + requested.format = SDL_AUDIO_S16LE; + requested.channels = 2; + stream = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, + &requested, provide_audio, &state); + if (stream == NULL) { + fprintf(stderr, "SDL3 open failed: %s\n", SDL_GetError()); + SDL_Quit(); + return 1; + } + if (!SDL_GetAudioStreamFormat(stream, &source_spec, &device_spec)) { + fprintf(stderr, "SDL3 format query failed: %s\n", SDL_GetError()); + SDL_DestroyAudioStream(stream); + SDL_Quit(); + return 1; + } + if (source_spec.freq != requested.freq || + source_spec.format != requested.format || + source_spec.channels != requested.channels || + device_spec.freq != requested.freq || + device_spec.format != requested.format || + device_spec.channels != requested.channels) { + fprintf(stderr, + "SDL3 changed an exactly supported spec: %d/%#x/%u -> %d/%#x/%u\n", + requested.freq, requested.format, (unsigned)requested.channels, + device_spec.freq, device_spec.format, + (unsigned)device_spec.channels); + SDL_DestroyAudioStream(stream); + SDL_Quit(); + return 1; + } + + start_ms = monotonic_ms(); + if (!SDL_ResumeAudioStreamDevice(stream)) { + fprintf(stderr, "SDL3 resume failed: %s\n", SDL_GetError()); + SDL_DestroyAudioStream(stream); + SDL_Quit(); + return 1; + } + SDL_Delay(RUN_MS); + end_ms = monotonic_ms(); + close_start_ms = end_ms; + SDL_DestroyAudioStream(stream); + close_end_ms = monotonic_ms(); + + SDL_Quit(); + + elapsed_ms = end_ms >= start_ms ? end_ms - start_ms : 0; + close_ms = close_end_ms >= close_start_ms ? close_end_ms - close_start_ms : 0; + expected_frames = source_spec.freq > 0 ? + ((uint64_t)source_spec.freq * elapsed_ms) / 1000u : 0; + paced = !state.failed && state.callbacks >= 2 && expected_frames > 0 && + state.frames >= expected_frames / 2u && + state.frames <= expected_frames * 2u + 4096u; + + if (!paced) { + fprintf(stderr, + "SDL3 callback pacing failed: frames=%llu expected=%llu elapsed=%llu ms\n", + (unsigned long long)state.frames, + (unsigned long long)expected_frames, + (unsigned long long)elapsed_ms); + return 1; + } + + printf("SDL_DSP_RESULT {\"sdl_major\":3,\"requested_rate\":48000," + "\"requested_format\":\"S16LE\",\"requested_channels\":2," + "\"actual_rate\":%d,\"actual_format\":\"%s\"," + "\"actual_channels\":%u,\"callbacks\":%llu,\"frames\":%llu," + "\"pcm_bytes\":%llu,\"elapsed_ms\":%llu,\"close_ms\":%llu," + "\"paced\":%s}\n", + device_spec.freq, format_name(device_spec.format), + (unsigned)device_spec.channels, + (unsigned long long)state.callbacks, + (unsigned long long)state.frames, + (unsigned long long)state.frames * FRAME_BYTES, + (unsigned long long)elapsed_ms, + (unsigned long long)close_ms, + "true"); + fflush(stdout); + return 0; +} diff --git a/packages/registry/sdl2-mixer-playwave/build-sdl2-mixer-playwave.sh b/packages/registry/sdl2-mixer-playwave/build-sdl2-mixer-playwave.sh new file mode 100755 index 0000000000..2450c403ae --- /dev/null +++ b/packages/registry/sdl2-mixer-playwave/build-sdl2-mixer-playwave.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Build upstream SDL_mixer's unmodified playwave sample against Kandelo SDL2. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kandelo-sdl2-mixer-playwave.XXXXXX")" +trap 'rm -rf "$WORK_DIR"' EXIT + +# shellcheck source=/dev/null +source "$REPO_ROOT/sdk/activate.sh" + +MIXER_VERSION="${WASM_POSIX_DEP_VERSION:-2.8.2}" +SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://github.com/libsdl-org/SDL_mixer/releases/download/release-${MIXER_VERSION}/SDL2_mixer-${MIXER_VERSION}.tar.gz}" +SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-938dff531d00ace2296557a6599abe6f34599e2f34f0a4a08a397e2ccac8b8f7}" +INSTALL_DIR="${WASM_POSIX_DEP_OUT_DIR:?WASM_POSIX_DEP_OUT_DIR must name the resolver staging directory}" +TARGET_ARCH="${WASM_POSIX_DEP_TARGET_ARCH:-wasm32}" +SDL2_PREFIX="${WASM_POSIX_DEP_SDL2_DIR:?resolver did not provide the direct sdl2 dependency}" + +if [ "$TARGET_ARCH" != "wasm32" ]; then + echo "ERROR: SDL_mixer playwave currently supports only wasm32, got $TARGET_ARCH" >&2 + exit 1 +fi + +export WASM_POSIX_SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}" +CC=wasm32posix-cc +CXX=wasm32posix-c++ +AR=wasm32posix-ar +RANLIB=wasm32posix-ranlib +NM=wasm32posix-nm +STRIP=wasm32posix-strip +for tool in "$CC" "$CXX" "$AR" "$RANLIB" "$NM" "$STRIP" \ + make curl tar shasum; do + command -v "$tool" >/dev/null || { + echo "ERROR: required build tool not found: $tool" >&2 + exit 1 + } +done + +test -f "$SDL2_PREFIX/lib/libSDL2.a" +test -f "$SDL2_PREFIX/include/SDL2/SDL.h" +test -f "$SDL2_PREFIX/lib/pkgconfig/sdl2.pc" + +TARBALL="$WORK_DIR/SDL2_mixer.tar.gz" +SRC_DIR="$WORK_DIR/source" +BUILD_DIR="$WORK_DIR/build" +REPRO_FLAGS="-ffile-prefix-map=$WORK_DIR=/usr/src/sdl2-mixer -fdebug-prefix-map=$WORK_DIR=/usr/src/sdl2-mixer -fmacro-prefix-map=$WORK_DIR=/usr/src/sdl2-mixer" + +echo "==> Downloading SDL_mixer $MIXER_VERSION..." +curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors \ + -fsSL "$SOURCE_URL" -o "$TARBALL" +echo "$SOURCE_SHA256 $TARBALL" | shasum -a 256 -c - +mkdir -p "$SRC_DIR" "$BUILD_DIR" "$INSTALL_DIR" +tar xzf "$TARBALL" -C "$SRC_DIR" --strip-components=1 + +echo "==> Configuring upstream playwave with only built-in WAVE support..." +( + cd "$BUILD_DIR" + export PKG_CONFIG_PATH="$SDL2_PREFIX/lib/pkgconfig" + export PKG_CONFIG_LIBDIR="$SDL2_PREFIX/lib/pkgconfig" + "$SRC_DIR/configure" \ + --host=wasm32-unknown-none \ + --prefix="$WORK_DIR/install-unused" \ + --enable-static \ + --disable-shared \ + --disable-sdltest \ + --disable-music-cmd \ + --enable-music-wave \ + --disable-music-mod \ + --disable-music-midi \ + --disable-music-gme \ + --disable-music-ogg \ + --disable-music-flac \ + --disable-music-mp3 \ + --disable-music-opus \ + --disable-music-wavpack \ + CC="$CC" CXX="$CXX" AR="$AR" RANLIB="$RANLIB" \ + NM="$NM" STRIP="$STRIP" \ + CFLAGS="-O2 -DSDL_MAIN_HANDLED $REPRO_FLAGS" + + make -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" build/playwave +) + +test -f "$BUILD_DIR/build/playwave" +cp "$BUILD_DIR/build/playwave" "$INSTALL_DIR/playwave.uninstrumented.wasm" +"$REPO_ROOT/scripts/run-wasm-fork-instrument.sh" \ + "$INSTALL_DIR/playwave.uninstrumented.wasm" \ + -o "$INSTALL_DIR/playwave.wasm" +rm -f "$INSTALL_DIR/playwave.uninstrumented.wasm" + +test -f "$INSTALL_DIR/playwave.wasm" +echo "==> SDL_mixer playwave fixture complete" diff --git a/packages/registry/sdl2-mixer-playwave/build.toml b/packages/registry/sdl2-mixer-playwave/build.toml new file mode 100644 index 0000000000..6873c8c473 --- /dev/null +++ b/packages/registry/sdl2-mixer-playwave/build.toml @@ -0,0 +1,11 @@ +script_path = "packages/registry/sdl2-mixer-playwave/build-sdl2-mixer-playwave.sh" +inputs = [ + "packages/registry/sdl2-mixer-playwave/build-sdl2-mixer-playwave.sh", + "scripts/run-wasm-fork-instrument.sh", +] +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "UNPUBLISHED" +revision = 1 + +[binary] +index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/sdl2-mixer-playwave/package.toml b/packages/registry/sdl2-mixer-playwave/package.toml new file mode 100644 index 0000000000..bf182a4735 --- /dev/null +++ b/packages/registry/sdl2-mixer-playwave/package.toml @@ -0,0 +1,45 @@ +kind = "program" +name = "sdl2-mixer-playwave" +version = "2.8.2" +kernel_abi = 40 +depends_on = ["sdl2@2.32.10"] +arches = ["wasm32"] + +[source] +url = "https://github.com/libsdl-org/SDL_mixer/releases/download/release-2.8.2/SDL2_mixer-2.8.2.tar.gz" +sha256 = "938dff531d00ace2296557a6599abe6f34599e2f34f0a4a08a397e2ccac8b8f7" + +[license] +spdx = "Zlib" +url = "https://github.com/libsdl-org/SDL_mixer/blob/release-2.8.2/LICENSE.txt" + +[build] +script_path = "packages/registry/sdl2-mixer-playwave/build-sdl2-mixer-playwave.sh" + +[[host_tools]] +name = "make" +version_constraint = ">=3.80" +probe = { args = ["--version"], version_regex = "GNU Make (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "curl" +version_constraint = ">=7.71.0" +probe = { args = ["--version"], version_regex = "curl (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "tar" +version_constraint = ">=1.30" +probe = { args = ["--version"], version_regex = "tar.*?(\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "shasum" +version_constraint = ">=6.0" +probe = { args = ["--version"], version_regex = "(\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[outputs]] +name = "playwave" +wasm = "playwave.wasm" diff --git a/packages/registry/sdl2/build-sdl2.sh b/packages/registry/sdl2/build-sdl2.sh new file mode 100644 index 0000000000..b5708e0d01 --- /dev/null +++ b/packages/registry/sdl2/build-sdl2.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# Build upstream SDL 2 with its unmodified OSS dsp backend for Kandelo. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kandelo-sdl2.XXXXXX")" +trap 'rm -rf "$WORK_DIR"' EXIT + +# shellcheck source=/dev/null +source "$REPO_ROOT/sdk/activate.sh" + +SDL_VERSION="${WASM_POSIX_DEP_VERSION:-2.32.10}" +SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://github.com/libsdl-org/SDL/releases/download/release-${SDL_VERSION}/SDL2-${SDL_VERSION}.tar.gz}" +SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-5f5993c530f084535c65a6879e9b26ad441169b3e25d789d83287040a9ca5165}" +INSTALL_DIR="${WASM_POSIX_DEP_OUT_DIR:?WASM_POSIX_DEP_OUT_DIR must name the resolver staging directory}" +TARGET_ARCH="${WASM_POSIX_DEP_TARGET_ARCH:-wasm32}" + +if [ "$TARGET_ARCH" != "wasm32" ]; then + echo "ERROR: SDL2 currently supports only wasm32, got $TARGET_ARCH" >&2 + exit 1 +fi + +export WASM_POSIX_SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}" +CC=wasm32posix-cc +CXX=wasm32posix-c++ +AR=wasm32posix-ar +RANLIB=wasm32posix-ranlib +NM=wasm32posix-nm +STRIP=wasm32posix-strip +for tool in "$CC" "$CXX" "$AR" "$RANLIB" "$NM" "$STRIP" \ + make patch curl shasum; do + command -v "$tool" >/dev/null || { + echo "ERROR: required build tool not found: $tool" >&2 + exit 1 + } +done + +TARBALL="$WORK_DIR/SDL2.tar.gz" +SRC_DIR="$WORK_DIR/source" +BUILD_DIR="$WORK_DIR/build" +REPRO_FLAGS="-ffile-prefix-map=$WORK_DIR=/usr/src/sdl2 -fdebug-prefix-map=$WORK_DIR=/usr/src/sdl2 -fmacro-prefix-map=$WORK_DIR=/usr/src/sdl2" + +echo "==> Downloading SDL2 $SDL_VERSION..." +curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors \ + -fsSL "$SOURCE_URL" -o "$TARBALL" +echo "$SOURCE_SHA256 $TARBALL" | shasum -a 256 -c - +mkdir -p "$SRC_DIR" "$BUILD_DIR" +tar xzf "$TARBALL" -C "$SRC_DIR" --strip-components=1 + +echo "==> Applying the Kandelo platform-classification patch..." +patch -d "$SRC_DIR" -p1 < "$SCRIPT_DIR/patches/0001-recognize-kandelo-as-unix.patch" + +echo "==> Configuring SDL2 with only the OSS playback backend..." +# Kandelo exposes neither the non-POSIX sysctl header nor its matching API. +# Pin the cross-compile probe so SDL uses its portable sysconf path. +# Executable links intentionally permit unresolved host imports, so link-only +# Autoconf probes cannot prove optional functions. Pin only helpers absent from +# the Kandelo musl headers/library; SDL provides portable fallbacks for them. +( + cd "$BUILD_DIR" + "$SRC_DIR/configure" \ + --host=wasm32-unknown-none \ + --prefix="$INSTALL_DIR" \ + --enable-static \ + --disable-shared \ + --enable-audio \ + --enable-oss \ + --disable-alsa \ + --disable-pulseaudio \ + --disable-pipewire \ + --disable-jack \ + --disable-sndio \ + --disable-arts \ + --disable-esd \ + --disable-nas \ + --disable-fusionsound \ + --disable-libsamplerate \ + --disable-diskaudio \ + --disable-dummyaudio \ + --disable-video \ + --disable-render \ + --disable-joystick \ + --disable-haptic \ + --disable-hidapi \ + --disable-sensor \ + --disable-power \ + --disable-loadso \ + --disable-libudev \ + --disable-dbus \ + --disable-ime \ + --disable-ibus \ + --disable-fcitx \ + --disable-assembly \ + CC="$CC" CXX="$CXX" AR="$AR" RANLIB="$RANLIB" \ + NM="$NM" STRIP="$STRIP" \ + CFLAGS="-O2 $REPRO_FLAGS" \ + ac_cv_func_dlopen=no \ + ac_cv_func_sysctlbyname=no \ + ac_cv_func_elf_aux_info=no \ + ac_cv_func_pthread_set_name_np=no \ + ac_cv_func__wcsdup=no \ + ac_cv_func__wcsicmp=no \ + ac_cv_func__wcsnicmp=no \ + ac_cv_func__strrev=no \ + ac_cv_func__strupr=no \ + ac_cv_func__strlwr=no \ + ac_cv_func_itoa=no \ + ac_cv_func__ltoa=no \ + ac_cv_func__uitoa=no \ + ac_cv_func__ultoa=no \ + ac_cv_func__i64toa=no \ + ac_cv_func__ui64toa=no \ + ac_cv_func__stricmp=no \ + ac_cv_func__strnicmp=no + + make -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" + make install +) + +# The resolver atomically moves this staging tree, so generated metadata must +# locate the package relative to itself instead of retaining the temp prefix. +sed -i.bak 's|^prefix=.*|prefix=${pcfiledir}/../..|' \ + "$INSTALL_DIR/lib/pkgconfig/sdl2.pc" +rm -f "$INSTALL_DIR/lib/pkgconfig/sdl2.pc.bak" +rm -rf "$INSTALL_DIR/bin" "$INSTALL_DIR/share" "$INSTALL_DIR/lib/cmake" +rm -f "$INSTALL_DIR/lib/"*.la + +test -f "$INSTALL_DIR/lib/libSDL2.a" +test -f "$INSTALL_DIR/include/SDL2/SDL.h" +test -f "$INSTALL_DIR/lib/pkgconfig/sdl2.pc" +echo "==> SDL2 OSS-only static package complete" diff --git a/packages/registry/sdl2/build.toml b/packages/registry/sdl2/build.toml new file mode 100644 index 0000000000..24b5bed7d3 --- /dev/null +++ b/packages/registry/sdl2/build.toml @@ -0,0 +1,11 @@ +script_path = "packages/registry/sdl2/build-sdl2.sh" +inputs = [ + "packages/registry/sdl2/build-sdl2.sh", + "packages/registry/sdl2/patches/0001-recognize-kandelo-as-unix.patch", +] +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "UNPUBLISHED" +revision = 1 + +[binary] +index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/sdl2/package.toml b/packages/registry/sdl2/package.toml new file mode 100644 index 0000000000..e850c7bdf7 --- /dev/null +++ b/packages/registry/sdl2/package.toml @@ -0,0 +1,53 @@ +kind = "library" + +name = "sdl2" +version = "2.32.10" +kernel_abi = 40 +depends_on = [] +arches = ["wasm32"] + +[source] +url = "https://github.com/libsdl-org/SDL/releases/download/release-2.32.10/SDL2-2.32.10.tar.gz" +sha256 = "5f5993c530f084535c65a6879e9b26ad441169b3e25d789d83287040a9ca5165" + +[license] +spdx = "Zlib" +url = "https://github.com/libsdl-org/SDL/blob/release-2.32.10/LICENSE.txt" + +[build] +script_path = "packages/registry/sdl2/build-sdl2.sh" + +[[host_tools]] +name = "make" +version_constraint = ">=3.80" +probe = { args = ["--version"], version_regex = "GNU Make (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "curl" +version_constraint = ">=7.71.0" +probe = { args = ["--version"], version_regex = "curl (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "tar" +version_constraint = ">=1.30" +probe = { args = ["--version"], version_regex = "tar.*?(\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "patch" +version_constraint = ">=2.0" +probe = { args = ["--version"], version_regex = "patch (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "shasum" +version_constraint = ">=6.0" +probe = { args = ["--version"], version_regex = "(\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[outputs] +libs = ["lib/libSDL2.a"] +headers = ["include/SDL2"] +pkgconfig = ["lib/pkgconfig/sdl2.pc"] diff --git a/packages/registry/sdl2/patches/0001-recognize-kandelo-as-unix.patch b/packages/registry/sdl2/patches/0001-recognize-kandelo-as-unix.patch new file mode 100644 index 0000000000..f59dba776a --- /dev/null +++ b/packages/registry/sdl2/patches/0001-recognize-kandelo-as-unix.patch @@ -0,0 +1,37 @@ +diff --git a/configure.ac b/configure.ac +index 7e03714..6e35ee4 100644 +--- a/configure.ac ++++ b/configure.ac +@@ -3757,6 +3757,6 @@ have_locale=no + dnl Set up the configuration based on the host platform! + case "$host" in +- *-*-linux*|*-*-uclinux*|*-*-gnu*|*-*-k*bsd*-gnu|*-*-bsdi*|*-*-freebsd*|*-*-dragonfly*|*-*-netbsd*|*-*-openbsd*|*-*-sysv5*|*-*-solaris*|*-*-hpux*|*-*-aix*|*-*-minix*|*-*-nto*) ++ wasm32-*-none*|*-*-linux*|*-*-uclinux*|*-*-gnu*|*-*-k*bsd*-gnu|*-*-bsdi*|*-*-freebsd*|*-*-dragonfly*|*-*-netbsd*|*-*-openbsd*|*-*-sysv5*|*-*-solaris*|*-*-hpux*|*-*-aix*|*-*-minix*|*-*-nto*) + case "$host" in + *-*-android*) + # Android +@@ -3774,5 +3774,6 @@ case "$host" in + fi + ;; ++ wasm32-*-none*) ARCH=kandelo ;; + *-*-linux*) ARCH=linux ;; + *-*-uclinux*) ARCH=linux ;; + *-*-kfreebsd*-gnu) ARCH=kfreebsd-gnu ;; +diff --git a/configure b/configure +index d64ae91..42a8f9b 100755 +--- a/configure ++++ b/configure +@@ -28635,5 +28635,5 @@ have_locale=no + case "$host" in +- *-*-linux*|*-*-uclinux*|*-*-gnu*|*-*-k*bsd*-gnu|*-*-bsdi*|*-*-freebsd*|*-*-dragonfly*|*-*-netbsd*|*-*-openbsd*|*-*-sysv5*|*-*-solaris*|*-*-hpux*|*-*-aix*|*-*-minix*|*-*-nto*) ++ wasm32-*-none*|*-*-linux*|*-*-uclinux*|*-*-gnu*|*-*-k*bsd*-gnu|*-*-bsdi*|*-*-freebsd*|*-*-dragonfly*|*-*-netbsd*|*-*-openbsd*|*-*-sysv5*|*-*-solaris*|*-*-hpux*|*-*-aix*|*-*-minix*|*-*-nto*) + case "$host" in + *-*-android*) + # Android +@@ -28653,5 +28653,6 @@ case "$host" in + fi + ;; ++ wasm32-*-none*) ARCH=kandelo ;; + *-*-linux*) ARCH=linux ;; + *-*-uclinux*) ARCH=linux ;; + *-*-kfreebsd*-gnu) ARCH=kfreebsd-gnu ;; diff --git a/packages/registry/sdl3/build-sdl3.sh b/packages/registry/sdl3/build-sdl3.sh new file mode 100644 index 0000000000..26e7070141 --- /dev/null +++ b/packages/registry/sdl3/build-sdl3.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Build upstream SDL 3 with its unmodified OSS dsp backend for Kandelo. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kandelo-sdl3.XXXXXX")" +trap 'rm -rf "$WORK_DIR"' EXIT + +# shellcheck source=/dev/null +source "$REPO_ROOT/sdk/activate.sh" + +SDL_VERSION="${WASM_POSIX_DEP_VERSION:-3.4.10}" +SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://github.com/libsdl-org/SDL/releases/download/release-${SDL_VERSION}/SDL3-${SDL_VERSION}.tar.gz}" +SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-12b34280415ec8418c864408b93d008a20a6530687ee613d60bfbd20411f2785}" +INSTALL_DIR="${WASM_POSIX_DEP_OUT_DIR:?WASM_POSIX_DEP_OUT_DIR must name the resolver staging directory}" +TARGET_ARCH="${WASM_POSIX_DEP_TARGET_ARCH:-wasm32}" + +if [ "$TARGET_ARCH" != "wasm32" ]; then + echo "ERROR: SDL3 currently supports only wasm32, got $TARGET_ARCH" >&2 + exit 1 +fi + +export WASM_POSIX_SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}" +for tool in wasm32posix-cc wasm32posix-c++ wasm32posix-ar \ + wasm32posix-ranlib wasm32posix-nm wasm32posix-strip \ + cmake patch curl shasum; do + command -v "$tool" >/dev/null || { + echo "ERROR: required build tool not found: $tool" >&2 + exit 1 + } +done + +TARBALL="$WORK_DIR/SDL3.tar.gz" +SRC_DIR="$WORK_DIR/source" +BUILD_DIR="$WORK_DIR/build" +REPRO_FLAGS="-ffile-prefix-map=$WORK_DIR=/usr/src/sdl3 -fdebug-prefix-map=$WORK_DIR=/usr/src/sdl3 -fmacro-prefix-map=$WORK_DIR=/usr/src/sdl3" + +echo "==> Downloading SDL3 $SDL_VERSION..." +curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors \ + -fsSL "$SOURCE_URL" -o "$TARBALL" +echo "$SOURCE_SHA256 $TARBALL" | shasum -a 256 -c - +mkdir -p "$SRC_DIR" +tar xzf "$TARBALL" -C "$SRC_DIR" --strip-components=1 + +echo "==> Applying the Kandelo platform-classification patch..." +patch -d "$SRC_DIR" -p1 < "$SCRIPT_DIR/patches/0001-recognize-kandelo-platform.patch" + +echo "==> Configuring SDL3 with only the OSS playback backend..." +cmake -S "$SRC_DIR" -B "$BUILD_DIR" \ + -DCMAKE_TOOLCHAIN_FILE="$SCRIPT_DIR/cmake/kandelo-toolchain.cmake" \ + -DCMAKE_INSTALL_PREFIX="$INSTALL_DIR" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_C_FLAGS_RELEASE="-O2 -DNDEBUG $REPRO_FLAGS" \ + -DCMAKE_CXX_FLAGS_RELEASE="-O2 -DNDEBUG $REPRO_FLAGS" \ + -DSDL_INSTALL=ON \ + -DSDL_INSTALL_DOCS=OFF \ + -DSDL_UNINSTALL=OFF \ + -DSDL_RELOCATABLE=ON \ + -DSDL_SHARED=OFF \ + -DSDL_STATIC=ON \ + -DSDL_TEST_LIBRARY=OFF \ + -DSDL_TESTS=OFF \ + -DSDL_EXAMPLES=OFF \ + -DSDL_AUDIO=ON \ + -DSDL_OSS=ON \ + -DSDL_UNIX_CONSOLE_BUILD=ON \ + -DSDL_ALSA=OFF \ + -DSDL_JACK=OFF \ + -DSDL_PIPEWIRE=OFF \ + -DSDL_PULSEAUDIO=OFF \ + -DSDL_SNDIO=OFF \ + -DSDL_DISKAUDIO=OFF \ + -DSDL_DUMMYAUDIO=OFF \ + -DSDL_VIDEO=OFF \ + -DSDL_GPU=OFF \ + -DSDL_RENDER=OFF \ + -DSDL_CAMERA=OFF \ + -DSDL_JOYSTICK=OFF \ + -DSDL_HAPTIC=OFF \ + -DSDL_HIDAPI=OFF \ + -DSDL_POWER=OFF \ + -DSDL_SENSOR=OFF \ + -DSDL_DIALOG=OFF \ + -DSDL_TRAY=OFF \ + -DSDL_DBUS=OFF \ + -DSDL_LIBURING=OFF \ + -DSDL_IBUS=OFF \ + -DSDL_LIBUDEV=OFF \ + -DSDL_ASSEMBLY=OFF \ + -DSDL_OFFSCREEN=OFF \ + -DSDL_RPATH=OFF + +cmake --build "$BUILD_DIR" --parallel +cmake --install "$BUILD_DIR" + +# The resolver atomically moves this staging tree. Keep pkg-config metadata +# relative, and reject CMake metadata that retained the temporary prefix. +sed -i.bak 's|^prefix=.*|prefix=${pcfiledir}/../..|' \ + "$INSTALL_DIR/lib/pkgconfig/sdl3.pc" +rm -f "$INSTALL_DIR/lib/pkgconfig/sdl3.pc.bak" +if grep -R -F "$INSTALL_DIR" "$INSTALL_DIR/lib/cmake/SDL3" >/dev/null; then + echo "ERROR: SDL3 CMake metadata retained its resolver staging prefix" >&2 + exit 1 +fi +rm -rf "$INSTALL_DIR/share" +test -f "$INSTALL_DIR/lib/libSDL3.a" +test -f "$INSTALL_DIR/include/SDL3/SDL.h" +test -f "$INSTALL_DIR/lib/pkgconfig/sdl3.pc" +test -d "$INSTALL_DIR/lib/cmake/SDL3" +echo "==> SDL3 OSS-only static package complete" diff --git a/packages/registry/sdl3/build.toml b/packages/registry/sdl3/build.toml new file mode 100644 index 0000000000..9152606177 --- /dev/null +++ b/packages/registry/sdl3/build.toml @@ -0,0 +1,13 @@ +script_path = "packages/registry/sdl3/build-sdl3.sh" +inputs = [ + "packages/registry/sdl3/build-sdl3.sh", + "packages/registry/sdl3/cmake/kandelo-toolchain.cmake", + "packages/registry/sdl3/cmake/Platform/Kandelo.cmake", + "packages/registry/sdl3/patches/0001-recognize-kandelo-platform.patch", +] +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "UNPUBLISHED" +revision = 1 + +[binary] +index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/sdl3/cmake/Platform/Kandelo.cmake b/packages/registry/sdl3/cmake/Platform/Kandelo.cmake new file mode 100644 index 0000000000..f4497742b7 --- /dev/null +++ b/packages/registry/sdl3/cmake/Platform/Kandelo.cmake @@ -0,0 +1,5 @@ +# Kandelo is a POSIX-compatible Unix platform targeting WebAssembly. +set(UNIX 1) +set(CMAKE_EXECUTABLE_SUFFIX ".wasm") +set(CMAKE_STATIC_LIBRARY_PREFIX "lib") +set(CMAKE_STATIC_LIBRARY_SUFFIX ".a") diff --git a/packages/registry/sdl3/cmake/kandelo-toolchain.cmake b/packages/registry/sdl3/cmake/kandelo-toolchain.cmake new file mode 100644 index 0000000000..c934103c91 --- /dev/null +++ b/packages/registry/sdl3/cmake/kandelo-toolchain.cmake @@ -0,0 +1,23 @@ +# CMake toolchain identity for Kandelo's wasm32 POSIX SDK. +set(CMAKE_SYSTEM_NAME Kandelo) +set(CMAKE_SYSTEM_PROCESSOR wasm32) + +set(CMAKE_C_COMPILER wasm32posix-cc) +set(CMAKE_CXX_COMPILER wasm32posix-c++) +set(CMAKE_AR wasm32posix-ar) +set(CMAKE_RANLIB wasm32posix-ranlib) +set(CMAKE_NM wasm32posix-nm) +set(CMAKE_STRIP wasm32posix-strip) + +set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY) +list(PREPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}") + +if(DEFINED ENV{WASM_POSIX_SYSROOT}) + set(CMAKE_SYSROOT "$ENV{WASM_POSIX_SYSROOT}") + set(CMAKE_FIND_ROOT_PATH "$ENV{WASM_POSIX_SYSROOT}") +endif() + +set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) +set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) diff --git a/packages/registry/sdl3/package.toml b/packages/registry/sdl3/package.toml new file mode 100644 index 0000000000..286cf50d40 --- /dev/null +++ b/packages/registry/sdl3/package.toml @@ -0,0 +1,60 @@ +kind = "library" + +name = "sdl3" +version = "3.4.10" +kernel_abi = 40 +depends_on = [] +arches = ["wasm32"] + +[source] +url = "https://github.com/libsdl-org/SDL/releases/download/release-3.4.10/SDL3-3.4.10.tar.gz" +sha256 = "12b34280415ec8418c864408b93d008a20a6530687ee613d60bfbd20411f2785" + +[license] +spdx = "Zlib" +url = "https://github.com/libsdl-org/SDL/blob/release-3.4.10/LICENSE.txt" + +[build] +script_path = "packages/registry/sdl3/build-sdl3.sh" + +[[host_tools]] +name = "cmake" +version_constraint = ">=3.20" +probe = { args = ["--version"], version_regex = "cmake version (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "curl" +version_constraint = ">=7.71.0" +probe = { args = ["--version"], version_regex = "curl (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "tar" +version_constraint = ">=1.30" +probe = { args = ["--version"], version_regex = "tar.*?(\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "patch" +version_constraint = ">=2.0" +probe = { args = ["--version"], version_regex = "patch (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "shasum" +version_constraint = ">=6.0" +probe = { args = ["--version"], version_regex = "(\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[outputs] +libs = ["lib/libSDL3.a"] +headers = ["include/SDL3"] +pkgconfig = ["lib/pkgconfig/sdl3.pc"] +files = [ + "lib/cmake/SDL3/SDL3headersTargets.cmake", + "lib/cmake/SDL3/SDL3staticTargets.cmake", + "lib/cmake/SDL3/SDL3staticTargets-release.cmake", + "lib/cmake/SDL3/SDL3Config.cmake", + "lib/cmake/SDL3/SDL3ConfigVersion.cmake", +] diff --git a/packages/registry/sdl3/patches/0001-recognize-kandelo-platform.patch b/packages/registry/sdl3/patches/0001-recognize-kandelo-platform.patch new file mode 100644 index 0000000000..b74995c516 --- /dev/null +++ b/packages/registry/sdl3/patches/0001-recognize-kandelo-platform.patch @@ -0,0 +1,24 @@ +diff --git a/cmake/sdlplatform.cmake b/cmake/sdlplatform.cmake +index 3a013ab..29056a2 100644 +--- a/cmake/sdlplatform.cmake ++++ b/cmake/sdlplatform.cmake +@@ -30,6 +30,8 @@ function(SDL_DetectCMakePlatform) + set(sdl_cmake_platform RISCOS) + elseif(VITA) + set(sdl_cmake_platform Vita) ++ elseif(CMAKE_SYSTEM_NAME MATCHES "Kandelo.*") ++ set(sdl_cmake_platform Kandelo) + elseif(CMAKE_SYSTEM_NAME MATCHES ".*Linux") + set(sdl_cmake_platform Linux) + elseif(CMAKE_SYSTEM_NAME MATCHES "kFreeBSD.*") +diff --git a/CMakeLists.txt b/CMakeLists.txt +index e0d3bf1..64ad4cc 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -130,0 +131,6 @@ ++# Kandelo is a POSIX-compatible Unix kernel. CMake does not know this custom ++# system name itself, so preserve that platform fact for SDL's Unix checks. ++if(KANDELO) ++ set(UNIX TRUE) ++endif() ++ diff --git a/programs/audiotest.c b/programs/audiotest.c index 269b709137..822b6ff3b6 100644 --- a/programs/audiotest.c +++ b/programs/audiotest.c @@ -5,19 +5,18 @@ * Used by host/test/audio-integration.test.ts to verify the kernel * - exposes /dev/dsp * - accepts OSS ioctls (SNDCTL_DSP_SPEED / STEREO / SETFMT / GETFMTS) - * - buffers `write()` bytes into the ring drained by - * `kernel_drain_audio` + * - compiles against Kandelo's installed OSS source-compatibility header + * - buffers `write()` bytes for the host PCM sink * - reports the configured sample rate / channel count via the - * dedicated wasm exports + * PCM transport descriptor * * On success the program prints: * * ready * wrote * - * and exits 0. The harness reads the rate/chans from the first line, - * then calls drainAudio() repeatedly until it has the same byte count - * back, asserting that the bytes match what we wrote. + * and exits 0 after the final close has drained. The host sink observes the + * same deterministic bytes while advancing the audio clock. */ #include #include @@ -25,18 +24,9 @@ #include #include #include +#include #include -/* OSS ioctls — same numeric values the kernel and Linux use. We - * hard-code rather than #include so the test program - * builds without pulling additional headers into the toolchain. */ -#define SNDCTL_DSP_RESET 0x00005000u -#define SNDCTL_DSP_SPEED 0xc0045002u -#define SNDCTL_DSP_STEREO 0xc0045003u -#define SNDCTL_DSP_SETFMT 0xc0045005u -#define SNDCTL_DSP_GETFMTS 0x8004500bu -#define AFMT_S16_LE 0x10 - int main(void) { int fd = open("/dev/dsp", O_WRONLY); if (fd < 0) { @@ -95,10 +85,19 @@ int main(void) { close(fd); return 1; } + if ((size_t)n != sizeof(pcm)) { + fprintf(stderr, "short blocking /dev/dsp write: %zd of %zu bytes\n", + n, sizeof(pcm)); + close(fd); + return 1; + } printf("wrote %zd\n", n); fflush(stdout); - close(fd); + if (close(fd) < 0) { + perror("close /dev/dsp"); + return 1; + } return 0; } diff --git a/run.sh b/run.sh index b7aa1247d4..9f1ec25977 100755 --- a/run.sh +++ b/run.sh @@ -169,12 +169,21 @@ KERNEL_REQUIRED_EXPORTS=( kernel_create_process kernel_create_process_with_stdio kernel_get_parent_pid + kernel_get_process_exit_signal kernel_get_process_state kernel_handle_channel kernel_has_sa_nocldstop kernel_host_adapter_manifest_len kernel_host_adapter_manifest_ptr kernel_mark_process_signaled + kernel_pcm_claim_transport + kernel_pcm_clock_update + kernel_pcm_reconcile + kernel_pcm_transport_len + kernel_pcm_transport_ptr + kernel_pipe_has_readers + kernel_posix_timer_fire + kernel_prepare_write_operation kernel_reap_exited_child kernel_remove_process kernel_wait_child_poll @@ -1896,6 +1905,7 @@ clean_target() { warn "Cleaned NetHack (also invalidated nethack.zip and shell.vfs.zst; run '$0 build shell-vfs' to regenerate for browser demo)" ;; fbdoom) rm -rf "$REPO_ROOT/packages/registry/fbdoom/fbdoom-src" \ + "$REPO_ROOT/packages/registry/fbdoom/fbdoom-build" \ "$REPO_ROOT/local-binaries/programs/wasm32/fbdoom" rm -f "$REPO_ROOT/packages/registry/fbdoom/fbdoom.wasm" \ "$REPO_ROOT/local-binaries/programs/wasm32/fbdoom.wasm" \ diff --git a/scripts/prepare-host-package.sh b/scripts/prepare-host-package.sh index 2232a048c3..08eef6b72d 100755 --- a/scripts/prepare-host-package.sh +++ b/scripts/prepare-host-package.sh @@ -5,6 +5,14 @@ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" HOST_WASM_DIR="$REPO_ROOT/host/wasm" mkdir -p "$HOST_WASM_DIR" +# `npm run build` copies the self-contained browser PCM worklet through the +# tsup onSuccess hook. Keep prepack strict so a publish can never contain a +# BrowserKernel bundle whose relative worklet URL resolves to a missing asset. +test -f "$REPO_ROOT/host/dist/audio/pcm-audio-worklet.js" || { + echo "prepare-host-package: missing dist/audio/pcm-audio-worklet.js; run npm run build in host/" >&2 + exit 1 +} + copy_first_existing() { local dest="$1" shift diff --git a/scripts/resolve-binary.sh b/scripts/resolve-binary.sh index 978bc8c60c..0632d33ded 100755 --- a/scripts/resolve-binary.sh +++ b/scripts/resolve-binary.sh @@ -153,12 +153,21 @@ kernel_required_exports=( kernel_create_process kernel_create_process_with_stdio kernel_get_parent_pid + kernel_get_process_exit_signal kernel_get_process_state kernel_handle_channel kernel_has_sa_nocldstop kernel_host_adapter_manifest_len kernel_host_adapter_manifest_ptr kernel_mark_process_signaled + kernel_pcm_claim_transport + kernel_pcm_clock_update + kernel_pcm_reconcile + kernel_pcm_transport_len + kernel_pcm_transport_ptr + kernel_pipe_has_readers + kernel_posix_timer_fire + kernel_prepare_write_operation kernel_reap_exited_child kernel_remove_process kernel_wait_child_poll diff --git a/sdk/src/lib/flags.ts b/sdk/src/lib/flags.ts index 444ae5784e..416e2f47c8 100644 --- a/sdk/src/lib/flags.ts +++ b/sdk/src/lib/flags.ts @@ -4,6 +4,13 @@ import { targetTriple, toolPrefix } from './arch.ts'; export function compileFlags(arch: WasmArch): string[] { return [ `--target=${targetTriple(arch)}`, + // LLVM's generic wasm target has no operating-system identity, but + // Kandelo is a Unix/POSIX userspace. Publish that source-environment + // contract through the reserved macros that Unix compilers conventionally + // predefine. This keeps upstream feature selection (including SDL's Unix + // platform headers) truthful without claiming Linux or another host OS. + '-D__unix__=1', + '-D__unix=1', '-matomics', '-mbulk-memory', '-mexception-handling', diff --git a/sdk/test/cc.test.ts b/sdk/test/cc.test.ts index 0ae07c0e89..2dcf711cfb 100644 --- a/sdk/test/cc.test.ts +++ b/sdk/test/cc.test.ts @@ -19,6 +19,8 @@ describe('buildClangArgs', () => { it('compile-only: adds compile flags, no link flags', () => { const args = buildClangArgs(['-c', 'foo.c', '-o', 'foo.o'], toolchain); expect(args).toContain('--target=wasm32-unknown-unknown'); + expect(args).toContain('-D__unix__=1'); + expect(args).toContain('-D__unix=1'); expect(args).toContain('--sysroot=/tmp/sysroot'); expect(args).toContain('-c'); expect(args).toContain('foo.c'); diff --git a/sdk/test/flags.test.ts b/sdk/test/flags.test.ts index acaedbefc1..519c8b9322 100644 --- a/sdk/test/flags.test.ts +++ b/sdk/test/flags.test.ts @@ -168,6 +168,8 @@ describe('needsLinking', () => { describe('COMPILE_FLAGS', () => { it('includes target and wasm features', () => { expect(COMPILE_FLAGS).toContain('--target=wasm32-unknown-unknown'); + expect(COMPILE_FLAGS).toContain('-D__unix__=1'); + expect(COMPILE_FLAGS).toContain('-D__unix=1'); expect(COMPILE_FLAGS).toContain('-matomics'); expect(COMPILE_FLAGS).toContain('-mbulk-memory'); }); diff --git a/tests/package-system/sdl-dsp-packages.test.ts b/tests/package-system/sdl-dsp-packages.test.ts new file mode 100644 index 0000000000..8255724b9d --- /dev/null +++ b/tests/package-system/sdl-dsp-packages.test.ts @@ -0,0 +1,175 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +function source(path: string): string { + return readFileSync(join(repoRoot, path), "utf8"); +} + +describe("SDL OSS package recipes", () => { + it("pins the official SDL2 and SDL3 release archives", () => { + const sdl2 = source("packages/registry/sdl2/package.toml"); + const sdl3 = source("packages/registry/sdl3/package.toml"); + + expect(sdl2).toContain('version = "2.32.10"'); + expect(sdl2).toContain( + 'sha256 = "5f5993c530f084535c65a6879e9b26ad441169b3e25d789d83287040a9ca5165"', + ); + expect(sdl3).toContain('version = "3.4.10"'); + expect(sdl3).toContain( + 'sha256 = "12b34280415ec8418c864408b93d008a20a6530687ee613d60bfbd20411f2785"', + ); + }); + + it("limits upstream patches to truthful Kandelo platform detection", () => { + for (const patch of [ + "packages/registry/sdl2/patches/0001-recognize-kandelo-as-unix.patch", + "packages/registry/sdl3/patches/0001-recognize-kandelo-platform.patch", + ]) { + const text = source(patch); + expect(text).toMatch(/kandelo/i); + expect(text).not.toMatch(/^diff --git a\/src\/audio\/dsp\//m); + } + const sdl2Patch = source( + "packages/registry/sdl2/patches/0001-recognize-kandelo-as-unix.patch", + ); + expect(sdl2Patch).toContain("wasm32-*-none*"); + expect(sdl2Patch).not.toContain("+ *-*-none*"); + }); + + it("declares its versioned host build tools for resolver preflight", () => { + const sdl2 = source("packages/registry/sdl2/package.toml"); + const sdl3 = source("packages/registry/sdl3/package.toml"); + + for (const tool of ["curl", "tar", "patch", "shasum"]) { + expect(sdl2).toContain(`name = "${tool}"`); + expect(sdl3).toContain(`name = "${tool}"`); + } + expect(sdl2).toContain('name = "make"'); + expect(sdl3).toContain('name = "cmake"'); + // Both source recipes use curl's --retry-all-errors, introduced in 7.71.0. + expect(sdl2).toContain('version_constraint = ">=7.71.0"'); + expect(sdl3).toContain('version_constraint = ">=7.71.0"'); + }); + + it("builds static OSS-only libraries through the worktree SDK", () => { + const sdl2 = source("packages/registry/sdl2/build-sdl2.sh"); + const sdl3 = source("packages/registry/sdl3/build-sdl3.sh"); + + for (const script of [sdl2, sdl3]) { + expect(script).toContain('source "$REPO_ROOT/sdk/activate.sh"'); + expect(script).toContain("WASM_POSIX_DEP_OUT_DIR"); + expect(script).toContain("ffile-prefix-map"); + } + expect(sdl2).toContain("--enable-oss"); + expect(sdl2).toContain("--disable-alsa"); + expect(sdl2).toContain("--disable-pulseaudio"); + expect(sdl2).toContain("CXX=wasm32posix-c++"); + expect(sdl2).toContain("NM=wasm32posix-nm"); + expect(sdl2).toContain("STRIP=wasm32posix-strip"); + expect(sdl2).toContain("ac_cv_func_sysctlbyname=no"); + expect(sdl3).toContain("-DSDL_OSS=ON"); + expect(sdl3).toContain("-DSDL_UNIX_CONSOLE_BUILD=ON"); + expect(sdl3).toContain("-DSDL_ALSA=OFF"); + expect(sdl3).toContain("-DSDL_PULSEAUDIO=OFF"); + const sdl3Toolchain = source( + "packages/registry/sdl3/cmake/kandelo-toolchain.cmake", + ); + expect(sdl3Toolchain).toContain("set(CMAKE_NM wasm32posix-nm)"); + expect(sdl3Toolchain).toContain("set(CMAKE_STRIP wasm32posix-strip)"); + }); +}); + +describe("SDL /dev/dsp integration fixture", () => { + it("declares both upstream-version test executables", () => { + const manifest = source("packages/registry/sdl-dsp-test/package.toml"); + expect(manifest).toContain('depends_on = ["sdl2@2.32.10", "sdl3@3.4.10"]'); + expect(manifest).toContain('wasm = "sdl2-dsp-test.wasm"'); + expect(manifest).toContain('wasm = "sdl3-dsp-test.wasm"'); + }); + + it("forces dsp, reports JSON pacing data, and instruments final Wasm", () => { + const build = source( + "packages/registry/sdl-dsp-test/build-sdl-dsp-test.sh", + ); + const sdl2 = source("packages/registry/sdl-dsp-test/src/sdl2-dsp-test.c"); + const sdl3 = source("packages/registry/sdl-dsp-test/src/sdl3-dsp-test.c"); + + expect(build).toContain("scripts/run-wasm-fork-instrument.sh"); + for (const fixture of [sdl2, sdl3]) { + expect(fixture).toContain('setenv("SDL_AUDIODRIVER", "dsp", 1)'); + expect(fixture).toContain("SDL_DSP_RESULT "); + expect(fixture).toContain('\\"pcm_bytes\\":%llu'); + expect(fixture).toContain('\\"close_ms\\":%llu'); + } + expect(sdl2).toContain("requested.freq = 22050"); + expect(sdl2).toContain("requested.format = AUDIO_U8"); + expect(sdl3).toContain("requested.freq = 48000"); + expect(sdl3).toContain("requested.format = SDL_AUDIO_S16LE"); + expect(sdl3).toContain("#ifndef SDL_PLATFORM_UNIX"); + }); +}); + +describe("SDL_mixer playwave /dev/dsp integration fixture", () => { + it("pins the official SDL_mixer 2.8.2 source and SDL2 dependency", () => { + const manifest = source( + "packages/registry/sdl2-mixer-playwave/package.toml", + ); + + expect(manifest).toContain('version = "2.8.2"'); + expect(manifest).toContain( + 'url = "https://github.com/libsdl-org/SDL_mixer/releases/download/release-2.8.2/SDL2_mixer-2.8.2.tar.gz"', + ); + expect(manifest).toContain( + 'sha256 = "938dff531d00ace2296557a6599abe6f34599e2f34f0a4a08a397e2ccac8b8f7"', + ); + expect(manifest).toContain('depends_on = ["sdl2@2.32.10"]'); + expect(manifest).toContain('name = "playwave"'); + expect(manifest).toContain('wasm = "playwave.wasm"'); + }); + + it("declares every host tool used by the source build", () => { + const manifest = source( + "packages/registry/sdl2-mixer-playwave/package.toml", + ); + + for (const tool of ["make", "curl", "tar", "shasum"]) { + expect(manifest).toContain(`name = "${tool}"`); + } + expect(manifest).toContain('version_constraint = ">=7.71.0"'); + }); + + it("builds unmodified upstream playwave with only WAVE support", () => { + const build = source( + "packages/registry/sdl2-mixer-playwave/build-sdl2-mixer-playwave.sh", + ); + + expect( + existsSync(join(repoRoot, "packages/registry/sdl2-mixer-playwave/patches")), + ).toBe(false); + expect(build).not.toMatch(/\bpatch\b/); + expect(build).toContain('source "$REPO_ROOT/sdk/activate.sh"'); + expect(build).toContain("WASM_POSIX_DEP_OUT_DIR"); + expect(build).toContain("WASM_POSIX_DEP_SDL2_DIR"); + expect(build).toContain("--enable-music-wave"); + for (const decoder of [ + "cmd", + "mod", + "midi", + "gme", + "ogg", + "flac", + "mp3", + "opus", + "wavpack", + ]) { + expect(build).toContain(`--disable-music-${decoder}`); + } + expect(build).toContain("make -j"); + expect(build).toContain("build/playwave"); + expect(build).toContain("scripts/run-wasm-fork-instrument.sh"); + }); +}); diff --git a/tools/xtask/src/dump_abi.rs b/tools/xtask/src/dump_abi.rs index 5b1b2e9185..9fc4728f8b 100644 --- a/tools/xtask/src/dump_abi.rs +++ b/tools/xtask/src/dump_abi.rs @@ -29,7 +29,7 @@ //! defeat the check. use std::collections::BTreeMap; -use std::mem::{offset_of, size_of}; +use std::mem::{align_of, offset_of, size_of}; use std::path::PathBuf; use serde_json::{Value, json}; @@ -85,18 +85,29 @@ pub fn run(args: Vec) -> Result<(), String> { let snapshot = build_snapshot(&kernel_wasm)?; let rendered = render_deterministic(&snapshot); let header = render_c_header(); + let soundcard_header = render_soundcard_header(); let ts_module = render_ts_module(); let out = out_path.unwrap_or_else(|| repo_root().join("abi/snapshot.json")); let header_out = repo_root().join("libc/glue/abi_constants.h"); + let soundcard_header_out = repo_root().join("libc/musl-overlay/include/sys/soundcard.h"); let ts_out = repo_root().join("host/src/generated/abi.ts"); if check { check_file(&out, &rendered, "ABI snapshot")?; check_file(&header_out, &header, "libc/glue/abi_constants.h")?; + check_file( + &soundcard_header_out, + &soundcard_header, + "libc/musl-overlay/include/sys/soundcard.h", + )?; check_file(&ts_out, &ts_module, "host/src/generated/abi.ts")?; println!("abi snapshot up-to-date: {}", out.display()); println!("abi header up-to-date: {}", header_out.display()); + println!( + "OSS header up-to-date: {}", + soundcard_header_out.display() + ); println!("abi TS bindings up-to-date: {}", ts_out.display()); return Ok(()); } @@ -105,6 +116,8 @@ pub fn run(args: Vec) -> Result<(), String> { println!("wrote {}", out.display()); write_file(&header_out, &header)?; println!("wrote {}", header_out.display()); + write_file(&soundcard_header_out, &soundcard_header)?; + println!("wrote {}", soundcard_header_out.display()); write_file(&ts_out, &ts_module)?; println!("wrote {}", ts_out.display()); Ok(()) @@ -189,6 +202,265 @@ fn render_c_header() -> String { ) } +/// Kandelo-owned wasm32 OSS source ABI. Generate this SDK header from the +/// same Rust constants recorded in the ABI snapshot so C and Rust cannot +/// silently assign different request numbers. +fn render_soundcard_header() -> String { + let mut out = String::from( + "/* GENERATED by `cargo xtask dump-abi`. Do not edit by hand. */\n\ + /* Kandelo-owned wasm32 OSS source ABI; independent of the host UAPI. */\n\ + #ifndef KANDELO_SYS_SOUNDCARD_H\n\ + #define KANDELO_SYS_SOUNDCARD_H\n\ + \n\ + #include \n\ + #include \n\ + \n", + ); + + for (name, value) in oss_format_constants() { + out.push_str(&format!("#define {name} 0x{value:08x}u\n")); + } + for (alias, target) in oss_format_aliases() { + out.push_str(&format!("#define {alias} {target}\n")); + } + out.push_str( + "\n\ + typedef struct audio_buf_info {\n\ + \tint32_t fragments;\n\ + \tint32_t fragstotal;\n\ + \tint32_t fragsize;\n\ + \tint32_t bytes;\n\ + } audio_buf_info;\n\ + \n\ + typedef struct count_info {\n\ + \tint32_t bytes;\n\ + \tint32_t blocks;\n\ + \tint32_t ptr;\n\ + } count_info;\n\ + \n\ + /* Declared for source compatibility; Kandelo does not support DSP mmap. */\n\ + typedef struct buffmem_desc {\n\ + \tvoid *buffer;\n\ + \tint32_t size;\n\ + } buffmem_desc;\n\ + \n", + ); + for (name, value) in oss_ioctl_constants() { + out.push_str(&format!("#define {name} 0x{value:08x}u\n")); + } + for (alias, target) in oss_source_aliases() { + out.push_str(&format!("#define {alias} {target}\n")); + } + out.push('\n'); + for (name, value) in oss_trigger_constants() { + out.push_str(&format!("#define {name} 0x{value:08x}u\n")); + } + out.push('\n'); + for (name, value) in oss_capability_constants() { + out.push_str(&format!("#define {name} 0x{value:08x}u\n")); + } + out.push_str( + "#define DSP_CAP_REVISION PCM_CAP_REVISION\n\ + #define DSP_CAP_DUPLEX PCM_CAP_DUPLEX\n\ + #define DSP_CAP_REALTIME PCM_CAP_REALTIME\n\ + #define DSP_CAP_BATCH PCM_CAP_BATCH\n\ + #define DSP_CAP_COPROC PCM_CAP_COPROC\n\ + #define DSP_CAP_TRIGGER PCM_CAP_TRIGGER\n\ + #define DSP_CAP_MMAP PCM_CAP_MMAP\n\ + #define DSP_CAP_MULTI PCM_CAP_MULTI\n\ + #define DSP_CAP_BIND PCM_CAP_BIND\n\ + #define DSP_CAP_INPUT PCM_CAP_INPUT\n\ + #define DSP_CAP_OUTPUT PCM_CAP_OUTPUT\n\ + #define DSP_CAP_VIRTUAL PCM_CAP_VIRTUAL\n\ + #define DSP_CAP_DEFAULT PCM_CAP_DEFAULT\n\ + \n\ + #if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L\n", + ); + for (name, value) in oss_format_constants() + .into_iter() + .chain(oss_ioctl_constants()) + .chain(oss_trigger_constants()) + .chain(oss_capability_constants()) + { + out.push_str(&format!( + "_Static_assert({name} == 0x{value:08x}u, \"{name} ABI\");\n" + )); + } + for (alias, target) in oss_source_aliases() { + out.push_str(&format!( + "_Static_assert({alias} == {target}, \"{alias} source alias\");\n" + )); + } + for (alias, target) in oss_format_aliases() { + out.push_str(&format!( + "_Static_assert({alias} == {target}, \"{alias} source alias\");\n" + )); + } + out.push_str( + "_Static_assert(sizeof(audio_buf_info) == 16, \"audio_buf_info ABI size\");\n\ + _Static_assert(_Alignof(audio_buf_info) == 4, \"audio_buf_info ABI align\");\n\ + _Static_assert(offsetof(audio_buf_info, fragments) == 0, \"audio_buf_info.fragments ABI offset\");\n\ + _Static_assert(offsetof(audio_buf_info, fragstotal) == 4, \"audio_buf_info.fragstotal ABI offset\");\n\ + _Static_assert(offsetof(audio_buf_info, fragsize) == 8, \"audio_buf_info.fragsize ABI offset\");\n\ + _Static_assert(offsetof(audio_buf_info, bytes) == 12, \"audio_buf_info.bytes ABI offset\");\n\ + _Static_assert(sizeof(count_info) == 12, \"count_info ABI size\");\n\ + _Static_assert(_Alignof(count_info) == 4, \"count_info ABI align\");\n\ + _Static_assert(offsetof(count_info, bytes) == 0, \"count_info.bytes ABI offset\");\n\ + _Static_assert(offsetof(count_info, blocks) == 4, \"count_info.blocks ABI offset\");\n\ + _Static_assert(offsetof(count_info, ptr) == 8, \"count_info.ptr ABI offset\");\n\ + #endif\n\ + \n\ + #endif /* KANDELO_SYS_SOUNDCARD_H */\n", + ); + out +} + +fn oss_format_constants() -> Vec<(&'static str, u32)> { + use shared::oss; + vec![ + ("AFMT_QUERY", oss::AFMT_QUERY), + ("AFMT_MU_LAW", oss::AFMT_MU_LAW), + ("AFMT_A_LAW", oss::AFMT_A_LAW), + ("AFMT_IMA_ADPCM", oss::AFMT_IMA_ADPCM), + ("AFMT_U8", oss::AFMT_U8), + ("AFMT_S16_LE", oss::AFMT_S16_LE), + ("AFMT_S16_BE", oss::AFMT_S16_BE), + ("AFMT_S8", oss::AFMT_S8), + ("AFMT_U16_LE", oss::AFMT_U16_LE), + ("AFMT_U16_BE", oss::AFMT_U16_BE), + ("AFMT_MPEG", oss::AFMT_MPEG), + ("AFMT_AC3", oss::AFMT_AC3), + ("AFMT_S32_LE", oss::AFMT_S32_LE), + ("AFMT_S32_BE", oss::AFMT_S32_BE), + ("AFMT_U32_LE", oss::AFMT_U32_LE), + ("AFMT_U32_BE", oss::AFMT_U32_BE), + ("AFMT_S24_LE", oss::AFMT_S24_LE), + ("AFMT_S24_BE", oss::AFMT_S24_BE), + ("AFMT_U24_LE", oss::AFMT_U24_LE), + ("AFMT_U24_BE", oss::AFMT_U24_BE), + ("AFMT_F32_LE", oss::AFMT_F32_LE), + ("AFMT_F32_BE", oss::AFMT_F32_BE), + ] +} + +/// The Kandelo SDK targets little-endian wasm32. Publish FreeBSD's canonical +/// native/opposite-endian format spellings without duplicating numeric values +/// in the ABI snapshot. +fn oss_format_aliases() -> Vec<(&'static str, &'static str)> { + vec![ + ("AFMT_S16_NE", "AFMT_S16_LE"), + ("AFMT_S16_OE", "AFMT_S16_BE"), + ("AFMT_S24_NE", "AFMT_S24_LE"), + ("AFMT_S24_OE", "AFMT_S24_BE"), + ("AFMT_S32_NE", "AFMT_S32_LE"), + ("AFMT_S32_OE", "AFMT_S32_BE"), + ("AFMT_U16_NE", "AFMT_U16_LE"), + ("AFMT_U16_OE", "AFMT_U16_BE"), + ("AFMT_U24_NE", "AFMT_U24_LE"), + ("AFMT_U24_OE", "AFMT_U24_BE"), + ("AFMT_U32_NE", "AFMT_U32_LE"), + ("AFMT_U32_OE", "AFMT_U32_BE"), + ("AFMT_F32_NE", "AFMT_F32_LE"), + ("AFMT_F32_OE", "AFMT_F32_BE"), + ("AFMT_FLOAT", "AFMT_F32_NE"), + ] +} + +fn oss_ioctl_constants() -> Vec<(&'static str, u32)> { + use shared::oss; + vec![ + ("SNDCTL_DSP_RESET", oss::SNDCTL_DSP_RESET), + ("SNDCTL_DSP_SYNC", oss::SNDCTL_DSP_SYNC), + ("SNDCTL_DSP_SPEED", oss::SNDCTL_DSP_SPEED), + ("SNDCTL_DSP_STEREO", oss::SNDCTL_DSP_STEREO), + ("SNDCTL_DSP_GETBLKSIZE", oss::SNDCTL_DSP_GETBLKSIZE), + ("SNDCTL_DSP_SETBLKSIZE", oss::SNDCTL_DSP_SETBLKSIZE), + ("SNDCTL_DSP_SETFMT", oss::SNDCTL_DSP_SETFMT), + ("SNDCTL_DSP_CHANNELS", oss::SNDCTL_DSP_CHANNELS), + ("SOUND_PCM_WRITE_FILTER", oss::SOUND_PCM_WRITE_FILTER), + ("SNDCTL_DSP_POST", oss::SNDCTL_DSP_POST), + ("SNDCTL_DSP_SUBDIVIDE", oss::SNDCTL_DSP_SUBDIVIDE), + ("SNDCTL_DSP_SETFRAGMENT", oss::SNDCTL_DSP_SETFRAGMENT), + ("SNDCTL_DSP_GETFMTS", oss::SNDCTL_DSP_GETFMTS), + ("SNDCTL_DSP_GETOSPACE", oss::SNDCTL_DSP_GETOSPACE), + ("SNDCTL_DSP_GETISPACE", oss::SNDCTL_DSP_GETISPACE), + ("SNDCTL_DSP_NONBLOCK", oss::SNDCTL_DSP_NONBLOCK), + ("SNDCTL_DSP_GETCAPS", oss::SNDCTL_DSP_GETCAPS), + ("SNDCTL_DSP_SETTRIGGER", oss::SNDCTL_DSP_SETTRIGGER), + ("SNDCTL_DSP_GETTRIGGER", oss::SNDCTL_DSP_GETTRIGGER), + ("SNDCTL_DSP_GETIPTR", oss::SNDCTL_DSP_GETIPTR), + ("SNDCTL_DSP_GETOPTR", oss::SNDCTL_DSP_GETOPTR), + ("SNDCTL_DSP_MAPINBUF", oss::SNDCTL_DSP_MAPINBUF), + ("SNDCTL_DSP_MAPOUTBUF", oss::SNDCTL_DSP_MAPOUTBUF), + ("SNDCTL_DSP_SETSYNCRO", oss::SNDCTL_DSP_SETSYNCRO), + ("SNDCTL_DSP_SETDUPLEX", oss::SNDCTL_DSP_SETDUPLEX), + ("SNDCTL_DSP_GETODELAY", oss::SNDCTL_DSP_GETODELAY), + ("SOUND_PCM_READ_RATE", oss::SOUND_PCM_READ_RATE), + ("SOUND_PCM_READ_BITS", oss::SOUND_PCM_READ_BITS), + ("SOUND_PCM_READ_CHANNELS", oss::SOUND_PCM_READ_CHANNELS), + ("SOUND_PCM_READ_FILTER", oss::SOUND_PCM_READ_FILTER), + ] +} + +/// Canonical OSS compatibility spellings that do not introduce additional +/// ioctl values. Keep aliases out of the ABI snapshot's numeric map: their +/// targets are already pinned there, while the generated C assertions protect +/// the source-level contract. +fn oss_source_aliases() -> Vec<(&'static str, &'static str)> { + vec![ + ("SNDCTL_DSP_HALT", "SNDCTL_DSP_RESET"), + ("SNDCTL_DSP_SAMPLESIZE", "SNDCTL_DSP_SETFMT"), + ("SOUND_PCM_WRITE_RATE", "SNDCTL_DSP_SPEED"), + ("SOUND_PCM_WRITE_CHANNELS", "SNDCTL_DSP_CHANNELS"), + ("SOUND_PCM_WRITE_BITS", "SNDCTL_DSP_SETFMT"), + ("SOUND_PCM_SETFMT", "SNDCTL_DSP_SETFMT"), + ("SOUND_PCM_POST", "SNDCTL_DSP_POST"), + ("SOUND_PCM_RESET", "SNDCTL_DSP_RESET"), + ("SOUND_PCM_SYNC", "SNDCTL_DSP_SYNC"), + ("SOUND_PCM_SUBDIVIDE", "SNDCTL_DSP_SUBDIVIDE"), + ("SOUND_PCM_SETFRAGMENT", "SNDCTL_DSP_SETFRAGMENT"), + ("SOUND_PCM_GETFMTS", "SNDCTL_DSP_GETFMTS"), + ("SOUND_PCM_GETOSPACE", "SNDCTL_DSP_GETOSPACE"), + ("SOUND_PCM_GETISPACE", "SNDCTL_DSP_GETISPACE"), + ("SOUND_PCM_NONBLOCK", "SNDCTL_DSP_NONBLOCK"), + ("SOUND_PCM_GETCAPS", "SNDCTL_DSP_GETCAPS"), + ("SOUND_PCM_GETTRIGGER", "SNDCTL_DSP_GETTRIGGER"), + ("SOUND_PCM_SETTRIGGER", "SNDCTL_DSP_SETTRIGGER"), + ("SOUND_PCM_SETSYNCRO", "SNDCTL_DSP_SETSYNCRO"), + ("SOUND_PCM_GETIPTR", "SNDCTL_DSP_GETIPTR"), + ("SOUND_PCM_GETOPTR", "SNDCTL_DSP_GETOPTR"), + ("SOUND_PCM_MAPINBUF", "SNDCTL_DSP_MAPINBUF"), + ("SOUND_PCM_MAPOUTBUF", "SNDCTL_DSP_MAPOUTBUF"), + ] +} + +fn oss_trigger_constants() -> Vec<(&'static str, u32)> { + use shared::oss; + vec![ + ("PCM_ENABLE_INPUT", oss::PCM_ENABLE_INPUT), + ("PCM_ENABLE_OUTPUT", oss::PCM_ENABLE_OUTPUT), + ] +} + +fn oss_capability_constants() -> Vec<(&'static str, u32)> { + use shared::oss; + vec![ + ("PCM_CAP_REVISION", oss::PCM_CAP_REVISION), + ("PCM_CAP_DUPLEX", oss::PCM_CAP_DUPLEX), + ("PCM_CAP_REALTIME", oss::PCM_CAP_REALTIME), + ("PCM_CAP_BATCH", oss::PCM_CAP_BATCH), + ("PCM_CAP_COPROC", oss::PCM_CAP_COPROC), + ("PCM_CAP_TRIGGER", oss::PCM_CAP_TRIGGER), + ("PCM_CAP_MMAP", oss::PCM_CAP_MMAP), + ("PCM_CAP_MULTI", oss::PCM_CAP_MULTI), + ("PCM_CAP_BIND", oss::PCM_CAP_BIND), + ("PCM_CAP_INPUT", oss::PCM_CAP_INPUT), + ("PCM_CAP_OUTPUT", oss::PCM_CAP_OUTPUT), + ("PCM_CAP_VIRTUAL", oss::PCM_CAP_VIRTUAL), + ("PCM_CAP_DEFAULT", oss::PCM_CAP_DEFAULT), + ] +} + /// TypeScript bindings consumed by `host/src/*`. /// /// Keep this generated from the same Rust/shared source of truth as @@ -261,6 +533,8 @@ fn render_ts_module() -> String { } out.push_str("] as const;\n\n"); + render_pcm_ts_bindings(&mut out); + out.push_str("export const HOST_ADAPTER_MANIFEST_FIELDS = {\n"); for field in host_adapter_manifest_fields() { out.push_str(&format!( @@ -587,6 +861,7 @@ fn render_ts_module() -> String { out.push_str(" | { type: \"cstring\" }\n"); out.push_str(" | { type: \"arg\"; argIndex: number; multiplier?: number; add?: number }\n"); out.push_str(" | { type: \"deref\"; argIndex: number }\n"); + out.push_str(" | { type: \"ioctl\"; requestArgIndex: number }\n"); out.push_str(" | { type: \"fixed\"; size: number };\n\n"); out.push_str("export interface SyscallArgDesc {\n"); out.push_str(" argIndex: number;\n"); @@ -610,6 +885,68 @@ fn render_ts_module() -> String { out } +fn render_pcm_ts_bindings(out: &mut String) { + use shared::pcm; + + for (name, value) in [ + ("PCM_TRANSPORT_MAGIC", pcm::PCM_TRANSPORT_MAGIC), + ("PCM_TRANSPORT_VERSION", pcm::PCM_TRANSPORT_VERSION), + ("PCM_TRANSPORT_HEADER_BYTES", pcm::PCM_TRANSPORT_HEADER_BYTES), + ("PCM_TRANSPORT_RING_BYTES", pcm::PCM_TRANSPORT_RING_BYTES), + ("PCM_TRANSPORT_BYTES", pcm::PCM_TRANSPORT_BYTES), + ("PCM_STATE_CLOSED", pcm::PCM_STATE_CLOSED), + ("PCM_STATE_STOPPED", pcm::PCM_STATE_STOPPED), + ("PCM_STATE_RUNNING", pcm::PCM_STATE_RUNNING), + ("PCM_STATE_DRAINING", pcm::PCM_STATE_DRAINING), + ("PCM_FORMAT_UNKNOWN", pcm::PCM_FORMAT_UNKNOWN), + ("PCM_FORMAT_U8", pcm::PCM_FORMAT_U8), + ("PCM_FORMAT_S16_LE", pcm::PCM_FORMAT_S16_LE), + ("PCM_FORMAT_S16_BE", pcm::PCM_FORMAT_S16_BE), + ("PCM_TRANSPORT_UNCLAIMED", pcm::PCM_TRANSPORT_UNCLAIMED), + ("PCM_TRANSPORT_LEGACY_PULL", pcm::PCM_TRANSPORT_LEGACY_PULL), + ("PCM_TRANSPORT_SHARED_CLOCK", pcm::PCM_TRANSPORT_SHARED_CLOCK), + ("PCM_FLAG_CONFIGURING", pcm::PCM_FLAG_CONFIGURING), + ("PCM_FLAG_UNDERRUN_ACTIVE", pcm::PCM_FLAG_UNDERRUN_ACTIVE), + ("PCM_FLAG_FATAL_ERROR", pcm::PCM_FLAG_FATAL_ERROR), + ] { + out.push_str(&format!("export const {name} = {value} as const;\n")); + } + out.push('\n'); + + out.push_str("export const PCM_SHARED_CONTROL_FIELDS = {\n"); + for (name, offset) in [ + ("magic", offset_of!(pcm::PcmSharedControl, magic)), + ("version", offset_of!(pcm::PcmSharedControl, version)), + ("headerBytes", offset_of!(pcm::PcmSharedControl, header_bytes)), + ("physicalCapacityBytes", offset_of!(pcm::PcmSharedControl, physical_capacity_bytes)), + ("activeCapacityBytes", offset_of!(pcm::PcmSharedControl, active_capacity_bytes)), + ("format", offset_of!(pcm::PcmSharedControl, format)), + ("sampleRate", offset_of!(pcm::PcmSharedControl, rate)), + ("channels", offset_of!(pcm::PcmSharedControl, channels)), + ("frameBytes", offset_of!(pcm::PcmSharedControl, frame_bytes)), + ("fragmentBytes", offset_of!(pcm::PcmSharedControl, fragment_bytes)), + ("fragments", offset_of!(pcm::PcmSharedControl, fragment_count)), + ("state", offset_of!(pcm::PcmSharedControl, state)), + ("generation", offset_of!(pcm::PcmSharedControl, generation)), + ("flags", offset_of!(pcm::PcmSharedControl, flags)), + ("transportMode", offset_of!(pcm::PcmSharedControl, transport_mode)), + ("producerSeq", offset_of!(pcm::PcmSharedControl, producer_seq)), + ("producerLo", offset_of!(pcm::PcmSharedControl, producer_lo)), + ("producerHi", offset_of!(pcm::PcmSharedControl, producer_hi)), + ("consumerSeq", offset_of!(pcm::PcmSharedControl, consumer_seq)), + ("consumerLo", offset_of!(pcm::PcmSharedControl, consumer_lo)), + ("consumerHi", offset_of!(pcm::PcmSharedControl, consumer_hi)), + ("discardSeq", offset_of!(pcm::PcmSharedControl, discard_seq)), + ("discardLo", offset_of!(pcm::PcmSharedControl, discard_lo)), + ("discardHi", offset_of!(pcm::PcmSharedControl, discard_hi)), + ("underruns", offset_of!(pcm::PcmSharedControl, underruns)), + ("wakeSeq", offset_of!(pcm::PcmSharedControl, wake_seq)), + ] { + out.push_str(&format!(" {name}: {{ offset: {offset}, size: 4 }},\n")); + } + out.push_str("} as const;\n\n"); +} + fn ts_syscall_arg_desc(desc: &shared::host_abi::SyscallArgDesc) -> String { let mut s = format!( "{{ argIndex: {}, direction: {:?}, size: {}", @@ -653,6 +990,9 @@ fn ts_syscall_arg_size(size: shared::host_abi::SyscallArgSize) -> String { SyscallArgSize::Deref { arg_index } => { format!("{{ type: \"deref\", argIndex: {arg_index} }}") } + SyscallArgSize::Ioctl { request_arg_index } => { + format!("{{ type: \"ioctl\", requestArgIndex: {request_arg_index} }}") + } SyscallArgSize::Fixed { size } => format!("{{ type: \"fixed\", size: {size} }}"), } } @@ -775,6 +1115,29 @@ fn build_struct_layout(total_size: usize, fields: Vec<(&'static str, usize)>) -> Value::Object(m.into_iter().collect()) } +fn build_typed_struct_layout( + total_size: usize, + alignment: usize, + fields: &[(&'static str, usize, usize, &'static str)], +) -> Value { + let fields = fields + .iter() + .map(|(name, offset, span, field_type)| { + let mut field: JsonMap = BTreeMap::new(); + field.insert("name".into(), json!(name)); + field.insert("offset".into(), json!(offset)); + field.insert("span".into(), json!(span)); + field.insert("type".into(), json!(field_type)); + Value::Object(field.into_iter().collect()) + }) + .collect(); + let mut layout: JsonMap = BTreeMap::new(); + layout.insert("size".into(), json!(total_size)); + layout.insert("align".into(), json!(alignment)); + layout.insert("fields".into(), Value::Array(fields)); + Value::Object(layout.into_iter().collect()) +} + fn build_snapshot(kernel_wasm: &std::path::Path) -> Result { let mut root: JsonMap = BTreeMap::new(); @@ -785,6 +1148,8 @@ fn build_snapshot(kernel_wasm: &std::path::Path) -> Result { root.insert("channel_buffers".into(), channel_buffers()); root.insert("marshalled_structs".into(), marshalled_structs()); + root.insert("oss_source_abi".into(), oss_source_abi()); + root.insert("pcm_transport_abi".into(), pcm_transport_abi()); root.insert("syscalls".into(), syscalls()); root.insert("pathconf_names".into(), pathconf_names()); root.insert("wait_contract".into(), wait_contract()); @@ -862,6 +1227,74 @@ fn channel_buffers() -> Value { Value::Object(m.into_iter().collect()) } +fn oss_source_abi() -> Value { + let mut ioctls: JsonMap = BTreeMap::new(); + for (name, value) in oss_ioctl_constants() { + ioctls.insert(name.into(), json!(value)); + } + let mut formats: JsonMap = BTreeMap::new(); + for (name, value) in oss_format_constants() { + formats.insert(name.into(), json!(value)); + } + let mut capabilities: JsonMap = BTreeMap::new(); + for (name, value) in oss_capability_constants() { + capabilities.insert(name.into(), json!(value)); + } + let mut trigger_values: JsonMap = BTreeMap::new(); + for (name, value) in oss_trigger_constants() { + trigger_values.insert(name.into(), json!(value)); + } + + let mut abi: JsonMap = BTreeMap::new(); + abi.insert( + "ioctls".into(), + Value::Object(ioctls.into_iter().collect()), + ); + abi.insert( + "formats".into(), + Value::Object(formats.into_iter().collect()), + ); + abi.insert( + "capabilities".into(), + Value::Object(capabilities.into_iter().collect()), + ); + abi.insert( + "trigger_values".into(), + Value::Object(trigger_values.into_iter().collect()), + ); + Value::Object(abi.into_iter().collect()) +} + +fn pcm_transport_abi() -> Value { + use shared::pcm; + + let mut constants: JsonMap = BTreeMap::new(); + for (name, value) in [ + ("magic", pcm::PCM_TRANSPORT_MAGIC), + ("version", pcm::PCM_TRANSPORT_VERSION), + ("header_bytes", pcm::PCM_TRANSPORT_HEADER_BYTES), + ("ring_bytes", pcm::PCM_TRANSPORT_RING_BYTES), + ("total_bytes", pcm::PCM_TRANSPORT_BYTES), + ("state_closed", pcm::PCM_STATE_CLOSED), + ("state_stopped", pcm::PCM_STATE_STOPPED), + ("state_running", pcm::PCM_STATE_RUNNING), + ("state_draining", pcm::PCM_STATE_DRAINING), + ("format_unknown", pcm::PCM_FORMAT_UNKNOWN), + ("format_u8", pcm::PCM_FORMAT_U8), + ("format_s16_le", pcm::PCM_FORMAT_S16_LE), + ("format_s16_be", pcm::PCM_FORMAT_S16_BE), + ("transport_unclaimed", pcm::PCM_TRANSPORT_UNCLAIMED), + ("transport_legacy_pull", pcm::PCM_TRANSPORT_LEGACY_PULL), + ("transport_shared_clock", pcm::PCM_TRANSPORT_SHARED_CLOCK), + ("flag_configuring", pcm::PCM_FLAG_CONFIGURING), + ("flag_underrun_active", pcm::PCM_FLAG_UNDERRUN_ACTIVE), + ("flag_fatal_error", pcm::PCM_FLAG_FATAL_ERROR), + ] { + constants.insert(name.into(), json!(value)); + } + Value::Object(constants.into_iter().collect()) +} + fn process_memory_layout() -> Value { use shared::process_memory as pm; @@ -1017,12 +1450,75 @@ fn marshalled_structs() -> Value { }; use shared::fbdev::{FbBitfield, FbFixScreenInfo, FbVarScreenInfo}; use shared::gl::{GlContextAttrs, GlQueryInfo, GlSubmitInfo, GlSurfaceAttrs}; + use shared::oss::{AudioBufInfo, CountInfo}; + use shared::pcm::PcmSharedControl; use shared::{ KernelWaitResult, WasmDirent, WasmFlock, WasmPollFd, WasmRusageWire, WasmStat, WasmStatfs, WasmTimespec, }; let mut structs: JsonMap = BTreeMap::new(); + structs.insert( + "AudioBufInfo".into(), + build_typed_struct_layout( + size_of::(), + align_of::(), + &[ + ("fragments", offset_of!(AudioBufInfo, fragments), 4, "i32"), + ("fragstotal", offset_of!(AudioBufInfo, fragstotal), 4, "i32"), + ("fragsize", offset_of!(AudioBufInfo, fragsize), 4, "i32"), + ("bytes", offset_of!(AudioBufInfo, bytes), 4, "i32"), + ], + ), + ); + structs.insert( + "CountInfo".into(), + build_typed_struct_layout( + size_of::(), + align_of::(), + &[ + ("bytes", offset_of!(CountInfo, bytes), 4, "i32"), + ("blocks", offset_of!(CountInfo, blocks), 4, "i32"), + ("ptr", offset_of!(CountInfo, ptr), 4, "i32"), + ], + ), + ); + structs.insert( + "PcmSharedControl".into(), + build_typed_struct_layout( + size_of::(), + align_of::(), + &[ + ("magic", offset_of!(PcmSharedControl, magic), 4, "u32"), + ("version", offset_of!(PcmSharedControl, version), 4, "u32"), + ("header_bytes", offset_of!(PcmSharedControl, header_bytes), 4, "u32"), + ("physical_capacity_bytes", offset_of!(PcmSharedControl, physical_capacity_bytes), 4, "u32"), + ("active_capacity_bytes", offset_of!(PcmSharedControl, active_capacity_bytes), 4, "u32"), + ("format", offset_of!(PcmSharedControl, format), 4, "u32"), + ("rate", offset_of!(PcmSharedControl, rate), 4, "u32"), + ("channels", offset_of!(PcmSharedControl, channels), 4, "u32"), + ("frame_bytes", offset_of!(PcmSharedControl, frame_bytes), 4, "u32"), + ("fragment_bytes", offset_of!(PcmSharedControl, fragment_bytes), 4, "u32"), + ("fragment_count", offset_of!(PcmSharedControl, fragment_count), 4, "u32"), + ("state", offset_of!(PcmSharedControl, state), 4, "u32"), + ("generation", offset_of!(PcmSharedControl, generation), 4, "u32"), + ("flags", offset_of!(PcmSharedControl, flags), 4, "u32"), + ("transport_mode", offset_of!(PcmSharedControl, transport_mode), 4, "u32"), + ("producer_seq", offset_of!(PcmSharedControl, producer_seq), 4, "u32"), + ("producer_lo", offset_of!(PcmSharedControl, producer_lo), 4, "u32"), + ("producer_hi", offset_of!(PcmSharedControl, producer_hi), 4, "u32"), + ("consumer_seq", offset_of!(PcmSharedControl, consumer_seq), 4, "u32"), + ("consumer_lo", offset_of!(PcmSharedControl, consumer_lo), 4, "u32"), + ("consumer_hi", offset_of!(PcmSharedControl, consumer_hi), 4, "u32"), + ("discard_seq", offset_of!(PcmSharedControl, discard_seq), 4, "u32"), + ("discard_lo", offset_of!(PcmSharedControl, discard_lo), 4, "u32"), + ("discard_hi", offset_of!(PcmSharedControl, discard_hi), 4, "u32"), + ("underruns", offset_of!(PcmSharedControl, underruns), 4, "u32"), + ("wake_seq", offset_of!(PcmSharedControl, wake_seq), 4, "u32"), + ("reserved", offset_of!(PcmSharedControl, reserved), 24, "[u32; 6]"), + ], + ), + ); structs.insert( "WasmStat".into(), struct_layout!(WasmStat { @@ -1766,6 +2262,10 @@ fn syscall_arg_size_json(size: shared::host_abi::SyscallArgSize) -> Value { m.insert("type".into(), json!("deref")); m.insert("argIndex".into(), json!(arg_index)); } + SyscallArgSize::Ioctl { request_arg_index } => { + m.insert("type".into(), json!("ioctl")); + m.insert("requestArgIndex".into(), json!(request_arg_index)); + } SyscallArgSize::Fixed { size } => { m.insert("type".into(), json!("fixed")); m.insert("size".into(), json!(size)); @@ -2308,6 +2808,9 @@ mod tests { assert!(rendered.contains("export const PATHCONF_NAMES = {")); assert!(rendered.contains(" PATH_MAX: 4,")); assert!(rendered.contains(" TIMESTAMP_RESOLUTION: 23,")); + assert!(rendered.contains("export const PCM_FLAG_CONFIGURING = 1 as const;")); + assert!(rendered.contains("export const PCM_FLAG_UNDERRUN_ACTIVE = 2 as const;")); + assert!(rendered.contains("export const PCM_FLAG_FATAL_ERROR = 4 as const;")); assert!(rendered.contains( "{ argIndex: 2, direction: \"out\", size: { type: \"fixed\", size: 8 }, required: true }" )); @@ -2316,6 +2819,75 @@ mod tests { assert_eq!(names["LINK_MAX"], json!(0)); assert_eq!(names["TIMESTAMP_RESOLUTION"], json!(23)); assert_eq!(names.as_object().unwrap().len(), 24); + + let pcm = pcm_transport_abi(); + assert_eq!(pcm["flag_configuring"], json!(1)); + assert_eq!(pcm["flag_underrun_active"], json!(2)); + assert_eq!(pcm["flag_fatal_error"], json!(4)); + } + + #[test] + fn generated_soundcard_header_contains_canonical_source_aliases() { + let rendered = render_soundcard_header(); + let aliases: BTreeMap<_, _> = oss_source_aliases().into_iter().collect(); + for (alias, target) in [ + ("SOUND_PCM_SUBDIVIDE", "SNDCTL_DSP_SUBDIVIDE"), + ("SOUND_PCM_SETFRAGMENT", "SNDCTL_DSP_SETFRAGMENT"), + ("SOUND_PCM_GETFMTS", "SNDCTL_DSP_GETFMTS"), + ("SOUND_PCM_GETOSPACE", "SNDCTL_DSP_GETOSPACE"), + ("SOUND_PCM_GETISPACE", "SNDCTL_DSP_GETISPACE"), + ("SOUND_PCM_NONBLOCK", "SNDCTL_DSP_NONBLOCK"), + ("SOUND_PCM_GETCAPS", "SNDCTL_DSP_GETCAPS"), + ("SOUND_PCM_GETTRIGGER", "SNDCTL_DSP_GETTRIGGER"), + ("SOUND_PCM_SETTRIGGER", "SNDCTL_DSP_SETTRIGGER"), + ("SOUND_PCM_SETSYNCRO", "SNDCTL_DSP_SETSYNCRO"), + ("SOUND_PCM_GETIPTR", "SNDCTL_DSP_GETIPTR"), + ("SOUND_PCM_GETOPTR", "SNDCTL_DSP_GETOPTR"), + ("SOUND_PCM_MAPINBUF", "SNDCTL_DSP_MAPINBUF"), + ("SOUND_PCM_MAPOUTBUF", "SNDCTL_DSP_MAPOUTBUF"), + ] { + assert_eq!( + aliases.get(alias), + Some(&target), + "missing canonical OSS PCM alias {alias}" + ); + } + for (alias, target) in oss_source_aliases() + .into_iter() + .chain(oss_format_aliases()) + { + let definition = format!("#define {alias} {target}"); + assert!( + rendered.contains(&definition), + "missing generated alias: {definition}" + ); + + let assertion = + format!("_Static_assert({alias} == {target}, \"{alias} source alias\");"); + assert!( + rendered.contains(&assertion), + "missing generated alias assertion: {assertion}" + ); + } + + let snapshotted_ioctls = oss_source_abi()["ioctls"].as_object().unwrap().clone(); + for (alias, _) in oss_source_aliases() { + assert!( + !snapshotted_ioctls.contains_key(alias), + "source alias {alias} must not duplicate its target in the ABI snapshot" + ); + } + let snapshotted_formats = oss_source_abi()["formats"].as_object().unwrap().clone(); + for (alias, _) in oss_format_aliases() { + assert!( + !snapshotted_formats.contains_key(alias), + "format alias {alias} must not duplicate its target in the ABI snapshot" + ); + } + + let trigger_values = &oss_source_abi()["trigger_values"]; + assert_eq!(trigger_values["PCM_ENABLE_INPUT"], json!(1)); + assert_eq!(trigger_values["PCM_ENABLE_OUTPUT"], json!(2)); } #[test] @@ -2353,6 +2925,15 @@ mod tests { let structs = marshalled_structs(); assert_eq!(structs["WasmRusageWire"]["size"], json!(144)); assert_eq!(structs["KernelWaitResult"]["size"], json!(160)); + assert_eq!(structs["AudioBufInfo"]["size"], json!(16)); + assert_eq!(structs["AudioBufInfo"]["align"], json!(4)); + assert_eq!( + structs["AudioBufInfo"]["fields"][3], + json!({"name": "bytes", "offset": 12, "span": 4, "type": "i32"}) + ); + assert_eq!(structs["CountInfo"]["size"], json!(12)); + assert_eq!(structs["PcmSharedControl"]["size"], json!(128)); + assert_eq!(structs["PcmSharedControl"]["align"], json!(4)); assert_eq!( structs["KernelWaitResult"]["fields"][4], json!({"name": "rusage", "offset": 16, "span": 144}) diff --git a/web-libs/kandelo-session/src/kernel-host.ts b/web-libs/kandelo-session/src/kernel-host.ts index bfc5c9fc27..37dd09a143 100644 --- a/web-libs/kandelo-session/src/kernel-host.ts +++ b/web-libs/kandelo-session/src/kernel-host.ts @@ -145,13 +145,21 @@ export interface KernelLike { */ kmsAttachStats?(crtcId: number, stats: SharedArrayBuffer): void; /** - * Drain PCM bytes buffered in `/dev/dsp` for browser playback. + * Legacy main-thread PCM drain endpoint. + * + * @deprecated PCM playback is owned by the machine-level host and its + * AudioWorklet transport. New callers should use `resumeAudio`. */ drainAudio?(maxBytes: number): Promise<{ bytes: Uint8Array; sampleRate: number; channels: number; }>; + prepareAudio?(): Promise; + resumeAudio?(): Promise; + suspendAudio?(): Promise; + getAudioState?(): MachineAudioState; + onAudioStateChange?(cb: (state: MachineAudioState) => void): () => void; /** * Subscribe to the kernel-worker's live syscall trace. Each event * carries the raw syscall number + args + firing pid. The underlying @@ -293,12 +301,12 @@ export interface PtyHandle { /** * Handle returned by `attachFramebuffer`. The canvas is wired up to paint - * frames; this handle lets the embedder bridge input/audio for whichever + * frames; this handle lets the embedder bridge input for whichever * process is currently bound to `/dev/fb0` (fbDOOM, fbtest, etc.). * * Keyboard input is delivered as raw bytes to the bound process's stdin — * the same channel fbDOOM reads scancodes from. Mouse input goes through - * `/dev/input/mice`; audio drains from `/dev/dsp`. + * `/dev/input/mice`. PCM playback belongs to the machine-level host. */ export interface FramebufferHandle { /** Send raw bytes to the fb-bound process's stdin. No-op if nothing is bound. */ @@ -309,8 +317,11 @@ export interface FramebufferHandle { */ sendMouseEvent(dx: number, dy: number, buttons: number): void; /** - * Start draining `/dev/dsp` into Web Audio. Returns null when the wrapped - * kernel does not expose audio draining. + * Return a compatibility handle for the machine-level PCM output. + * + * @deprecated PCM is not framebuffer-scoped. Call `KernelHost.resumeAudio` + * from a trusted user gesture instead. Closing this compatibility handle + * does not stop machine audio. */ startAudio(): Promise; /** Pid currently bound to /dev/fb0, or null if no binding is live. */ @@ -321,12 +332,26 @@ export interface FramebufferHandle { close(): void; } +/** + * Legacy Web Audio-shaped output handle retained for source compatibility. + * + * @deprecated Use the machine-level audio methods on `KernelHost`. + */ export interface AudioOutputHandle { resume(): Promise; close(): void; getState(): AudioContextState | "unavailable"; } +export type MachineAudioState = + | "unavailable" + | "unprepared" + | "suspended" + | "running" + | "interrupted" + | "closed" + | "error"; + /** * Handle returned by `attachKmsDisplay`. The wrapped canvas is wired up * as the scanout target for a KMS CRTC; whatever wasm process holds @@ -558,9 +583,18 @@ export interface KernelHost { subscribeSyscalls(cb: (e: SyscallEvent) => void, filter?: SyscallFilter): () => void; syscallHistory(filter?: SyscallFilter): SyscallEvent[]; + // Machine-level physical/default PCM sink. Browser callers should invoke + // resumeAudio directly from a trusted user gesture. + prepareAudio(): Promise; + resumeAudio(): Promise; + suspendAudio(): Promise; + getAudioState(): MachineAudioState; + subscribeAudioState(cb: (state: MachineAudioState) => void): () => void; + // framebuffer — mirrors /dev/fb0 into a 2D canvas and returns a handle - // that the embedder uses to forward keyboard, mouse, and audio device - // traffic for the bound process. + // that the embedder uses to forward keyboard and mouse input for the bound + // process. PCM output is machine-level; startAudio remains as a deprecated + // compatibility adapter. attachFramebuffer(canvas: HTMLCanvasElement): FramebufferHandle; // KMS display — registers a canvas as the scanout target for a @@ -788,6 +822,7 @@ export class LiveKernelHost implements KernelHost { private surfaceListeners = new ListenerSet(); private galleryListeners = new ListenerSet(); private demoGuideListeners = new ListenerSet(); + private audioStateListeners = new ListenerSet(); private _descriptor: BootDescriptor; private presentation: DemoPresentation; @@ -798,6 +833,7 @@ export class LiveKernelHost implements KernelHost { private surfaceAvailability: SurfaceAvailability = { ...DEFAULT_SURFACE_AVAILABILITY }; private offFramebufferAvailability: (() => void) | null = null; private offLazyDownloads: (() => void) | null = null; + private offAudioState: (() => void) | null = null; private kernel?: KernelLike; private shell?: NonNullable; @@ -847,6 +883,8 @@ export class LiveKernelHost implements KernelHost { this.offFramebufferAvailability = null; this.offLazyDownloads?.(); this.offLazyDownloads = null; + this.offAudioState?.(); + this.offAudioState = null; this.kernel = kernel; this.ptySessions.clear(); this.ptyAttachPromises.clear(); @@ -862,6 +900,12 @@ export class LiveKernelHost implements KernelHost { this.emitLazyDownloadEvent(event); }); } + if (kernel.onAudioStateChange) { + this.offAudioState = kernel.onAudioStateChange((state) => { + this.audioStateListeners.emit(state); + }); + } + this.audioStateListeners.emit(this.getAudioState()); this.refreshTerminalAvailability(); this.refreshFramebufferAvailability(); this.refreshKmsAvailability(); @@ -875,11 +919,14 @@ export class LiveKernelHost implements KernelHost { this.offFramebufferAvailability = null; this.offLazyDownloads?.(); this.offLazyDownloads = null; + this.offAudioState?.(); + this.offAudioState = null; this.kernel = undefined; this.ptySessions.clear(); this.ptyAttachPromises.clear(); this.ptyCommandQueues.clear(); this.shellPids.clear(); + this.audioStateListeners.emit("unavailable"); this.refreshTerminalAvailability(); this.refreshFramebufferAvailability(); this.setSurfaceAvailability({ web: false, kms: false }); @@ -1120,6 +1167,8 @@ export class LiveKernelHost implements KernelHost { this.offFramebufferAvailability = null; this.offLazyDownloads?.(); this.offLazyDownloads = null; + this.offAudioState?.(); + this.offAudioState = null; this.setSurfaceAvailability({ terminal: false, framebuffer: false, web: false, kms: false }); this.setDemoGuide(null); await this.kernel?.destroy?.(); @@ -1544,6 +1593,34 @@ export class LiveKernelHost implements KernelHost { return history.slice(); } + async prepareAudio(): Promise { + if (!this.kernel?.prepareAudio) { + throw new Error("PCM output is unavailable"); + } + await this.kernel.prepareAudio(); + } + + async resumeAudio(): Promise { + if (!this.kernel?.resumeAudio) { + throw new Error("PCM output is unavailable"); + } + await this.kernel.resumeAudio(); + } + + async suspendAudio(): Promise { + await this.kernel?.suspendAudio?.(); + } + + getAudioState(): MachineAudioState { + return this.kernel?.getAudioState?.() ?? "unavailable"; + } + + subscribeAudioState(cb: (state: MachineAudioState) => void): () => void { + const off = this.audioStateListeners.add(cb); + cb(this.getAudioState()); + return off; + } + /** * Walk the parent chain of `pid` and return the shell pid it descends from * when it shares a terminal PTY for stdin. Used by attachFramebuffer to pick @@ -1693,11 +1770,28 @@ export class LiveKernelHost implements KernelHost { kernel.injectMouseEvent?.(dx, dy, buttons); }, startAudio: async () => { - if (!kernel.drainAudio) return null; - const { createPcmAudioScheduler } = await import("../../../host/src/framebuffer/browser-controls.js"); - return createPcmAudioScheduler({ - drainAudio: kernel.drainAudio.bind(kernel), - }); + if (!kernel.resumeAudio) return null; + return { + resume: () => kernel.resumeAudio!(), + // Audio is a machine resource shared by all Unix applications. A + // framebuffer-scoped compatibility handle must not tear it down. + close: () => {}, + getState: () => { + switch (kernel.getAudioState?.() ?? "unavailable") { + case "running": + return "running"; + case "closed": + return "closed"; + case "suspended": + case "interrupted": + case "unprepared": + return "suspended"; + case "unavailable": + case "error": + return "unavailable"; + } + }, + }; }, getBoundPid: () => attachedPid, onBoundPidChange: (cb) => boundPidListeners.add(cb), diff --git a/web-libs/kandelo-session/test/kandelo-session.test.ts b/web-libs/kandelo-session/test/kandelo-session.test.ts index 01dd7fef0b..a1e018c9a6 100644 --- a/web-libs/kandelo-session/test/kandelo-session.test.ts +++ b/web-libs/kandelo-session/test/kandelo-session.test.ts @@ -303,6 +303,83 @@ describe("LiveKernelHost: process listing", () => { }); }); +describe("LiveKernelHost: machine PCM lifecycle", () => { + it("forwards explicit activation and state changes independently of framebuffer", async () => { + let state: import("../src/kernel-host").MachineAudioState = "suspended"; + let emit: ((next: import("../src/kernel-host").MachineAudioState) => void) | null = null; + const prepareAudio = vi.fn(async () => {}); + const resumeAudio = vi.fn(async () => { + state = "running"; + emit?.(state); + }); + const suspendAudio = vi.fn(async () => { + state = "suspended"; + emit?.(state); + }); + const host = new LiveKernelHost({ + kernel: { + prepareAudio, + resumeAudio, + suspendAudio, + getAudioState: () => state, + onAudioStateChange: (cb: typeof emit) => { + emit = cb; + cb?.(state); + return () => { emit = null; }; + }, + } as never, + }); + const observed: string[] = []; + const off = host.subscribeAudioState((next) => observed.push(next)); + + await host.prepareAudio(); + await host.resumeAudio(); + expect(prepareAudio).toHaveBeenCalledOnce(); + expect(resumeAudio).toHaveBeenCalledOnce(); + expect(host.getAudioState()).toBe("running"); + await host.suspendAudio(); + expect(host.getAudioState()).toBe("suspended"); + expect(observed).toContain("running"); + expect(observed.at(-1)).toBe("suspended"); + + host.detachKernel(); + expect(observed.at(-1)).toBe("unavailable"); + off(); + }); + + it("retains framebuffer startAudio as a non-owning machine-audio adapter", async () => { + let state: import("../src/kernel-host").MachineAudioState = "suspended"; + const resumeAudio = vi.fn(async () => { state = "running"; }); + const suspendAudio = vi.fn(async () => { state = "suspended"; }); + const framebuffers = { + list: () => [], + onChange: () => () => {}, + }; + const host = new LiveKernelHost({ + kernel: { + framebuffers, + getProcessMemory: () => undefined, + resumeAudio, + suspendAudio, + getAudioState: () => state, + } as never, + }); + + const framebuffer = host.attachFramebuffer({} as HTMLCanvasElement); + const output = await framebuffer.startAudio(); + expect(output).not.toBeNull(); + expect(output!.getState()).toBe("suspended"); + await output!.resume(); + expect(resumeAudio).toHaveBeenCalledOnce(); + expect(output!.getState()).toBe("running"); + + output!.close(); + expect(suspendAudio).not.toHaveBeenCalled(); + expect(output!.getState()).toBe("running"); + framebuffer.close(); + }); +}); + describe("LiveKernelHost: shell command queue", () => { it("does not treat heredoc continuation prompts as command completion", async () => { const encoder = new TextEncoder();