Skip to content

Releases: libraz/hashidate

v0.7.0

Choose a tag to compare

@libraz libraz released this 06 Oct 07:23

A minor release. The wire vocabulary only gains: BGM has two new effect groups, dsp.pitch and dsp.presence, and a line can carry a new [@bgm set {JSON}] cue. Both are off at their defaults, so a caller written against 0.6.0 sends and hears the same thing. One behaviour did change:

  • A descriptor's anatomy override now reaches the profile. buildProfile used to drop it and fall back to the human joint table, so an avatar that declares one may now move differently.

Backdrop rooms

The window no longer shows a painted sky plane. It samples a crop of a CC0 panorama, chosen by the direction the window faces, for morning, dusk, night and rain. The rain view runs through a rain-on-glass lens shader in place of the old alpha streaks on the pane.

The rooms gain painted plaster walls, a plain ceiling, linen curtains with sheers, an oval wool rug and a wall shelf with books and a lamp. They also bring in more furniture: a plant, a low table, a tea set, vases, framed prints, a basket, and a wall clock whose hands show local time. The bed, sofa and pillows take linen and timber finishes. The new textures are credited in the README.

Feet stay on the floor

The profile now resolves the upper leg, lower leg, foot and toe, and a two-link solver keeps both ankles where they stand while the pelvis moves. The knees absorb the drop through breathing, weight shift and hops instead of the feet sliding or sinking. Hips sway and lift are written in world metres through the parent transform and recomputed every frame, so a root or wrapper that is turned or scaled after the avatar loads no longer skews them. avatars.md and motions.md describe which bones are needed.

BGM

The BGM-only chain gains a tempo-preserving pitch shifter (semitones −24..24, mix 0..1) and a nonlinear presence enhancer (amount 0..1, drive 0..8, frequencyHz 500..8000). A pitch mix of 0 and a presence amount of 0 are the defaults and leave the track untouched. The controls are in the panel's BGM tab, the bgm command, the MCP tools and the CLI.

[@bgm set {JSON}] applies one BGM command patch at the point in the line where it is written: transport, track, volume, looping, fade durations and any DSP field. Omitted fields keep their values and the change persists after the line. Unknown fields, empty patches, envelope fields and out-of-range values reject the line before it is queued. The field table is in lines-and-cues.md.

Voice and lip sync

  • When the last renderer waiting for a line goes away, the server aborts the upstream synthesis and the sidecar stops generating, so the GPU is not left speaking to nobody.
  • A line that fell back to silence because the browser refused the audio device is synthesised again once the device unlocks, without spending its one retry.
  • ゔ and ヴ read as the u viseme. Ellipses and dashes are pauses, and quotation marks and brackets are skipped instead of being voiced as morae.
  • Envelope windows sit on exact 10 ms boundaries at any sample rate, and a take that is mostly silence still moves the mouth on its loudest burst.

Motion and rendering

  • The wave follows the hand it was given, mirroring its head and chest accents, instead of always using the right.
  • Gaze bearing is computed in avatar-root space, so tracking follows where the avatar is placed rather than the room's axes.
  • Every spring joint takes its origin from its freshly solved parent, so multi-joint chains stay connected. A disabled group returns to its rest pose every frame and is reseeded when enabled again.
  • Cloth casts shadows in proportion to its opacity instead of as a solid silhouette.
  • Disposing the studio environment frees its PMREM render target and generator, including when building it throws.

v0.6.0

Choose a tag to compare

@libraz libraz released this 06 Oct 03:19

A minor release. The wire vocabulary only gains: fates and setupFates are new fields in two responses, and nothing a caller sends has changed shape. Three behaviours did change, and an orchestrator written against 0.5.0 should check them:

  • ok on POST /api/command is false only when a command was lost. A command the server keeps for the next renderer is retained, and with no viewer attached that is still ok: true.
  • The control server refuses a request whose Host is not 127.0.0.1 or localhost, whose Origin is not the address it was sent to, or whose Sec-Fetch-Site is cross-site or same-site. A caller with no browser behind it sends no Origin and is unaffected.
  • wear with a slot and no item leaves the slot alone. Only item: null takes a garment off.

The server's queue is the queue

Every turn goes through the control server's queue, a say posted to /api/command included. POST /api/command answers with one fate per command: delivered to at least one viewer, retained by the server for whichever renderer attaches next, or lost. The setup, the queue verbs, BGM transport and the effect of a cue are all retained, so a renderer that opens mid-show is handed the shot, page, mood and music the others already have. POST /api/scripts/run reports its setup the same way as setupFates, and yarn ctl play, the panel and the native shell warn only about a setup command that was actually lost.

A caller's id on a say is honoured while no turn holds it and replaced with a fresh one otherwise; ids in the response says which. A rewind with mode: from that cuts the line on air puts that line back as well, after the lines it followed. JSON bodies are capped at 4 MiB and refused on their declared length before they are read.

Loopback, checked at the door

Binding to 127.0.0.1 kept other machines out but not a page open in the operator's own browser, which can reach loopback by naming it from another origin or by rebinding its own hostname onto it. Both arrive with a giveaway in Host, Origin or Sec-Fetch-Site, and src/server/guard.ts refuses them with 403 before any route, file or stream is touched. No CORS header is sent on the refusal or anywhere else.

The voice

HASHIDATE_TTS_MODEL picks the sidecar's checkpoint: small (Irodori-TTS v4.1-Small, the default), large (v4-Large) or mf (a MeanFlow distillation of small that samples at 4 steps instead of 16). Large is closer to the reference speaker and takes about twice as long per line, roughly the length of the line itself on an M5 Max, which a queued script hides and a caller sending one turn at a time does not. All three share the codec, so the encoded reference set serves any of them. Any other value is refused at startup.

The model now loads after the socket is up, so /health answers ready: false for the whole load instead of leaving requests hanging, and turns true only once the watermark self-test has passed on a real take. A sidecar whose model cannot load or whose self-test fails prints the error and exits. On the renderer's side, a /health reply without a boolean ready counts as no answer.

A line now waits five seconds plus its own spoken length for its voice, so a long line is no longer discarded against a short line's deadline. A line the voice answered without a take is asked for once more when the voice next succeeds, and a line that leaves the queue unsaid (interrupt, clear, replacement, teardown) has its synthesis aborted rather than finished for nobody.

Motion

  • Every outgoing gesture fades at its own weight, and each gesture slot has its own elbow solution. Switching gestures quickly no longer drops one pose or re-raises another, and an arm no longer snaps between two elbow positions mid-handoff.
  • A sustained pose held past its hold eases out from the moment it is released. It used to drop to rest in one frame.
  • Breath depth follows its target across the end of a line instead of changing 30% in a single frame.
  • The spring solver samples the body at each fixed step's own time. At 50 Hz a frame sometimes takes two steps, and both used to see the same body pose, which made hair and cloth step unevenly (1-1-1-1-2). One step per frame at 60 Hz is unchanged, and a recorded trajectory pins it.
  • The tail's slower wag term keeps its own phase and no longer restarts when the main phase wraps.
  • Lid droop eases in and out, an idle face hands back through the preset fade, and a rig with a single-eye blink shape blinks through it.

What a descriptor can declare

  • armRest sets resting arm directions for a garment that needs the arms held clear of the body, such as a full skirt. Gestures blend from it; slots left out keep the standing pose.
  • anchor on a spring group attaches the group's root to a point skinned across bones that earlier groups already simulate, so a part of a garment follows the skin it sits on. An anchor that cannot be built, or that overlaps another group's bones, is reported in Profile.missing and the group is dropped.
  • reflection on a preserved PBR material sets how strongly it reflects the runtime's studio environment, which is built once and shared between rooms and avatar materials. Without it a metal sees only the scene environment, which is empty without a backdrop.

A third avatar, Yonagi Neru (neru), is registered after Yoka and Manuka, and Yoka stays the default. Its GLB comes from a separate production workspace and is not redistributable either.

Also in this release

  • The MCP status tool returns the expression, performance, gesture and per-arm strain, so a caller can see what react changed and whether an aim was met.
  • yarn ctl refuses a malformed number rather than truncating it or reading it as zero, and --page and --slide take whole numbers. wear takes its item positionally or as --item, with none to take it off. vocab lists backdrops, voice presets and wear presets.
  • A script validation error counts array positions from 1, as the outline numbers lines.
  • GET /api/motions lists a malformed motion, or a file whose name is not a usable id, under errors instead of dropping the whole list.
  • The camera vocabulary is derived from the wire schema and can no longer drift from what the wire accepts.
  • A page command that arrives while a deck is still opening is kept and applied once the page count is known.
  • A stop names the take it ends, so a late stop for an earlier take leaves a newer one running. A page whose browser has not yet allowed audio still records.
  • The console's stop goes through the server's interrupt, so the server queue empties with it.
  • The panel polls state one read at a time, keeps the mood and look sliders under the pointer while the renderer catches up, keeps an open draft when its line goes on air or is dropped, warns about unknown gestures and expressions even when the vocabulary is empty, and names a take only after a script in show/scripts/.
  • The native shell keeps a control server it started that answers just after the startup wait gave up, and stops it on quit.
  • make glb fails on a Blender error, a missing export marker or an empty file. A relative HASHIDATE_TTS_SOCKET is made absolute once in the Makefile, so the sidecar and the control server read one path. make check-assets runs on the bash 3.2 macOS ships and also measures the staged blob.
  • Dependencies: vitest 5, three r186, libsonare 1.8.1, Node 22.23.3 and Yarn 4.18.1. The speech sidecar's Irodori-TTS pin moved one upstream commit; on MPS the voice is unchanged.

v0.5.0

Choose a tag to compare

@libraz libraz released this 06 Sep 04:22

A minor release, and additive throughout: the wire vocabulary gains names but loses none, and every new field is optional in both directions. An orchestrator written against 0.4.1 needs no attention.

Six groups, thirty gestures

affection, playful, idol, cheer, dance and photo hold five gestures each. The table is filed by group in declaration order and that order is what a control surface draws, so each group is a file of its own under src/engine/motion/gestures and the suite pins both table sizes — a dropped or duplicated entry fails the build rather than quietly shortening a menu.

Every one of the thirty is paired with a performance of the same name and a mood, so one id means the same thing whether it is asked for as a gesture or as a performance, and the thirty arrive with a face instead of being played deadpan. All of them have a lead and a hold and no sustain, the camera poses included: pose is the only group meant to stay up until it is released, and a camera pose that outlived its line would leave the hands in shot for everything said after it.

GestureGroup and PerformanceGroup widen together with the two protocol enums a compile-time equality check holds equal to them. That is what keeps the viewer, the server and the CLI naming one set of groups, and it is the whole of the minor.

One picker instead of thirteen chip rows

Thirteen groups ran the performance section far past the height of the pane, so choosing a mood meant scrolling through every pose to reach it. PresetPicker is one selected group over a fixed result area, mounted in both the panel's and the console's performance sections.

The search crosses every group at once and matches the id and both labels, with all space-separated terms having to hit: an operator who knows the name of a preset should not have to remember which group it was filed under, and the two labels are the only way a name found in one language stays findable when the other one is on screen. The group holding the current performance sits under an inset edge, so the preset that is on can be located while a different group is shown. Pressing a group clears the search and emptying the search goes back to it, which keeps a search a detour rather than a change of place the operator then has to undo.

A line whose renderers have all left

Several renderers running the same queue is the normal case, so the page identity is carried on reports as well as on the stream and every distinct renderer that acknowledged a turn.start is recorded: one start heard three times is one line with three owners. A line finishes as interrupted once all of its named owners have been gone for the stale window, keeping the pending lines behind it — a page closed mid-sentence used to leave that line airing forever, with everything after it stuck behind a turn nobody was saying any more.

The grace is armed rather than expired when a different renderer attaches, since a page that is already connected may still be posting its first turn.start, and a preview opened during a line must not shorten another window's grace or file its live line as finished.

On the renderer's own side, a teardown no longer leaks a take or a load. StageRecorder has an explicit active-take record and a generation counter, so a start that overlaps a stop, a dispose or another start cannot leave two takes running; one shutdown is shared per take rather than per caller, so a stop that arrives while a final upload is flushing waits for it. AvatarRuntime releases a GLTF that lands after teardown through a walk over the raw object graph, since the loader owns the graph but not the lifetime of the GPU resources in it. A queue torn down with its renderer forgets its pending lines without publishing queue.dropped and reports the line on air as a terminal turn.end with interrupted: true — the server queue is authoritative and has to survive an avatar swap.

The counted page is a decision, not an observation

Standing takes an optional page-count resolver, which is handed a deck id and answers a fixed count for it, never a renderer's current page. The page a deck opens on and every page a slide resolves to are clamped against that count as well as against the floor at 1, so a run of relative turns cannot count past the end of the document: an over-counted page is invisible while it climbs, and then several turns back move nothing, because a renderer has been clamping the replayed number all along while the counter is still out beyond it. With no resolver, no deck open or no usable count, the floor at 1 remains the only bound and a Standing built with nothing folds and replays exactly as it did before.

The cloned voice is on a socket and nowhere else

--port, the loopback bind address and the port environment reading are gone from the speech sidecar, so its endpoint resolves to a socket path and to nothing else. A port is reachable by every process and every user on the machine while a socket in a 0700 directory is reachable by this user alone, and while the option existed a stray environment variable was enough to put the voice on TCP without anybody deciding to. The native shell no longer starts the bundled sidecar when the configured endpoint is a port: a port endpoint means an external synthesiser the operator supplies and starts.

The reference latents are published as one directory swap — encoded into a staging directory and moved into place only once every clip has been written, with the old directory moved aside first and put back if the second move fails. At no point does the destination hold neither a complete old set nor a complete new one. The set is taken from the clips that exist now, so a recording deleted from the reference directory stops conditioning the voice.

make stop refuses a speech path that is not a socket, or is a symlink at all, before anything is probed. A path pointing at an ordinary file used to make the target delete that file; a symlink is refused even when it resolves to a socket, because the thing lsof inspects and the thing rm removes are then not the same object.

Also in this release

  • A loaded motion compiles one keyframe track per channel, so a value a keyframe leaves out is genuinely unstated instead of being taken from whichever bracketing frame happened to carry it, and a spine stated once stays constant while the arms move over four keyframes. An all-zero arm or palm direction is refused on the wire, since a direction is read for its bearing and a zero one names none.
  • A refused queue edit stays on screen with the text, the reading and the performance as they were typed, and the error beside the form. reading: null on a queue update removes a stored reading where an omitted field still leaves it untouched, which is the difference between clearing a reading and never having had one.
  • The MCP tool list rebuilds on a fingerprint of the vocabulary rather than on the avatar id: custom motions reloaded for the same avatar changed the ids a caller may name while leaving the announced schema exactly as it was. A command the control server refused now returns as an error result rather than a plain success.
  • A fit change repaints the page already decoded, costing a drawImage instead of the 30-80 ms a page takes to come back from pdf.js.
  • A static or BGM response that closes mid-transfer destroys the file stream it was reading; a browser that navigates away or an OBS source that is removed drops the socket without ever emitting an error.
  • show exits nonzero when the server refused the command, so a script driving the client can tell a command that was delivered from one that reached nothing, and ControlClient.command derives its timeout from the ?wait it is sending.
  • The demo tour selects the avatar it narrates as, and reads --base with parseArgs rather than by looking the flag up in the argument list.

v0.4.1

Choose a tag to compare

@libraz libraz released this 30 Aug 23:58

A patch release: nothing a caller depends on changed shape. The one wire change is additive and optional, so an orchestrator written against 0.4.0 needs no attention.

A forearm can no longer teleport between two frames

The anatomical clamp is a projection onto the poses a joint allows, and that set is not convex — the humeral rotation is bounded to half a circle, so a request that drifts past the far side of it is equally near both stops, and the nearer one can change in a single frame. The forearm answered by swinging half a turn on the switch frame, which is the one artefact a viewer reads as the character being replaced rather than moving.

Rig.slew now holds the limiter's own correction to a speed a limb has. The cap is the lowest rate that leaves the movement alone: every entrance in the gesture table plays unchanged under it, while the seams it exists to catch asked for five times as much. The body layer hands the rig its frame time, and toggling limitsEnabled drops the held history rather than crawling the arm back from wherever the unbounded pose had left it. Measured at the wrist over every ordered switch between poses that sit against a stop, the worst case was twenty-six metres a second against a movement peak of two or three; it is now inside the movement.

The words of the line on air

snapshot.airing carries the turns a renderer has started and not reported an end for. state.turn says which line is being said and this says what it says — until now a panel had an id and nothing to read, because a started line is out of the queue by then and does not reach the history until it is over.

The field is optional in both directions, and deliberately: a client that does not know it is unaffected, and a control server that does not send it is still one the shell can adopt over its port probe. Only what went through the queue is reported — a say posted straight to /api/command is not the server's to report, on the same boundary the history draws.

The broadcast panel

  • The line on air is read out under the preview, in the sticky column every tab shares, so the answer to "what is it saying right now" stays in one place through a segment spent turning slides.
  • A segmented picker is announced as the radio group it is; the two strips that really do swap a region are tabs, and name the region with aria-controls. Both are a single stop in the tab order, with the arrows moving and selecting within the group — nine tabs across the top of the panel were nine stops between the header and the first control under them.
  • Every control row shares one label column and one readout column, so a fader, a toggle and a picker in the same section start on the same axis and end on the same edge. The fader's track takes the slack instead of the label, which on the panel makes it three times the length it had.
  • An action is no longer a member of the list it acts on: the rescans sit beside their section titles, the release chips have a row of their own instead of reading as one more mood, and the "none" and "bare" chips are drawn as the states they are.
  • The browser-source bar is rebuilt on a new select whose popup stays the platform's, with each label bound to its own control, and it carries the header's padding so the three strips at the top of the page share a left edge.
  • The queue row's three actions are drawn marks at one optical weight, quiet until the row is under the pointer.

Also in this release

The guides are settled into a neutral reference voice in both languages. Nothing about a command, an endpoint or a default changed with them.

v0.4.0

Choose a tag to compare

@libraz libraz released this 30 Aug 20:57

The first release since 0.3.0 that a caller can feel. Nothing here requires action on the orchestrator side, but four things behave differently enough to be worth reading before upgrading.

A command batch is decoded element by element

commandRequestSchema and streamMessageSchema parse each element on its own instead of accepting or rejecting the batch whole. An element a peer does not know now costs that element and not the request around it; a body with nothing known still fails with 400 no command, and a stream frame stays valid even when none of it is recognised, because the stream has to keep flowing. parseCommandRequest and parseStreamMessage hand the rejected elements back, so a caller that wants to report them can.

Which commands spend id on their own payload is named once in PAYLOAD_ID_COMMAND_NAMES, so the server cannot drift into stamping a correlation id onto one of them.

The CLI gains voice

yarn ctl voice <preset> and yarn ctl voice --bypass send the existing voice command. The detailed dsp fields stay on the API alone: a processing chain is judged by ear against a render, so the CLI carries the choice a person makes and not the knobs behind it.

point now fails on a third numeric positional, naming the extras, rather than dropping them silently.

A take and the line on air belong to a renderer

Several renderers at once is the normal case here — the panel's preview, the stage window, whatever OBS has open — so ownership is now something the server holds rather than something inferred from one open connection. A renderer names itself on /api/stream and on every recording chunk; a second renderer posting into an owned session is answered with a conflict and its bytes never interleave. A renderer that disappears mid-take closes the file on a bounded watchdog instead of leaving it open for the life of the process, and a sink failure is retained and reported on the recording instead of reading as a take in progress with no bytes.

On the viewer side the stream is tied to the page rather than to the loaded model, so an avatar swap or a StrictMode remount is still one renderer, and a slow or failed load is a connected renderer saying what went wrong.

One path guard for every file-backed surface

The BGM library, the deck and motion rosters, the script loader and the static server share src/files.ts. Every path component is walked with lstat rather than stat, so a symlink or a non-directory component is refused instead of followed, and two normalization-equivalent directory entries are an error rather than a race decided by directory order.

That change also shipped a regression in which /, /panel/ and /monitor/ were answered with 404 — a directory has no extension, and the name check demanded one before the directory-to-index branch could run. It is fixed in this release, and the prefixless document root is now covered by the suite.

Validation tightened where a caller can feel it

  • tune is strict at the command and at every group, since a misspelled knob that strips to an empty patch reads as a successful no-op.
  • say.reading must be non-empty.
  • A 2xx answer from the speech sidecar whose media type is not audio/* is refused rather than retained as a line and read back by every renderer asking for that sentence.
  • A BGM track is taken through bgmTrackIdSchema, so a name that is a path or carries no .mp3/.flac extension is refused where the caller can still read why.

Also in this release

The speech sidecar hardens its socket — a stale path is removed only after a connection is refused and the node re-checked, and the socket's parent directory is created at 0700 or validated rather than chmod-ed. The panel reads take output and avatar phase from the server instead of from its own controls, and no longer discards a line when Escape ends an IME conversion. The MCP tool inputs are rebuilt from the schemas they have to match. The guides are back in step with the runtime in both languages.

v0.3.1

Choose a tag to compare

@libraz libraz released this 30 Aug 17:58

A structural release. Nothing on the wire changed, no behaviour changed, and a caller on 0.3.0 has nothing to do.

Twelve files were split by responsibility

Twelve source files had grown past a thousand lines each, and a file that size stops being read and starts being searched. Each is now a directory filed by what its parts answer, and each kept its module path and its barrel — so no import anywhere in the tree moved, and src/protocol/index.ts, the one public door of the wire protocol, is untouched.

  • engine/types is filed by what a declaration describes: the canonical slots, the joint table, one avatar resolved, what a model states about itself, one frame of movement, the staging, a turn, the voice, the ports a renderer implements, the session readouts.
  • protocol/commands is filed by what a command talks to, and protocol/messages by who is speaking to whom.
  • engine/motion/gestures is one file per group, spread back into one flat table in group order, so an id is still reachable as GESTURES.wave.
  • i18n/messages/panel is one file per tab. Each now carries its own English/Japanese parity check, so a string added in one language and forgotten in the other is an error in that tab's file.
  • Session, Rig, ArmAnatomy, Body, BrowserBgm and AvatarRuntime each gave up the questions that were not theirs — the turn queue, the staging order, the elbow search, the fingertip back-solve, the body volumes, the character-space boundary, the insert chain, the camera — while keeping their public surfaces.
  • ctl is one file per command family over shared argv handling.

The engine's tuned numbers were checked mechanically

Most constants in src/engine were arrived at by watching two real avatars, and most carry a comment saying which failure they exist to prevent. Those comments are the most valuable thing in the tree, so the move was verified rather than trusted: the multiset of numeric literals across src/ has no removals against 0.3.0, only the additions the new wiring brought, and the one comment that went missing in the first pass — the block explaining the trunk cylinder's measured radius — was restored before anything was committed.

The suites were split the same way

The four largest test files became one file per subject over a shared harness and a shared set of renderer fakes. Every case moved verbatim; the suite asserts exactly what it did before.

The one thing a reader might notice

debug is now filed with the renderer commands rather than the body ones, since it reaches nothing in the session and only says what a renderer draws over itself. That is where its schema lives now, not a change to the command.

v0.3.0

Choose a tag to compare

@libraz libraz released this 30 Aug 15:52

An additive release. Nothing on the wire changed meaning, and a caller that says nothing new behaves exactly as it did.

A line can name the hand its gesture uses

A one-handed gesture picks an arm afresh on every playback. The table authors one pose and mirrors it onto whichever hand is free — every entry was checked on both — and the draw is there because a character who waves with the same arm every time reads as a mechanism rather than a person.

side, L or R, pins it for a caller that has a reason to: the hand away from the document, or the same hand the line before used.

yarn ctl gesture peace --side L
yarn ctl say "こっちだよ" --gesture pointUp --side L

It is one optional field per line rather than one per sub-field, because a turn plays a single movement — gesture's, or the one perform names — and a hand written twice could only ever disagree with itself. On a two-handed gesture there is no hand to choose and it fixes which way the head turns instead.

It reaches say, perform and gesture on the control API, --side on the three matching ctl verbs, speak, react and revise in the MCP tools, and a side key on a script line. An inline cue deliberately does not carry it: [@gesture peace] mid-sentence draws its hand as before, and a line that needs a particular one names the movement on the line rather than inside it.

A motion loaded off disk states L or R itself and ignores the field, since a file that keyframed one arm keeps that arm.

A take holds its document layer between frames

The recorder composites the slide layer into a canvas of its own and copies it flat while nothing about it moves. A page held for thirty seconds was previously the same rescale of the largest rectangle in the frame eighteen hundred times, which is the difference between meeting the rate that was asked for and recording under the one the file claims.

The held layer is invalidated by the page, the crossfade opacity, the rectangle and the output size, so a page turn costs what it always did. The recording context opens desynchronized, and the held canvas is released when a take ends, since the next one may be a different size.

The guides

recording.md now explains why a take comes out variable-rate and what to do about it: a frame exists only when the compositing canvas is redrawn, so a 50 Hz display caps a 60 fps request at an average like 49.36. The file itself is in sync — packet spacing, frame spacing and both stream start times line up — and the drift only appears in a player reading a variable rate as a fixed one. The ffmpeg pass that settles it is written out, audio stream-copied.

shell.md is new, and is the first place the native shell is written down in full: the three processes yarn shell starts, the server it adopts rather than replaces and the one it refuses by name, the menu, the status lines, where the window state and the children's output are written, and which two URLs its windows are allowed to load.

The rest of the pass brought the guides back in step with the runtime — the renderer's URL parameters, the server flags and environment variables, the ctl verbs that are not commands, the limits on three MCP tools, and what each speech state means all have tables now — and settled the Japanese edition on one register and one vocabulary.

v0.2.1

Choose a tag to compare

@libraz libraz released this 30 Aug 10:14

A corrective release. Nothing on the wire changed, and nothing that worked before works differently.

A filename outside ASCII now works on Linux

A name with a Japanese character in it is stored decomposed by the filesystem it was typed on, and composed everywhere the id then travels — the roster, the panel, the URL a browser sends. One name, two strings. macOS resolves the difference in the kernel and never notices; Linux compares bytes, so on the machine a broadcast actually runs on:

  • a document was missing from its own roster — the scan composed the id and then spelled a path back out of it, asking the directory for a name it does not hold
  • reading a document's words found nothing, on the same guess
  • the bytes 404'd, because the static server compared the URL to the directory byte for byte
  • a script was listed in the panel and then refused to run

The scan now keeps the name the directory holds and stats that, Decks.file reads the directory again for an id the last scan did not have, the script reader resolves through the listing, and the static server falls back to a normalisation-insensitive lookup — only after a miss, only for a name that has a second spelling, and still inside the one root guard it has always had.

The tests assert the resolved path rather than that a lookup succeeded. An existence check cannot fail on macOS, where the composed path opens either way, so it could not have caught this and did not.

The MCP version comes from the manifest

The version the adapter introduces itself with is displayed by an MCP client, so it is a published number. It was written out a second time in the source, where it stays correct until the release nobody remembers to edit it in. It is now read from package.json, and the manifest is the only place the number appears.

v0.2.0

Choose a tag to compare

@libraz libraz released this 30 Aug 10:00

0.1.0 could be told what to say and how to say it. This release is most of what a broadcast needs around that turn: the sound under it, the script it comes from, the motion it moves with, the window an operator frames it in, and the file it is recorded to.

The boundary is unchanged. There is still no provider SDK in the dependency tree, no API key and no prompt state, and everything below is reachable from the control API, the MCP tools, the CLI and the panel alike.

Background music

MP3 and FLAC from a local show directory, played under one page-owned audio graph, with its own level, looping, a bounded crossfade and its own effects. Reachable from the panel's BGM tab, the bgm MCP tool, yarn ctl bgm, and inline in a line as [@bgm play]. A different-track play crossfades; the first play uses only the fade-in.

Scripts on disk

A run of turns, written down as a file. A line is a TurnRequest and a setup entry is a Command — the wire vocabulary reused rather than re-described, so a script cannot become a second dialect. The CLI runs one, the server serves the roster, and the panel picks one onto the queue.

Motions off disk

Keyframe motions load from the show directory beside the built-in procedural table without joining it. A loaded motion may not take a built-in's name and is never drawn by the idle autopilot: what the performance table names has to keep meaning what it means, on every machine.

Recording

A take written to a file by the renderer that is already on air, with no compositor in the way.

The stage window and the native shell

A monitor surface showing exactly the frame a browser source sees, an address composer for pasting into OBS, and yarn shell — the panel and the stage as one native application. Whether a renderer makes a sound is still decided by its URL and by nothing else.

Typed inline cues

[@perform id], [@expression id], [@gesture id], [@hop id], [@camera …], [@slide n] and [@bgm …] are ordinary line text and travel through speak and script queues alike, checked in the panel before the line is said.

Speech on a socket

The sidecar binds no port at all. It answers on a UNIX socket in a directory it makes 0700: the control server is its only caller, so there is no browser for it to be reachable from, and loopback would have left it open to every process on the machine.

Several renderers at once

The panel's preview, the stage window and whatever OBS has open are three renderers on one queue, which is the normal case rather than the odd one. One line ending said by three renderers is one event, and one line wanted by three is one synthesis.

Fixes

Arms are posed in the character's frame rather than world X; five gestures no longer drive joints to their stops; fingers fan in the palm plane so a V reads as a V; the upper shot stays below the bust edge on a short torso; a queue held for framing survives an avatar swap; a room changed mid-document no longer paints over the page; and a script whose filename is stored decomposed now opens on Linux as well as on macOS.

Development

The tree is now verified on GitHub Actions — lint, typecheck, the suite and the bundle — with coverage reported for the layers that run without an avatar.


The avatars and the voice are not in the box and are not ours to redistribute: a checkout is the runtime, not the characters it was built against. See the README for what to bring.