From 7a152d37d157ed856432598071261607f22efbec Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 7 Oct 2026 17:28:16 -0700 Subject: [PATCH 1/3] Simplify Whistlegraph Brain and automate TestFlight releases --- apple/whistlegraph/README.md | 56 +++- apple/whistlegraph/Resources/Web/engine.mjs | 2 +- .../whistlegraph/Sources/BrainSettings.swift | 262 ++++++++++++------ .../Sources/PreviewNavigation.swift | 4 +- .../Sources/WhistlegraphApp.swift | 16 +- .../Sources/WhistlegraphBraincells.swift | 26 +- .../Sources/WhistlegraphHeader.swift | 3 - .../Sources/WhistlegraphScreen.swift | 5 +- .../Tests/PreviewNavigationCheck.swift | 6 + .../Tests/rename-compatibility.test.mjs | 3 + .../UITests/HeaderSheetsTests.swift | 109 +++++--- .../UITests/PreviewFormatTests.swift | 4 + .../Whistlegraph.xcodeproj/project.pbxproj | 6 +- apple/whistlegraph/fastlane/.gitignore | 3 + apple/whistlegraph/fastlane/Fastfile | 122 ++++++++ apple/whistlegraph/fastlane/README.md | 72 +++++ .../fastlane/refresh-service-key.mjs | 26 ++ apple/whistlegraph/project.yml | 2 +- slab/menuband/bin/asc.mjs | 1 + 19 files changed, 572 insertions(+), 156 deletions(-) create mode 100644 apple/whistlegraph/fastlane/.gitignore create mode 100644 apple/whistlegraph/fastlane/Fastfile create mode 100644 apple/whistlegraph/fastlane/README.md create mode 100644 apple/whistlegraph/fastlane/refresh-service-key.mjs diff --git a/apple/whistlegraph/README.md b/apple/whistlegraph/README.md index c2c04a44cf..71b0393025 100644 --- a/apple/whistlegraph/README.md +++ b/apple/whistlegraph/README.md @@ -6,6 +6,36 @@ From this directory, run `./run.sh device` to bundle, build, install, and open t For an unsigned build transferred from poorslice, run `bash sign-device.sh ` before packaging or installing. This signs embedded debug libraries before the app and verifies all nested signatures. An install succeeding does not prove launch succeeds: verify the unlocked phone opens the workspace before marking a build launch-verified. +## TestFlight + +App Store Connect record: [Whistlegraph, 6820297686](https://appstoreconnect.apple.com/apps/6820297686/testflight/ios). + +From this directory, use `fastlane ios status`, `fastlane ios build`, and +`fastlane ios upload`. These use the same Admin API key as AC, Oskiewar and +Menu Band. Build always archives **Release**, with two compiler jobs, and +exports to `~/Library/Developer/Whistlegraph-Releases/-/export/`. +Set `WHISTLEGRAPH_IPA` to upload an already exported package. + +For the first app record only, run +`FASTLANE_USER= fastlane ios create`. Fastlane can perform this login +in the terminal, including two-factor authentication; a browser is optional. +Apple's public API does not support creating app records, and Fastlane +[`produce` does not support API-key authentication](https://docs.fastlane.tools/app-store-connect-api/). +An app-specific upload password cannot replace that Apple ID login. +The create lane refreshes Fastlane's public login widget-key cache from Apple's +current redirect before signing in, avoiding the retired Olympus endpoint in +older Fastlane installations. It sends no credentials and follows no redirect. + +Upload starts Apple's processing; it does not submit an App Store release or +automatically invite external testers. External TestFlight distribution requires +test information, a tester group, and Apple's beta review when required. +`fastlane ios distribute` submits the version/build from `project.yml` to the +private **Whistlegraph Friends** group and enables notification after approval. +`fastlane ios invite email: name:` adds a tester to that group. +Both use the API key. Review-account credentials stay in the vault and App Store +Connect. `node ../../slab/menuband/bin/asc.mjs status --app whistlegraph --platform IOS` +also reads the release state through the shared account CLI. + Typing uses AC's `compkey` sample and QWERTY pitch mapping. Enter sends the prompt, pasted line breaks become spaces, and the limit is 96 characters. Account settings control key and button sounds together. ## Story cards @@ -18,6 +48,8 @@ Export uses the shared AC `canvas-tape.mjs` hardware encoder, also used by BIOS ## Update compatibility +The workspace navigation guard accepts both the current `whistlegraph=1` and legacy `walkie=1` entry marker. Artwork keeps `preview=walkieware`, which the deployed runtime uses to suppress public piece telemetry. + Whistlegraph updates the existing Walkieware installation. Keep these persisted and deployed contracts until an explicit migration replaces them: - `computer.aesthetic.walkieware`: iOS bundle identifier and Keychain service. Changing the bundle identifier installs a separate app and loses access to the existing container. @@ -126,14 +158,22 @@ Story playback owns a separate WebKit runtime so it can play during generation without replacing the pixels under visual review. The wood frame belongs to the phone's chrome and is excluded from the piece, TV output, and story exports. -## Tezos and cost units (build 105) +## Braincells and advanced settings (build 111) + +Brain opens with the available braincell total, a segmented daily refill meter, +and saved braincells that never expire. Free allowance is used first. The refill +button shows the exact pack and localized App Store price before Apple's purchase +confirmation. Pending transactions recover automatically when Brain opens or the +app returns to the foreground; manual recovery remains under Advanced. An absent +StoreKit product shows an unavailable state with retry, never an invented price. -Brain settings remembers Braincells / USD / Tezos and applies it to balances, -request costs and the thread meter. Balance value uses the existing $5 per -million pack. Inference meters keep provider cost; equivalent braincells are -not wallet deductions. Hosted AC inference charges twice provider cost, drawing -from free allowance first. Tezos conversions use an expiring, timestamped TzKT -rate from `/api/easel-tezos`; an unavailable rate is never replaced with zero. +Canvas contains pixel size and aspect ratio. Advanced contains model selection, +provider details, source, diagnostics, and the Braincells / USD / Tezos cost-unit +preference. Provider-cost estimates appear only there, not beside the canvas or +in the piece menu. These estimates are not wallet deductions. Hosted AC inference +charges twice provider cost, drawing from free allowance first. Tezos conversions +use an expiring, timestamped TzKT rate from `/api/easel-tezos`; an unavailable rate +is never replaced with zero. Balances always display braincells. Buy braincells with tez opens an AC checkout in the browser, where Beacon pairs with Temple or another Tezos wallet. Choose $3 / 600,000 or $5 / 1,000,000 braincells in checkout before connecting. @@ -257,7 +297,7 @@ on being a reader app or a free companion exemption. App Review decides approval Before submission: -1. Confirm the existing App Store Connect record's bundle ID is +1. Create or confirm the App Store Connect record with bundle ID `computer.aesthetic.walkieware` and obtain its real numeric Apple ID. Create the consumable `computer.aesthetic.walkieware.braincells.1m`, set availability and price tiers, add localization and the purchase screenshot, and complete diff --git a/apple/whistlegraph/Resources/Web/engine.mjs b/apple/whistlegraph/Resources/Web/engine.mjs index b3e166f6ea..5271dfe1f1 100644 --- a/apple/whistlegraph/Resources/Web/engine.mjs +++ b/apple/whistlegraph/Resources/Web/engine.mjs @@ -101,7 +101,7 @@ ui.innerHTML = '
String { costUnit.amount(usd: number / CostUnit.cellsPerUSD, rate: prices.rate) } var body: some View { NavigationStack { List { Section { - Picker("Pixel size", selection: Binding(get: { session.pixelSize }, set: { session.setPixelSize($0) })) { - ForEach(1...4, id: \.self) { Text("\($0)×").tag($0) } - }.pickerStyle(.segmented).disabled(disabled).accessibilityIdentifier("brain-pixel-size") - Picker("Aspect ratio", selection: Binding(get: { session.previewFormat }, set: { session.setPreviewFormat($0) })) { - ForEach(PreviewFormat.allCases) { Text($0.rawValue).tag($0) } - }.pickerStyle(.segmented).disabled(disabled).accessibilityIdentifier("brain-preview-format") - } header: { Label("Preview", systemImage: "eye") } + if session.snapshot.handle.isEmpty { + Label("Sign in for your daily braincells", systemImage: "brain") + Button("Sign in") { dismiss(); session.command("signIn") } + } else if let balance = session.snapshot.inference?.braincells { + BraincellMeter(balance: balance) + } else if let message = session.snapshot.inference?.braincellsError, !message.isEmpty { + Text(message).foregroundStyle(.secondary) + Button("Try again") { session.command("refreshBraincells") } + } else { + ProgressView("Loading braincells…") + } + } + if !session.snapshot.handle.isEmpty, session.snapshot.inference?.braincells?.unlimited != true { + Section { + BraincellPurchase(purchase: session.braincells, signedIn: true) + } + } Section { - NavigationLink { WhistlegraphSourceSheet(session: session) } label: { - Label("View and edit source", systemImage: "curlybraces") - }.disabled(!session.engineReady).accessibilityIdentifier("brain-source") + NavigationLink { BrainCanvasSettings(session: session) } label: { + Label("Canvas", systemImage: "aspectratio") + }.accessibilityIdentifier("brain-canvas") NavigationLink { WhistlegraphPrivacySheet(session: session) } label: { Label("AI & privacy", systemImage: "hand.raised") }.accessibilityIdentifier("brain-privacy") - NavigationLink { WhistlegraphDebugLog() } label: { Label("Debug log", systemImage: "list.bullet.rectangle") } - .accessibilityIdentifier("brain-debug-log") + NavigationLink { BrainAdvancedSettings(session: session) } label: { + Label("Advanced", systemImage: "slider.horizontal.3") + }.accessibilityIdentifier("brain-advanced") } - if let inference = session.snapshot.inference { - Section { - Picker("Model", selection: Binding(get: { inference.selection }, set: { session.command("setModel", text: $0) })) { - ForEach(inference.models) { Text($0.label).tag($0.id) } - }.disabled(disabled || session.snapshot.handle.isEmpty).accessibilityIdentifier("brain-model") - LabeledContent("Service provider", value: inference.provider) - if session.snapshot.busy { LabeledContent("Running", value: inference.label) } - } - Section("Balance") { - CostUnitPicker() - if let balance = inference.braincells { - if balance.unlimited == true { - LabeledContent("Allowance", value: "Unlimited") - } else { - LabeledContent("Daily remaining", value: cells(balance.remaining) + " / " + cells(balance.limit)) - .accessibilityIdentifier("brain-balance") - ProgressView(value: min(balance.used, balance.limit), total: max(1, balance.limit)) - .accessibilityLabel("Daily braincells used").accessibilityValue(cells(balance.used)) - } - LabeledContent("Purchased", value: cells(balance.purchased)) - if balance.unlimited != true, let reset = balance.resetsAt.flatMap({ ISO8601DateFormatter().date(from: $0) ?? ISO8601DateFormatter.fullPrecision.date(from: $0) }) { - LabeledContent("Daily reset", value: reset.formatted(date: .omitted, time: .shortened)) + } + .tint(.purple) + .navigationTitle("Brain") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { session.command("refreshBraincells") } label: { Image(systemName: "arrow.clockwise") } + .accessibilityLabel("Refresh balance") + } + ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } + } + }.presentationDetents([.large]) + .onAppear { DeviceActionLog.shared.record(.screen, .presented, control: .brain) } + .onDisappear { DeviceActionLog.shared.record(.screen, .dismissed, control: .brain) } + .task { await refreshPurchases() } + .onChange(of: scenePhase) { _, phase in + if phase == .active { + session.command("refreshBraincells") + Task { await refreshPurchases() } + } + } + } + private func refreshPurchases() async { + await session.braincells.load() + await session.braincells.recover() + #if WHISTLEGRAPH_INTERNAL_PAYMENTS && DEBUG + await session.tezosBraincells.prepare() + await session.tezosBraincells.refresh(session: session) + #endif + } +} + +private struct BraincellMeter: View { + let balance: InferenceSnapshot.Braincells + @Environment(\.accessibilityReduceMotion) private var reduceMotion + private var unlimited: Bool { balance.unlimited == true } + private var total: Double { balance.remaining + balance.purchased } + private var fraction: Double { unlimited ? 1 : min(1, max(0, balance.remaining / max(1, balance.limit))) } + private func cells(_ number: Double) -> String { number.formatted(.number.precision(.fractionLength(0))) } + private var reset: Date? { + balance.resetsAt.flatMap { ISO8601DateFormatter().date(from: $0) ?? ISO8601DateFormatter.fullPrecision.date(from: $0) } + } + var body: some View { + VStack(spacing: 18) { + HStack(spacing: 16) { + Image(systemName: "brain") + .font(.system(size: 42, weight: .medium)).foregroundStyle(.purple) + .padding(14).background(.purple.opacity(0.1), in: Circle()) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Text(unlimited ? "Unlimited" : cells(total)) + .font(.system(.largeTitle, design: .rounded, weight: .bold)) + .contentTransition(.numericText()).minimumScaleFactor(0.6).lineLimit(1) + Text("braincells").font(.headline).foregroundStyle(.secondary) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Braincells available") + .accessibilityValue(unlimited ? "Unlimited" : cells(total)) + .accessibilityIdentifier("brain-balance") + }.frame(maxWidth: .infinity, alignment: .leading) + if !unlimited { + VStack(spacing: 8) { + HStack { + Text("Free today") + Spacer() + Text(cells(balance.remaining) + " / " + cells(balance.limit)).monospacedDigit() + }.font(.subheadline) + HStack(spacing: 5) { + ForEach(0..<10, id: \.self) { cell in + GeometryReader { geometry in + Capsule().fill(.purple.opacity(0.12)) + .overlay(alignment: .leading) { + Capsule().fill(.purple) + .frame(width: geometry.size.width * min(1, max(0, fraction * 10 - Double(cell)))) + } } - } else if inference.braincellsError.isEmpty { - ProgressView("Loading braincells…") - } else { - Text(inference.braincellsError).foregroundStyle(.secondary) } - Button("Refresh") { session.command("refreshBraincells"); Task { await prices.refresh() } } - BraincellPurchase(purchase: session.braincells, signedIn: !session.snapshot.handle.isEmpty) - #if WHISTLEGRAPH_INTERNAL_PAYMENTS && DEBUG - TezosPurchaseButton(session: session, purchase: session.tezosBraincells) - #endif + }.frame(height: 12).accessibilityHidden(true) + if let reset { + Text("Refills at " + reset.formatted(date: .omitted, time: .shortened)) + .font(.footnote).foregroundStyle(.secondary) + } + } + HStack { + Label("Saved", systemImage: "sparkles") + Spacer() + Text(cells(balance.purchased)).monospacedDigit() + }.font(.subheadline) + Text("Free braincells go first. Saved braincells never expire.") + .font(.footnote).foregroundStyle(.secondary).multilineTextAlignment(.center) + } + } + .frame(maxWidth: .infinity).padding(.vertical, 12) + .animation(reduceMotion ? nil : .easeOut(duration: 0.35), value: total) + } +} + +private struct BrainCanvasSettings: View { + @ObservedObject var session: WhistlegraphSession + private var disabled: Bool { session.snapshot.busy || session.capturePhase != .idle } + var body: some View { + List { + Section("Pixel size") { + Picker("Pixel size", selection: Binding(get: { session.pixelSize }, set: { session.setPixelSize($0) })) { + ForEach(1...4, id: \.self) { Text("\($0)×").tag($0) } + }.pickerStyle(.segmented).disabled(disabled).accessibilityIdentifier("brain-pixel-size") + } + Section("Aspect ratio") { + Picker("Aspect ratio", selection: Binding(get: { session.previewFormat }, set: { session.setPreviewFormat($0) })) { + ForEach(PreviewFormat.allCases) { Text($0.rawValue).tag($0) } + }.pickerStyle(.segmented).disabled(disabled).accessibilityIdentifier("brain-preview-format") + } + }.navigationTitle("Canvas") + } +} + +private struct BrainAdvancedSettings: View { + @ObservedObject var session: WhistlegraphSession + @AppStorage(CostUnit.preference) private var costUnit: CostUnit = .usd + private var disabled: Bool { session.snapshot.busy || session.capturePhase != .idle } + var body: some View { + List { + if let inference = session.snapshot.inference { + Section("Model") { + Picker("Model", selection: Binding(get: { inference.selection }, set: { session.command("setModel", text: $0) })) { + ForEach(inference.models) { Text($0.label).tag($0.id) } + }.disabled(disabled || session.snapshot.handle.isEmpty).accessibilityIdentifier("brain-model") + LabeledContent("Service provider", value: inference.provider) + if session.snapshot.busy { LabeledContent("Running", value: inference.label) } + } + Section { + CostUnitPicker() + if let cost = inference.threadCost { + LabeledContent("Piece provider cost") { ThreadCostLabel(cost: cost) } } if let usage = inference.usage { - Section(session.snapshot.busy ? "This request" : "Last request") { - if let cost = usage.cost { - LabeledContent("Provider cost") { ThreadCostLabel(cost: cost) } - } - LabeledContent("Input tokens", value: usage.inputTokens.formatted()) - LabeledContent("Output tokens", value: usage.outputTokens.formatted()) - LabeledContent("Inference calls", value: usage.rounds.formatted()) - LabeledContent("Repairs", value: usage.repairs.formatted()) + if let cost = usage.cost { + LabeledContent("Last request provider cost") { ThreadCostLabel(cost: cost) } } + LabeledContent("Input tokens", value: usage.inputTokens.formatted()) + LabeledContent("Output tokens", value: usage.outputTokens.formatted()) + LabeledContent("Inference calls", value: usage.rounds.formatted()) + LabeledContent("Repairs", value: usage.repairs.formatted()) } - Section { - Text("Inference costs show provider value in the selected unit. Hosted AC inference charges twice provider cost in braincells; free allowance is used first.") - .font(.footnote).foregroundStyle(.secondary) - } + } header: { Text("Usage") } footer: { + Text("Provider costs are diagnostic estimates, not your balance. Hosted AC inference charges twice provider cost in braincells; free allowance is used first.") } } - .navigationTitle("Brain") - .navigationBarTitleDisplayMode(.inline) - .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } - }.presentationDetents([.medium, .large]) - .onAppear { DeviceActionLog.shared.record(.screen, .presented, control: .brain) } - .onDisappear { DeviceActionLog.shared.record(.screen, .dismissed, control: .brain) } - .onChange(of: costUnit) { _, value in DeviceActionLog.shared.record(.setting, .succeeded, control: .costUnit, [.format: CostUnit.allCases.firstIndex(of: value) ?? -1]) } - .task { - await prices.refresh() + Section { + NavigationLink { WhistlegraphSourceSheet(session: session) } label: { + Label("View and edit source", systemImage: "curlybraces") + }.disabled(!session.engineReady).accessibilityIdentifier("brain-source") + NavigationLink { WhistlegraphDebugLog() } label: { Label("Debug log", systemImage: "list.bullet.rectangle") } + .accessibilityIdentifier("brain-debug-log") + Button("Check pending purchases") { Task { await session.braincells.recover() } } #if WHISTLEGRAPH_INTERNAL_PAYMENTS && DEBUG - await session.tezosBraincells.prepare() - await session.tezosBraincells.refresh(session: session) + TezosPurchaseButton(session: session, purchase: session.tezosBraincells) #endif } - .task(id: costUnit) { if costUnit == .tezos { await prices.refresh() } } - .onChange(of: scenePhase) { _, phase in - if phase == .active { - Task { - await prices.refresh() - #if WHISTLEGRAPH_INTERNAL_PAYMENTS && DEBUG - await session.tezosBraincells.refresh(session: session) - #endif - } - } - } + } + .navigationTitle("Advanced") + .onChange(of: costUnit) { _, value in + DeviceActionLog.shared.record(.setting, .succeeded, control: .costUnit, [.format: CostUnit.allCases.firstIndex(of: value) ?? -1]) + } + .task(id: costUnit) { if costUnit == .tezos { await TezDisplayRate.shared.refresh() } } } } diff --git a/apple/whistlegraph/Sources/PreviewNavigation.swift b/apple/whistlegraph/Sources/PreviewNavigation.swift index f067635823..7c57852f15 100644 --- a/apple/whistlegraph/Sources/PreviewNavigation.swift +++ b/apple/whistlegraph/Sources/PreviewNavigation.swift @@ -16,7 +16,9 @@ enum PreviewNavigation { guard let url, let mainFrame, url.user == nil, url.password == nil, url.port == nil else { return false } if mainFrame { guard url.scheme == "walkieware", url.host == "app", url.path == document.rawValue else { return false } - return document == .workspace ? query(url) == ["walkie": "1"] : url.query == nil + if document == .story { return url.query == nil } + // The storage origin stays put across the Whistlegraph rename. + return query(url) == ["whistlegraph": "1"] || query(url) == ["walkie": "1"] } // Creating an iframe can first navigate its empty document. if url.absoluteString == "about:blank" { return true } diff --git a/apple/whistlegraph/Sources/WhistlegraphApp.swift b/apple/whistlegraph/Sources/WhistlegraphApp.swift index cbabf69c1f..015a175e04 100644 --- a/apple/whistlegraph/Sources/WhistlegraphApp.swift +++ b/apple/whistlegraph/Sources/WhistlegraphApp.swift @@ -779,8 +779,20 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand layout = NativeLayout(tokens: values) print("[whistlegraph] native layout spacing=\(layout.spacing) title=\(layout.titleSize)") case "snapshot": - guard let value = body["snapshot"] as? [String: Any], - let data = try? JSONSerialization.data(withJSONObject: value), data.count < 2_000_000, + guard var value = body["snapshot"] as? [String: Any] else { return } + #if DEBUG + if NativeScreenFixture.enabled, + let state = ProcessInfo.processInfo.environment["WHISTLEGRAPH_BRAINCELLS_FIXTURE"], + var inference = value["inference"] as? [String: Any] { + inference["braincells"] = ["remaining": state == "empty" ? 0 : 75000, + "used": state == "empty" ? 100000 : 25000, "limit": 100000, + "purchased": state == "empty" ? 0 : 1000000, "unlimited": state == "unlimited", + "resetsAt": ISO8601DateFormatter().string(from: Date().addingTimeInterval(3600))] as [String: Any] + inference["braincellsError"] = "" + value["inference"] = inference + } + #endif + guard let data = try? JSONSerialization.data(withJSONObject: value), data.count < 2_000_000, var next = try? JSONDecoder().decode(PieceSnapshot.self, from: data), next.versions.count <= 256, next.code.count <= 32, next.handle.count <= 100 else { return } if next.revisions == nil { next.versions = snapshot.versions } diff --git a/apple/whistlegraph/Sources/WhistlegraphBraincells.swift b/apple/whistlegraph/Sources/WhistlegraphBraincells.swift index 65a8229703..e51e7b67c6 100644 --- a/apple/whistlegraph/Sources/WhistlegraphBraincells.swift +++ b/apple/whistlegraph/Sources/WhistlegraphBraincells.swift @@ -11,7 +11,7 @@ import StoreKit private let delivery = StoreCreditDelivery() private var listener: Task? private var recovering = false - private var loading = false + @Published private(set) var loading = false deinit { listener?.cancel() } @@ -141,19 +141,27 @@ struct BraincellPurchase: View { Task { await purchase.buy() } } label: { HStack { - Text("1,000,000 braincells") + VStack(alignment: .leading, spacing: 3) { + Label("Refill", systemImage: "plus.circle.fill").font(.headline) + Text("1,000,000 braincells").font(.subheadline) + } Spacer() - if purchase.busy { ProgressView() } else { Text(product.displayPrice) } + if purchase.busy { ProgressView().tint(.white) } else { Text(product.displayPrice).font(.headline) } } - }.disabled(purchase.busy || !signedIn).accessibilityIdentifier("brain-buy-app-store") - Text("For AC inference. Purchased braincells do not expire.").font(.footnote).foregroundStyle(.secondary) + .frame(maxWidth: .infinity).padding(.vertical, 6) + }.buttonStyle(.borderedProminent).tint(.purple) + .disabled(purchase.busy || !signedIn).accessibilityIdentifier("brain-buy-app-store") + Text("One-time purchase. No subscription.").font(.footnote).foregroundStyle(.secondary) } else { - Text(purchase.storeStatus).font(.footnote).foregroundStyle(.secondary) - .accessibilityIdentifier("brain-app-store-status") - Button("Reload App Store purchases") { Task { await purchase.load() } } + if purchase.loading { + ProgressView("Loading refills…").accessibilityIdentifier("brain-app-store-status") + } else { + Text(purchase.storeStatus).font(.footnote).foregroundStyle(.secondary) + .accessibilityIdentifier("brain-app-store-status") + Button("Try again") { Task { await purchase.load() } } + } } if !purchase.notice.isEmpty { Text(purchase.notice).font(.footnote).accessibilityIdentifier("brain-purchase-notice") } - Button("Check pending purchases") { Task { await purchase.recover() } }.disabled(purchase.busy) if !signedIn { Text("Sign in to AC to buy braincells.").font(.footnote).foregroundStyle(.secondary) } } } diff --git a/apple/whistlegraph/Sources/WhistlegraphHeader.swift b/apple/whistlegraph/Sources/WhistlegraphHeader.swift index a5640af474..812ff1c320 100644 --- a/apple/whistlegraph/Sources/WhistlegraphHeader.swift +++ b/apple/whistlegraph/Sources/WhistlegraphHeader.swift @@ -37,9 +37,6 @@ struct PiecesSheet: View { .font(.custom("ComicRelief-Bold", size: 20, relativeTo: .title3)) }.disabled(disabled).accessibilityIdentifier("pieces-new") } - if let inference { - Section { LabeledContent(inference.label, value: inference.provider) } - } #if WHISTLEGRAPH_INTERNAL_PAYMENTS && DEBUG if let session = mintSession, session.snapshot.handle == "jeffrey", session.snapshot.hasPiece { Section { diff --git a/apple/whistlegraph/Sources/WhistlegraphScreen.swift b/apple/whistlegraph/Sources/WhistlegraphScreen.swift index d8facc19ba..3ca5a02882 100644 --- a/apple/whistlegraph/Sources/WhistlegraphScreen.swift +++ b/apple/whistlegraph/Sources/WhistlegraphScreen.swift @@ -181,10 +181,7 @@ struct WhistlegraphScreen: View { .accessibilityLabel("Send chalk").accessibilityIdentifier("drawing-send") .disabled(!canTalk || session.capturePhase != .idle) } - HStack(spacing: 6) { - if let cost = session.snapshot.inference?.threadCost { ThreadCostLabel(cost: cost) } - BrainButton(session: session) { showComposer = false } - } + BrainButton(session: session) { showComposer = false } }.font(.title3).buttonStyle(.plain).frame(minHeight: 44) if drawing.full { Text("Chalk full · send or undo a stroke").font(.caption) } if session.capturePhase == .recording || session.capturePhase == .opening { diff --git a/apple/whistlegraph/Tests/PreviewNavigationCheck.swift b/apple/whistlegraph/Tests/PreviewNavigationCheck.swift index 8183c93f7a..f5f7778f25 100644 --- a/apple/whistlegraph/Tests/PreviewNavigationCheck.swift +++ b/apple/whistlegraph/Tests/PreviewNavigationCheck.swift @@ -6,6 +6,12 @@ import Foundation func allowed(_ value: String, main: Bool? = false, document: PreviewNavigation.Document = .workspace) -> Bool { PreviewNavigation.allows(URL(string: value), mainFrame: main, document: document) } + precondition(allowed("walkieware://app/index.html?whistlegraph=1", main: true)) + precondition(!allowed(runtime.replacingOccurrences(of: "preview=walkieware", with: "preview=whistlegraph")), "Use the deployed runtime's private preview marker") + precondition(!allowed("walkieware://app/index.html?whistlegraph=1&walkie=1", main: true)) + precondition(!allowed(runtime + "&preview=whistlegraph")) + precondition(!allowed(runtime.replacingOccurrences(of: "preview=walkieware", with: "preview=unknown"))) + precondition(PreviewNavigation.bridge(URL(string: "walkieware://app/index.html?whistlegraph=1"), mainFrame: true, document: .workspace) == .workspace) precondition(allowed("walkieware://app/index.html?walkie=1", main: true)) precondition(allowed("walkieware://app/index.html?walkie=1#local", main: true)) precondition(allowed("walkieware://app/story.html", main: true, document: .story)) diff --git a/apple/whistlegraph/Tests/rename-compatibility.test.mjs b/apple/whistlegraph/Tests/rename-compatibility.test.mjs index 61da7f9a29..c6487e3d95 100644 --- a/apple/whistlegraph/Tests/rename-compatibility.test.mjs +++ b/apple/whistlegraph/Tests/rename-compatibility.test.mjs @@ -16,6 +16,9 @@ test('Whistlegraph updates the existing app, Keychain service and WebView origin assert.match(read('Sources/WhistlegraphApp.swift'), /WhistlegraphBundle\.storageScheme\).*app\/index\.html/); assert.match(read('Sources/WhistlegraphApp.swift'), /forURLScheme: WhistlegraphBundle\.storageScheme/); assert.match(read('Resources/Web/shell.html'), /Whistlegraph<\/title>/); + // The deployed runtime uses this marker to suppress public piece telemetry. + assert.match(read('Resources/Web/engine.mjs'), /preview=walkieware/); + assert.match(read('Resources/Web/story.html'), /preview=walkieware/); }); test('saved Walkieware work, branch history and cloud identity survive unchanged', () => { diff --git a/apple/whistlegraph/UITests/HeaderSheetsTests.swift b/apple/whistlegraph/UITests/HeaderSheetsTests.swift index a54b5cb213..558ac11e72 100644 --- a/apple/whistlegraph/UITests/HeaderSheetsTests.swift +++ b/apple/whistlegraph/UITests/HeaderSheetsTests.swift @@ -11,7 +11,7 @@ final class HeaderSheetsTests: XCTestCase { let brain = app.buttons["brain-settings"] XCTAssertTrue(brain.waitForExistence(timeout: 30)) brain.tap() - app.swipeUp() + XCTAssertTrue(app.buttons["brain-advanced"].waitForExistence(timeout: 10)); app.buttons["brain-advanced"].tap() let units = app.segmentedControls["brain-cost-unit"] XCTAssertTrue(units.waitForExistence(timeout: 15)) for unit in ["Braincells", "USD", "Tezos"] { @@ -25,12 +25,14 @@ final class HeaderSheetsTests: XCTestCase { #else XCTAssertFalse(app.buttons["brain-buy-tezos"].exists) #endif + app.navigationBars.buttons["Brain"].tap() app.buttons["Done"].tap() app.terminate(); app.launch() XCTAssertTrue(brain.waitForExistence(timeout: 30)) - brain.tap(); app.swipeUp() + brain.tap(); XCTAssertTrue(app.buttons["brain-advanced"].waitForExistence(timeout: 10)); app.buttons["brain-advanced"].tap() XCTAssertTrue(units.waitForExistence(timeout: 15)) XCTAssertTrue(units.buttons["Tezos"].isSelected, "unit survives relaunch") + app.navigationBars.buttons["Brain"].tap() app.buttons["Done"].tap() } @@ -41,7 +43,7 @@ final class HeaderSheetsTests: XCTestCase { app.launch() let brain = app.buttons["brain-settings"] XCTAssertTrue(brain.waitForExistence(timeout: 30)) - brain.tap(); app.swipeUp() + brain.tap(); XCTAssertTrue(app.buttons["brain-advanced"].waitForExistence(timeout: 10)); app.buttons["brain-advanced"].tap() let units = app.segmentedControls["brain-cost-unit"] XCTAssertTrue(units.waitForExistence(timeout: 15)) units.buttons["Tezos"].tap() @@ -78,7 +80,7 @@ final class HeaderSheetsTests: XCTestCase { let app = XCUIApplication() app.launch() XCTAssertTrue(app.buttons["brain-settings"].waitForExistence(timeout: 30)) - app.buttons["brain-settings"].tap(); app.swipeUp() + openBrain(app); XCTAssertTrue(app.buttons["brain-advanced"].waitForExistence(timeout: 10)); app.buttons["brain-advanced"].tap() XCTAssertTrue(app.buttons["brain-buy-tezos"].waitForExistence(timeout: 15)) app.buttons["brain-buy-tezos"].tap() let browsers = ["com.apple.mobilesafari", "com.google.chrome.ios"].map { XCUIApplication(bundleIdentifier: $0) } @@ -156,9 +158,19 @@ final class HeaderSheetsTests: XCTestCase { return XCTWaiter().wait(for: [gone], timeout: timeout) == .completed } + private func openBrain(_ app: XCUIApplication) { + let brain = app.buttons["brain-settings"] + XCTAssertTrue(brain.waitForExistence(timeout: 20)) + brain.tap() + // The preview can finish sizing between finding the button and tapping. + if !app.navigationBars["Brain"].waitForExistence(timeout: 5) { brain.tap() } + XCTAssertTrue(app.navigationBars["Brain"].waitForExistence(timeout: 10)) + } + private func launch() -> XCUIApplication { let app = XCUIApplication() app.launchEnvironment["WHISTLEGRAPH_NATIVE_SCREEN_FIXTURE"] = "history" + app.launchEnvironment["WHISTLEGRAPH_BRAINCELLS_FIXTURE"] = "ready" app.launch() // The button exists as "Log in" before the engine has loaded the fixture. let account = app.buttons.matching(identifier: "workspace-account") @@ -170,20 +182,23 @@ final class HeaderSheetsTests: XCTestCase { #if !WHISTLEGRAPH_INTERNAL_PAYMENTS func testAppStoreMonetizationControls() { let app = launch() - app.buttons["brain-settings"].tap() - app.swipeUp() - let units = app.segmentedControls["brain-cost-unit"] - XCTAssertTrue(units.waitForExistence(timeout: 15)) - for unit in ["Braincells", "USD", "Tezos"] { XCTAssertTrue(units.buttons[unit].exists) } + openBrain(app) + XCTAssertTrue(app.buttons["brain-advanced"].waitForExistence(timeout: 15)) + XCTAssertFalse(app.segmentedControls["brain-cost-unit"].exists) + XCTAssertFalse(app.buttons["brain-model"].exists) + XCTAssertFalse(app.buttons["Check pending purchases"].exists) let store = app.descendants(matching: .any).matching(NSPredicate(format: "identifier == %@ OR identifier == %@", "brain-buy-app-store", "brain-app-store-status")).firstMatch XCTAssertTrue(store.waitForExistence(timeout: 15), "App Store product or honest unavailable state") XCTAssertFalse(app.buttons["brain-buy-tezos"].exists) + let image = XCTAttachment(screenshot: app.screenshot()) + image.name = "Simple brain and refill"; image.lifetime = .keepAlways; add(image) app.buttons["Done"].tap() - XCTAssertTrue(waitForDisappearance(of: units)) + XCTAssertTrue(waitForDisappearance(of: store)) app.buttons["workspace-settings"].tap() XCTAssertTrue(app.buttons["pieces-new"].waitForExistence(timeout: 10)) XCTAssertFalse(app.buttons["pieces-mint"].exists) + XCTAssertFalse(app.staticTexts["OpenRouter"].exists) } #endif @@ -191,30 +206,17 @@ final class HeaderSheetsTests: XCTestCase { func testPhoneBrainPanelAndPieceMenu() { let app = XCUIApplication() app.launch() - let brain = app.buttons["brain-settings"] - XCTAssertTrue(brain.waitForExistence(timeout: 30)) - func capture(_ name: String) { - let image = XCTAttachment(screenshot: app.screenshot()) - image.name = name; image.lifetime = .keepAlways; add(image) - } - capture("Build 98 phone workspace") - brain.tap() - XCTAssertTrue(app.segmentedControls["brain-pixel-size"].waitForExistence(timeout: 10)) - XCTAssertTrue(app.staticTexts["OpenRouter"].waitForExistence(timeout: 15)) - capture("Build 98 phone Brain settings") - // Expand the sheet to inspect the allowance and request usage below it. - app.swipeUp() - XCTAssertTrue(app.descendants(matching: .any).matching(identifier: "brain-balance").firstMatch.waitForExistence(timeout: 15), app.debugDescription) - capture("Build 98 phone braincells and usage") + XCTAssertTrue(app.buttons["brain-settings"].waitForExistence(timeout: 30)) + openBrain(app) + XCTAssertTrue(app.buttons["brain-canvas"].waitForExistence(timeout: 10)) + XCTAssertFalse(app.staticTexts["OpenRouter"].exists) + let image = XCTAttachment(screenshot: app.screenshot()) + image.name = "Phone Brain"; image.lifetime = .keepAlways; add(image) app.buttons["Done"].tap() - XCTAssertTrue(waitForDisappearance(of: app.segmentedControls["brain-pixel-size"])) app.buttons["workspace-settings"].tap() XCTAssertTrue(app.buttons["pieces-new"].waitForExistence(timeout: 10)) - XCTAssertTrue(app.staticTexts["OpenRouter"].exists) - XCTAssertFalse(app.segmentedControls["brain-pixel-size"].exists) - capture("Build 98 phone piece menu") + XCTAssertFalse(app.staticTexts["OpenRouter"].exists) app.buttons["Done"].tap() - XCTAssertTrue(waitForDisappearance(of: app.buttons["pieces-new"])) } func testPieceCodeOpensPiecesSheetWithNewPiece() { @@ -240,17 +242,40 @@ final class HeaderSheetsTests: XCTestCase { XCTAssertTrue(waitForDisappearance(of: app.buttons["account-sign-out"]), "Done closes the account sheet") } - func testThreadCostAppearsLeftOfBrain() { + func testProviderCostsStayInAdvanced() { let app = launch() - let cost = app.staticTexts["thread-cost"] - XCTAssertTrue(cost.waitForExistence(timeout: 20)) + XCTAssertFalse(app.staticTexts["thread-cost"].exists) + openBrain(app) + XCTAssertTrue(app.buttons["brain-advanced"].waitForExistence(timeout: 10)) + XCTAssertFalse(app.staticTexts["thread-cost"].exists) + XCTAssertTrue(app.buttons["brain-advanced"].waitForExistence(timeout: 10)); app.buttons["brain-advanced"].tap() + let units = app.segmentedControls["brain-cost-unit"] + XCTAssertTrue(units.waitForExistence(timeout: 10)) + units.buttons["USD"].tap() + let cost = app.staticTexts["thread-cost"].firstMatch + XCTAssertTrue(cost.waitForExistence(timeout: 10)) XCTAssertEqual(cost.value as? String, "$0.42") - let brain = app.buttons["brain-settings"] - XCTAssertTrue(brain.exists) - XCTAssertLessThanOrEqual(cost.frame.maxX, brain.frame.minX) - XCTAssertLessThan(abs(cost.frame.midY - brain.frame.midY), 3) - let image = XCTAttachment(screenshot: app.screenshot()) - image.name = "Thread cost left of brain"; image.lifetime = .keepAlways; add(image) + } + + func testBraincellBalances() { + for state in ["ready", "empty", "unlimited"] { + let app = XCUIApplication() + app.launchEnvironment["WHISTLEGRAPH_NATIVE_SCREEN_FIXTURE"] = "history" + app.launchEnvironment["WHISTLEGRAPH_BRAINCELLS_FIXTURE"] = state + app.launch() + let account = app.buttons.matching(identifier: "workspace-account") + .matching(NSPredicate(format: "label == %@", "@preview, account")).firstMatch + XCTAssertTrue(account.waitForExistence(timeout: 30)) + openBrain(app) + let balance = app.descendants(matching: .any).matching(identifier: "brain-balance").firstMatch + XCTAssertTrue(balance.waitForExistence(timeout: 15)) + XCTAssertEqual(balance.value as? String, state == "ready" ? "1,075,000" : state == "empty" ? "0" : "Unlimited") + XCTAssertEqual(app.staticTexts["Saved"].exists, state != "unlimited") + if state == "unlimited" { XCTAssertFalse(app.buttons["brain-buy-app-store"].exists) } + let image = XCTAttachment(screenshot: app.screenshot()) + image.name = "Brain balance " + state; image.lifetime = .keepAlways; add(image) + app.terminate() + } } func testUnsentDrawingSurvivesRelaunch() { @@ -271,7 +296,8 @@ final class HeaderSheetsTests: XCTestCase { let app = launch() let code = app.buttons["brain-settings"] XCTAssertTrue(code.waitForExistence(timeout: 20)) - code.tap() + openBrain(app) + XCTAssertTrue(app.buttons["brain-canvas"].waitForExistence(timeout: 10)); app.buttons["brain-canvas"].tap() let picker = app.segmentedControls["brain-pixel-size"] XCTAssertTrue(picker.waitForExistence(timeout: 10)) for size in [1, 2, 3, 4] { @@ -283,7 +309,8 @@ final class HeaderSheetsTests: XCTestCase { app.terminate() app.launch() XCTAssertTrue(code.waitForExistence(timeout: 20)) - code.tap() + openBrain(app) + XCTAssertTrue(app.buttons["brain-canvas"].waitForExistence(timeout: 10)); app.buttons["brain-canvas"].tap() XCTAssertTrue(picker.waitForExistence(timeout: 10)) XCTAssertTrue(picker.buttons["4×"].isSelected) picker.buttons["2×"].tap() diff --git a/apple/whistlegraph/UITests/PreviewFormatTests.swift b/apple/whistlegraph/UITests/PreviewFormatTests.swift index 18cbf710c3..a5f69c49d5 100644 --- a/apple/whistlegraph/UITests/PreviewFormatTests.swift +++ b/apple/whistlegraph/UITests/PreviewFormatTests.swift @@ -11,6 +11,7 @@ final class PreviewFormatTests: XCTestCase { XCTAssertTrue(brain.waitForExistence(timeout: 30)) for format in ["9:16", "1:1", "4:3", "16:9", "2:3"] { brain.tap() + XCTAssertTrue(app.buttons["brain-canvas"].waitForExistence(timeout: 10)); app.buttons["brain-canvas"].tap() let choices = app.segmentedControls["brain-preview-format"] XCTAssertTrue(choices.waitForExistence(timeout: 10)) XCTAssertTrue(app.segmentedControls["brain-pixel-size"].exists) @@ -19,6 +20,7 @@ final class PreviewFormatTests: XCTestCase { if format == "2:3" { let settings = XCTAttachment(screenshot: app.screenshot()); settings.name = "Brain preview controls"; settings.lifetime = .keepAlways; add(settings) } + app.navigationBars.buttons["Brain"].tap() app.buttons["Done"].tap() let picture = app.otherElements["story-picture"] XCTAssertTrue(picture.waitForExistence(timeout: 10)) @@ -32,7 +34,9 @@ final class PreviewFormatTests: XCTestCase { } app.terminate(); app.launch() XCTAssertTrue(brain.waitForExistence(timeout: 30)); brain.tap() + XCTAssertTrue(app.buttons["brain-canvas"].waitForExistence(timeout: 10)); app.buttons["brain-canvas"].tap() XCTAssertTrue(app.segmentedControls["brain-preview-format"].buttons["2:3"].isSelected) + app.navigationBars.buttons["Brain"].tap() app.buttons["Done"].tap() } } diff --git a/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj b/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj index a74f27d929..cbb7170cb2 100644 --- a/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj +++ b/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj @@ -606,7 +606,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 110; + CURRENT_PROJECT_VERSION = 111; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = FB5948YR3S; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -691,7 +691,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 110; + CURRENT_PROJECT_VERSION = 111; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = FB5948YR3S; ENABLE_NS_ASSERTIONS = NO; @@ -751,7 +751,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 110; + CURRENT_PROJECT_VERSION = 111; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = FB5948YR3S; ENABLE_STRICT_OBJC_MSGSEND = YES; diff --git a/apple/whistlegraph/fastlane/.gitignore b/apple/whistlegraph/fastlane/.gitignore new file mode 100644 index 0000000000..32df3e45ca --- /dev/null +++ b/apple/whistlegraph/fastlane/.gitignore @@ -0,0 +1,3 @@ +report.xml +Preview.html +test_output/ diff --git a/apple/whistlegraph/fastlane/Fastfile b/apple/whistlegraph/fastlane/Fastfile new file mode 100644 index 0000000000..512708dc09 --- /dev/null +++ b/apple/whistlegraph/fastlane/Fastfile @@ -0,0 +1,122 @@ +# Whistlegraph TestFlight. Release signing and uploads use the same Admin key +# as AC, Oskiewar and Menu Band. First-time app creation uses an Apple ID +# session: Apple's public API does not support creating an apps resource. +require "shellwords" +require "yaml" + +APP_DIR = File.expand_path("..", __dir__) +REPO_DIR = File.expand_path("../..", APP_DIR) +SETTINGS = YAML.load_file(File.join(APP_DIR, "project.yml")).fetch("settings").fetch("base") +VERSION = SETTINGS.fetch("MARKETING_VERSION").to_s +BUILD = SETTINGS.fetch("CURRENT_PROJECT_VERSION").to_s +BUNDLE = "computer.aesthetic.walkieware" +TESTER_GROUP = "Whistlegraph Friends" +TEAM = "FB5948YR3S" +KEY_ID = "S4TQKG6U99" +ISSUER = "69a6de78-fa3c-47e3-e053-5b8c7c11a4d1" +KEY_PATH = File.expand_path("~/.appstoreconnect/private_keys/AuthKey_#{KEY_ID}.p8") +RELEASE_DIR = File.expand_path(ENV.fetch("WHISTLEGRAPH_RELEASE_DIR", "~/Library/Developer/Whistlegraph-Releases/#{VERSION}-#{BUILD}")) +IPA = File.expand_path(ENV.fetch("WHISTLEGRAPH_IPA", File.join(RELEASE_DIR, "export/Whistlegraph.ipa"))) + +def asc_key + app_store_connect_api_key(key_id: KEY_ID, issuer_id: ISSUER, + key_filepath: KEY_PATH, in_house: false) +end + +def store_app + Spaceship::ConnectAPI::App.find(BUNDLE) +end + +platform :ios do + desc "Create the first app record using a terminal Apple ID login (once)" + lane :create do + asc_key + if (app = store_app) + UI.success("Whistlegraph already exists: #{app.id}") + next + end + unless Spaceship::ConnectAPI::BundleId.find(BUNDLE) + Spaceship::ConnectAPI::BundleId.create(name: "Whistlegraph", identifier: BUNDLE, platform: "IOS") + end + # produce requires an Apple ID session; seeding an API token cannot replace it. + sh("node", File.join(__dir__, "refresh-service-key.mjs")) + Spaceship::ConnectAPI.token = nil + produce(username: ENV["FASTLANE_USER"], app_identifier: BUNDLE, + app_name: "Whistlegraph", app_version: VERSION, sku: "whistlegraph", + language: "en-US", platform: "ios", skip_devcenter: true) + end + + desc "Bundle, archive Release with two compiler jobs, and export a signed IPA" + lane :build do + sh("node", File.join(APP_DIR, "bundle.mjs")) + sh("xcodegen", "generate", "--spec", File.join(APP_DIR, "project.yml"), "--project", APP_DIR) + auth = "-allowProvisioningUpdates -authenticationKeyPath #{KEY_PATH.shellescape} " \ + "-authenticationKeyID #{KEY_ID} -authenticationKeyIssuerID #{ISSUER}" + build_ios_app(project: File.join(APP_DIR, "Whistlegraph.xcodeproj"), + scheme: "Whistlegraph", configuration: "Release", + archive_path: File.join(RELEASE_DIR, "Whistlegraph.xcarchive"), + export_method: "app-store", export_team_id: TEAM, + xcargs: "-jobs 2 #{auth}", export_xcargs: auth, + output_directory: File.dirname(IPA), output_name: File.basename(IPA)) + end + + desc "Upload the signed IPA to TestFlight using the Apple API key" + lane :upload do + key = asc_key + UI.user_error!("No app record yet. Run FASTLANE_USER=<Apple ID> fastlane ios create once.") unless store_app + UI.user_error!("Missing #{IPA}; run fastlane ios build first.") unless File.file?(IPA) + upload_to_testflight(api_key: key, app_identifier: BUNDLE, ipa: IPA, + skip_waiting_for_build_processing: true, + distribute_external: false) + end + + desc "Submit this version/build for external beta review and notify its private group" + lane :distribute do + upload_to_testflight(api_key: asc_key, app_identifier: BUNDLE, app_platform: "ios", + app_version: VERSION, build_number: BUILD, + distribute_only: true, distribute_external: true, + groups: [TESTER_GROUP], notify_external_testers: true, + submit_beta_review: true, + changelog: ENV.fetch("WHISTLEGRAPH_TEST_NOTES", "Test voice, typing and drawing, piece revisions, version history, and story-video export. Sign in with your Aesthetic Computer account and allow AI creation in Brain > AI & privacy.")) + end + + desc "Add one external tester: fastlane ios invite email:<email> name:<first-name>" + lane :invite do |options| + email = options[:email].to_s.strip + UI.user_error!("Provide email:<tester email>") if email.empty? + asc_key + app = store_app + UI.user_error!("Create the app record first") unless app + group = app.get_beta_groups(includes: "betaTesters").find { |g| g.name == TESTER_GROUP && !g.is_internal_group } + UI.user_error!("Create the private #{TESTER_GROUP} group first") unless group + if group.beta_testers.to_a.any? { |t| t.email.casecmp?(email) } + UI.success("Tester is already in #{TESTER_GROUP}") + next + end + tester = Spaceship::ConnectAPI::BetaTester.find(email: email) + if tester + group.add_beta_testers(beta_tester_ids: [tester.id]) + else + attributes = {email: email} + attributes[:firstName] = options[:name] if options[:name] + Spaceship::ConnectAPI.post_beta_tester_assignment(beta_group_ids: [group.id], attributes: attributes) + end + UI.success("Tester added to #{TESTER_GROUP}; install access depends on an approved build.") + end + + desc "Read App Store Connect versions and builds using the Apple API key" + lane :status do + asc_key + if (app = store_app) + sh("node", File.join(REPO_DIR, "slab/menuband/bin/asc.mjs"), + "status", "--app", app.id, "--platform", "IOS") + build = Spaceship::ConnectAPI::Build.all(app_id: app.id, version: VERSION, + build_number: BUILD, platform: "IOS").first + if build&.build_beta_detail + UI.message("TestFlight #{VERSION} (#{BUILD}): #{build.build_beta_detail.external_build_state}") + end + else + UI.important("No Whistlegraph app record yet; the signed IPA can be prepared before creation.") + end + end +end diff --git a/apple/whistlegraph/fastlane/README.md b/apple/whistlegraph/fastlane/README.md new file mode 100644 index 0000000000..7cfdf04dda --- /dev/null +++ b/apple/whistlegraph/fastlane/README.md @@ -0,0 +1,72 @@ +fastlane documentation +---- + +# Installation + +Make sure you have the latest version of the Xcode command line tools installed: + +```sh +xcode-select --install +``` + +For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) + +# Available Actions + +## iOS + +### ios create + +```sh +[bundle exec] fastlane ios create +``` + +Create the first app record using a terminal Apple ID login (once) + +### ios build + +```sh +[bundle exec] fastlane ios build +``` + +Bundle, archive Release with two compiler jobs, and export a signed IPA + +### ios upload + +```sh +[bundle exec] fastlane ios upload +``` + +Upload the signed IPA to TestFlight using the Apple API key + +### ios distribute + +```sh +[bundle exec] fastlane ios distribute +``` + +Submit this version/build for external beta review and notify its private group + +### ios invite + +```sh +[bundle exec] fastlane ios invite +``` + +Add one external tester: fastlane ios invite email:<email> name:<first-name> + +### ios status + +```sh +[bundle exec] fastlane ios status +``` + +Read App Store Connect versions and builds using the Apple API key + +---- + +This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. + +More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). + +The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). diff --git a/apple/whistlegraph/fastlane/refresh-service-key.mjs b/apple/whistlegraph/fastlane/refresh-service-key.mjs new file mode 100644 index 0000000000..aec3a0a485 --- /dev/null +++ b/apple/whistlegraph/fastlane/refresh-service-key.mjs @@ -0,0 +1,26 @@ +// Compatibility for Fastlane versions using Apple's retired Olympus endpoint. +// The public login widget key is not our private App Store Connect API key. +// https://github.com/fastlane/fastlane/pull/30206 +import {writeFile, rename, rm} from 'node:fs/promises'; +import {randomUUID} from 'node:crypto'; + +// A bare request: never attach session cookies or follow the signout redirect. +const response = await fetch('https://appstoreconnect.apple.com/logout', { + method: 'HEAD', redirect: 'manual', signal: AbortSignal.timeout(15000), +}); +if (![302, 303].includes(response.status)) throw Error(`Apple login configuration returned HTTP ${response.status}`); +const location = new URL(response.headers.get('location') || '', response.url); +if (location.origin !== 'https://idmsa.apple.com' || location.pathname !== '/appleauth/signout') { + throw Error('Apple login configuration returned an unexpected redirect'); +} +const key = location.searchParams.get('widgetKey'); +if (!/^[a-f0-9]{64}$/i.test(key || '')) throw Error('Apple login configuration has no valid widget key'); +const cache = '/tmp/spaceship_itc_service_key.txt'; +const temporary = `${cache}.${randomUUID()}`; +try { + await writeFile(temporary, key, {mode: 0o600, flag: 'wx'}); + await rename(temporary, cache); +} finally { + await rm(temporary, {force: true}); +} +console.log('Refreshed Fastlane public login configuration.'); diff --git a/apple/whistlegraph/project.yml b/apple/whistlegraph/project.yml index 3665f497be..45136caf9e 100644 --- a/apple/whistlegraph/project.yml +++ b/apple/whistlegraph/project.yml @@ -16,7 +16,7 @@ settings: CODE_SIGN_STYLE: Automatic SWIFT_VERSION: "5.0" MARKETING_VERSION: "0.1.0" - CURRENT_PROJECT_VERSION: "110" + CURRENT_PROJECT_VERSION: "111" targets: Whistlegraph: type: application diff --git a/slab/menuband/bin/asc.mjs b/slab/menuband/bin/asc.mjs index 59bdba7f84..c1484b1c75 100644 --- a/slab/menuband/bin/asc.mjs +++ b/slab/menuband/bin/asc.mjs @@ -37,6 +37,7 @@ const APPS = { fingerquilt: "1153451161", softwallpaper: "1390237091", aestheticcomputer: "6450940883", + whistlegraph: "6820297686", }; // --app / --platform are pulled out before the positional args are read, so -- 2.51.2 From d76b25f4f69ddcb12a46feb329839387050573ab Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" <mail@aesthetic.computer> Date: Wed, 7 Oct 2026 18:17:10 -0700 Subject: [PATCH 2/3] Preview animated SVGs on the Aesel card Aesel now treats .svg as a picture, and the Slab card stages it as artifact.svg so WebKit draws it (it never sniffs SVG), sizes it from width/height or viewBox, and checks it as text rather than through NSImage. Loaded as an img, its CSS and SMIL animation play while no script runs. Bumps Aesel to 0.8.21; the card probe now includes SoundSketch so it compiles again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- aesel/package.json | 2 +- aesel/src/media.mjs | 1 + .../SlabMenubar/LocalArtifactPreview.swift | 55 ++++++++++++++++++- .../Sources/SlabMenubar/PromptPreview.swift | 4 +- .../tests/local-artifact-preview.sh | 13 ++++- 5 files changed, 68 insertions(+), 7 deletions(-) diff --git a/aesel/package.json b/aesel/package.json index 9f9d4c7a2e..542f3bcb6e 100644 --- a/aesel/package.json +++ b/aesel/package.json @@ -1,6 +1,6 @@ { "name": "aesel", - "version": "0.8.20", + "version": "0.8.21", "private": true, "type": "module", "scripts": { diff --git a/aesel/src/media.mjs b/aesel/src/media.mjs index 0ffcbb7d9f..5d1760bcaf 100644 --- a/aesel/src/media.mjs +++ b/aesel/src/media.mjs @@ -15,6 +15,7 @@ export const MEDIA_TYPES = new Map([ ["jpg", { kind: "picture", mime: "image/jpeg", glyph: "🖼" }], ["jpeg", { kind: "picture", mime: "image/jpeg", glyph: "🖼" }], ["webp", { kind: "picture", mime: "image/webp", glyph: "🖼" }], + ["svg", { kind: "picture", mime: "image/svg+xml", glyph: "🖼" }], ["wav", { kind: "sound", mime: "audio/wav", glyph: "🔊" }], ["mp3", { kind: "sound", mime: "audio/mpeg", glyph: "🔊" }], ["pdf", { kind: "paper", mime: "application/pdf", glyph: "📄" }], diff --git a/slab/menubar-swift/Sources/SlabMenubar/LocalArtifactPreview.swift b/slab/menubar-swift/Sources/SlabMenubar/LocalArtifactPreview.swift index 7885765e5c..343181794c 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/LocalArtifactPreview.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/LocalArtifactPreview.swift @@ -20,7 +20,7 @@ struct LocalArtifactPreview: Equatable { let version = marker["version"] as? Int, version > 0, let artifactID = marker["artifactId"] as? String, !artifactID.isEmpty else { return nil } let allowed: [String: [String]] = [ - "picture": ["image/png", "image/jpeg", "image/webp"], + "picture": ["image/png", "image/jpeg", "image/webp", "image/svg+xml"], "sound": ["audio/wav", "audio/x-wav", "audio/mpeg"], "paper": ["application/pdf", "text/plain", "text/markdown", "text/x-tex", "application/x-tex"], "video": ["video/mp4", "video/quicktime", "video/webm"], @@ -35,6 +35,12 @@ struct LocalArtifactPreview: Equatable { /// The file's own name, drawn under it on the card. var name: String { (path as NSString).lastPathComponent } + var isSVG: Bool { mime == "image/svg+xml" } + + /// The name the file takes in the staging directory. WebKit never sniffs + /// SVG, so a vector picture keeps its extension or the img stays blank. + var stagedName: String { isSVG ? "artifact.svg" : "artifact" } + /// Pictures, sounds and papers are read whole and re-written into the /// staging directory. A video is linked there instead of copied, so it may /// be as large as a render actually is. @@ -64,7 +70,7 @@ struct LocalArtifactPreview: Equatable { /// alone. func stageFile(into directory: URL) throws -> URL { let source = try validatedFile() - let target = directory.appendingPathComponent("artifact") + let target = directory.appendingPathComponent(stagedName) do { try FileManager.default.linkItem(at: source, to: target) } catch { try FileManager.default.copyItem(at: source, to: target) } return target @@ -85,6 +91,7 @@ struct LocalArtifactPreview: Equatable { /// Pixel dimensions for pictures; PDF points for the first page. UI resizing /// scales this surface and never changes the artifact's logical viewport. func dimensions(_ data: Data) -> CGSize { + if isSVG, let size = Self.svgDimensions(data) { return size } if kind == "picture", let rep = NSBitmapImageRep(data: data), rep.pixelsWide > 0, rep.pixelsHigh > 0 { return CGSize(width: rep.pixelsWide, height: rep.pixelsHigh) } @@ -95,6 +102,46 @@ struct LocalArtifactPreview: Equatable { return CGSize(width: 768, height: 512) } + /// Whether the bytes are a picture this card can draw. NSImage reads the + /// rasters; an SVG only has to be UTF-8 with an svg root, and WebKit + /// draws it, animations and all. + func readable(_ data: Data) -> Bool { + if isSVG { return Self.svgRoot(data) != nil } + return NSImage(data: data) != nil + } + + /// The opening `<svg ...>` tag, or nil when the text has none. + static func svgRoot(_ data: Data) -> String? { + guard let text = String(data: data, encoding: .utf8), + let start = text.range(of: "<svg", options: .caseInsensitive), + let end = text.range(of: ">", range: start.upperBound..<text.endIndex) else { return nil } + return String(text[start.lowerBound..<end.upperBound]) + } + + /// An SVG's size from its width and height, or failing those its viewBox. + /// Percentages and other relative units say nothing about shape, so they + /// fall through to the viewBox. + static func svgDimensions(_ data: Data) -> CGSize? { + guard let root = svgRoot(data) else { return nil } + func attribute(_ name: String) -> String? { + guard let match = root.range(of: "\\s\(name)\\s*=\\s*[\"']([^\"']*)[\"']", options: .regularExpression) else { return nil } + let pair = root[match] + guard let quote = pair.firstIndex(where: { $0 == "\"" || $0 == "'" }) else { return nil } + return String(pair[pair.index(after: quote)..<pair.index(before: pair.endIndex)]) + } + func length(_ name: String) -> Double? { + guard let value = attribute(name)?.trimmingCharacters(in: .whitespaces), + value.range(of: "^[0-9.]+(px)?$", options: .regularExpression) != nil else { return nil } + return Double(value.replacingOccurrences(of: "px", with: "")) + } + if let width = length("width"), let height = length("height"), width > 0, height > 0 { + return CGSize(width: width, height: height) + } + let box = (attribute("viewBox") ?? "").split(whereSeparator: { $0 == " " || $0 == "," }).compactMap { Double($0) } + if box.count == 4, box[2] > 0, box[3] > 0 { return CGSize(width: box[2], height: box[3]) } + return nil + } + static func escaped(_ text: String) -> String { text.replacingOccurrences(of: "&", with: "&") .replacingOccurrences(of: "<", with: "<") @@ -111,7 +158,9 @@ struct LocalArtifactPreview: Equatable { content = Self.soundContent ready = Self.soundScript(sketch) } else if kind == "picture" { - content = "<img id='artifact' src='artifact' alt='Picture preview'>" + // An SVG loaded as an img plays its own CSS and SMIL animation + // but runs no script and fetches nothing, like any other picture. + content = "<img id='artifact' src='\(stagedName)' alt='Picture preview'>" ready = "const a=document.getElementById('artifact'); a.onload=ready; a.onerror=failed; if(a.complete&&a.naturalWidth)ready();" } else if kind == "sound" { content = (waveform ?? "") + "<audio id='artifact' controls preload='metadata' src='artifact'></audio>" diff --git a/slab/menubar-swift/Sources/SlabMenubar/PromptPreview.swift b/slab/menubar-swift/Sources/SlabMenubar/PromptPreview.swift index 4d823d76cd..38ee861585 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/PromptPreview.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/PromptPreview.swift @@ -464,7 +464,7 @@ final class PromptPreview { // staging directory and WebKit streams it from there. let bytes = artifact.kind == "video" ? Data() : try artifact.readValidatedFile() let dimensions = artifact.kind == "video" ? artifact.videoDimensions() : artifact.dimensions(bytes) - if artifact.kind == "picture", NSImage(data: bytes) == nil { throw CocoaError(.fileReadCorruptFile) } + if artifact.kind == "picture", !artifact.readable(bytes) { throw CocoaError(.fileReadCorruptFile) } if artifact.mime == "application/pdf", (PDFDocument(data: bytes)?.pageCount ?? 0) == 0 { throw CocoaError(.fileReadCorruptFile) } let directory = FileManager.default.temporaryDirectory.appendingPathComponent("slab-artifact-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, @@ -484,7 +484,7 @@ final class PromptPreview { _ = try artifact.stageFile(into: directory) } else { text = nil - try bytes.write(to: directory.appendingPathComponent("artifact"), options: .atomic) + try bytes.write(to: directory.appendingPathComponent(artifact.stagedName), options: .atomic) } target = directory.appendingPathComponent("index.html") let sketch = artifact.kind == "sound" ? (sketches[artifact.key] ?? nil) : nil diff --git a/slab/menubar-swift/tests/local-artifact-preview.sh b/slab/menubar-swift/tests/local-artifact-preview.sh index 09a9bd7b13..910512b3c7 100755 --- a/slab/menubar-swift/tests/local-artifact-preview.sh +++ b/slab/menubar-swift/tests/local-artifact-preview.sh @@ -5,7 +5,7 @@ root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) probeDir=$(mktemp -d /tmp/slab-artifact-check.XXXXXX) probe="$probeDir/check.swift" trap 'rm -rf "$probeDir"' EXIT -cat "$root/Sources/SlabMenubar/LocalArtifactPreview.swift" > "$probe" +cat "$root/Sources/SlabMenubar/LocalArtifactPreview.swift" "$root/Sources/SlabMenubar/SoundSketch.swift" > "$probe" cat >> "$probe" <<'SWIFT' let marker: [String: Any] = ["path": "/tmp/picture.png", "mime": "image/png", "version": 2, "artifactId": "picture-one"] let picture = LocalArtifactPreview(marker: marker, kind: "picture")! @@ -17,6 +17,17 @@ invalid = marker; invalid["mime"] = "text/html" assert(LocalArtifactPreview(marker: invalid, kind: "picture") == nil) invalid = marker; invalid["version"] = 0 assert(LocalArtifactPreview(marker: invalid, kind: "picture") == nil) +let vector = LocalArtifactPreview(marker: ["path":"/tmp/turtle.svg", "mime":"image/svg+xml", "version":1,"artifactId":"svg-one"], kind:"picture")! +assert(vector.stagedName == "artifact.svg") +assert(picture.stagedName == "artifact") +assert(vector.html(nonce:"test").contains("<img id='artifact' src='artifact.svg'")) +let walking = Data("<?xml version='1.0'?><svg xmlns='http://www.w3.org/2000/svg' width=\"1024\" height='768' viewBox='0 0 10 10'><style>@keyframes a{}</style></svg>".utf8) +assert(vector.readable(walking)) +assert(vector.dimensions(walking) == CGSize(width: 1024, height: 768)) +let boxed = Data("<svg viewBox=\"0,0,300,150\" width='100%' stroke-width='4'></svg>".utf8) +assert(vector.dimensions(boxed) == CGSize(width: 300, height: 150)) +assert(!vector.readable(Data("<html>not a picture</html>".utf8))) +assert(vector.dimensions(Data("nope".utf8)) == CGSize(width: 768, height: 512)) let sound = LocalArtifactPreview(marker: ["path":"/tmp/sound.wav", "mime":"audio/wav", "version":1,"artifactId":"sound-one"], kind:"sound")! let audioHTML = sound.html(nonce:"test") assert(audioHTML.contains("controls preload='metadata'")) -- 2.51.2 From e2a83d97d8e573b3ef6d59bcf37831569226eadb Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" <mail@aesthetic.computer> Date: Wed, 7 Oct 2026 22:33:48 -0700 Subject: [PATCH 3/3] Add Jade Bonfires Menu Band remixes --- pop/jade-bonfires/bin/remix-v1.py | 131 ++++++++++++++++++++++ pop/jade-bonfires/bin/remix.py | 173 ++++++++++++++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 pop/jade-bonfires/bin/remix-v1.py create mode 100644 pop/jade-bonfires/bin/remix.py diff --git a/pop/jade-bonfires/bin/remix-v1.py b/pop/jade-bonfires/bin/remix-v1.py new file mode 100644 index 0000000000..9960704e3f --- /dev/null +++ b/pop/jade-bonfires/bin/remix-v1.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""A 30-second affectionate Menu Band remix, aligned from the take's MIDI.""" +from pathlib import Path +import json, subprocess, re +import numpy as np +import soundfile as sf +from scipy.signal import butter, sosfilt +SR=44100; BPM=128; BEAT=60/BPM; N=30*SR +ROOT=Path.home()/'Documents/Shelf/Jade Bonfires 2026-10-07'; SRC=ROOT/'Original/jade-bonfires-oct-7-stems' +OUT=ROOT/'Remixes/jade-bonfires-adoration'; OUT.mkdir(exist_ok=True) +# Read the Menu Band type-0 MIDI, including running status and tempo changes. +b=(SRC/'notes.mid').read_bytes(); ppq=int.from_bytes(b[12:14],'big'); p=22; clock=0; status=0; tempo=500000; notes=[] +def var(): + global p + v=0 + while True: + x=b[p];p+=1;v=(v<<7)|(x&127) + if x<128:return v +while p<len(b): + clock+=var()*tempo/ppq/1e6 + if b[p]&128:status=b[p];p+=1 + if status==255: + typ=b[p];p+=1;size=var();data=b[p:p+size];p+=size + if typ==81:tempo=int.from_bytes(data,'big') + elif status>=240:size=var();p+=size + else: + kind=status&240;size=1 if kind in [192,208] else 2;data=b[p:p+size];p+=size + if kind==144 and data[1]>0:notes.append((clock,data[0])) +tones,rate=sf.read(SRC/'tones.wav',always_2d=True); drums,drate=sf.read(SRC/'percussion.wav',always_2d=True) +assert rate==drate==SR +# Clean subsonics and round the recorded percussion before arranging. +tones=sosfilt(butter(2,65,fs=SR,btype='highpass',output='sos'),tones,axis=0) +drums=sosfilt(butter(2,35,fs=SR,btype='highpass',output='sos'),drums,axis=0) +drums=.32*np.tanh(drums/.32) +music=np.zeros((N,2)); rhythm=np.zeros((N,2)); glints=np.zeros((N,2)) +def fade(x,a=.005,r=.045): + x=x.copy();na=min(len(x),round(a*SR));nr=min(len(x),round(r*SR)) + if na:x[:na]*=np.linspace(0,1,na)[:,None] + if nr:x[-nr:]*=np.linspace(1,0,nr)[:,None] + return x +def put(bus,x,at,gain=1,pan=0): + i=round(at*SR) + if i<0 or i>=N:return + if x.ndim==1:x=np.repeat(x[:,None],2,axis=1) + count=min(len(x),N-i) + bus[i:i+count]+=x[:count]*gain*np.array([1-max(pan,0)*.5,1+min(pan,0)*.5]) +# This is an existing composition: retain its elected melody, snap its attacks +# to eighths, and preserve each recorded note's pitch rather than speeding it up. +start=notes[3][0]; source_beat=float(np.median(np.diff([t for t,n in notes if 4<t<22]))) +events=[] +for idx,(at,note) in enumerate(notes): + if at<start or at>35.2:continue + grid=round((at-start)/source_beat*2)/2 + if grid>=62:continue + dest=grid*BEAT + next_at=notes[idx+1][0] if idx+1<len(notes) else at+.5 + stop=min(next_at+.035,at+.85) + segment=fade(tones[round(at*SR):round(stop*SR)],.003,.065) + put(music,segment,dest,1.9) + # Delicate dotted echoes answer the main phrase without blurring attacks. + if grid%4 in [0,2]: + put(glints,segment,dest+BEAT*.75,.24,-.55) + put(glints,segment,dest+BEAT*1.5,.12,.55) + hit=fade(drums[round(at*SR):round(min(next_at,at+.23)*SR)],.002,.04) + put(rhythm,hit,dest,.50 if grid<8 or 32<=grid<40 else .70) + events.append({'source':round(at,4),'at':round(dest,4),'midi':note}) +# Soft chord-tone bells: an upper third answers the recorded line, panned gently. +scale=[48,50,52,53,55,57,59,60,62,64,65,67,69,71,72,74,76,77,79,81,83,84] +def bell(note,duration=1.25): + t=np.arange(round(duration*SR))/SR;f=440*2**((note-69)/12) + y=np.sin(2*np.pi*f*t)*np.exp(-t/.38)+.16*np.sin(2*np.pi*f*2*t)*np.exp(-t/.12) + return fade(np.repeat(y[:,None],2,axis=1),.008,.13) +for j,e in enumerate(events): + if j%4!=1:continue + lead=e['midi']+24 + degree=min(range(len(scale)),key=lambda i:abs(scale[i]-lead)) + put(glints,bell(scale[min(len(scale)-1,degree+2)]),e['at']+BEAT*.5,.033,(-1)**j*.5) +# A tender Cmaj9 landing, then let its tail breathe. +for k,note in enumerate([60,64,67,71,74]):put(glints,bell(note,2.8),27.9+k*.075,.025,(k-2)*.2) +# Warm low support, chosen from the actual phrase's pitch at each bar. +for bar in range(15): + at=bar*4*BEAT + candidates=[e for e in events if at<=e['at']<at+4*BEAT] + if not candidates:continue + root=candidates[0]['midi'];f=440*2**((root-69)/12) + for offset in [0,2.5]: + t=np.arange(round(.48*SR))/SR + bass=(np.sin(2*np.pi*f*t)+.2*np.sin(4*np.pi*f*t))*np.exp(-t/.20) + put(music,fade(np.repeat(bass[:,None],2,axis=1),.025,.1),at+offset*BEAT,.035) +# Quiet stereo room on the decorative layer, keeping the low end centered. +for delay,gain in [(.087,.10),(.143,.08),(.231,.06)]: + shift=round(delay*SR);glints[shift:]+=glints[:-shift,::-1]*gain +phase=(np.arange(N)/SR)%BEAT +music*= (1-.10*np.exp(-phase/.065))[:,None] +mix=fade(music+rhythm+glints,.025,1.25) +assert np.isfinite(mix).all() and np.max(abs(mix))>0 +sf.write(OUT/'premaster.wav',mix,SR,subtype='FLOAT') +def ff(*args): + return subprocess.run(['ffmpeg','-hide_banner','-nostats','-y',*map(str,args)],capture_output=True,text=True,check=True) +def measure(path): + r=ff('-i',path,'-af','loudnorm=I=-10:TP=-2:LRA=7:print_format=json','-f','null','-') + return json.loads(re.search(r'\{\s*"input_i"[\s\S]*?\}',r.stderr)[0]) +levels=measure(OUT/'premaster.wav') +# Static gain with oversampled peak limiting; never ride the loudness envelope. +gain=min(-10.5-float(levels['input_i']),-2.5-float(levels['input_tp'])+2) +master=OUT/'jade-bonfires-adoration.wav';mp3=OUT/'jade-bonfires-adoration.mp3' +ff('-i',OUT/'premaster.wav','-af',f'volume={gain}dB,aresample=176400,alimiter=limit=0.749894:attack=5:release=70:level=false,aresample=44100','-c:a','pcm_s24le',master) +ff('-i',master,'-c:a','libmp3lame','-b:a','256k','-id3v2_version','3','-metadata','title=Jade Bonfires — Adoration','-metadata','artist=Aesthetic Dot Computer',mp3) +report={'mp3':str(mp3),'seconds':30,'bpm':BPM,'source_beat':source_beat,'events':events,'master':measure(mp3),'voice':'Mic stem nearly silent; omitted rather than amplifying noise.'} +(OUT/'remix.json').write_text(json.dumps(report,indent=2)+'\n') +print(json.dumps({k:v for k,v in report.items() if k!='events'})) +# One player, one document, no loop. +script='''on run argv +if application "Music" is running then + tell application "Music" to pause +end if +tell application "QuickTime Player" + repeat with d in documents + try + pause d + end try + end repeat + set preview to open POSIX file (item 1 of argv) + set looping of preview to false + set current time of preview to 0 + set audio volume of preview to 0.35 + activate + play preview +end tell +end run''' +subprocess.run(['osascript','-e',script,str(mp3)],check=True) diff --git a/pop/jade-bonfires/bin/remix.py b/pop/jade-bonfires/bin/remix.py new file mode 100644 index 0000000000..6e74969777 --- /dev/null +++ b/pop/jade-bonfires/bin/remix.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""A 30-second affectionate Menu Band remix, aligned from the take's MIDI.""" +from pathlib import Path +import json, subprocess, re +import numpy as np +import soundfile as sf +from scipy.signal import butter, sosfilt +SR=44100; BPM=128; BEAT=60/BPM; N=30*SR +ROOT=Path.home()/'Documents/Shelf/Jade Bonfires 2026-10-07'; SRC=ROOT/'Original/jade-bonfires-oct-7-stems' +OUT=ROOT/'Remixes/jade-bonfires-adoration-full'; OUT.mkdir(exist_ok=True) +# Read the Menu Band type-0 MIDI, including running status and tempo changes. +b=(SRC/'notes.mid').read_bytes(); ppq=int.from_bytes(b[12:14],'big'); p=22; clock=0; status=0; tempo=500000; notes=[] +def var(): + global p + v=0 + while True: + x=b[p];p+=1;v=(v<<7)|(x&127) + if x<128:return v +while p<len(b): + clock+=var()*tempo/ppq/1e6 + if b[p]&128:status=b[p];p+=1 + if status==255: + typ=b[p];p+=1;size=var();data=b[p:p+size];p+=size + if typ==81:tempo=int.from_bytes(data,'big') + elif status>=240:size=var();p+=size + else: + kind=status&240;size=1 if kind in [192,208] else 2;data=b[p:p+size];p+=size + if kind==144 and data[1]>0:notes.append((clock,data[0])) +tones,rate=sf.read(SRC/'tones.wav',always_2d=True); drums,drate=sf.read(SRC/'percussion.wav',always_2d=True) +assert rate==drate==SR +# Clean subsonics and round the recorded percussion before arranging. +tones=sosfilt(butter(2,65,fs=SR,btype='highpass',output='sos'),tones,axis=0) +drums=sosfilt(butter(2,35,fs=SR,btype='highpass',output='sos'),drums,axis=0) +drums=.32*np.tanh(drums/.32) +music=np.zeros((N,2)); rhythm=np.zeros((N,2)); glints=np.zeros((N,2)) +def fade(x,a=.005,r=.045): + x=x.copy();na=min(len(x),round(a*SR));nr=min(len(x),round(r*SR)) + if na:x[:na]*=np.linspace(0,1,na)[:,None] + if nr:x[-nr:]*=np.linspace(1,0,nr)[:,None] + return x +def put(bus,x,at,gain=1,pan=0): + i=round(at*SR) + if i<0 or i>=N:return + if x.ndim==1:x=np.repeat(x[:,None],2,axis=1) + count=min(len(x),N-i) + bus[i:i+count]+=x[:count]*gain*np.array([1-max(pan,0)*.5,1+min(pan,0)*.5]) +# This is an existing composition: retain its elected melody, snap its attacks +# to eighths, and preserve each recorded note's pitch rather than speeding it up. +start=notes[3][0]; source_beat=float(np.median(np.diff([t for t,n in notes if 4<t<22]))) +events=[] +for idx,(at,note) in enumerate(notes): + if at<start or at>35.2:continue + grid=round((at-start)/source_beat*2)/2 + if grid>=62:continue + dest=grid*BEAT + next_at=notes[idx+1][0] if idx+1<len(notes) else at+.5 + stop=min(next_at+.035,at+.85) + segment=fade(tones[round(at*SR):round(stop*SR)],.003,.065) + put(music,segment,dest,1.9) + # Delicate dotted echoes answer the main phrase without blurring attacks. + if grid%4 in [0,2]: + put(glints,segment,dest+BEAT*.75,.24,-.55) + put(glints,segment,dest+BEAT*1.5,.12,.55) + events.append({'source':round(at,4),'at':round(dest,4),'midi':note}) +# Soft chord-tone bells: an upper third answers the recorded line, panned gently. +scale=[48,50,52,53,55,57,59,60,62,64,65,67,69,71,72,74,76,77,79,81,83,84] +def bell(note,duration=1.25): + t=np.arange(round(duration*SR))/SR;f=440*2**((note-69)/12) + y=np.sin(2*np.pi*f*t)*np.exp(-t/.38)+.16*np.sin(2*np.pi*f*2*t)*np.exp(-t/.12) + return fade(np.repeat(y[:,None],2,axis=1),.008,.13) +for j,e in enumerate(events): + if j%4!=1:continue + lead=e['midi']+24 + degree=min(range(len(scale)),key=lambda i:abs(scale[i]-lead)) + put(glints,bell(scale[min(len(scale)-1,degree+2)]),e['at']+BEAT*.5,.033,(-1)**j*.5) +# A tender Cmaj9 landing, then let its tail breathe. +for k,note in enumerate([60,64,67,71,74]):put(glints,bell(note,2.8),27.9+k*.075,.025,(k-2)*.2) +# Warm low support, chosen from the actual phrase's pitch at each bar. +for bar in range(15): + at=bar*4*BEAT + candidates=[e for e in events if at<=e['at']<at+4*BEAT] + if not candidates:continue + root=candidates[0]['midi'];f=440*2**((root-69)/12) + for offset in [0,2.5]: + t=np.arange(round(.48*SR))/SR + bass=(np.sin(2*np.pi*f*t)+.2*np.sin(4*np.pi*f*t))*np.exp(-t/.20) + put(music,fade(np.repeat(bass[:,None],2,axis=1),.025,.1),at+offset*BEAT,.035) +# Fill the spaces with voice-led chords and a quiet, regular answering line. +# Each chord supports the take's actual bass note; no new random harmony. +chords={0:[48,55,59,64],2:[50,57,60,65],4:[48,55,59,64], + 5:[53,57,60,64],7:[55,59,62,65],9:[45,52,55,60],11:[55,59,62,65]} +for bar in range(16): + at=bar*4*BEAT + candidates=[e for e in events if at<=e['at']<at+4*BEAT] + root=candidates[0]['midi'] if candidates else 36 + chord=chords.get(root%12,chords[0]) if bar<15 else chords[0] + duration=4*BEAT+.32;t=np.arange(round(duration*SR))/SR + pad=np.zeros((len(t),2)) + for j,note in enumerate(chord): + f=440*2**((note-69)/12) + core=np.sin(2*np.pi*f*t)+.14*np.sin(4*np.pi*f*t) + pad[:,0]+=core*.010+np.sin(2*np.pi*f*1.001*t)*.002 + pad[:,1]+=core*.010+np.sin(2*np.pi*f*.999*t)*.002 + put(music,fade(pad,.10,.35),at) + for j,offset in enumerate([.5,1.5,2.5,3.5]): + if at+offset*BEAT>29:continue + note=chord[[1,2,3,2][j]]+12 + put(glints,bell(note,.9),at+offset*BEAT,.026 if bar<8 else .034,(-1)**j*.35) +# Rebuild a straightforward pocket from the recorded kick/backbeat timbres. +# Fixed hat samples on straight eighths replace accidental chopped accents. +kick=fade(sosfilt(butter(2,1800,fs=SR,output='sos'),drums[:round(.18*SR)],axis=0),.003,.045) +snare=fade(sosfilt(butter(2,180,fs=SR,btype='highpass',output='sos'),drums[round(.48*SR):round(.68*SR)],axis=0),.003,.045) +def hit_level(x,peak):return x*peak/max(float(np.max(abs(x))),1e-8) +kick=hit_level(kick,.19);snare=hit_level(snare,.10) +rng=np.random.default_rng(17);t=np.arange(round(.055*SR))/SR +hat=sosfilt(butter(2,[6000,12000],fs=SR,btype='bandpass',output='sos'),rng.normal(size=len(t)))*np.exp(-t/.011) +hat=hit_level(fade(np.repeat(hat[:,None],2,axis=1),.002,.012),.021) +for bar in range(16): + at=bar*4*BEAT + for k in range(4): + if bar==15 and k>1:continue + if k in [0,2]:put(rhythm,kick,at+k*BEAT,.9) + if k in [1,3]:put(rhythm,snare,at+k*BEAT,.9) + put(rhythm,hat,at+k*BEAT,.8,.15) + put(rhythm,hat,at+(k+.5)*BEAT,1,.15) + +# Quiet stereo room on the decorative layer, keeping the low end centered. +for delay,gain in [(.087,.10),(.143,.08),(.231,.06)]: + shift=round(delay*SR);glints[shift:]+=glints[:-shift,::-1]*gain +phase=(np.arange(N)/SR)%BEAT +music*= (1-.10*np.exp(-phase/.065))[:,None] +mix=fade(music+rhythm+glints,.025,1.25) +assert np.isfinite(mix).all() and np.max(abs(mix))>0 +sf.write(OUT/'premaster.wav',mix,SR,subtype='FLOAT') +def ff(*args): + return subprocess.run(['ffmpeg','-hide_banner','-nostats','-y',*map(str,args)],capture_output=True,text=True,check=True) +def measure(path): + r=ff('-i',path,'-af','loudnorm=I=-10:TP=-2:LRA=7:print_format=json','-f','null','-') + return json.loads(re.search(r'\{\s*"input_i"[\s\S]*?\}',r.stderr)[0]) +levels=measure(OUT/'premaster.wav') +# Static gain with oversampled peak limiting; never ride the loudness envelope. +gain=min(-10.5-float(levels['input_i']),-2.5-float(levels['input_tp'])+2) +master=OUT/'jade-bonfires-adoration-full.wav';mp3=OUT/'jade-bonfires-adoration-full.mp3' +ff('-i',OUT/'premaster.wav','-af',f'volume={gain}dB,aresample=176400,alimiter=limit=0.749894:attack=5:release=70:level=false,aresample=44100','-c:a','pcm_s24le',master) +ff('-i',master,'-c:a','libmp3lame','-b:a','256k','-id3v2_version','3','-map_metadata','-1',mp3) +report={'mp3':str(mp3),'seconds':30,'bpm':BPM,'source_beat':source_beat,'events':events,'master':measure(mp3),'voice':'Mic stem nearly silent; omitted rather than amplifying noise.'} +(OUT/'remix.json').write_text(json.dumps(report,indent=2)+'\n') +print(json.dumps({k:v for k,v in report.items() if k!='events'})) +# One player, one document, no loop. +script='''on run argv +if application "Music" is running then + tell application "Music" to pause +end if +tell application "QuickTime Player" + repeat with d in documents + try + pause d + end try + end repeat + set preview to open POSIX file (item 1 of argv) + if preview is missing value then + delay 0.5 + set preview to open POSIX file (item 1 of argv) + end if + if preview is missing value then error "QuickTime could not open the preview" + set looping of preview to false + set current time of preview to 0 + set audio volume of preview to 0.35 + activate + play preview +end tell +end run''' +subprocess.run(['osascript','-e',script,str(mp3)],check=True) -- 2.51.2