diff --git a/CHANGELOG.md b/CHANGELOG.md index a937085f..d4a6f17c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ 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. - **Menus scroll.** A menu or submenu taller than the window (a bank of 128 presets) scrolls with the wheel, the arrow keys and PgUp/PgDn, with ▴ and ▾ marking more rows; the rows per menu went from 72 to 256. diff --git a/docs/08-services.md b/docs/08-services.md index 2ec49df8..cb0d4c68 100644 --- a/docs/08-services.md +++ b/docs/08-services.md @@ -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 @@ -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. diff --git a/src/machine.zig b/src/machine.zig index d2711887..483a8f65 100644 --- a/src/machine.zig +++ b/src/machine.zig @@ -348,6 +348,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) }; @@ -377,6 +380,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); } @@ -395,7 +404,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 @@ -405,10 +414,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() }); @@ -417,6 +427,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, '}'); } @@ -440,6 +452,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); } }; @@ -599,3 +612,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()); +} diff --git a/src/machines/fy_raw_machine.zig b/src/machines/fy_raw_machine.zig index c59e9aa6..52517c3b 100644 --- a/src/machines/fy_raw_machine.zig +++ b/src/machines/fy_raw_machine.zig @@ -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, @@ -3107,6 +3108,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 }; @@ -3115,9 +3122,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) }; } @@ -3138,6 +3149,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; diff --git a/src/ui/unison_panel.zig b/src/ui/unison_panel.zig index 993b3b03..bd42396c 100644 --- a/src/ui/unison_panel.zig +++ b/src/ui/unison_panel.zig @@ -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. @@ -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; @@ -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;