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.
Musubi/App/Sources/SoundView.swift

268 lines
11 KiB

import AudioMatrix
import DesignSystem
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.matrix.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.matrix.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.matrix.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.matrix.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.matrix.setGain(route.id, db: 0) }
Button(route.muted ? "Stummschaltung aufheben" : "Stummschalten") { audio.matrix.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()
}
}
}

Powered by TurnKey Linux.