Use this guide when preparing changes so SpeakSwiftly stays understandable, runnable, and reviewable.
- Overview
- Contribution Workflow
- Local Setup
- Development Expectations
- Pull Request Expectations
- Communication
- License and Contribution Terms
This guide is for people changing the SpeakSwiftly package, its tests, its worker executable, its maintainer scripts, or its contributor-facing documentation.
Agent-facing maintainer rules live in AGENTS.md. Keep the public README.md focused on end users and agents deciding whether SpeakSwiftly fits their local speech workflow.
Start from the package root. Read AGENTS.md, ROADMAP.md, and any maintainer note under docs/maintainers that matches the area you are changing.
Use this repository as the source-of-truth development home for SpeakSwiftly. The larger speak-to-user repository consumes this package as a submodule and should not be used for primary package work.
Keep changes focused on one coherent package concern: API, generation, normalization, playback, runtime, tests, scripts, or docs. If a change needs to widen into a coordinated speak-to-user submodule bump, a release, or a runtime-publishing pass, confirm that scope before doing it.
Use Swift Package Manager as the source of truth for package structure. Keep public normalization state, lifecycle, and API in Sources/SpeakSwiftly, and keep pure normalization and summarization algorithms in the internal Sources/SpeakSwiftlyNormalization target. Mirror those surfaces in Tests/SpeakSwiftlyTests/Normalization and Tests/SpeakSwiftlyNormalizationTests respectively. The internal target is not a package product; public callers use the SpeakSwiftly namespace.
Use Swift Testing for new package tests unless an existing external constraint requires XCTest. Keep operator-facing diagnostics descriptive enough that a maintainer can tell what broke and where to look next.
For generation changes, keep Qwen stream handling on the shared event-stream path so raw reference-audio generation and prepared-conditioning generation record token, info, audio, live-chunk, and cancellation behavior consistently. Keep voice-profile reference-audio materialization in the shared Generation support path so generated profiles, rerolls, clone imports, and future profile-authoring flows normalize and persist canonical audio the same way.
For runtime changes, keep worker request execution rules on the shared WorkerRequest.ExecutionPolicy surface so queueing, acknowledgement, resident-model, playback, and ordered-control behavior stay in one place. Reuse the package response and artifact conversion models directly instead of introducing parallel payload or generated-file mapping shapes. Keep generated-audio chunk capture and observation-stream subscription behavior on shared Runtime helpers when live playback and stream output need the same lifecycle handling.
Before asking for review, make sure the docs split still holds:
README.mdis short, end-user and agent focused, and nontechnical.CONTRIBUTING.mdowns contributor workflow, validation, maintainer operations, and technical reference pointers.AGENTS.mdowns agent-facing maintainer guidance.docs/maintainers/owns deeper plans, runbooks, audits, and validation notes.
Resolve package dependencies from the package root:
swift package resolveRuntime state normally lives under the platform Application Support directory. Use an explicit state root only when a test, host, or local investigation needs isolated profiles, configuration, text profiles, and generated artifacts.
Useful environment variables include:
SPEAKSWIFTLY_STATE_ROOTfor an isolated worker state root when startup arguments are not availableSPEAKSWIFTLY_PROFILE_ROOTis a deprecated compatibility alias for older hosts; do not use it in new integrationsSPEAKSWIFTLY_SPEECH_BACKENDfor backend selection fallback, includingqwen3_smol,qwen3_smol_4bit,qwen3_smol_5bit,qwen3_smol_6bit,qwen3_smol_8bit,qwen3_smol_bf16,qwen3_big,qwen3_big_4bit,qwen3_big_5bit,qwen3_big_6bit,qwen3_big_8bit, andqwen3_big_bf16SPEAKSWIFTLY_QWEN_RESIDENT_MODELis a deprecated compatibility alias for older hosts; choose a Qwen backend value insteadSPEAKSWIFTLY_ALLOW_PROFILE_CPU_FALLBACK=1to explicitly allow the voice-profile model to load on CPU when the process cannot see a Metal GPU device; this is off by default because CPU-generated voice-profile conditioning is slow and should not become the normal package or host behaviorSPEAKSWIFTLY_NORMALIZATION_OPENAI_SUMMARY_MODELto select the OpenAI Responses model used by normalization summarization; the default remains package-ownedSPEAKSWIFTLY_E2E=1for opt-in real-model end-to-end testsSPEAKSWIFTLY_PLAYBACK_TRACE=1for playback trace diagnostics
System voice profiles are package-owned bundled resources, not ordinary end-user library creations. The supported authoring workflow inserts a new profile or updates the existing profile with the same name:
- Run the
upsert-system-voice-profileSwiftPM command plugin from the consumer package checkout. - Submit
upsert_system_voice_profile_from_descriptionthrough the tool JSONL surface orSpeakSwiftly.Tool.upsertBuiltInVoiceProfile(...). - Review the generated profile under
Resources/SystemProfiles/profiles/<profile-name>/in the consumer target. - Bundle the reviewed profile directory with the target that owns it by declaring
.copy("Resources/SystemProfiles")in that target's package manifest entry. - Pass that target's bundled root into SpeakSwiftly at startup with
SpeakSwiftly.Configuration(systemProfileResourceRoots:), usually fromSpeakSwiftly.SupportResources.systemProfileRootURL(in: .module).
For example, a consumer package target can generate a bundled system profile with:
swift package plugin --allow-writing-to-package-directory upsert-system-voice-profile \
--target SpeakSwiftlyServer \
--name server-announcer \
--text "A clear server status voice." \
--vibe femme \
--voice-description "Clear, bright, steady, and concise."SwiftPM is the intended command-plugin entry point. Downstream packages get that entry point from the UpsertSystemVoiceProfile plugin product, while the user-facing command remains the verb upsert-system-voice-profile. The plugin itself resolves and runs SpeakSwiftlyTool through context.tool(named: "SpeakSwiftlyTool"), but host packages should not treat another checkout's .build/.../SpeakSwiftlyTool path as the plugin API. Launching that executable directly can be useful for lower-level maintainer diagnosis when paired with --system-profile-resource-root PATH, but it bypasses SwiftPM's plugin context, target lookup, write-permission gate, and artifact-path management.
If a host deliberately runs the helper executable directly, it owns the launch contract explicitly: pass --system-profile-resource-root PATH, send one JSONL request per stdin line, and review output under PATH/profiles/<profile-name>/. Direct helper launches may also pass --allow-profile-cpu-fallback when a nonstandard launch context cannot expose Metal and CPU authoring is an intentional one-off fallback.
The profile store uses .profile-store.lock inside the active profiles/ directory to coordinate writers while profiles are generated or seeded. For plugin-authored system profiles, that means the lock can appear under Resources/SystemProfiles/profiles/ after a successful run. It is an advisory coordination artifact, not a generated profile resource; leave it out of commits and reviews, and remove it only after confirming no SpeakSwiftly process is using that profile root.
From this repository, use the same command plugin with --target SpeakSwiftly to create first-party bundled built-in profiles under Sources/SpeakSwiftly/Resources/SystemProfiles/profiles/<profile-name>/:
swift package plugin --allow-writing-to-package-directory upsert-system-voice-profile \
--target SpeakSwiftly \
--name package-announcer \
--text "A clear SpeakSwiftly package voice." \
--vibe neutral \
--voice-description "Clear, warm, steady, and concise."Then the consuming target should pass its bundled system-profile root during startup:
let systemProfileRoots = [
SpeakSwiftly.SupportResources.systemProfileRootURL(in: .module),
].compactMap(\.self)
let runtime = await SpeakSwiftly.liftoff(
configuration: .init(systemProfileResourceRoots: systemProfileRoots)
)At runtime, SpeakSwiftly loads bundled system profiles from its own package resource bundle and from configured consumer resource roots, then seeds them into the writable profile store. Do not expose system-profile upserts through runtime.voices or any ordinary public end-user API. If upsert_system_voice_profile_from_description is used without --system-profile-resource-root, the request must fail instead of writing package-owned profiles into ordinary runtime state.
When SpeakSwiftlyTool is launched with --system-profile-resource-root, it starts with resident playback models unloaded so the resource-authoring process does not warm unrelated runtime models before it can read JSONL input. The generated profile is still valid bundled profile material; prepared Qwen conditioning remains lazy and is created later by ordinary runtime use after resident models are explicitly loaded.
For ordinary package work, use the SwiftPM build and test lane first. Real standalone worker runs should use the deterministic runtime launcher produced by the repo-maintenance scripts, not a plain SwiftPM-built worker executable.
If a standalone worker run reports default.metallib or mlx-swift_Cmlx.bundle errors, treat that as a build-and-launch-path problem first. Rebuild the deterministic runtime and launch through the generated run-speakswiftly script.
The typed Swift surface uses Cocoa-style names rooted at SpeakSwiftly.liftoff(...) and SpeakSwiftly.Runtime concern handles such as generate, playback, voices, normalizer, jobs, and artifacts.
Typed observation follows the RequestEvent, RequestState, RequestUpdate, and RequestSnapshot convention. Generate, Playback, Runtime, and per-request Synthesis observation families should keep rhyming with that model; see docs/maintainers/typed-observation-api.md.
The JSONL worker surface uses snake_case, verb-first operation names. Use get_* for one resource, list_* for collections, create_*, update_*, replace_*, and delete_* for CRUD-shaped mutations, and literal control verbs such as queue_*, set_*, reload_*, unload_*, pause, resume, clear_*, and cancel_* when those words match the real operation.
When adding or renaming a JSONL operation, update the worker contract article and this guide in the same pass.
Keep upsert_system_voice_profile_from_description as a development-time resource authoring operation. The flat JSONL operation name is intentional, but the operation belongs behind the SpeakSwiftlyTool development workflow and the SpeakSwiftly.Tool namespace, not the normal SpeakSwiftly.Runtime.voices library surface.
SpeakSwiftly is not a UI repository, but it is part of Gale's local accessibility and speech-output surface. Treat local service disruption, unexpected audio playback, resident model memory pressure, and unclear diagnostics as accessibility-impacting concerns.
Before worker-backed E2E, use the repo-maintenance wrappers so they can unload and later reload resident models in the live SpeakSwiftlyServer service without uninstalling or stopping that service.
Baseline package checks:
swift build
swift testOpt-in MLX conditioning persistence checks:
SPEAKSWIFTLY_MLX_PERSISTENCE_TESTS=1 swift test --filter ProfileStoreTests
SPEAKSWIFTLY_MLX_PERSISTENCE_TESTS=1 swift test --filter SpeechModelClientTestsKeep this lane separate from the baseline package checks. It exercises prepared Qwen conditioning persistence and may touch heavier MLX-backed code paths than the ordinary fast SwiftPM lane.
Full repo-maintenance validation:
bash scripts/repo-maintenance/validate-all.shFormatting and linting:
sh scripts/repo-maintenance/install-hooks.sh
swiftformat --lint --config .swiftformat .
swiftlint lint --config .swiftlint.ymlWorker-backed E2E:
sh scripts/repo-maintenance/run-e2e.sh --suite quick
sh scripts/repo-maintenance/run-e2e.sh --suite qwen-backends
sh scripts/repo-maintenance/run-e2e-full.shDeterministic runtime publishing and verification:
sh scripts/repo-maintenance/publish-runtime.sh --configuration Debug
sh scripts/repo-maintenance/verify-runtime.sh --configuration Debug
sh scripts/repo-maintenance/verify-runtime.sh --configuration ReleaseUse docs/maintainers/validation-lanes.md for Xcode-backed fallback validation, real-model E2E details, and benchmark lanes.
Summarize what changed, why it changed, and how it was verified. Call out any skipped validation, runtime behavior change, JSONL operation change, package dependency change, or public API change.
Keep README edits brief and reader-facing. Put contributor workflow, release operations, validation lanes, and maintainer details here or under docs/maintainers.
Surface uncertainty early when a change crosses package boundaries, changes runtime ownership, affects resident-model behavior, touches live-service safety, or needs a coordinated downstream adoption.
Ask before changing the shared test profile convention, release validation lane, runtime publishing layout, resident-model queue behavior, or the speak-to-user submodule pointer.
By contributing, you agree that your contribution is provided under this repository's Apache License 2.0 terms. See LICENSE and NOTICE.