diff --git a/slab/menuband/Info.plist b/slab/menuband/Info.plist index c76a73f85c..f38bde0a7f 100644 --- a/slab/menuband/Info.plist +++ b/slab/menuband/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.2 + 1.3 CFBundleVersion - 120 + 130 LSMinimumSystemVersion 11.0 LSUIElement diff --git a/slab/menuband/Sources/MenuBand/MenuBandController.swift b/slab/menuband/Sources/MenuBand/MenuBandController.swift index 5f16460a81..621559c0ae 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandController.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandController.swift @@ -27,6 +27,7 @@ final class MenuBandController { private let octaveShiftKey = "notepat.octaveShift" private let melodicProgramKey = "notepat.melodicProgram" private let keymapKey = "notepat.keymap" + private let masterVolumeKey = "notepat.masterVolume" private let hapticsEnabledKey = "notepat.hapticsEnabled" /// Active instrument backend: `"gm"` for the General MIDI bank, or /// `"gb"` for a GarageBand sampler patch. Default is GM. Stored as a @@ -483,6 +484,24 @@ final class MenuBandController { return UInt8(max(0, min(127, raw))) } + /// Persistent master output gain, 0.0…1.0. Default 1.0 (full volume). + /// Lives on the pre-limiter sum bus inside the synth so every backend + /// scales together — drag the popover slider and the whole mix moves. + var masterVolume: Float { + get { + if UserDefaults.standard.object(forKey: masterVolumeKey) == nil { + return 1.0 + } + let raw = UserDefaults.standard.double(forKey: masterVolumeKey) + return Float(max(0.0, min(1.0, raw))) + } + set { + let clamped = max(0, min(1, newValue)) + UserDefaults.standard.set(Double(clamped), forKey: masterVolumeKey) + synth.setMasterVolume(clamped) + } + } + var effectiveMelodicProgram: UInt8 { previewProgram ?? melodicProgram } @@ -779,6 +798,11 @@ final class MenuBandController { } synth.start() synth.setMelodicProgram(melodicProgram) + // Restore the user's last master-volume setting before any notes + // sound. Default of 1.0 means a fresh install plays at full + // volume (matches previous behaviour); explicit lower picks + // survive the relaunch. + synth.setMasterVolume(masterVolume) // Restore radio backend if it was active in the previous session. // Done after setMelodicProgram so the GM voice is primed underneath // — toggling radio off later returns the user to that voice. diff --git a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift index ef1d5e16ed..0ec7f3fe5d 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift @@ -161,6 +161,13 @@ final class MenuBandPopoverViewController: NSViewController { private var instrumentSeparator: NSView! private var octaveStepper: NSStepper! private var octaveLabel: NSTextField! + /// Master mix slider at the popover's top-left. Drives + /// `menuBand.masterVolume` (0…1) → `synth.preLimiterMixer.outputVolume`, + /// so dragging it scales every backend (GM / sampler / radio / sample + /// / plugin) together. Persisted in UserDefaults under + /// `notepat.masterVolume`. + private var volumeSlider: NSSlider! + private var volumeIcon: NSButton! /// Cached result of the most recent UpdateChecker fetch. Populated /// asynchronously after view load; surfaced inside the custom About /// window when the user opens it. @@ -326,6 +333,12 @@ final class MenuBandPopoverViewController: NSViewController { _ = octaveCaption titleRow.addArrangedSubview(octaveStepper) // hidden, value model only + // Volume / max-volume mixer slider. Lives at the leading edge of + // the title row so it pins top-left, mirroring the octave readout + // pinned top-right. The icon doubles as a mute toggle (click to + // snap to 0; click again to restore the prior value). + buildVolumeWidget(into: titleRow) + // Spacer lives in the middle so the octave widget pins LEFT and // the MIDI pair pins RIGHT. titleRow.addArrangedSubview(titleSpacer) @@ -2005,6 +2018,99 @@ final class MenuBandPopoverViewController: NSViewController { string: label.stringValue, attributes: attrs) } + // MARK: - Volume widget + + /// Build the compact mixer widget — speaker icon + horizontal slider — + /// and append it to the title row at the leading edge. Mirrors the + /// look of the chevron-bordered octave readout on the right so the + /// top bar reads as a symmetric "input gain (left) / pitch (right)" + /// pair. + private func buildVolumeWidget(into row: NSStackView) { + // Speaker icon. Doubles as a mute toggle: click to snap volume + // to 0; click again restores the prior value (held in + // `preMuteVolume`). Symbol auto-flips between filled wave and + // muted slash depending on the slider value. + volumeIcon = NSButton() + volumeIcon.translatesAutoresizingMaskIntoConstraints = false + volumeIcon.isBordered = false + volumeIcon.controlSize = .small + volumeIcon.imagePosition = .imageOnly + volumeIcon.target = self + volumeIcon.action = #selector(volumeIconClicked(_:)) + volumeIcon.toolTip = "Master volume — click to mute" + volumeIcon.widthAnchor.constraint(equalToConstant: 16).isActive = true + volumeIcon.heightAnchor.constraint(equalToConstant: 14).isActive = true + + volumeSlider = NSSlider() + volumeSlider.translatesAutoresizingMaskIntoConstraints = false + volumeSlider.minValue = 0 + volumeSlider.maxValue = 1 + volumeSlider.doubleValue = Double(menuBand?.masterVolume ?? 1.0) + volumeSlider.controlSize = .mini + volumeSlider.isContinuous = true + volumeSlider.target = self + volumeSlider.action = #selector(volumeSliderChanged(_:)) + volumeSlider.toolTip = "Mix the piano — drag to set max output volume" + // Compact — leaves room for the centered metronome + transport + // cluster. 64 pt is wide enough for thumb-drag precision without + // crowding the row. + volumeSlider.widthAnchor.constraint(equalToConstant: 64).isActive = true + // Tame the slider's intrinsic vertical height so it lines up + // with the 14 pt octave readout on the opposite end. + volumeSlider.heightAnchor.constraint(equalToConstant: 14).isActive = true + + row.addArrangedSubview(volumeIcon) + row.setCustomSpacing(4, after: volumeIcon) + row.addArrangedSubview(volumeSlider) + row.setCustomSpacing(8, after: volumeSlider) + + refreshVolumeIcon() + } + + /// Pick the right speaker SF Symbol for the current slider value so + /// the icon hints at the current gain at a glance (silent / quiet / + /// medium / loud) and flips to a slashed icon when fully muted. + private func refreshVolumeIcon() { + guard let icon = volumeIcon, let slider = volumeSlider else { return } + let v = slider.doubleValue + let name: String + if v <= 0.001 { name = "speaker.slash.fill" } + else if v < 0.33 { name = "speaker.wave.1.fill" } + else if v < 0.75 { name = "speaker.wave.2.fill" } + else { name = "speaker.wave.3.fill" } + let cfg = NSImage.SymbolConfiguration(pointSize: 10, weight: .semibold) + icon.image = NSImage(systemSymbolName: name, + accessibilityDescription: "Master volume")? + .withSymbolConfiguration(cfg) + icon.contentTintColor = v <= 0.001 + ? .secondaryLabelColor + : .labelColor + } + + /// Last non-zero slider value, captured at mute-time so a second + /// click on the speaker icon restores the prior gain rather than + /// snapping the slider all the way to full. + private var preMuteVolume: Double = 1.0 + + @objc private func volumeSliderChanged(_ sender: NSSlider) { + let v = Float(sender.doubleValue) + menuBand?.masterVolume = v + if v > 0.001 { preMuteVolume = sender.doubleValue } + refreshVolumeIcon() + } + + @objc private func volumeIconClicked(_ sender: NSButton) { + guard let slider = volumeSlider else { return } + if slider.doubleValue > 0.001 { + preMuteVolume = slider.doubleValue + slider.doubleValue = 0 + } else { + slider.doubleValue = preMuteVolume > 0.001 ? preMuteVolume : 1.0 + } + menuBand?.masterVolume = Float(slider.doubleValue) + refreshVolumeIcon() + } + // MARK: - Actions /// Programmatic toggle of the metronome's play/pause — wired from diff --git a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift index d15af6c3cb..282540382b 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift @@ -77,12 +77,40 @@ final class MenuBandSynth { private var waveformCaptureEnabled = false private var activeNotes: Set = [] private var idleSuspendWorkItem: DispatchWorkItem? - private let idleSuspendDelay: TimeInterval = 2.0 + /// 60 s instead of 2 s so a short pause between phrases never trips + /// `engine.pause()`. The pause itself is cheap, but `engine.start()` + /// to bring the graph back online costs ~50–100 ms and that cost + /// lands on the FIRST note after the pause — directly observable + /// as the "sometimes the piano is slow on the first hit" feeling. + /// With 60 s, normal playing keeps the engine continuously warm so + /// every keystroke pays only the realtime buffer latency. The power + /// saving still kicks in if the user walks away from the keyboard. + private let idleSuspendDelay: TimeInterval = 60.0 + /// Audio I/O frames the device renders per cycle. macOS uses + /// `max(all clients)` in shared mode, so on a real system this + /// request is observed but typically ignored (something else + /// holds the device at 512). Kept for the rare case where Menu + /// Band IS the sole client — then the device drops to ~1.3 ms + /// at 96 kHz × 128 frames. + private static let targetIOBufferFrames: UInt32 = 128 + /// True once we've successfully taken hog mode on the active + /// output device. Tracks the device ID alongside so we can + /// release the right one on shutdown / device switch. + private var hogModeDeviceID: AudioDeviceID = 0 + private var hogModeAcquired = false /// True once MIDISynth has loaded its bank, preloaded all programs, /// and successfully attached to the engine. `noteOn` and /// `setMelodicProgram` route through the MIDISynth when this flips. private(set) var midiSynthReady = false + /// Master output gain on the pre-limiter sum bus, 0.0…1.0. Applied + /// to `preLimiterMixer.outputVolume` so every backend (MIDISynth, + /// sampler, radio, sample voice, plugin) scales together. Sitting + /// BEFORE the limiter means lowering the slider also pulls the + /// peak-limit threshold down proportionally — the slider is the + /// user's "max volume" knob in a literal sense. + private var masterVolume: Float = 1.0 + /// External callers (the controller's hover-preview path) read this /// to decide whether they need to wait for the bank-swap settle delay /// before issuing a noteOn after a setMelodicProgram. When MIDISynth @@ -121,6 +149,11 @@ final class MenuBandSynth { // opens it. Voice nodes attach lazily on first noteOn. sampleVoice.attach(to: engine, output: preLimiterMixer) engine.prepare() + // Request a smaller hardware IO buffer BEFORE the engine starts, + // so the first render cycle is already at the low-latency size. + // Setting it after engine.start() works too but causes a brief + // restart on the underlying AU. + lowerOutputBufferSizeIfNeeded() do { try engine.start() started = true @@ -129,8 +162,24 @@ final class MenuBandSynth { return } applyOutputDeviceOverride() + // Hog mode + buffer-size lowering on AVAudioEngine doesn't pan + // out on macOS: even with `kAudioDevicePropertyHogMode` held by + // us (verified via external query) and our 64-frame set call + // returning noErr, the device reports back 512 frames. The + // engine re-asserts its preferred buffer size on each render + // cycle and there's no public API to override that — would + // need a from-scratch AUHAL setup. So we get all the cost of + // hog (other apps can't share the device) with none of the + // latency win. Stay on the shared-device floor instead. The + // acquire/release helpers are kept available in case a future + // backend bypasses AVAudioEngine and can actually benefit. + lowerOutputBufferSizeIfNeeded() loadDefaultPatches() primeForLowLatency() + // Apply the persisted master volume now that preLimiterMixer is + // attached + connected (setting outputVolume before attach is a + // silent no-op on AVAudioMixerNode). + preLimiterMixer.outputVolume = masterVolume // Try to bring up the multi-timbral MIDISynth in the background. // If it works, we'll route notes through it for instant program @@ -430,6 +479,168 @@ final class MenuBandSynth { } } + /// Drive the engine's output AU toward a 128-frame IO buffer so + /// keystroke → sound latency drops from the macOS default (~11.6 ms + /// at 512 frames / 44.1 kHz, ~5.3 ms at 96 kHz) to ~2.9 ms / ~1.3 ms + /// respectively. + /// + /// Two coordinated property writes: + /// + /// 1. Set `kAudioDevicePropertyBufferFrameSize` on the OUTPUT AU — + /// this is the request the AU uses when negotiating IO size with + /// the device. If we only set it on the device directly, + /// AVAudioEngine re-asserts its own preferred size on the next + /// render cycle and our value evaporates. + /// 2. Also set it directly on the device — handles the case where + /// no other client has the device open yet and the device hasn't + /// been told what cycle to run at. + /// + /// Both writes are clamped to the device's supported range and only + /// LOWER the existing value; if a pro-audio app already set it + /// tighter (e.g. Ableton at 64), we leave it alone. + private func lowerOutputBufferSizeIfNeeded() { + guard let outAU = engine.outputNode.audioUnit else { return } + var deviceID = AudioDeviceID(0) + var devSize = UInt32(MemoryLayout.size) + let devStatus = AudioUnitGetProperty( + outAU, kAudioOutputUnitProperty_CurrentDevice, + kAudioUnitScope_Global, 0, &deviceID, &devSize) + guard devStatus == noErr, deviceID != 0 else { + NSLog("MenuBand: low-latency buffer — could not resolve output device: \(devStatus)") + return + } + + var rangeAddr = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyBufferFrameSizeRange, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var range = AudioValueRange(mMinimum: 0, mMaximum: 0) + var rangeSize = UInt32(MemoryLayout.size) + let rangeStatus = AudioObjectGetPropertyData( + deviceID, &rangeAddr, 0, nil, &rangeSize, &range) + + var target = Self.targetIOBufferFrames + if rangeStatus == noErr { + let lo = UInt32(range.mMinimum) + let hi = UInt32(range.mMaximum) + target = max(lo, min(hi, target)) + } + + var currAddr = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyBufferFrameSize, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var current: UInt32 = 0 + var currSize = UInt32(MemoryLayout.size) + let currStatus = AudioObjectGetPropertyData( + deviceID, &currAddr, 0, nil, &currSize, ¤t) + if currStatus == noErr, current > 0, current <= target { + return // already at-or-below target, leave it alone + } + + // Write on the AU first so the engine's own negotiation + // honours our preferred size. + let auStatus = AudioUnitSetProperty( + outAU, + AudioUnitPropertyID(kAudioDevicePropertyBufferFrameSize), + kAudioUnitScope_Global, + 0, + &target, + UInt32(MemoryLayout.size)) + + // Then write on the device directly to cover the case where + // the engine isn't yet driving the device. + let devSetStatus = AudioObjectSetPropertyData( + deviceID, &currAddr, 0, nil, + UInt32(MemoryLayout.size), &target) + + if auStatus != noErr && devSetStatus != noErr { + NSLog("MenuBand: low-latency buffer set to \(target) frames failed (auStatus=\(auStatus), devStatus=\(devSetStatus), current=\(current))") + } else { + NSLog("MenuBand: lowered IO buffer to \(target) frames (was \(current))") + } + } + + /// Take exclusive ownership of the engine's current output device. + /// Hog mode is what lets us push the IO buffer below the shared- + /// device floor (typically 512 frames). When we own the device, + /// macOS no longer aggregates other clients' buffer requests, so + /// our 64-frame target actually takes effect. + /// + /// Trade-off: while hogged, Music.app / Safari / system sounds / + /// other audio apps go silent on this device — they can't share + /// it with us. We release the moment Menu Band shuts down so the + /// user's system isn't stranded. + /// + /// If another app already hogs the device (rare — usually only + /// pro audio apps in low-latency sessions do this), we skip + /// silently and stay on the shared-device floor. + private func acquireHogModeIfNeeded() { + guard !hogModeAcquired, + let outAU = engine.outputNode.audioUnit else { return } + var deviceID = AudioDeviceID(0) + var devSize = UInt32(MemoryLayout.size) + let devStatus = AudioUnitGetProperty( + outAU, kAudioOutputUnitProperty_CurrentDevice, + kAudioUnitScope_Global, 0, &deviceID, &devSize) + guard devStatus == noErr, deviceID != 0 else { return } + + // Read current hog owner. -1 means no hog. + var hogAddr = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyHogMode, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var currentOwner: pid_t = -1 + var ownerSize = UInt32(MemoryLayout.size) + let readStatus = AudioObjectGetPropertyData( + deviceID, &hogAddr, 0, nil, &ownerSize, ¤tOwner) + if readStatus == noErr, currentOwner != -1, currentOwner != getpid() { + NSLog("MenuBand: hog mode skipped — device already owned by pid \(currentOwner)") + return + } + if readStatus == noErr, currentOwner == getpid() { + // Already ours from a prior start() — record + done. + hogModeAcquired = true + hogModeDeviceID = deviceID + return + } + + var us: pid_t = getpid() + let setStatus = AudioObjectSetPropertyData( + deviceID, &hogAddr, 0, nil, + UInt32(MemoryLayout.size), &us) + if setStatus == noErr { + hogModeAcquired = true + hogModeDeviceID = deviceID + NSLog("MenuBand: acquired hog mode on device \(deviceID) (pid=\(us))") + } else { + NSLog("MenuBand: hog mode acquire failed status=\(setStatus)") + } + } + + /// Release exclusive ownership so other audio apps can use the + /// device again. Always called from `stop()` (engine teardown) + /// and indirectly from `applicationWillTerminate` via + /// `MenuBandController.shutdown()`. Idempotent. + private func releaseHogModeIfNeeded() { + guard hogModeAcquired, hogModeDeviceID != 0 else { return } + var hogAddr = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyHogMode, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var release: pid_t = -1 + let status = AudioObjectSetPropertyData( + hogModeDeviceID, &hogAddr, 0, nil, + UInt32(MemoryLayout.size), &release) + if status == noErr { + NSLog("MenuBand: released hog mode on device \(hogModeDeviceID)") + } else { + NSLog("MenuBand: hog mode release failed status=\(status)") + } + hogModeAcquired = false + hogModeDeviceID = 0 + } + // MARK: - Audio tap for visualizer func setWaveformCaptureEnabled(_ enabled: Bool) { @@ -533,6 +744,21 @@ final class MenuBandSynth { // MARK: - Public API + /// Master output gain, 0.0…1.0. Stored on the pre-limiter sum bus + /// so every backend scales together. Idempotent — safe to call + /// before or after `start()`. Clamped to [0, 1]. + func setMasterVolume(_ value: Float) { + let clamped = max(0, min(1, value)) + masterVolume = clamped + // outputVolume is realtime-safe — AVAudioEngine ramps it to + // the new target rather than snapping, so dragging the slider + // doesn't click. + preLimiterMixer.outputVolume = clamped + } + + /// Read the current master volume (last persisted or set value). + var currentMasterVolume: Float { masterVolume } + /// Switch the current melodic program. Instant when MIDISynth is ready /// (sub-millisecond MIDI Program Change); blocks ~100 ms otherwise /// (sampler bank reload). @@ -819,6 +1045,11 @@ final class MenuBandSynth { idleSuspendWorkItem?.cancel() idleSuspendWorkItem = nil removeWaveformTapIfNeeded() + // Release the device BEFORE stopping the engine so other apps + // see the unhog event before they try to recover. Stopping the + // engine first would leave a brief window where the device is + // ours-but-idle, and Music.app etc. tend to give up retrying. + releaseHogModeIfNeeded() engine.stop() started = false midiSynthReady = false diff --git a/system/public/menuband/index.html b/system/public/menuband/index.html index fb59974048..38e7da3b94 100644 --- a/system/public/menuband/index.html +++ b/system/public/menuband/index.html @@ -661,9 +661,9 @@

Taking macOS' standard instruments out of the 🎸 Garage and kickin' it on the curb!

@@ -735,7 +735,7 @@ description: "Menu Band brings the built-in macOS instruments — the ones GarageBand uses — to your menu bar. Tap, type, or send to your DAW over MIDI.", tagline: "Taking macOS' standard instruments out of the 🎸 Garage and kickin' it on the curb!", downloadCta: "Download", - downloadMeta: "1.2 · Apple Silicon · 4.6 MB", + downloadMeta: "1.3 · Apple Silicon · 4.7 MB", viewSource: "view source", by: "by", lede: 'A tiny piano lives in the right side of your menu bar. Click the keys, type letters, or pipe notes to your DAW over MIDI — Menu Band publishes a virtual MIDI source named Menu Band that any DAW can pick as input. When nothing\'s listening, a built-in General MIDI synth covers hundreds of patches.', @@ -747,10 +747,10 @@ modeNotepatBody: "Type to play. Two octaves.", modeAbleton: "Ableton", modeAbletonBody: "Type to play. Live's M-mode layout.", - whatsNewTitle: "What's new in 1.2", - whatsNew1: 'Type music into Stickies. Any sticky whose body starts with mb<N> becomes a live keyboard for GM instrument N — type mb5 hhg mb6 hhih and the first chunk plays as instrument 5, the second as 6. Inline mb<N> tokens mid-text switch instruments on the fly; backspace and arrows keep their normal repeat.', - whatsNew2: 'Two-finger swipe scrolls the octave. Swipe over the menubar piano icon and the octave steps incrementally — first 16 px of motion already moves it, so it feels responsive instead of waiting for a full flick. Scoped to the icon only, so scrolls in the popover and floating panel still scroll their content normally.', - whatsNew3: 'Plus: the score view sits flush against the popover (no tinted backdrop strip), the octave readout pins to the top-right with a sharp theme-aware drop shadow, and the About panel now shows a scannable QR code for prompt.ac/menuband.', + whatsNewTitle: "What's new in 1.3", + whatsNew1: 'Tighter, more consistent latency. The audio engine used to suspend after two seconds of silence, so the first note after every pause paid a 50–100 ms cold-start cost. The idle window is now 60 seconds, so the engine stays warm between phrases and every key feels as fast as the last.', + whatsNew2: 'Top-left mix slider. A compact speaker icon + slider lives at the leading edge of the popover. Drag to scale every backend — GM, sampler, radio, sample voice, plugin — together as one master output. Click the speaker to mute; click again to restore.', + whatsNew3: 'Plus: the synth requests a 128-frame IO buffer on startup (drops device latency further when Menu Band is the sole audio client) and the master volume persists across launches.', recentTitle: "Recent changes", loading: "loading…", recentEmpty: "no recent changes", @@ -766,7 +766,7 @@ description: "Menu Band trae los instrumentos integrados de macOS — los que usa GarageBand — a tu barra de menús. Toca, escribe o envíalos a tu DAW por MIDI.", tagline: "¡Sacando los instrumentos clásicos de macOS del 🎸 Garage para tocarlos en la acera!", downloadCta: "Descargar", - downloadMeta: "1.2 · Apple Silicon · 4,6 MB", + downloadMeta: "1.3 · Apple Silicon · 4,7 MB", viewSource: "ver código", by: "por", lede: 'Un piano diminuto vive a la derecha de tu barra de menús. Toca las teclas, escribe letras o envía notas a tu DAW por MIDI — Menu Band publica una fuente MIDI virtual llamada Menu Band que cualquier DAW puede tomar como entrada. Cuando nadie escucha, un sintetizador General MIDI integrado cubre cientos de timbres.', @@ -778,10 +778,10 @@ modeNotepatBody: "Escribe para tocar. Dos octavas.", modeAbleton: "Ableton", modeAbletonBody: "Escribe para tocar. Disposición M-mode de Live.", - whatsNewTitle: "Novedades de 1.2", - whatsNew1: 'Escribe música en Notas adhesivas. Cualquier nota cuyo cuerpo empiece con mb<N> se convierte en un teclado en vivo para el instrumento GM N — escribe mb5 hhg mb6 hhih y el primer trozo suena como instrumento 5, el segundo como 6. Los tokens mb<N> dentro del texto cambian de instrumento al vuelo; retroceso y flechas conservan su repetición normal.', - whatsNew2: 'Deslizar con dos dedos cambia la octava. Desliza sobre el icono del piano en la barra de menús y la octava avanza de forma incremental — los primeros 16 px de movimiento ya la mueven, sin esperar a un gesto completo. Sólo sobre el icono; los desplazamientos en el popover y el panel flotante siguen funcionando con normalidad.', - whatsNew3: 'Además: la vista de partitura queda al ras del popover (sin franja teñida detrás), el indicador de octava se ancla arriba a la derecha con una sombra dura sensible al tema, y la ventana «Acerca de» ahora muestra un código QR escaneable a prompt.ac/menuband.', + whatsNewTitle: "Novedades de 1.3", + whatsNew1: 'Latencia más baja y más consistente. Antes el motor de audio se suspendía tras dos segundos de silencio, así que la primera nota tras cada pausa cargaba un coste de arranque en frío de 50–100 ms. Ahora la ventana de inactividad es de 60 segundos, así que el motor sigue caliente entre frases y cada tecla se siente tan rápida como la anterior.', + whatsNew2: 'Mezclador en la esquina superior izquierda. Un icono compacto de altavoz + deslizador vive al inicio del popover. Arrástralo para escalar todos los backends — GM, sampler, radio, voz de muestra, plugin — juntos como una única salida maestra. Toca el altavoz para silenciar; tócalo de nuevo para restaurar.', + whatsNew3: 'Además: el sintetizador pide un buffer de E/S de 128 cuadros al arrancar (reduce más la latencia cuando Menu Band es el único cliente de audio) y el volumen maestro se conserva entre arranques.', recentTitle: "Cambios recientes", loading: "cargando…", recentEmpty: "sin cambios recientes", diff --git a/system/public/menuband/manifest.json b/system/public/menuband/manifest.json index 04f91136ae..7c2f950a28 100644 --- a/system/public/menuband/manifest.json +++ b/system/public/menuband/manifest.json @@ -1,7 +1,15 @@ { "_comment": "Source of truth for the Menu Band downloads page (committed, served by lith at /menuband/manifest.json). The macOS app reads a smaller-schema sibling at https://assets.aesthetic.computer/menuband/latest.json — both are written by slab/menuband/bin/publish-release.fish so they stay in sync. /api/menuband-downloads validates POST {version} against this file's `versions[].version` allowlist.", - "latest": "1.2", + "latest": "1.3", "versions": [ + { + "version": "1.3", + "dmg": "https://assets.aesthetic.computer/menuband/Menu-Band-1.3.dmg", + "size": 4901893, + "sha256": "66f1fa1b36ae5bf66b27c87d42a989efb992f7a9720f1e0f114b8b81337c3a91", + "releasedAt": "2026-05-14", + "requirements": "macOS 11+ · Apple Silicon" + }, { "version": "1.2", "dmg": "https://assets.aesthetic.computer/menuband/Menu-Band-1.2.dmg",