Skip to content

aecdump updates and build alignment - #50

Merged
afq984 merged 24 commits into
chromeos:mainfrom
afq984:push-wsxltouvppnn
Aug 5, 2026
Merged

aecdump updates and build alignment#50
afq984 merged 24 commits into
chromeos:mainfrom
afq984:push-wsxltouvppnn

Conversation

@afq984

@afq984 afq984 commented Aug 5, 2026

Copy link
Copy Markdown
Member

No description provided.

afq984 added 24 commits July 28, 2026 22:22
The file input's accept filter only listed .pb and .aecdump, so
.aecdump.binpb dumps were greyed out in the click-to-select dialog.
Add both the compound extension and the plain .binpb suffix.
Picks up two upstream changes to Config: fields 6-8 (AECm) are now
reserved rather than declared, and api_config_string (21) is added --
a stringified webrtc::AudioProcessing::Config that upstream documents
as more likely to be up-to-date than the individual bool fields.

Source: modules/audio_processing/debug.proto @ webrtc.googlesource.com/src.
No decoder code reads Config yet, so this is generation-only for now.
protobufjs returns bytes fields as subarray views into the dump buffer
at an arbitrary byteOffset, but Int16Array requires 2-byte alignment.
Roughly half of int16 payloads therefore threw

  RangeError: start offset of Int16Array should be a multiple of 2

which escaped parseAecDump (the try/catch only covers Event.decode) and
failed the whole file load. Copy to an aligned buffer when the offset is
odd, mirroring what the float path already does.

Also floor the frame count so a trailing partial frame drops a few
samples instead of throwing on a fractional typed-array length.

Verified against a synthetic int16 dump: 15 of 30 payloads land on odd
offsets; the previous decoder throws, this one returns all three streams
at the expected length with correct sample values.
audioBufferToWav downmixes sources with more than two channels to the
first channel, but still wrote the source channel count into the header.
The player then read mono samples as N-channel interleaved frames, so
the track came out 1/N the length and N times too fast -- which on
ChromeOS hardware with 3-4 mic channels looked like broken time
alignment between tracks.

Describe the data actually written instead. Verified with a 100 ms
source: 3ch previously produced a 33.3 ms file and 4ch a 25.0 ms file;
both now produce 100.0 ms mono. 1ch and 2ch output is unchanged.
Input and output were appended in independent branches, so any STREAM
event carrying only one of them shifted that stream earlier by 10ms
relative to the other, permanently, for the rest of the dump.

Track the capture frame index and pad each stream with silence up to
that frame's position before appending. Padding before the append (not
after) also places a late-starting stream at its true offset instead of
at t=0. A stream that never appears stays empty rather than becoming a
full-length silent track. Frame size is sample_rate/100, matching
unpack.cc.

This addresses input-vs-output only. Reference-vs-capture alignment
needs the shared-timeline work and is out of scope here.

Verified on synthetic float dumps: with output missing from frames 2-4
the old decoder produced 7 frames of output against 10 of input; it now
produces 10 with the audio at the right positions. With output starting
at frame 5, its energy centroid moves from frame 2.54 to 7.54.
wavesurfer's drag path emits 'interaction' immediately but debounces the
actual seekTo by 200ms while paused, so ws.getCurrentTime() inside the
handler still returned the pre-drag position. Dragging one waveform
therefore left the other tracks where they already were. The click path
seeks before emitting, which is why only dragging was affected --
dragToSeek is enabled in the wavesurfer options.

The event carries the correct target time (typed 'interaction:
[newTime: number]'); use it.

Verified in Chromium against a 30s synthetic dump: dragging the
reference waveform to 70% previously left mic and out at 0s while ref
moved to 21.01s; all three now land on 21.01s.
Adds tests/fixtures/make-dump.ts, which synthesizes aecdump files in the
[int32 length][Event protobuf] framing that WebRTC's writer produces:
int16 and deinterleaved-float variants, arbitrary rates and channel
counts, and the ability to omit a stream from chosen capture frames.
Payload sizes vary enough that roughly half the bytes fields land on an
odd byteOffset, which is what exercises the decoder's alignment path.

Also commits a 2s int16 fixture (~193KB) so browser tests can run
without a generation step; regenerate with 'yarn fixtures:generate'.
Output is byte-for-byte deterministic.

Adds vitest for unit tests -- pinned to ^2 because vitest 4 requires
Vite 6+ and would pull a second Vite alongside the project's Vite 5.
Tests land in the next commit.
Unit tests (vitest, 'yarn test:unit'):
  - decoder: unaligned int16 payloads, sample values through the
    alignment copy, INIT format, input/output lockstep across dropped
    frames, late-start positioning, streams that never appear, truncated
    and empty input
  - wav-helper: header must describe the data actually written, for 1-8
    channels, plus the 16-bit and float32 paths

Browser tests (playwright, 'yarn test:dev'):
  - track-sync.spec.ts: dragging and clicking a waveform move every
    track to that position, using the 2s fixture

Checked that these fail without the fixes: reverting decoder.ts and
wav-helper.ts to 23e5556 fails 11 of the 18 unit tests, and restoring
the old 'interaction' handler fails the drag test while the click test
still passes -- the click path seeks before emitting, so only dragging
was ever broken.

Scopes playwright's testMatch to *.spec.ts so it no longer picks up the
vitest files, which its default testMatch would have matched.
'yarn test' ran playwright only, so the vitest suite was skipped by the
default entry point. Split the granular scripts out (test:unit,
test:e2e) and make 'test' run both, unit first so it fails fast.

Wiring them up surfaced two problems:

Playwright reuses whatever already answers on the preview port, so a
foreign service on 8080 means the browser suite silently tests the wrong
server -- here a local git server, which returned 403 for every asset.
Make the port overridable via PREVIEW_PORT and pass --strictPort so a
collision fails immediately instead of drifting to another port.

'tsc' in the build step emits to out-tsc/ and now compiles tests/ too,
so vitest's path filter matched both the sources and their compiled
copies and ran everything twice. Scope discovery with --dir.

CI does not run either suite today (build only); wiring it up is a
separate decision.
Padding runs before an append, so it needs a following chunk to trigger
it. A stream whose last frames were missing therefore ended short: with
output absent from frames 17-19 of a 20-frame dump, output came back
2720 samples against input's 3200.

Unlike a mid-dump gap this misplaces no audio -- the stream simply stops
early, and nothing follows it to be shifted. It shows up in rendering,
where each track's duration is stretched to the container width, so a
short tail skews that track's whole x-axis.

Pad both streams to the capture timeline length after the parse loop.
A stream that never appeared stays empty. This also makes the invariant
total rather than partial: input and output are each exactly
captureFrameCount frames, or empty -- which is what Phase 1's positioned
tracks will assume.

Reported in review; verified the two new tests fail without the change.
Adds MODULE.bazel on bzlmod -- Bazel 9 removed WORKSPACE entirely -- pinned to
9.2.0 via .bazelversion, plus REPO.bazel ignoring **/node_modules so package
manager installs do not churn the build graph.

//:site assembles the published tree with copy_to_directory, replacing the
hardcoded cp list in the workflow. The five static demos each expose a filegroup.
The two JavaScript subprojects follow in later commits.

Verified byte-identical to the current CI output for the 34 static files:
replicated the workflow's staging into a baseline tree and diffed it against
bazel-bin/site with no differences.

Also adds a root .gitignore for Bazel's convenience symlinks; the repo had none.
Adds aspect_rules_js and an npm_translate_lock for aecdump-viewer, plus
npm_link_all_packages so //src/aecdump-viewer:node_modules is a Bazel-managed
tree. Verified vite, vitest, lit, protobufjs, protobufjs-cli, typescript,
wavesurfer.js and @playwright all link.

rules_js reads pnpm-lock.yaml, so one is derived from yarn.lock with
'pnpm import' and committed alongside it. yarn.lock remains the developer-facing
lockfile. Feeding yarn_lock to npm_translate_lock directly would enable
update_pnpm_lock and let the repository rule write into the source tree during
builds; reading a committed lock keeps fetches read-only, at the cost of
regenerating it by hand when yarn.lock changes. See the follow-up note below.

pnpm-workspace.yaml declares allowBuilds for esbuild and protobufjs, the only
two dependencies with install lifecycle scripts. rules_js requires this to be
explicit so nothing runs code at install time undeclared.

Not yet wired: the proto codegen and vite bundle targets, and a guard against
yarn.lock and pnpm-lock.yaml drifting apart.
rules_js resolves npm dependencies through pnpm regardless of which lockfile is
committed, so keeping yarn.lock alongside the generated pnpm-lock.yaml would
mean two lockfiles that can silently drift. Drop yarn.lock and let pnpm-lock.yaml
be the single source of truth; it was produced by 'pnpm import' from yarn.lock,
so resolved versions carry over unchanged.

Pins pnpm@10.34.5 via packageManager, switches the package.json scripts,
playwright.config.ts and CI to pnpm, and keeps CI working until Bazel takes over
the build in a later step.

Fixes an undeclared dependency this surfaced: the generated proto bindings do
'import Long = require("long")', but 'long' was never declared -- it only
resolved because yarn hoisted protobufjs's copy to the top level. pnpm's strict
layout does not, so the build failed with TS2307. Declared it explicitly.

Verified the pnpm-built bundle is byte-identical to the yarn-built baseline
(same content hash, index-C2bCgrdw.js), 20 unit tests pass, and Bazel still
links the tree including long.
aspect_rules_js pulls in aspect_tools_telemetry, which is enabled by default.
It shells out to curl from a repository rule and POSTs to
https://telemetry.aspect.build/ingest. The payload includes an organization
name, a hash of the repository, and a salted hash of the username -- the local
report.json already had the repo and user hashes populated after the first
build. A public Google repository should not be sending build metadata to a
third party without an explicit decision, so opt out in .bazelrc.

--repo_env is required because the value is read by a repository rule. Note the
lockfile only records ENV:ASPECT_TOOLS_TELEMETRY_TEST as a tracked variable, so
the change does not necessarily invalidate an already-fetched repo; verified by
forcing a re-fetch, after which report.json is {} with no payload envelope.
The module also honours DO_NOT_TRACK.

Also corrects the MODULE.bazel comment, which still described yarn.lock as the
developer-facing lockfile after it had been deleted.
pbjs and pbts are modelled as two dependent actions, since pbts reads the
JavaScript pbjs emits, and both mirror the package.json proto:generate script
via chdir. The vite bundle declares the generated bindings as inputs.

Verified against the current toolchain: debug.js and debug.d.ts are byte-identical
to the npm script's output, and dist/ is byte-identical to the CI baseline down
to the content hash (assets/index-C2bCgrdw.js).

Removes the generated proto files from the working tree; they were gitignored
build output that would collide with Bazel's declared outputs. Running
'pnpm run build' recreates them and will break 'bazel build' until the
authoritative-build-path follow-up lands.
Applies the same decision taken for aecdump-viewer. rules_js resolves through
pnpm regardless of which lockfile is committed, so keeping package-lock.json
would mean either two lockfiles that can drift or a repository rule writing into
the source tree during builds. pnpm-lock.yaml was produced by 'pnpm import' from
package-lock.json, so resolved versions carry over unchanged.

Pins pnpm@10.34.5 via packageManager and switches CI. Verified the pnpm-built
dist is byte-identical to the npm-built baseline.

pnpm-workspace.yaml allows install scripts for core-js-bundle and esbuild.
husky is deliberately excluded: its hook only configures local git state, which
is developer-machine setup rather than a build input.

This leaves both subprojects on one package manager; the repo no longer mixes
npm and Yarn.
Adds an npm_translate_lock and BUILD targets for ds-playground: tsc emits
out-tsc/, which index.html references and rollup bundles into dist/. The npm
script's rimraf is unnecessary in Bazel's sandbox, and cem analyze is dropped
per the plan -- it writes custom-elements.json to the project root, never to
dist/, so it has never been part of the published site.

Wires both bundles into //:site.

*** bazel build //:site is now byte-identical to the current CI output:
    38 files, diff -r clean. This is the step 2 acceptance gate. ***

Two things the sandbox exposed that the old build masked:

- @material/web's .d.ts files import lit-html while declaring only lit, so its
  types do not resolve under pnpm's strict layout. Hoisted that one package with
  public_hoist_packages rather than declaring another package's missing
  dependency in our own manifest.
- rollup-plugin-esbuild reads tsconfig.json to choose its transform target.
  It was not declared as an input, so esbuild silently picked a different
  default and downlevelled differently, changing the bundle by 52 bytes. tsc
  output was identical throughout; only the bundle differed.
'bazel test //...' now runs both: the 20 vitest unit tests, and tsc as a
typecheck-only gate. Both are test targets rather than build targets, since
'bazel test //...' does not select plain build targets.

The typecheck uses --noEmit, which the package.json build does not: it runs tsc
purely as a gate but still writes JavaScript nobody consumes into out-tsc/ --
the same stray output that made vitest run every test twice during Phase 0.

Hoists @types/node for aecdump-viewer. undici-types, a transitive dependency of
@types/node pulled in by vitest, carries /// <reference types="node" /> which
does not resolve under pnpm's strict layout; the local typecheck does not hit
this, so it is a layout artifact rather than a real type error.

//:site remains byte-identical to the CI baseline.
@types/node was a genuine undeclared direct dependency, not the layout artifact
the previous commit described. Our own TypeScript imports node builtins --
node:url and node:path in tests/track-sync.spec.ts, node:fs in
tests/fixtures/generate.ts -- so the types are required directly and were only
resolving transitively through vitest.

Declared as a dev dependency at ^22 to match the Node the toolchain actually
provides (v22.22.0), rather than the transitive @types/node@25.9.1 that was
typing a Node 22 runtime against Node 25 APIs. With it declared, the
public_hoist_packages workaround for undici-types is unnecessary and is removed;
the typecheck passes without it.

Also narrows the unit target, which declared every TypeScript file under tests/
including the Playwright specs. Verified: changing an e2e spec now reruns only
the typecheck, whose tsconfig legitimately covers all TypeScript, while the unit
target stays cached.

//:site remains byte-identical to the CI baseline.
Completes step 4. 'bazel test //...' now runs all three suites: unit,
typecheck, and the browser tests.

Pins @playwright/test to exactly 1.56.0 in package.json, not just the lock, so
a future lock regeneration cannot silently cross the 1.57 Chrome-for-Testing
boundary that rules_playwright 0.5.3 cannot generate URLs for. browsers.json is
vendored from playwright-core so the browser revision (1194) is a committed
input rather than an unhashed unpkg fetch.

**rules_playwright 0.5.3 works on Bazel 9**, which its BCR presubmit does not
cover -- this was the acceptance check for the whole approach, so the 1.50.1
fallback is not needed.

Replaces the package-manager script in playwright.config.ts with a plain Node
preview server, so the tests serve the bundle Bazel declared rather than
whatever dist/ happens to be in the source tree, and work in a test action
where no package manager exists.

Three integration problems, each diagnosed by bisecting against a direct CLI
run in the same directory:

- rules_js's Node fs patch and Playwright's module loader hooks each resolve a
  spec by a different path, yielding two TestType instances and 'did not expect
  test.describe() to be called here'. Fixed with patch_node_fs = False.
- Runfiles link each file individually and Playwright's scanner does not follow
  symlinked spec files, so collection found nothing. Fixed by materializing the
  specs and fixtures as one copy_to_directory artifact, whose contents are real
  files.
- Bazel's sandbox stages files the same way, so discovery fails there too. The
  target is tagged no-sandbox, as upstream's own example does for its browser
  tests.

Also notes untidy state found along the way: after the downgrade, stale 1.60.0
links survived in bazel-bin alongside 1.56.0 and a 'bazel clean' was needed to
clear them. They did NOT cause the failure -- the error was unchanged after the
clean, and only patch_node_fs = False altered it. The lockfile never referenced
1.60.0.

//:site remains byte-identical to the CI baseline.
The e2e target runs unsandboxed, so Node resolves packages through runfiles
symlinks into the shared bazel-bin tree and can read files that are not declared
inputs. Bazel was memoising the verdict as though the inputs were complete: a
result influenced by ambient output-tree state could be reused indefinitely.
Tag the target external, which forces unconditional execution.

Note that 'no-cache' does NOT do this, which is worth recording because it is
the obvious-looking choice. Bazel has two distinct layers: action-output
caching, which no-cache governs, and test-result caching, which produces the
'(cached) PASSED' annotation and is governed by --cache_test_results and the
external tag. Measured: with +no-cache the second run still reported
'(cached) PASSED'. So did +external injected via --modify_execution_info,
because tags like external are resolved at analysis time and never consult
execution info -- injecting an execution requirement is not equivalent to
setting a tag.

Verified: three consecutive runs of the e2e target all execute, while unit and
typecheck stay cached, so the change is scoped to the one unsound target.
Costs 3.5s per invocation. //:site remains byte-identical.

This is a mitigation, not a fix. It removes the memoisation hazard; it does not
make the inputs complete. Staging specs into TEST_TMPDIR so the sandbox can be
re-enabled is the next step, and the tag should stay until Node no longer
canonicalises package symlinks out of the sandbox.
Replaces the per-subproject install and build steps with 'bazel test //...' and
'bazel build //:site'. Bazel supplies the Node toolchain, npm dependencies and
Playwright browsers, so there is no setup-node, package-manager install or
playwright install step -- which was the point of the migration. The tests now
run on pull requests; previously they ran nowhere.

setup-bazel's caches all default to false, so they are enabled explicitly.

The upload and gh-pages deploy jobs are unchanged. Only the way out/ is produced
changes: bazel-bin is a symlink the artifact upload does not follow, so the tree
is staged with cp -RL. Bazel's outputs are mode 555 and the deploy job commits
this tree, so modes are normalised to 644/755 -- without that every deployed
file would flip to executable and the first deploy would diff the whole tree.

Verified by running the exact sequence locally: the staged artifact is 38 files,
byte-identical to the pre-migration CI output, with file modes matching too.
Replaces the cp -RL plus two chmod passes with 'bazel run //:stage_site', using
write_source_files from aspect_bazel_lib -- already a dependency, so no new
module. It is used here as a directory-copy primitive rather than for its usual
round-tripping purpose, so diff_test and the destination-exists check are off:
out/ is build output, not something checked in.

Three measured improvements over the shell it replaces:

- Writes 644/755 directly, so the post-hoc chmod normalisation is gone. The
  deploy job commits this tree, and Bazel's own outputs are 555, so that
  compensation was load-bearing -- now it is simply not needed.
- Does not reach into bazel-bin, a convenience symlink that --symlink_prefix can
  move or suppress.
- Removes files that are no longer part of the site. cp left them; verified by
  planting a stale file and re-running.

Considered rules_pkg's pkg_install for the same job. write_source_files wins on
handling directory outputs natively -- both bundles are tree artifacts, which is
the fiddly part of pkg_files -- and on adding no dependency.

Verified end to end: the staged artifact is 38 files, content and modes
identical to the pre-migration CI output.
Pins all four action references to commit SHAs, keeping the version as a
trailing comment so the intent stays readable and Dependabot can still update
them. This was the reported failure: unpinned-uses is a mandatory check under a
blanket pin-to-hash policy.

Fixing only that would have left CI red, because the gate also fails on any
Medium finding and three more were present, all pre-existing:

- artipacked: actions/checkout persists the GITHUB_TOKEN into .git/config,
  which is reachable from build output and uploaded artifacts. The build job
  never pushes and the deploy job supplies its own token, so
  persist-credentials: false costs nothing.
- excessive-permissions (x2): the workflow declared no permissions, so both
  jobs ran with broad defaults. Deny at the top level and opt in per job --
  contents: read for build, and deploy keeps the contents: write it already had.

Verified with zizmor 1.29.0 locally: 'No findings to report', exit 0, where the
same run previously reported four errors plus three warnings. The build pipeline
is unaffected -- tests pass and the staged artifact is still byte-identical.

Note actions/checkout is pinned at v2, which is old enough to be worth bumping
on its own merits; left alone here to keep this change to the security fix.
@afq984
afq984 merged commit c33f7ac into chromeos:main Aug 5, 2026
9 checks passed
@afq984
afq984 deleted the push-wsxltouvppnn branch August 5, 2026 14:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant