From 0c5c8dc37c0c2344d379aebbd1154b78acd7d489 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 18:27:08 +0000 Subject: [PATCH] Add six-channel mixer board face to Porta424 app Recreates the TASCAM Portastudio 424 mkIII front-panel mixer using the existing retro controls and warm cream theme instead of reinventing them. - MixerState: per-channel state (trim, 3-band EQ, FX sends, pan, fader, MIC/LINE source, record-arm), wired into TapeDeckViewModel - MixerBoardView: full board built from RetroKnob / RetroFader / RetroTransportButton, reusing CassetteView, TapeCounterView, VUMeterView, and TransportBar; master fader bound to the live DSP master volume - Reachable via a MIXER button in the tape-deck header and the iPad utility drawer, presented as a full-screen cover with a DECK return Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01T9m6a6N9BTYjq448t2XAza --- App/Sources/Models/MixerState.swift | 39 ++ .../ViewModels/TapeDeckViewModel.swift | 20 + App/Sources/Views/MixerBoardView.swift | 483 ++++++++++++++++++ App/Sources/Views/SideDrawerView.swift | 32 +- App/Sources/Views/TapeDeckView.swift | 38 +- App/Sources/Views/iPadTapeDeckView.swift | 9 +- README.md | 7 +- 7 files changed, 616 insertions(+), 12 deletions(-) create mode 100644 App/Sources/Models/MixerState.swift create mode 100644 App/Sources/Views/MixerBoardView.swift diff --git a/App/Sources/Models/MixerState.swift b/App/Sources/Models/MixerState.swift new file mode 100644 index 0000000..479fc64 --- /dev/null +++ b/App/Sources/Models/MixerState.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Per-channel mixer state for the Porta424 six-channel board. +/// +/// All continuous values are normalized `0...1`. `pan` follows the hardware +/// convention where `0` is hard-left, `0.5` is center, and `1` is hard-right. +/// EQ knobs are centered at `0.5` (flat); their detent snaps back to flat. +struct ChannelState: Identifiable, Equatable, Sendable { + /// Channel number as printed on the chassis (1...6). + let id: Int + + var trim: Double = 0.6 + var eqHigh: Double = 0.5 + var eqMid: Double = 0.5 + var eqLow: Double = 0.5 + var fx1Send: Double = 0.0 + var fx2Send: Double = 0.0 + var pan: Double = 0.5 + var level: Double = 0.65 // channel fader + var source: ChannelSource = .line + var isArmed: Bool = false // record-armed to the assigned tape track +} + +/// Input source selection for a mixer channel. +enum ChannelSource: String, Equatable, Sendable { + case mic = "MIC" + case line = "LINE" + + mutating func toggle() { + self = (self == .mic) ? .line : .mic + } +} + +extension Array where Element == ChannelState { + /// The standard six-channel Porta424 board, numbered 1...6. + static var defaultBoard: [ChannelState] { + (1...6).map { ChannelState(id: $0) } + } +} diff --git a/App/Sources/ViewModels/TapeDeckViewModel.swift b/App/Sources/ViewModels/TapeDeckViewModel.swift index d21f365..16a6f4e 100644 --- a/App/Sources/ViewModels/TapeDeckViewModel.swift +++ b/App/Sources/ViewModels/TapeDeckViewModel.swift @@ -18,6 +18,10 @@ final class TapeDeckViewModel { var tapePosition: Double = 0 // 0...1 var counterSeconds: Double = 0 + /// Six-channel mixer board state. UI-side mixer parameters (trim, EQ, pan, + /// channel faders) that mirror the hardware front panel. + var channels: [ChannelState] = .defaultBoard + var factoryPresets: [PresetItem] = [] var userPresets: [PresetItem] = [] var activePresetId: String? @@ -127,6 +131,22 @@ final class TapeDeckViewModel { tapePosition = 0 } + // MARK: - Mixer Controls + + /// Toggle the record-arm state of a mixer channel by its index (0-based). + func toggleArm(channelIndex: Int) { + guard channels.indices.contains(channelIndex) else { return } + channels[channelIndex].isArmed.toggle() + HapticEngine.buttonPress() + } + + /// Toggle a channel's input source between MIC and LINE. + func toggleSource(channelIndex: Int) { + guard channels.indices.contains(channelIndex) else { return } + channels[channelIndex].source.toggle() + HapticEngine.buttonPress() + } + // MARK: - Counter String var counterString: String { diff --git a/App/Sources/Views/MixerBoardView.swift b/App/Sources/Views/MixerBoardView.swift new file mode 100644 index 0000000..4828628 --- /dev/null +++ b/App/Sources/Views/MixerBoardView.swift @@ -0,0 +1,483 @@ +import SwiftUI + +/// The full six-channel mixer board face of the Porta424 — a faithful recreation +/// of the TASCAM Portastudio 424 mkIII front panel, built from the same retro +/// controls (`RetroKnob`, `RetroFader`, `RetroTransportButton`) and warm cream +/// theme used across the rest of the app. +/// +/// Layout (landscape — primary): +/// ``` +/// ┌──────────────────────────────────────────────────────────────┐ +/// │ TITLE [Cassette] [Tape Counter] VU-L VU-R ✕ │ +/// ├──────────────────────────────────────────────────────────────┤ +/// │ CH1 CH2 CH3 CH4 CH5 CH6 ║ MASTER │ +/// │ trim trim ... ║ │ +/// │ EQ EQ ║ fader │ +/// │ fx fx ║ │ +/// │ pan pan ║ │ +/// │ fader fader ║ │ +/// ├──────────────────────────────────────────────────────────────┤ +/// │ [transport] PITCH POWER │ +/// └──────────────────────────────────────────────────────────────┘ +/// ``` +/// +/// On compact widths the channel strips scroll horizontally so the board +/// remains usable on iPhone. +struct MixerBoardView: View { + @Environment(TapeDeckViewModel.self) private var environmentModel + + /// Dismiss handler so the host can flip back to the tape-deck face. + var onClose: () -> Void = {} + + var body: some View { + @Bindable var viewModel = environmentModel + + ZStack { + chassisBackground + + VStack(spacing: 10) { + header(viewModel: viewModel) + + Divider() + .overlay(Porta.bezel.opacity(0.5)) + .padding(.horizontal, 16) + + channelRow(viewModel: viewModel) + + Divider() + .overlay(Porta.bezel.opacity(0.5)) + .padding(.horizontal, 16) + + bottomBar(viewModel: viewModel) + } + .padding(.vertical, 14) + } + .ignoresSafeArea(edges: .all) + .statusBarHidden(true) + .persistentSystemOverlays(.hidden) + .onChange(of: viewModel.dsp) { _, _ in + viewModel.syncDSP() + } + } + + // MARK: - Header + + private func header(viewModel: TapeDeckViewModel) -> some View { + HStack(alignment: .center, spacing: 16) { + // Title block + VStack(alignment: .leading, spacing: 2) { + Text("PORTASTUDIO 424") + .font(Porta.titleFont) + .foregroundStyle(Porta.label) + HStack(spacing: 6) { + Text("mkIII") + .font(.system(size: 11, weight: .heavy, design: .rounded)) + .foregroundStyle(Porta.saturationOrange) + Text("· 6-CHANNEL MIXER") + .font(Porta.subtitleFont) + .tracking(2) + .foregroundStyle(Porta.labelLight) + } + } + + Spacer(minLength: 8) + + // Cassette window + CassetteView( + transportMode: viewModel.transportMode, + tapePosition: viewModel.tapePosition + ) + .frame(width: 150, height: 86) + + // Tape counter + TapeCounterView( + counterText: viewModel.counterString, + isRunning: viewModel.isTransportActive, + onReset: { viewModel.resetCounter() } + ) + + Spacer(minLength: 8) + + // Stereo VU meters + HStack(spacing: 6) { + VUMeterView(value: viewModel.meterL, channel: "L") + .frame(width: 78, height: 58) + VUMeterView(value: viewModel.meterR, channel: "R") + .frame(width: 78, height: 58) + } + + // Return to the tape-deck face + Button { + HapticEngine.buttonPress() + onClose() + } label: { + VStack(spacing: 3) { + Image(systemName: "cassette") + .font(.system(size: 16, weight: .bold)) + Text("DECK") + .font(.system(size: 8, weight: .heavy, design: .rounded)) + .tracking(1) + } + .foregroundStyle(Porta.label) + .frame(width: 46, height: 46) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Porta.chassis) + .shadow(color: Porta.softShadow, radius: 2, x: 0, y: 1) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Porta.bezel.opacity(0.5), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 18) + } + + // MARK: - Channel Row + + private func channelRow(viewModel: TapeDeckViewModel) -> some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 6) { + ForEach(viewModel.channels.indices, id: \.self) { index in + MixerChannelStrip(viewModel: viewModel, index: index) + } + + // Vertical bezel separating channels from master + Rectangle() + .fill(Porta.bezel.opacity(0.4)) + .frame(width: 1.5) + .padding(.vertical, 6) + + MixerMasterSection(viewModel: viewModel) + } + .padding(.horizontal, 16) + } + } + + // MARK: - Bottom Bar + + private func bottomBar(viewModel: TapeDeckViewModel) -> some View { + HStack(alignment: .center, spacing: 18) { + TransportBar(viewModel: viewModel) + + Spacer(minLength: 8) + + // Pitch control + RetroKnob( + value: Bindable(viewModel).dsp.bandwidth, + title: "PITCH", + accentColor: Porta.flutterBlue, + size: 46, + detents: [0.5] + ) + + // Power / reset + Button { + HapticEngine.transportTap() + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + viewModel.stop() + viewModel.resetCounter() + } + } label: { + Text("POWER") + .font(.system(size: 11, weight: .heavy, design: .rounded)) + .tracking(1) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Porta.transportRed) + .shadow(color: Porta.deepShadow, radius: 2, x: 0, y: 2) + ) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 18) + } + + // MARK: - Chassis + + private var chassisBackground: some View { + ZStack { + Porta.chassis + .ignoresSafeArea() + + Canvas { context, size in + for _ in 0..<300 { + let x = Double.random(in: 0.. Void + + var body: some View { + Button(action: action) { + HStack(spacing: 0) { + segment(title: "MIC", isOn: source == .mic) + segment(title: "LINE", isOn: source == .line) + } + .frame(height: 16) + .background( + RoundedRectangle(cornerRadius: 3) + .fill(Porta.well.opacity(0.6)) + ) + .overlay( + RoundedRectangle(cornerRadius: 3) + .strokeBorder(Porta.bezel.opacity(0.4), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + } + + private func segment(title: String, isOn: Bool) -> some View { + Text(title) + .font(.system(size: 7, weight: .heavy, design: .rounded)) + .foregroundStyle(isOn ? .white : Porta.labelLight) + .frame(maxWidth: .infinity) + .frame(height: 16) + .background( + RoundedRectangle(cornerRadius: 2) + .fill(isOn ? Porta.transportBlue : Color.clear) + .padding(1) + ) + } +} + +/// Per-channel record-arm button. Glows red and breathes while armed. +private struct ChannelArmButton: View { + let isArmed: Bool + let action: () -> Void + + @State private var pulse = false + + var body: some View { + Button(action: action) { + HStack(spacing: 4) { + Circle() + .fill(isArmed ? Porta.transportRed : Porta.meterOff) + .frame(width: 8, height: 8) + .shadow(color: isArmed ? Porta.transportRed.opacity(0.7) : .clear, + radius: isArmed ? 3 : 0) + Text("REC") + .font(.system(size: 8, weight: .heavy, design: .rounded)) + .foregroundStyle(isArmed ? Porta.transportRed : Porta.labelLight) + } + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(isArmed ? Porta.transportRed.opacity(0.12) : Porta.chassis) + ) + .overlay( + RoundedRectangle(cornerRadius: 4) + .strokeBorder( + isArmed ? Porta.transportRed.opacity(0.7) : Porta.bezel.opacity(0.4), + lineWidth: isArmed ? 1 : 0.5 + ) + ) + .scaleEffect(isArmed && pulse ? 1.06 : 1.0) + } + .buttonStyle(.plain) + .onChange(of: isArmed) { _, armed in + pulse = armed + } + .animation( + isArmed ? .easeInOut(duration: 0.6).repeatForever(autoreverses: true) : .default, + value: pulse + ) + } +} + +#if DEBUG +#Preview("Mixer Board") { + MixerBoardView() + .environment(TapeDeckViewModel()) +} +#endif diff --git a/App/Sources/Views/SideDrawerView.swift b/App/Sources/Views/SideDrawerView.swift index 031cb7a..bdde861 100644 --- a/App/Sources/Views/SideDrawerView.swift +++ b/App/Sources/Views/SideDrawerView.swift @@ -6,6 +6,9 @@ import SwiftUI struct SideDrawerView: View { @Bindable var viewModel: TapeDeckViewModel + /// Invoked when the user taps the mixer-board entry in the header. + var onOpenMixer: () -> Void = {} + @State private var selectedTab: DrawerTab = .effects var body: some View { @@ -73,9 +76,32 @@ struct SideDrawerView: View { Spacer() - Image(systemName: "slider.horizontal.3") - .font(.system(size: 14)) - .foregroundStyle(Porta.label.opacity(0.4)) + // Open the full six-channel mixer board + Button { + HapticEngine.buttonPress() + onOpenMixer() + } label: { + HStack(spacing: 4) { + Image(systemName: "slider.vertical.3") + .font(.system(size: 13, weight: .bold)) + Text("MIXER") + .font(.system(size: 9, weight: .heavy, design: .rounded)) + .tracking(1) + } + .foregroundStyle(Porta.label) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 5) + .fill(Porta.chassis) + .shadow(color: Porta.softShadow, radius: 1, x: 0, y: 1) + ) + .overlay( + RoundedRectangle(cornerRadius: 5) + .strokeBorder(Porta.bezel.opacity(0.5), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) } .padding(.horizontal, 14) .padding(.top, 14) diff --git a/App/Sources/Views/TapeDeckView.swift b/App/Sources/Views/TapeDeckView.swift index a3ffd8e..5071fcc 100644 --- a/App/Sources/Views/TapeDeckView.swift +++ b/App/Sources/Views/TapeDeckView.swift @@ -8,6 +8,8 @@ import SwiftUI struct TapeDeckView: View { @Environment(TapeDeckViewModel.self) var viewModel + @State private var showMixer = false + var body: some View { GeometryReader { geo in let isCompact = geo.size.width < 700 @@ -29,6 +31,10 @@ struct TapeDeckView: View { } .statusBarHidden(true) .persistentSystemOverlays(.hidden) + .fullScreenCover(isPresented: $showMixer) { + MixerBoardView(onClose: { showMixer = false }) + .environment(viewModel) + } } // MARK: - Landscape Layout (Primary - matches concept art) @@ -204,16 +210,38 @@ struct TapeDeckView: View { Spacer() - // Cassette icon + settings + // Cassette icon + mixer board entry HStack(spacing: 10) { Image(systemName: "cassette.fill") .font(.system(size: 16)) .foregroundStyle(Porta.label.opacity(0.5)) - // Settings placeholder - Image(systemName: "slider.horizontal.3") - .font(.system(size: 14)) - .foregroundStyle(Porta.label.opacity(0.4)) + // Open the six-channel mixer board face + Button { + HapticEngine.buttonPress() + showMixer = true + } label: { + HStack(spacing: 4) { + Image(systemName: "slider.vertical.3") + .font(.system(size: 13, weight: .bold)) + Text("MIXER") + .font(.system(size: 9, weight: .heavy, design: .rounded)) + .tracking(1) + } + .foregroundStyle(Porta.label) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 5) + .fill(Porta.chassis) + .shadow(color: Porta.softShadow, radius: 1, x: 0, y: 1) + ) + .overlay( + RoundedRectangle(cornerRadius: 5) + .strokeBorder(Porta.bezel.opacity(0.5), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) } } } diff --git a/App/Sources/Views/iPadTapeDeckView.swift b/App/Sources/Views/iPadTapeDeckView.swift index 611b697..9197f9e 100644 --- a/App/Sources/Views/iPadTapeDeckView.swift +++ b/App/Sources/Views/iPadTapeDeckView.swift @@ -20,6 +20,7 @@ struct iPadTapeDeckView: View { @State private var isDrawerOpen = true @State private var drawerDragOffset: CGFloat = 0 + @State private var showMixer = false var body: some View { GeometryReader { geo in @@ -51,6 +52,10 @@ struct iPadTapeDeckView: View { } .statusBarHidden(true) .persistentSystemOverlays(.hidden) + .fullScreenCover(isPresented: $showMixer) { + MixerBoardView(onClose: { showMixer = false }) + .environment(viewModel) + } } // MARK: - Landscape Layout (Primary) @@ -58,7 +63,7 @@ struct iPadTapeDeckView: View { private func landscapeLayout(geo: GeometryProxy, drawerWidth: CGFloat) -> some View { HStack(spacing: 0) { // Left: Side drawer (1/3) - SideDrawerView(viewModel: viewModel) + SideDrawerView(viewModel: viewModel, onOpenMixer: { showMixer = true }) .frame(width: drawerWidth) // Divider rail @@ -93,7 +98,7 @@ struct iPadTapeDeckView: View { // Drawer panel HStack(spacing: 0) { - SideDrawerView(viewModel: viewModel) + SideDrawerView(viewModel: viewModel, onOpenMixer: { showMixer = true }) .frame(width: drawerWidth) .background(Porta.chassis) .shadow(color: .black.opacity(0.3), radius: 12, x: 4, y: 0) diff --git a/README.md b/README.md index 230e484..2109ed4 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,9 @@ guard loaded.isCompatible() else { /* handle version mismatch */ } The repository includes a full-featured tape deck application built with SwiftUI: - **Retro cassette UI** with animated reels and realistic tape deck controls +- **Six-channel mixer board** -- a faithful Portastudio 424 mkIII front panel with + per-channel trim, 3-band EQ, FX sends, pan, record-arm, and faders. Reachable from + the **MIXER** button in the header (or the iPad utility drawer) - **4-track mixing** with per-channel VU meters - **Real-time parameter control** via the `@Observable` TapeDeckViewModel - **Haptic feedback** on transport controls (iOS) @@ -310,9 +313,9 @@ Or in Xcode: select the **PortaDSPKit** scheme and press **Cmd+U**. Porta424/ ├── App/ Porta424 tape deck app (SwiftUI) │ └── Sources/ -│ ├── Models/ DSPState, data models +│ ├── Models/ DSPState, MixerState, data models │ ├── ViewModels/ TapeDeckViewModel -│ ├── Views/ TapeDeckView, CassetteView, VUMeterView +│ ├── Views/ TapeDeckView, MixerBoardView, CassetteView, VUMeterView │ ├── Controls/ Custom UI controls │ ├── Theme/ Visual styling │ ├── Haptics/ Haptic feedback engine