Imported from PeekAndPoke/klang (
.claude/skills/code-style/SKILL.md). Install upstream withnpx skills add PeekAndPoke/klang --skill code-style. Copyright stays with the author.
What This Skill Does
Loads the code style and coding convention rules for the Klang project. Apply these rules whenever writing or editing code.
Formatting Rules
1. Always Use Curly Braces
All if, else, for, while, and when branch bodies must use { }, even for one-liners.
Wrong:
if (condition) doSomething()
if (condition) doSomething() else doOther()
Correct:
if (condition) {
doSomething()
}
if (condition) {
doSomething()
} else {
doOther()
}
Exception — expression form (decided 2026-08-28): an if/else used as an EXPRESSION (its
value is consumed) may stay brace-free on one line. The same applies to when arms in expression
position (X -> value).
val x = if (a) b else c
phase = if (pm != null) phase.wrapPhase(1.0) else phase.smallNumFastMod(1.0)
Two limits: statement-position ifs (value discarded) always get braces, even one-liners; and the
moment ANY branch of an expression if needs braces or multiple lines, brace ALL its branches —
no } else 0.0 mixing.
2. Blank Lines Around if Blocks
Leave a blank line before and after an if statement (and other block statements like for/
when) when it is not the first or last statement in its enclosing block. Especially in
early-return ladders (e.g. fast-path branches falling through to a general case), the blank
lines make each branch read as its own step.
The same spacing applies to variable declarations (added 2026-08-28): leave a blank line after a
block of val/var declarations, and before a declaration that follows other statements.
Consecutive declarations stay together as one group.
val step = readParam(rate, freqHz, ctx) * PERLIN_STEP
val end = ctx.offset + ctx.length
for (i in ctx.offset until end) {
val white = rng.nextDouble() * 2.0 - 1.0
out = (out + k * white) / denom
buffer[i] = out
}
And a blank line before every return (added 2026-08-31), unless the return is the only
statement in its block. Early-return ladders read as steps that way, and the final return of a
function separates from the work that produced its value. A comment attached to the return stays
attached: the blank goes above the comment, not between comment and return.
if (kw != null) {
val kwInv = 1.0 - kw
from.generate(buffer, freqHz, ctx)
return
}
// fine as-is — the return is the whole block
fun Ignitor.mul(factor: Ignitor): Ignitor = this * factor
Wrong:
if (bConst) {
// ...
return
}
if (aConst) {
// ...
return
}
a.generate(buffer, freqHz, ctx)
Correct:
if (bConst) {
// ...
return
}
if (aConst) {
// ...
return
}
a.generate(buffer, freqHz, ctx)
3. File Naming Conventions
Flat directories. Source files sit at the module's package root (module/src/commonMain/kotlin/),
never nested under io/peekandpoke/... directories. Sub-packages get one flat directory
(runtime/, intel/), not a mirrored package path.
- Files containing a class/object/interface: PascalCase matching the primary declaration.
- Files containing only utility/helper/extension functions:
lower_case.kt. In folders that also contain class files, use a_prefix (e.g.,_staff_pos_helpers.kt) to group utility files at the top of the file tree. In folders with only utility files, skip the prefix — when every file starts with_, it adds noise instead of value. - Utility file names must be unique and descriptive. Generic names like
_utils.ktmake it impossible to tell what's inside without opening the file.
Wrong: ShapingFunctions.kt (class inside is ShapingFuncs — name mismatch)
Wrong: _utils.kt (not unique, not descriptive)
Correct: ShapingFuncs.kt (matches class — this pair was the real offender, fixed 2026-08-26)
Correct: _staff_pos_helpers.kt (utility file alongside class files — _ groups it at top)
Correct: math.kt, chain_rendering.kt (utility-only folder — no _ prefix needed)
No Duplication Rules
3. No Duplicated Utility Functions
Shared DSP utilities (flushState, shape resolution, etc.) must live in exactly one place
and be imported. Never copy a utility function into another file as a private copy.
Canonical location: DspUtil.kt in the module root package.
4. No Duplicated Data Classes or Resolution Logic
If two files need the same data class (e.g., ResolvedShape) or lookup logic (e.g., distortion
shape resolution), extract it to a shared file. Both callers import from the same source.
Kotlin/JS Performance Rules
5. No Boxed Types Anywhere in the Project
This is a real-time audio engine — every CPU cycle counts, in DSP code and the UI layer. Several Kotlin types are boxed (heap-allocated wrapper objects) when compiled to JavaScript. Never use them anywhere in the project.
Banned types and why
| Type | JS representation | Problem |
|---|---|---|
Long |
Emulated kotlin.Long class (pair of 32-bit ints) |
Always boxed, allocation on every operation |
ULong |
Wraps Long |
Same boxing as Long |
Byte |
JS number + range checks |
Coercion/masking overhead on every operation |
Short |
JS number + range checks |
Coercion/masking overhead on every operation |
UByte |
Inline class wrapping Byte |
Same overhead as Byte |
UShort |
Inline class wrapping Short |
Same overhead as Short |
Char |
Boxed kotlin.Char wrapper |
Heap allocation |
Safe alternatives
| Instead of | Use |
|---|---|
Long / ULong |
Int for counts/indices, Double for time values |
Byte / UByte |
Int (mask with and 0xFF if needed) |
Short / UShort |
Int (mask with and 0xFFFF if needed) |
Char |
Int (char code), or String for text |
Scope
This applies project-wide — audio DSP, audio bridge, klangscript, sprudel, UI layer,
everywhere. If a Long (or other banned type) arrives from an external API, convert it to
Int or Double at the boundary immediately.
Wrong:
var absPos = ctx.blockStart - startFrame // Long arithmetic
for (i in 0 until ctx.length) {
doWork(absPos) // Long in hot loop
absPos++
}
val midi: Byte = 60 // Byte with range-check overhead
val char: Char = 'A' // Boxed Char wrapper
Correct:
var absPos = ((ctx.blockStart + ctx.offset) - startFrame).toInt() // Convert at boundary
for (i in 0 until ctx.length) {
doWork(absPos) // Int in hot loop
absPos++
}
val midi: Int = 60 // Plain JS number
val char: Int = 65 // Char code as Int, or use String for text
6. No Allocations in Audio Hot Paths
No FloatArray(), listOf(), toList(), Pair(), String concatenation, or object creation
in per-sample or per-block audio processing code. Pre-allocate buffers at construction time.
Wrong:
override fun process(buffer: FloatArray, offset: Int, length: Int) {
val temp = FloatArray(length) // Allocation every block!
}
Correct:
private var temp: FloatArray = FloatArray(0)
override fun process(buffer: FloatArray, offset: Int, length: Int) {
if (temp.size < length) {
temp = FloatArray(length)
} // Resize only when needed
}
7. No Exceptions in Audio Hot Paths
Never use require(), check(), or throw exceptions in audio processing code.
These allocate strings and can kill the AudioWorklet thread.
DSP Rules
8. Flush IIR Filter State
Every IIR filter (SVF, one-pole, allpass, DC blocker) must flush its state variables after
each update. Use the shared flushState() from DspUtil.kt.
Why, two reasons: denormal floats cause 10-100x CPU spikes on some platforms, and a
NON-FINITE carry latches the filter permanently — an IIR whose state goes NaN can never
recover, and one Inf is enough (the next sample computes -Inf + Inf). The master round
measured where that ends: one such sample silenced the whole backend until a page reload.
flushState rejects both, so following this rule is what makes a filter unable to latch.
ic1eq = (2.0 * v1 - ic1eq).flushState()
ic2eq = (2.0 * v2 - ic2eq).flushState()
Exception: Reverb uses + ANTI_DENORMAL instead (deliberate, documented at the class —
its comb/allpass count makes the per-sample add cheaper). That exception is about DENORMALS
only; it does not protect against a non-finite carry.
9. Band-Limit Discontinuous Waveforms
All oscillators with signal discontinuities (saw, square, pulse) must use PolyBLEP anti-aliasing. Triangle is exempt (aliasing at -12dB/oct from derivative discontinuity is acceptable).
10. Wrap Oscillator Phase
LFO and oscillator phase accumulators must be wrapped to prevent unbounded growth.
Use if (phase >= TWO_PI) phase -= TWO_PI or the wrapPhase() helper.
Why: Unbounded phase loses sin() precision as the mantissa runs out of bits.
Naming Rules
11. Single-Letter DSP Variable Names Are Acceptable
Standard DSP coefficient names (a, k, q, g, v, p) are accepted when they
match established mathematical or DSP textbook conventions.
12. .toFloat() / .toDouble() at Buffer Boundaries Are Expected
Audio code computes in Double for precision and stores in FloatArray for memory/perf.
The conversions at the boundary are intentional — do not flag them as unnecessary casts.
Annotation Rules
13. @Suppress("NOTHING_TO_INLINE") Is Accepted on Audio Hot-Path Inline Functions
The Kotlin compiler warns that inlining is unnecessary for non-lambda functions. In audio code, avoiding call overhead is intentional. The suppression is accepted.
14. @Suppress("unused") Is Accepted on API Surface Libraries
Collections of utility functions (e.g., ShapingFuncs) may have members that aren't all
currently referenced but form a coherent API. The suppression is accepted.
Boolean and Null Rules
15. == true on Boolean? Is Correct Kotlin Idiom
When a Boolean is nullable, x == true is the correct and idiomatic null-safe check.
Do not flag this as a style issue.
Comment Rules
16. Algorithm Constants May Be Inline Literals
Well-known algorithm tuning constants (Freeverb delay lengths, PolyBLEP thresholds, etc.) are acceptable as inline numeric literals when documented with a source comment.
// Freeverb standard comb tunings (designed for 44100 Hz)
private val combTuning = intArrayOf(1116, 1188, 1277, 1356, ...)
File Header Rules
17. Every Source File Starts With the Copyright Header
Every .kt source file must begin with the project license header as its very first lines,
above any @file: annotation or package declaration:
/*
* Copyright (C) 2025-2026 The Klangmotor Authors (see AUTHORS.MD)
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-
New files: IntelliJ inserts this automatically via the "Klang AGPL" copyright profile in
.idea/copyright/. When creating files outside the IDE, add the header manually. -
Year: the end year tracks the current year — IntelliJ's "Update copyright" before-commit action keeps it current. Don't hand-edit the year per file.
-
Brand: always "Klangmotor" / "Motor" with a plain o. The old metal-umlaut spelling "Motör" was retired 2026-08-25; it survives only in historical records (
DEV-DIARY.MD,docs/history/,docs/tasks-archive/), which are never "fixed". -
Exempt:
.ktsbuild scripts, and any third-party / vendored file that carries its own copyright notice (never overwrite someone else's notice with ours). -
tones/module is MIT, not AGPL. It is a Kotlin port of tonal.js (MIT) and is licensed MIT to match upstream. Files there use a different header that also credits danigb — never apply the AGPL header insidetones/:/* * Copyright (C) 2025-2026 The Klangmotor Authors (see AUTHORS.MD) * Portions derived from tonal.js — Copyright (c) 2015 danigb. * SPDX-License-Identifier: MIT * Full license: tones/LICENSE */IntelliJ applies this automatically via the "Klang tones MIT" copyright profile scoped to
tones/.
Language Rules
18. Imports, Not Fully Qualified Names
Use an import for every referenced type or function. io.peekandpoke.klang.audio_bridge.VoiceData
inline in code is a finding; the only exception is a genuine name clash inside one file.
19. Exhaustive when in Expression Form
Over a sealed class or enum, write when as an expression so the compiler checks every arm. Arms
that do nothing are written out (is Foo -> Unit or {}); never a bare non-exhaustive
statement when that silently skips a new variant.
20. Annotate NaN Guards
A self-comparison NaN check is not obvious to the next reader. Always mark it:
if (x != x) { // NaN-guard
return 0.0
}
21. Coerce User Input, require() Only Internal Invariants
Anything a user can reach (sprudel args, KlangScript DSL args, UI inputs, imports) is coerced
into range (coerceIn, defaults, clamping of INDICES and COUNTS), never asserted. require()
and check() are for internal invariants only. This is the "coerce" half of /dsl-design §6;
the "raw" half (no unasked safety clamps on AUDIO parameters) lives there too.
22. No Em-Dashes in User-Facing Text
Never — or – in docs, KDoc, UI strings, tutorials, commit messages or reports. Use commas,
colons, parentheses or a new sentence. The A/B comment suffix convention is , swap.
(Maintainer, 2026-08: the dash reads as an AI tell.)
Test Rules
23. Negated Equality Is shouldNotBe
A negated equality in a spec is a shouldNotBe b, never (a == b) shouldBe false, so a failure
names both values instead of "expected false but was true". The one exception is a boxed NaN,
where shouldNotBe passes for the wrong reason and the raw comparison is deliberate; say so in a
comment at that site. (Added 2026-09-18 after the form recurred one round after it was retired
at three sites; /review-loop's escape-ledger.md has the row.)
