Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ GitHub are its section here (`tools/release.sh` copies them), so add to

## Unreleased

- **Unison can be a wall.** The UNI panel has a WALL switch. On, a stack
gets louder as voices are added (about +6 dB at 5) instead of staying at
one voice's level, and its voices spread across the stereo field in
pitch order, so the channels beat apart and the stack sounds wider.
Off by default; existing projects sound the same.

## 0.0.15 — 2026-10-22

- **The SM-24 Mono has a patch panel.** Its new PATCH page holds four
Expand Down
16 changes: 16 additions & 0 deletions docs/08-services.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ its LED lit when on). It opens a small panel (`src/ui/unison_panel.zig`):
| DETUNE | 0–400 cents | lowest voice to highest; default 50. The JP-8000 supersaw reaches about 380 |
| SPREAD | 0–1 | the outer voices' pan, hard at 1 |
| BLEND | 0–1 | the side voices' level against the centre one (or two) |
| WALL | off / on | a stack that adds up and pans in pitch order (below) |

**Pool.** A poly machine shares its pool: notes = VOICES / UNISON, so
juno2's 8 voices at UNISON 4 play two notes. When one note fills the
Expand Down Expand Up @@ -139,6 +140,21 @@ sounding groups through the machine's `note-expr` word.
outside in, so pitch and place don't line up. The pan law is equal
power, unity in the middle.

**WALL.** Off by default, and saved only when on (`"wall":true` in the
track's `unison` object), so earlier projects keep their sound. It changes
two things, fitted to a Virus TI's unison measured on one oscillator with
effects off (scratch notes: level, left/right correlation, per-voice pan):

- The stack is no longer held at one voice's power. Group amplitude grows
as n^0.42 over the normalized stack: +2.5, +4, +6 and +7.6 dB for 2, 3,
5 and 8 voices (the hardware reads +1.5, +3.8, +6.4 and +6.9). Turn the
track down, or the master limiter will take it.
- Voices pan in pitch order, lowest left to highest right, with the places
bent outward (|p|^0.5) so the inner voices sit well off centre too. Each
channel then beats at its own rate. Measured left/right correlation at
full SPREAD on Concoction, 5 voices: 0.56 without WALL, 0.47 with it
(8 voices 0.46 to 0.28; the hardware reads 0.23 and 0.16).

**Kernel side.** Note-on gets `ctx.uni` (p) and `ctx.phase`, a random
0–1 start phase. Oscillators that free-run from 0 seed from `phase`
while still at 0, so the stack doesn't flange on its first note.
Expand Down
47 changes: 44 additions & 3 deletions src/machine.zig
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,9 @@ pub const Unison = struct {
detune_bits: std.atomic.Value(u32) = .init(@bitCast(DEF_DETUNE)),
spread_bits: std.atomic.Value(u32) = .init(@bitCast(DEF_SPREAD)),
blend_bits: std.atomic.Value(u32) = .init(@bitCast(DEF_BLEND)),
/// WALL: the stack adds up instead of holding one voice's power, and
/// its voices pan in pitch order (docs/08 §Unison, WALL).
wall: std.atomic.Value(bool) = .init(false),

pub fn init(native: u8) Unison {
return .{ .native = native, .pool = .init(native) };
Expand Down Expand Up @@ -373,6 +376,12 @@ pub const Unison = struct {
pub fn blend(u: *const Unison) f32 {
return @bitCast(u.blend_bits.load(.monotonic));
}
pub fn isWall(u: *const Unison) bool {
return u.wall.load(.monotonic);
}
pub fn setWall(u: *Unison, on: bool) void {
u.wall.store(on, .monotonic);
}
pub fn setCount(u: *Unison, n: u8) void {
u.count.store(std.math.clamp(n, 1, MAX_COUNT), .monotonic);
}
Expand All @@ -391,7 +400,7 @@ pub const Unison = struct {

/// Off, on the machine's own pool: nothing to save.
pub fn isDefault(u: *const Unison) bool {
return u.voices() == 1 and (u.mono() or u.pool.load(.monotonic) == u.native);
return u.voices() == 1 and !u.isWall() and (u.mono() or u.pool.load(.monotonic) == u.native);
}
pub fn reset(u: *Unison) void {
// UI reset may overlap audio reads; retain atomic stores for each
Expand All @@ -401,10 +410,11 @@ pub const Unison = struct {
u.detune_bits.store(@bitCast(DEF_DETUNE), .monotonic);
u.spread_bits.store(@bitCast(DEF_SPREAD), .monotonic);
u.blend_bits.store(@bitCast(DEF_BLEND), .monotonic);
u.wall.store(false, .monotonic);
}

/// `{"count":4,"detune":20,"spread":0.7,"blend":0.75,"voices":8}`
/// (voices only for a poly machine).
/// `{"count":4,"detune":20,"spread":0.7,"blend":0.75,"voices":8,"wall":true}`
/// (voices only for a poly machine, wall only when on).
pub fn writeJson(u: *const Unison, out: *std.ArrayList(u8), alloc: std.mem.Allocator) !void {
var buf: [160]u8 = undefined;
const s = try std.fmt.bufPrint(&buf, "{{\"count\":{d},\"detune\":{d},\"spread\":{d},\"blend\":{d}", .{ u.voices(), u.detune(), u.spread(), u.blend() });
Expand All @@ -413,6 +423,8 @@ pub const Unison = struct {
const p = try std.fmt.bufPrint(&buf, ",\"voices\":{d}", .{u.pool.load(.monotonic)});
try out.appendSlice(alloc, p);
}
// Only when on: a project saved before WALL, or with it off, is byte-identical.
if (u.isWall()) try out.appendSlice(alloc, ",\"wall\":true");
try out.append(alloc, '}');
}

Expand All @@ -436,6 +448,7 @@ pub const Unison = struct {
if (num(o, "detune")) |x| u.setDetune(@floatCast(x));
if (num(o, "spread")) |x| u.setSpread(@floatCast(x));
if (num(o, "blend")) |x| u.setBlend(@floatCast(x));
if (o.get("wall")) |w| if (w == .bool) u.setWall(w.bool);
}
};

Expand Down Expand Up @@ -585,3 +598,31 @@ pub const Machine = struct {
return self.note_labels;
}
};

test "unison WALL is saved only when on and old projects load without it" {
const alloc = std.testing.allocator;
var u = Unison.init(8);
u.setCount(4);

var out: std.ArrayList(u8) = .empty;
defer out.deinit(alloc);
try u.writeJson(&out, alloc);
try std.testing.expect(std.mem.indexOf(u8, out.items, "wall") == null);

u.setWall(true);
out.clearRetainingCapacity();
try u.writeJson(&out, alloc);
try std.testing.expect(std.mem.indexOf(u8, out.items, "\"wall\":true") != null);

var parsed = try std.json.parseFromSlice(std.json.Value, alloc, out.items, .{});
defer parsed.deinit();
var v = Unison.init(8);
v.applyJson(parsed.value);
try std.testing.expect(v.isWall());
try std.testing.expect(!v.isDefault());

var old = try std.json.parseFromSlice(std.json.Value, alloc, "{\"count\":4,\"detune\":20,\"spread\":0.7,\"blend\":0.75,\"voices\":8}", .{});
defer old.deinit();
v.applyJson(old.value);
try std.testing.expect(!v.isWall());
}
18 changes: 15 additions & 3 deletions src/machines/fy_raw_machine.zig
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,7 @@ pub const FyRawMachine = struct {
uni_detune: f32 = machine.Unison.DEF_DETUNE,
uni_spread: f32 = machine.Unison.DEF_SPREAD,
uni_blend: f32 = machine.Unison.DEF_BLEND,
uni_wall: bool = false,
uni_rng: u64 = 0x9E37_79B9_7F4A_7C15,
// Some voice renders through the panned path this block.
uni_panned: bool = false,
Expand Down Expand Up @@ -3097,6 +3098,12 @@ fn voiceHz(self: *const FyRawMachine, v: usize, pitch: f32) f64 {
/// sides at BLEND, the group's power at one voice's; outer pairs panned
/// across SPREAD, alternating sides from one pair to the next so pitch
/// and place don't line up. Equal power, 1/1 in the middle.
///
/// WALL changes two things, after measuring a Virus TI's unison: the stack
/// is not held at one voice's power but grows as n^0.42 in amplitude
/// (+4 dB at 3 voices, +6 at 5, +7.6 at 8; the hardware reads +3.8, +6.4,
/// +6.9), and the voices pan in pitch order, the lowest hard left and the
/// highest hard right at full SPREAD, so each channel beats at its own rate.
fn voiceGains(self: *const FyRawMachine, v: usize) [2]f64 {
const n: usize = self.voice_uni_n[v];
if (n <= 1) return .{ 1, 1 };
Expand All @@ -3105,9 +3112,13 @@ fn voiceGains(self: *const FyRawMachine, v: usize) [2]f64 {
const centres: f64 = if (n % 2 == 1) 1 else 2;
const b: f64 = self.uni_blend;
const w: f64 = if (j == (n - 1) / 2) 1 else b;
const g = w / @sqrt(centres + (@as(f64, @floatFromInt(n)) - centres) * b * b);
const side: f64 = if (j % 2 == 1) -1 else 1;
const pan = @as(f64, self.uni_spread) * uniPos(k, n) * side;
var g = w / @sqrt(centres + (@as(f64, @floatFromInt(n)) - centres) * b * b);
if (self.uni_wall) g *= std.math.pow(f64, @floatFromInt(n), 0.42);
const side: f64 = if (self.uni_wall or j % 2 == 0) 1 else -1;
const place = uniPos(k, n);
// WALL bends the places outward (|p|^0.5) so the inner voices sit well off centre too.
const spot = if (self.uni_wall) std.math.copysign(@sqrt(@abs(place)), place) else place;
const pan = @as(f64, self.uni_spread) * spot * side;
const th = (pan + 1.0) * (std.math.pi / 4.0);
return .{ g * std.math.sqrt2 * @cos(th), g * std.math.sqrt2 * @sin(th) };
}
Expand All @@ -3128,6 +3139,7 @@ fn syncUnison(self: *FyRawMachine) !void {
self.uni_count = @min(u.voices(), pool);
self.uni_spread = u.spread();
self.uni_blend = u.blend();
self.uni_wall = u.isWall();
const det = u.detune();
if (det != self.uni_detune) {
self.uni_detune = det;
Expand Down
12 changes: 10 additions & 2 deletions src/ui/unison_panel.zig
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Unison panel (docs/08 §Unison): the context panel an instrument's UNI
//! chip opens. A small faceplate hung under the chip with the stack's
//! knobs: VOICES (a poly machine's pool), UNISON (voices per note), DETUNE,
//! SPREAD and BLEND, over a readout of what the pool plays. Modal like a
//! SPREAD, BLEND and the WALL switch, over a readout of what the pool plays. Modal like a
//! dialog (main suppresses the input behind it while `active`); Esc or a
//! click outside closes it.

Expand Down Expand Up @@ -43,7 +43,7 @@ pub fn draw(ui: *Ui, screen: Rect, st: *State, u: *Unison) bool {
defer ui.popId();

const poly = !u.mono();
const cols: i32 = if (poly) 5 else 4;
const cols: i32 = if (poly) 6 else 5;
const cell = ctl.knobCell(KNOB, true);
const cw = cell[0] + 8;
const w = cols * cw + 2 * PAD;
Expand Down Expand Up @@ -118,6 +118,14 @@ pub fn draw(ui: *Ui, screen: Rect, st: *State, u: *Unison) bool {
}
}

{
var on = u.isWall();
if (ctl.latch(ui, row.cutLeft(cw), "wall", &on, .{ .label = "WALL", .led = style.accent })) {
u.setWall(on);
changed = true;
}
}

// What the pool plays now.
_ = body.cutTop(4);
var lbuf: [40]u8 = undefined;
Expand Down
Loading