You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
269 lines
11 KiB
269 lines
11 KiB
import AudioMatrix
|
|
import DesignSystem
|
|
import MusubiCore
|
|
import SwiftUI
|
|
|
|
/// Channel strips of the selected output device; drop an NDI source or a microphone onto a strip.
|
|
struct SoundView: View {
|
|
let audio: AudioController
|
|
let sourceList: SourceListModel
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: Theme.Space.card) {
|
|
header
|
|
if let device = audio.selectedDevice {
|
|
if let error = audio.matrix.errors[device.uid] {
|
|
Text(error).font(Theme.Font.body).foregroundStyle(Theme.Color.kohaku)
|
|
}
|
|
if audio.matrix.routes.isEmpty {
|
|
Text("Noch kein Ton geroutet. Zieh eine Quelle oder ein Mikrofon auf einen Kanal.")
|
|
.font(Theme.Font.body)
|
|
.foregroundStyle(Theme.Color.nezumi)
|
|
}
|
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 300), spacing: Theme.Space.card)], spacing: Theme.Space.card) {
|
|
ForEach(0..<device.outputChannels, id: \.self) { channel in
|
|
ChannelStrip(audio: audio, sourceList: sourceList, device: device, channel: channel)
|
|
}
|
|
}
|
|
} else {
|
|
Text("Kein Ausgabegerät gefunden. Schließ das Audio-Interface an.")
|
|
.font(Theme.Font.body)
|
|
.foregroundStyle(Theme.Color.nezumi)
|
|
}
|
|
}
|
|
.padding(Theme.Space.card)
|
|
.padding(.bottom, 120)
|
|
}
|
|
.background(Theme.Color.sumi)
|
|
.overlay(alignment: .bottomTrailing) {
|
|
TalkButton(talkKey: audio.talkKey).padding(Theme.Space.card)
|
|
}
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack {
|
|
Text("Ton").font(Theme.Font.title).foregroundStyle(Theme.Color.washi)
|
|
Spacer()
|
|
Picker("Ausgabegerät", selection: Binding(
|
|
get: { audio.selectedDevice?.uid ?? "" },
|
|
set: { audio.selectedDeviceUID = $0 }
|
|
)) {
|
|
ForEach(audio.matrix.outputDevices) { device in
|
|
Text(verbatim: "\(device.name) · \(device.outputChannels) Kanäle").tag(device.uid)
|
|
}
|
|
}
|
|
.fixedSize()
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ChannelStrip: View {
|
|
let audio: AudioController
|
|
let sourceList: SourceListModel
|
|
let device: AudioDevice
|
|
let channel: Int
|
|
@State private var targeted = false
|
|
|
|
private var routes: [AudioMatrix.Route] {
|
|
audio.matrix.routes.filter { $0.deviceUID == device.uid && $0.channel == channel }
|
|
}
|
|
|
|
private var addMenu: some View {
|
|
Menu {
|
|
Section("NDI") {
|
|
ForEach(sourceList.sources, id: \.ndiName) { source in
|
|
Button(source.label) { audio.add(.ndi(name: source.ndiName), channel: channel) }
|
|
}
|
|
}
|
|
Section("Mikrofone") {
|
|
ForEach(audio.matrix.inputDevices) { input in
|
|
Button(input.name) { audio.add(.input(deviceUID: input.uid), channel: channel) }
|
|
}
|
|
}
|
|
} label: {
|
|
Image(systemName: "plus")
|
|
}
|
|
.menuStyle(.borderlessButton)
|
|
.menuIndicator(.hidden)
|
|
.fixedSize()
|
|
.help("Quelle hinzufügen")
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: Theme.Space.unit) {
|
|
HStack {
|
|
Text(verbatim: "Out \(channel + 1)").font(Theme.Font.title).foregroundStyle(Theme.Color.washi)
|
|
Spacer()
|
|
LevelText(db: audio.channelMeters["\(device.uid)#\(channel)"] ?? Level.floorDB)
|
|
addMenu
|
|
}
|
|
LevelMeter(db: audio.channelMeters["\(device.uid)#\(channel)"] ?? Level.floorDB)
|
|
|
|
Divider().overlay(Theme.Color.usuzumi)
|
|
|
|
if routes.isEmpty {
|
|
Text("Quelle oder Mikrofon hierher ziehen")
|
|
.font(Theme.Font.body)
|
|
.foregroundStyle(Theme.Color.nezumi)
|
|
}
|
|
ForEach(routes) { route in
|
|
RouteRow(audio: audio, route: route)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 140, alignment: .topLeading)
|
|
.padding(Theme.Space.card)
|
|
.background(Theme.Color.kasumi, in: RoundedRectangle(cornerRadius: Theme.Radius.card))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Theme.Radius.card)
|
|
.strokeBorder(targeted || !routes.isEmpty ? Theme.Color.ai : Theme.Color.usuzumi,
|
|
lineWidth: routes.isEmpty && !targeted ? Theme.Stroke.line : Theme.Stroke.cord)
|
|
)
|
|
.dropDestination(for: String.self) { items, _ in
|
|
guard let item = items.first else { return false }
|
|
if item.hasPrefix("input:") {
|
|
audio.add(.input(deviceUID: String(item.dropFirst("input:".count))), channel: channel)
|
|
} else {
|
|
audio.add(.ndi(name: item), channel: channel)
|
|
}
|
|
return true
|
|
} isTargeted: { targeted = $0 }
|
|
}
|
|
}
|
|
|
|
private struct RouteRow: View {
|
|
let audio: AudioController
|
|
let route: AudioMatrix.Route
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: Theme.Space.unit / 2) {
|
|
HStack(spacing: Theme.Space.unit) {
|
|
if case .input = route.source {
|
|
Image(systemName: "mic").foregroundStyle(Theme.Color.nezumi)
|
|
}
|
|
Text(verbatim: audio.label(for: route.source))
|
|
.font(Theme.Font.body)
|
|
.foregroundStyle(Theme.Color.washi)
|
|
.lineLimit(1)
|
|
Spacer()
|
|
Picker("Kanal", selection: Binding(get: { route.selection }, set: { audio.setSelection(route.id, $0) })) {
|
|
Text("L").tag(ChannelSelection.left)
|
|
Text("R").tag(ChannelSelection.right)
|
|
Text("L+R").tag(ChannelSelection.mono)
|
|
}
|
|
.labelsHidden()
|
|
.pickerStyle(.segmented)
|
|
.fixedSize()
|
|
Button {
|
|
audio.setMuted(route.id, !route.muted)
|
|
} label: {
|
|
Text("M").font(Theme.Font.caption.bold())
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.tint(route.muted ? Theme.Color.kohaku : nil)
|
|
.help(route.muted ? "Stummschaltung aufheben" : "Stummschalten")
|
|
Button {
|
|
audio.remove(route.id)
|
|
} label: {
|
|
Image(systemName: "xmark")
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.help("Route entfernen")
|
|
}
|
|
HStack(spacing: Theme.Space.unit) {
|
|
Slider(value: Binding(get: { Double(route.gainDB) }, set: { audio.setGain(route.id, db: Float($0)) }),
|
|
in: -60...12)
|
|
.controlSize(.small)
|
|
Text(verbatim: route.gainDB <= Level.floorDB ? "−∞ dB" : String(format: "%+.1f dB", route.gainDB))
|
|
.font(Theme.Font.number)
|
|
.foregroundStyle(route.gainDB == 0 ? Theme.Color.nezumi : Theme.Color.washi)
|
|
.frame(width: 70, alignment: .trailing)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture(count: 2) { audio.setGain(route.id, db: 0) }
|
|
.help("Doppelklick: Pegel auf 0 dB")
|
|
}
|
|
LevelMeter(db: audio.meters[route.id] ?? Level.floorDB)
|
|
if let count = audio.dropouts[route.id], count > 0 {
|
|
Text("\(count) Aussetzer").font(Theme.Font.caption).foregroundStyle(Theme.Color.kohaku)
|
|
}
|
|
}
|
|
.padding(.vertical, Theme.Space.unit / 2)
|
|
.contextMenu {
|
|
Button("Pegel auf 0 dB") { audio.setGain(route.id, db: 0) }
|
|
Button(route.muted ? "Stummschaltung aufheben" : "Stummschalten") { audio.setMuted(route.id, !route.muted) }
|
|
Divider()
|
|
Button("Route entfernen", role: .destructive) { audio.remove(route.id) }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Horizontal peak meter; amber close to 0 dBFS.
|
|
private struct LevelMeter: View {
|
|
let db: Float
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
let fraction = CGFloat((max(db, Level.floorDB) - Level.floorDB) / -Level.floorDB)
|
|
ZStack(alignment: .leading) {
|
|
Capsule().fill(Theme.Color.usuzumi)
|
|
Capsule()
|
|
.fill(db > -3 ? Theme.Color.kohaku : Theme.Color.washi.opacity(0.7))
|
|
.frame(width: geometry.size.width * fraction)
|
|
}
|
|
}
|
|
.frame(height: 4)
|
|
}
|
|
}
|
|
|
|
private struct LevelText: View {
|
|
let db: Float
|
|
|
|
var body: some View {
|
|
Text(verbatim: db <= Level.floorDB ? "−∞" : String(format: "%.0f dBFS", db))
|
|
.font(Theme.Font.number)
|
|
.foregroundStyle(db > -3 ? Theme.Color.kohaku : Theme.Color.nezumi)
|
|
}
|
|
}
|
|
|
|
/// Large push-to-talk button; red only while talking.
|
|
private struct TalkButton: View {
|
|
let talkKey: TalkKey
|
|
@State private var pressing = false
|
|
|
|
var body: some View {
|
|
VStack(alignment: .trailing, spacing: Theme.Space.unit) {
|
|
VStack(spacing: Theme.Space.unit / 2) {
|
|
Image(systemName: talkKey.isActive ? "mic.fill" : "mic")
|
|
.font(.system(size: 28, weight: .semibold))
|
|
Text("Sprechen").font(Theme.Font.title)
|
|
Text(verbatim: talkKey.displayName).font(Theme.Font.caption)
|
|
}
|
|
.foregroundStyle(Theme.Color.washi)
|
|
.frame(width: 160, height: 120)
|
|
.background(talkKey.isActive ? Theme.Color.shu : Theme.Color.kasumi, in: RoundedRectangle(cornerRadius: Theme.Radius.card))
|
|
.overlay(RoundedRectangle(cornerRadius: Theme.Radius.card).strokeBorder(Theme.Color.usuzumi, lineWidth: Theme.Stroke.line))
|
|
.contentShape(RoundedRectangle(cornerRadius: Theme.Radius.card))
|
|
.gesture(
|
|
DragGesture(minimumDistance: 0)
|
|
.onChanged { _ in
|
|
guard !pressing else { return }
|
|
pressing = true
|
|
talkKey.press()
|
|
}
|
|
.onEnded { _ in
|
|
pressing = false
|
|
talkKey.release()
|
|
}
|
|
)
|
|
|
|
Picker("Sprechtaste", selection: Binding(get: { talkKey.mode }, set: { talkKey.mode = $0 })) {
|
|
Text("Gedrückt halten").tag(TalkKey.Mode.hold)
|
|
Text("Umschalten").tag(TalkKey.Mode.toggle)
|
|
}
|
|
.labelsHidden()
|
|
.pickerStyle(.segmented)
|
|
.fixedSize()
|
|
}
|
|
}
|
|
}
|