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

262 lines
11 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: Theme.Space.card) {
header
MicrophoneChips(audio: audio)
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, 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 MicrophoneChips: View {
let audio: AudioController
var body: some View {
VStack(alignment: .leading, spacing: Theme.Space.unit) {
Text("Mikrofone – über die Sprechtaste")
.font(Theme.Font.caption)
.foregroundStyle(Theme.Color.nezumi)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Theme.Space.unit) {
ForEach(audio.matrix.inputDevices) { device in
Text(verbatim: device.name)
.font(Theme.Font.caption)
.foregroundStyle(Theme.Color.washi)
.padding(.horizontal, Theme.Space.unit)
.padding(.vertical, Theme.Space.unit / 2)
.background(Theme.Color.kasumi, in: RoundedRectangle(cornerRadius: Theme.Radius.chip))
.overlay(RoundedRectangle(cornerRadius: Theme.Radius.chip).strokeBorder(Theme.Color.usuzumi))
.draggable("input:\(device.uid)")
}
}
}
}
}
}
private struct ChannelStrip: View {
let audio: AudioController
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 }
}
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)
}
LevelMeter(db: audio.channelMeters["\(device.uid)#\(channel)"] ?? Level.floorDB)
Divider().overlay(Theme.Color.usuzumi)
if routes.isEmpty {
Text("Quelle 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(Theme.Color.nezumi)
.frame(width: 70, alignment: .trailing)
}
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)
}
}
/// 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.