diff --git a/slab/menuband/Info-AppStore.plist b/slab/menuband/Info-AppStore.plist index 5393752bbf..c0127efc59 100644 --- a/slab/menuband/Info-AppStore.plist +++ b/slab/menuband/Info-AppStore.plist @@ -44,7 +44,7 @@ LSApplicationCategoryType public.app-category.music LSMinimumSystemVersion - 11.0 + 12.0 LSUIElement NSHighResolutionCapable diff --git a/slab/menuband/Info.plist b/slab/menuband/Info.plist index 4633ad59cf..f4ca134b55 100644 --- a/slab/menuband/Info.plist +++ b/slab/menuband/Info.plist @@ -36,9 +36,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.6.12 + 1.6.13 CFBundleVersion - 172 + 173 ITSAppUsesNonExemptEncryption LSApplicationCategoryType diff --git a/slab/menuband/STORE-APP-STORE.md b/slab/menuband/STORE-APP-STORE.md index 5e19009632..0a578ecb13 100644 --- a/slab/menuband/STORE-APP-STORE.md +++ b/slab/menuband/STORE-APP-STORE.md @@ -42,6 +42,16 @@ sandbox-safe TrackDrum companion bridge and the focused percussion/slider UX. **PUBLISHED — v1.6.9 (build 169), 2026-08-07.** App Store Connect reports `READY_FOR_SALE`. +**SUBMITTED — v1.6.13 (build 173), 2026-09-18.** ABC letter layer, Mono +fold, macOS 27 click fixes, sampler + monitor persistence, stall-safe cues. +Two lane changes worth knowing: Xcode 27 refuses a macOS 11.0 deployment +target ("supported range is 12.0 to 27.0.x"), so `project.yml` and +`Info-AppStore.plist` now say **12.0**; and the DMX room lane +(`DMXOut.swift`) is `#if MAC_APP_STORE`-gated to a no-op, since the sandbox +has no serial entitlement. Imported-symbol diff against the approved build +170 showed no new privileged API. The store price is **$9.99** (not the +$4.99 it launched at). Archive/export/upload/ship ran from blueberry. + **SUBMITTED — v1.6.10 (build 170), 2026-08-09.** App Store Connect reports `WAITING_FOR_REVIEW`. Fixes TrackDrum focus activation, physical-click focus loss, and false Command-gesture activation. Automatic release is enabled. diff --git a/slab/menuband/Sources/MenuBand/DMXOut.swift b/slab/menuband/Sources/MenuBand/DMXOut.swift index 8a523a1f46..33b0703be9 100644 --- a/slab/menuband/Sources/MenuBand/DMXOut.swift +++ b/slab/menuband/Sources/MenuBand/DMXOut.swift @@ -1,85 +1,224 @@ import Foundation -/// USB DMX stage-light lane — mirrors whatever the synth sounds onto a room -/// fixture, notepat's pitch-class palette roomward. The light model is the -/// AC OS npscore one (fedac/native/pieces/npscore.mjs): each note-on is a -/// pulse that decays from the strike, weighted average picks the color, -/// summed activity sets the level — the room carries the music's dynamic, -/// not a held average. Silent no-op until a USB DMX serial widget appears; -/// unplug/replug self-heals. +/// USB DMX stage-light lane — the room plays along with the synth. +/// +/// Two sources, routed to different fixtures: +/// • KEYS hold. A note lights its pitch-class colour the frame it lands +/// and stays lit until the key lifts (short release tail); chords blend. +/// • DRUMS flash. A TrackDrum hit is a strobe-shaped pulse — full for a +/// beat, then a fast fall — in a colour per drum. Low drums and high +/// drums go to different pars. +/// Weighted average picks each group's colour, summed activity its level. +/// +/// The house rig is four 6-channel pars (from the start address: red, +/// green, blue, amber, white, uv — found with `ac-dmx-cli sweep`). Silent +/// no-op until a USB DMX serial widget appears; unplug/replug self-heals. +/// Bench the widget and fixtures from the shell with `ac-dmx-cli` +/// (slab/bin) — same framing, same port. final class DMXOut { static let shared = DMXOut() - /// One pulse per note-on, like an npscore light cue. - private struct Pulse { - let r: Double, g: Double, b: Double - let gain: Double // velocity / 127 - let start: Double // systemUptime at note-on + // MARK: - Rig + + /// What a fixture's channels mean, from its start address. + enum Layout { + case rgb // 3ch: r g b + case rgbawuv // 6ch: r g b amber white uv (the house LED pars) } - // Notepat ROYGBIV by pitch class (NoteColors.base, 0=C..11=B). Sharps - // are deliberately black — naturals carry the chromatic identity, so a - // sharp dims the blend the way it reads as a gap on the strip. - private static let palette: [(Double, Double, Double)] = [ - (255, 50, 50), (0, 0, 0), (255, 160, 0), (0, 0, 0), - (255, 230, 0), (50, 200, 50), (0, 0, 0), (50, 120, 255), - (0, 0, 0), (130, 50, 200), (0, 0, 0), (180, 80, 255), + /// Which source a fixture listens to. + enum Role { case keys, drumsLow, drumsHigh } + + struct Fixture { let address: Int; let layout: Layout; let role: Role } // 1-based DMX address + + /// The rig. Two pars share address 1 (they mirror each other until one + /// is re-addressed on its display); one each at 7 and 13. Change a + /// role here to re-route. (The older rgb fixture at d.510 is off this + /// chain; adding it back means 518-byte frames — see `slotCount`.) + static let fixtures = [ + Fixture(address: 1, layout: .rgbawuv, role: .keys), + Fixture(address: 7, layout: .rgbawuv, role: .drumsLow), + Fixture(address: 13, layout: .rgbawuv, role: .drumsHigh), ] - /// Pulse window in seconds — the e^-3 envelope dies to ~5% here, where - /// npscore cuts the cue. - private static let window = 0.8 + /// Slots per frame: just far enough to cover the fixtures, never fewer + /// than DMX's 24. Frame SIZE is what the widget's serial link can't + /// keep up with (~9.5 KB/s, measured): 518-byte full-universe frames at + /// 40 Hz back up in the kernel until a close() or open() of the port + /// hangs for minutes (the 2026-09-18 wedge). This rig needs 30 bytes. + private static let slotCount: Int = max(24, fixtures.map { f in + f.address - 1 + (f.layout == .rgb ? 3 : 6) + }.max() ?? 24) + + // MARK: - Colour + + /// A roomward colour in the par's own terms. Amber, white and uv are + /// real LEDs on the house pars — far brighter than mixing them from + /// rgb — and an rgb-only fixture folds them back in. + struct Colour { + var r = 0.0, g = 0.0, b = 0.0, a = 0.0, w = 0.0, uv = 0.0 + static let off = Colour() + } + + // Notepat ROYGBIV by pitch class (NoteColors.base, 0=C..11=B), every + // hue pushed so its brightest LED is a full 255. D is the amber LED + // (its orange, only brighter). Sharps are black on the strip; roomward + // they hit the WHITE LED, so the strip's gaps are the room's hardest + // hits instead of holes in the blend. + private static let keyPalette: [Colour] = [ + Colour(r: 255, g: 50, b: 50), // C + Colour(w: 255), // C# + Colour(a: 255), // D + Colour(w: 255), // D# + Colour(r: 255, g: 230), // E + Colour(r: 64, g: 255, b: 64), // F + Colour(w: 255), // F# + Colour(r: 50, g: 120, b: 255), // G + Colour(w: 255), // G# + Colour(r: 166, g: 64, b: 255), // A + Colour(w: 255), // A# + Colour(r: 180, g: 80, b: 255), // B + ] + + /// One colour per TrackDrum voice, and which par it lands on. Kick is + /// the white slam; skins are warm; metal is uv and cool. + private static func drumColour(_ d: MenuBandPercussion.Drum) -> (Colour, Role) { + switch d { + case .kick: return (Colour(w: 255), .drumsLow) + case .snare: return (Colour(r: 255), .drumsLow) + case .clap: return (Colour(a: 255), .drumsLow) + case .snap: return (Colour(r: 255, g: 255), .drumsLow) + case .cowbell: return (Colour(r: 128, a: 255), .drumsLow) + case .block: return (Colour(g: 255), .drumsLow) + case .crash: return (Colour(w: 255, uv: 255), .drumsHigh) + case .splash: return (Colour(b: 255, w: 128), .drumsHigh) + case .ride: return (Colour(b: 128, uv: 255), .drumsHigh) + case .hatClosed: return (Colour(uv: 255), .drumsHigh) + case .hatOpen: return (Colour(w: 100, uv: 255), .drumsHigh) + case .tambo: return (Colour(g: 255, b: 255), .drumsHigh) + } + } + + // MARK: - Envelopes (seconds) + + /// Key release tail after the lift — short, so a staccato line reads + /// as separate lights, but not a hard cut. + private static let keyRelease = 0.08 - // House fixture: rgb on DMX channels 510/511/512 → slot indices 509–511 - // of the 512-byte universe (display d.510, 3-channel mode). - private static let rgbSlot = 509 + /// Drum flash: full for `hold`, then e^-(t/tau), cut at `window` + /// (~3% of full). Velocity never scales the strike — a half-bright + /// flash reads as nothing on a small par. + private static let flashHold = 0.05 + private static let flashTau = 0.07 + private static let flashWindow = 0.30 + + /// Tick period. The widget refreshes the line at 40 Hz (see + /// `ac-dmx-cli info`), so frames faster than that are thrown away. + private static let tickMs = 25 + + /// Seconds of silence after the last light dies before the lane lets go + /// of the widget. The widget keeps transmitting its last frame (a black + /// one — the port is left with DTR up), and a released port means + /// `ac-dmx-cli` or anything else can drive the chain with nothing of + /// ours blinking through. The next note reopens it. + private static let idleRelease = 2.0 + + // MARK: - State (all on q) + + private struct Held { // a key + let colour: Colour + let gain: Double // velocity / 127 — weights the colour mix only + var liftedAt: Double? // nil while the key is down + } + private struct Flash { // a drum hit + let colour: Colour + let gain: Double + let start: Double + let role: Role + } private let q = DispatchQueue(label: "menuband.dmx") private var timer: DispatchSourceTimer? - private var pulses: [Pulse] = [] + private var held: [Int: Held] = [:] + private var flashes: [Flash] = [] private var fd: Int32 = -1 - private var lastSent = (-1, -1, -1) + private var lastSlots: [UInt8] = [] private var lastSendAt = 0.0 private var lastOpenAttempt = 0.0 + private var lastEventAt = 0.0 + + // MARK: - Inputs func noteOn(midi: Int, velocity: Int) { q.async { + let now = self.now() + self.lastEventAt = now + self.held[midi] = Held( + colour: Self.keyPalette[((midi % 12) + 12) % 12], + gain: Double(max(1, min(127, velocity))) / 127.0, + liftedAt: nil) self.ensureRunning() - let c = Self.palette[((midi % 12) + 12) % 12] - self.pulses.append(Pulse( - r: c.0, g: c.1, b: c.2, - gain: Double(max(0, min(127, velocity))) / 127.0, - start: ProcessInfo.processInfo.systemUptime)) + self.tick() // the attack goes out now, not up to a tick later } } - /// Release is silent roomward: the pulse model fades from note-on, so a - /// lift never cuts the light. Kept as the paired hook so call sites read - /// on/off and a hold-style fixture model can slot in later. - func noteOff(midi: Int) {} + func noteOff(midi: Int) { + q.async { + guard self.held[midi] != nil else { return } + self.held[midi]?.liftedAt = self.now() + self.lastEventAt = self.now() + } + } + + func drum(_ d: MenuBandPercussion.Drum, velocity: Int) { + q.async { + let now = self.now() + self.lastEventAt = now + let (colour, role) = Self.drumColour(d) + self.flashes.append(Flash( + colour: colour, + gain: Double(max(1, min(127, velocity))) / 127.0, + start: now, role: role)) + self.ensureRunning() + self.tick() + } + } func blackout() { q.async { - self.pulses.removeAll() - if self.fd >= 0 { self.send(0, 0, 0) } + self.held.removeAll() + self.flashes.removeAll() + if self.fd >= 0 { self.send(Self.blackSlots()) } } } // MARK: - Tick (on q) - /// ~30Hz — cheap enough to leave running once the first note arrives; - /// the widget wants a periodic refresh anyway (1s keepalive). + private func now() -> Double { ProcessInfo.processInfo.systemUptime } + + /// 40 Hz to match the widget's line refresh — cheap enough to leave + /// running while anything is lit. private func ensureRunning() { guard timer == nil else { return } let t = DispatchSource.makeTimerSource(queue: q) - t.schedule(deadline: .now(), repeating: .milliseconds(33)) + t.schedule(deadline: .now(), repeating: .milliseconds(Self.tickMs)) t.setEventHandler { [weak self] in self?.tick() } t.resume() timer = t } private func tick() { - let now = ProcessInfo.processInfo.systemUptime + let now = now() + // Prune what has died. + held = held.filter { _, h in + guard let off = h.liftedAt else { return true } + return now - off < Self.keyRelease * 4 + } + flashes.removeAll { now - $0.start >= Self.flashWindow } + if held.isEmpty && flashes.isEmpty && now - lastEventAt > Self.idleRelease { + release() // before any open — idle never wants the port + return + } if fd < 0 { // Quiet hot-plug retry — no widget is the normal case. guard now - lastOpenAttempt > 3 else { return } @@ -87,48 +226,110 @@ final class DMXOut { openWidget() guard fd >= 0 else { return } } - pulses.removeAll { now - $0.start >= Self.window } - var r = 0.0, g = 0.0, b = 0.0, w = 0.0 - for p in pulses { - let k = p.gain * exp(-3 * (now - p.start) / Self.window) - r += p.r * k; g += p.g * k; b += p.b * k; w += k + + // Keys: gate while down, short exponential tail after the lift. + var keys = Blend() + for h in held.values { + let k = h.liftedAt.map { exp(-(now - $0) / Self.keyRelease) } ?? 1.0 + keys.add(h.colour, weight: h.gain * k, level: k) + } + // Drums: strobe shape, per role. + var low = Blend(), high = Blend() + for f in flashes { + let t = now - f.start + let k = t < Self.flashHold ? 1.0 : exp(-(t - Self.flashHold) / Self.flashTau) + if f.role == .drumsLow { low.add(f.colour, weight: f.gain * k, level: k) } + else { high.add(f.colour, weight: f.gain * k, level: k) } + } + + var slots = [UInt8](repeating: 0, count: Self.slotCount) + for f in Self.fixtures { + let c: Colour + switch f.role { + case .keys: c = keys.colour + case .drumsLow: c = low.colour + case .drumsHigh: c = high.colour + } + Self.paint(c, layout: f.layout, into: &slots, at: f.address - 1) + } + if slots != lastSlots || now - lastSendAt > 1 { send(slots) } + } + + /// Weighted average picks the COLOUR; summed activity sets the LEVEL. + /// Normalizing alone would cancel the envelopes (one dying note would + /// still render full-bright) — scaling by min(1, level) is what lets + /// rests and releases actually reach black. + private struct Blend { + private var sum = Colour(), w = 0.0, level = 0.0 + mutating func add(_ c: Colour, weight: Double, level l: Double) { + sum.r += c.r * weight; sum.g += c.g * weight; sum.b += c.b * weight + sum.a += c.a * weight; sum.w += c.w * weight; sum.uv += c.uv * weight + w += weight; level += l + } + var colour: Colour { + guard w > 0 else { return .off } + let s = min(1, level) / w + return Colour(r: sum.r * s, g: sum.g * s, b: sum.b * s, + a: sum.a * s, w: sum.w * s, uv: sum.uv * s) } - // Weighted average picks the COLOR; summed activity sets the LEVEL. - // Normalizing alone would cancel the envelopes (one dying note would - // still render full-bright) — scaling by min(1, w) is what lets - // rests and decays actually reach black. - if w > 0 { - let lvl = min(1, w) - r = r / w * lvl; g = g / w * lvl; b = b / w * lvl + } + + private static func paint(_ c: Colour, layout: Layout, into slots: inout [UInt8], at i: Int) { + let u = { (v: Double) in UInt8(max(0, min(255, v.rounded()))) } + switch layout { + case .rgb: + // No amber/white/uv LEDs: fold them into the three we have. + slots[i] = u(max(c.r, c.w, c.a)) + slots[i + 1] = u(max(c.g, c.w, c.a * 0.6)) + slots[i + 2] = u(max(c.b, c.w, c.uv)) + case .rgbawuv: + slots[i] = u(c.r); slots[i + 1] = u(c.g); slots[i + 2] = u(c.b) + slots[i + 3] = u(c.a); slots[i + 4] = u(c.w); slots[i + 5] = u(c.uv) } - let R = min(255, Int(r.rounded())), G = min(255, Int(g.rounded())), - B = min(255, Int(b.rounded())) - if (R, G, B) != lastSent || now - lastSendAt > 1 { send(R, G, B) } + } + + private static func blackSlots() -> [UInt8] { [UInt8](repeating: 0, count: slotCount) } + + /// Black frame, port closed, timer stopped — see `idleRelease`. The + /// open-retry throttle is cleared so the next note reopens at once. + private func release() { + if lastSlots != Self.blackSlots() { send(Self.blackSlots()) } + dropPort() + lastOpenAttempt = 0 + timer?.cancel() + timer = nil + } + + /// Close without draining. close() on a tty waits for pending output, + /// and if the widget's link is behind that wait can run for minutes — + /// the dmx queue hangs and, worse, every later open() of the device in + /// ANY process blocks in the kernel until it drains (seen 2026-09-18). + /// Flushing first makes the close immediate. + private func dropPort() { + guard fd >= 0 else { return } + tcflush(fd, TCIOFLUSH) + close(fd) + fd = -1 } // MARK: - Wire (on q) - private func send(_ r: Int, _ g: Int, _ b: Int) { + private func send(_ slots: [UInt8]) { guard fd >= 0 else { return } - var slots = [UInt8](repeating: 0, count: 512) - slots[Self.rgbSlot] = UInt8(r) - slots[Self.rgbSlot + 1] = UInt8(g) - slots[Self.rgbSlot + 2] = UInt8(b) let pkt = Self.frame(slots) let n = pkt.withUnsafeBytes { write(fd, $0.baseAddress, $0.count) } if n == pkt.count { - lastSent = (r, g, b) - lastSendAt = ProcessInfo.processInfo.systemUptime + lastSlots = slots + lastSendAt = now() } else { - // Unplugged mid-frame — close so the tick's retry reopens. - close(fd) - fd = -1 + // Unplugged mid-frame — drop so the tick's retry reopens. + dropPort() } } /// Enttec DMX USB Pro "send DMX" packet — the one place the wire framing /// lives (matches the house DMXking DMX USB PRO), so a different cable's - /// protocol can swap in here. + /// protocol can swap in here. Any slot count from 24 to 512 is legal. private static func frame(_ slots: [UInt8]) -> [UInt8] { // 7E 06 lenLo lenHi | 00 start code + slots | E7 let payload = UInt16(slots.count + 1) @@ -147,16 +348,25 @@ final class DMXOut { cfmakeraw(&t) // frame bytes must pass untouched cfsetspeed(&t, speed_t(B115200)) // Pro widgets ignore baud; set anyway t.c_cflag |= tcflag_t(CSTOPB) // 8N2 + t.c_cflag &= ~tcflag_t(HUPCL) // keep DTR up on close: the + // widget stops transmitting + // when it drops, and the pars + // fall dark on lost signal tcsetattr(f, TCSANOW, &t) } fd = f - lastSent = (-1, -1, -1) // force a fresh frame to the new widget + lastSlots = [] // force a fresh frame to the new widget NSLog("MenuBand DMX: opened \(path)") } /// First USB serial widget wins — covers FTDI (usbserial), Silicon Labs /// (SLAB), and WCH (wchusbserial) bridges. private static func discoverWidget() -> String? { + #if MAC_APP_STORE + // The sandboxed store build has no serial-device entitlement, so the + // room lane is a silent no-op there: never look for a widget. + return nil + #else for pattern in ["/dev/cu.usbserial*", "/dev/cu.SLAB_USBtoUART*", "/dev/cu.wchusbserial*"] { var g = glob_t() defer { globfree(&g) } @@ -165,5 +375,6 @@ final class DMXOut { } } return nil + #endif } } diff --git a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift index 84bc7d5a90..d3efae1095 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift @@ -2492,6 +2492,9 @@ final class MenuBandSynth { triggerMelodicDuck(depth: depth, velocity: velocity, hold: drum == .kick ? 0.105 : 0.055) let g = percussion.noteOn(drum, velocity: velocity, pan: p, accent: accent) + // Roomward: a TrackDrum hit flashes its own pars (no-op without a + // widget) — routed apart from the keys, see DMXOut.fixtures. + DMXOut.shared.drum(drum, velocity: Int(velocity)) if !keepEngineWarm { scheduleIdleSuspendAfterPercussion() } return g } diff --git a/slab/menuband/fastlane/metadata/en-US/release_notes.txt b/slab/menuband/fastlane/metadata/en-US/release_notes.txt index 061d35b020..2398cd8aed 100644 --- a/slab/menuband/fastlane/metadata/en-US/release_notes.txt +++ b/slab/menuband/fastlane/metadata/en-US/release_notes.txt @@ -1,3 +1,7 @@ -The polyrhythm trainer now gives every rhythm its own circle, side by side, instead of stacking them in one ring. Each finger can hold its own grid: strike the left circle for the threes, the right for the twos, and each strike is scored against that circle's own beat rather than whichever beat happened to be nearest. Press / on the TrackDrum surface to cycle 3:2, 4:3, 5:4, and 3:4:5, and - or = to change tempo. Every circle keeps a distinct click so you can hear the parts apart. +Letters can now ride the keys. Turn on the ABC layer and each key shows and speaks its letter as you play, so a keyboard becomes a spelling instrument as easily as a musical one. A Mono checkbox beside it folds the output to a single signal for rooms with one speaker. -The free TrackDrum companion now keeps itself up to date. +Clicked keys now hold for as long as you hold the mouse, and clicks on the menu-bar piano land on the key under the pointer, so the far ends of the strip are reachable again on the newest system. The menu-bar keys also refit themselves when the screen or bar changes size. + +The hold-to-sample microphone voice survives switching input devices, and your chosen studio monitor stays selected across launches. When the audio engine stalls, the surface cues now skip instead of freezing the app. + +The permission notice no longer traps the board in focus, and the TrackDrum polyrhythm trainer draws its lanes on the skin. diff --git a/slab/menuband/install.sh b/slab/menuband/install.sh index 1c7f020b44..21def83f37 100755 --- a/slab/menuband/install.sh +++ b/slab/menuband/install.sh @@ -31,6 +31,10 @@ INSTALLED_APP_DIR="${REPO_HOME}/Applications/Menu Band.app" # running signed app invalidates executable pages that macOS has not faulted in # yet (SIGKILL/CODESIGNING "Invalid Page"). A sibling staging directory keeps # the final rename on the same filesystem and therefore atomic. +# Sweep staging dirs an earlier run left behind (a SIGKILL — the parity deploy, +# a hung codesign — skips the EXIT trap). Each one is a bundle with Menu Band's +# id sitting next to the real app, which is exactly what poisons TCC. +find "${REPO_HOME}/Applications" -maxdepth 1 -name '.menuband-install.*' -exec rm -rf {} + 2>/dev/null || true STAGE_ROOT="$(mktemp -d "${REPO_HOME}/Applications/.menuband-install.XXXXXX")" APP_DIR="${STAGE_ROOT}/Menu Band.app" APP_BIN_DIR="${APP_DIR}/Contents/MacOS" @@ -41,7 +45,7 @@ APP_RES="${APP_DIR}/Contents/Resources" cleanup_stage() { rm -rf "${STAGE_ROOT}" } -trap cleanup_stage EXIT +trap cleanup_stage EXIT INT TERM HUP say() { printf "%s• %s%s\n" "$CYAN" "$1" "$RESET"; } ok() { printf "%s✓ %s%s\n" "$GREEN" "$1" "$RESET"; } diff --git a/slab/menuband/project.yml b/slab/menuband/project.yml index cd3444a1be..3eec790e72 100644 --- a/slab/menuband/project.yml +++ b/slab/menuband/project.yml @@ -21,7 +21,7 @@ name: MenuBand options: bundleIdPrefix: computer.aestheticcomputer deploymentTarget: - macOS: "11.0" + macOS: "12.0" createIntermediateGroups: true settings: @@ -38,8 +38,8 @@ settings: # sat a build behind through the whole 1.5.3 release. # The direct-download build keeps its own literals in Info.plist (SwiftPM # never sees these settings); bump both when releasing. - MARKETING_VERSION: "1.6.12" - CURRENT_PROJECT_VERSION: "172" + MARKETING_VERSION: "1.6.13" + CURRENT_PROJECT_VERSION: "173" packages: ACMacAudio: @@ -49,7 +49,7 @@ targets: MenuBand: type: application platform: macOS - deploymentTarget: "11.0" + deploymentTarget: "12.0" sources: # All Swift under Sources/MenuBand (incl. KidLisp/ + PianoWaveformWindow/ @@ -176,7 +176,7 @@ targets: ScorePreview: type: app-extension platform: macOS - deploymentTarget: "11.0" + deploymentTarget: "12.0" sources: - path: quicklook/Preview/PreviewViewController.swift settings: @@ -195,7 +195,7 @@ targets: ScoreThumbnail: type: app-extension platform: macOS - deploymentTarget: "11.0" + deploymentTarget: "12.0" sources: - path: quicklook/Thumbnail/ThumbnailProvider.swift settings: