Skip to content

Commit 8e675ae

Browse files
committed
use standard logging interface, only
1 parent aa702d6 commit 8e675ae

4 files changed

Lines changed: 154 additions & 55 deletions

File tree

gambaterm/audio.py

Lines changed: 85 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import atexit
4+
import logging
45
import os
56
from typing import Generator, Iterator, TYPE_CHECKING
67
from contextlib import contextmanager
@@ -16,6 +17,8 @@
1617
import miniaudio
1718
import samplerate
1819

20+
logger = logging.getLogger(__name__)
21+
1922

2023
class AudioOut:
2124
output_rate: float = 48000.0 # Hz
@@ -66,24 +69,22 @@ def __init__(
6669
# Diagnostics variables
6770
self._underruns = 0
6871
self._overruns = 0
69-
self._diag_enabled = bool(
70-
os.environ.get("GAMBATERM_AUDIO_CSV")
71-
or os.environ.get("GAMBATERM_AUDIO_LOG")
72-
)
73-
if self._diag_enabled:
72+
self._frame_num = 0
73+
self._csv_enabled = bool(os.environ.get("GAMBATERM_AUDIO_CSV"))
74+
self._diag_fill_min = 1.0
75+
self._diag_ratio_min = self.nominal_sampling_ratio
76+
self._diag_ratio_max = self.nominal_sampling_ratio
77+
if self._csv_enabled:
7478
self._diag_frames: list[dict] = []
75-
self._diag_frame_num = 0
76-
self._diag_fill_min = 1.0
77-
self._diag_ratio_min = self.nominal_sampling_ratio
78-
self._diag_ratio_max = self.nominal_sampling_ratio
79-
atexit.register(self._dump_audio_stats)
79+
atexit.register(self._dump_csv)
80+
atexit.register(self._log_summary)
8081

8182
# Controller configuration
8283
self.correction_min = 1 - self.correction_clamp
8384
self.correction_max = 1 + self.correction_clamp
8485

85-
# Batch variable-length emulator output to avoid starving the
86-
# ring buffer as runFor() sometimes returns partial frames.
86+
# Batch the variable-length emulator audio output, avoids starving the ring buffer, runFor()
87+
# sometimes returns partial frames!
8788
self._acc_buf: npt.NDArray[np.float32] = np.empty((0, 2), dtype=np.float32)
8889

8990
# Controller state
@@ -107,17 +108,8 @@ def start(self) -> miniaudio.PlaybackDevice:
107108
device.start(stream)
108109
return device
109110

110-
def _dump_audio_stats(self) -> None:
111-
if (audio_log := os.environ.get("GAMBATERM_AUDIO_LOG")):
112-
with open(audio_log, "w") as fout:
113-
fout.write(f"underruns={self._underruns}\n")
114-
fout.write(f"overruns={self._overruns}\n")
115-
fout.write(f"fill_min={self._diag_fill_min:.4f}\n")
116-
fout.write(f"ratio_min={self._diag_ratio_min:.8f}\n")
117-
fout.write(f"ratio_max={self._diag_ratio_max:.8f}\n")
118-
_rng = self._diag_ratio_max - self._diag_ratio_min
119-
fout.write(f"ratio_range={_rng:.8f}\n")
120-
if (diag_csv := os.environ.get("GAMBATERM_AUDIO_CSV")):
111+
def _dump_csv(self) -> None:
112+
if diag_csv := os.environ.get("GAMBATERM_AUDIO_CSV"):
121113
with open(diag_csv, "w") as fout:
122114
fout.write("frame,input,acc,proc,output,fill\n")
123115
for _df in self._diag_frames:
@@ -126,6 +118,19 @@ def _dump_audio_stats(self) -> None:
126118
f"{_df['proc']},{_df['output']},{_df['fill']:.4f}\n"
127119
)
128120

121+
def _log_summary(self) -> None:
122+
logger.debug(
123+
"Audio stats: underruns=%d overruns=%d "
124+
"fill_min=%.4f ratio_min=%.8f ratio_max=%.8f "
125+
"ratio_range=%.8f",
126+
self._underruns,
127+
self._overruns,
128+
self._diag_fill_min,
129+
self._diag_ratio_min,
130+
self._diag_ratio_max,
131+
self._diag_ratio_max - self._diag_ratio_min,
132+
)
133+
129134
@property
130135
def fill_fraction(self) -> float:
131136
# Ring buffer fill ratio (0-1.0)
@@ -162,52 +167,68 @@ def adapt_sample_rate(self) -> None:
162167
self._diag_track_ratio()
163168

164169
def _diag_record_skip(self, input_len: int, acc_len: int) -> None:
165-
if not self._diag_enabled:
166-
return
167170
fill = self.fill_fraction
168171
self._diag_fill_min = min(self._diag_fill_min, fill)
169-
self._diag_frames.append({
170-
"frame": self._diag_frame_num,
171-
"input": input_len,
172-
"acc": acc_len,
173-
"proc": 0,
174-
"output": 0,
175-
"fill": fill,
176-
})
177-
self._diag_frame_num += 1
172+
frame = self._frame_num
173+
self._frame_num += 1
174+
logger.debug(
175+
"skip frame=%d input=%d acc=%d fill=%.4f", frame, input_len, acc_len, fill
176+
)
177+
if self._csv_enabled:
178+
self._diag_frames.append(
179+
{
180+
"frame": frame,
181+
"input": input_len,
182+
"acc": acc_len,
183+
"proc": 0,
184+
"output": 0,
185+
"fill": fill,
186+
}
187+
)
178188

179189
def _diag_record_process(
180-
self, input_len: int, acc_len: int, output_len: int,
190+
self,
191+
input_len: int,
192+
acc_len: int,
193+
output_len: int,
181194
) -> None:
182-
if not self._diag_enabled:
183-
return
184195
fill = self.fill_fraction
185196
self._diag_fill_min = min(self._diag_fill_min, fill)
186-
self._diag_frames.append({
187-
"frame": self._diag_frame_num,
188-
"input": input_len,
189-
"acc": acc_len,
190-
"proc": 1,
191-
"output": output_len,
192-
"fill": fill,
193-
})
194-
self._diag_frame_num += 1
197+
frame = self._frame_num
198+
self._frame_num += 1
199+
logger.debug(
200+
"process frame=%d input=%d acc=%d output=%d fill=%.4f",
201+
frame,
202+
input_len,
203+
acc_len,
204+
output_len,
205+
fill,
206+
)
207+
if self._csv_enabled:
208+
self._diag_frames.append(
209+
{
210+
"frame": frame,
211+
"input": input_len,
212+
"acc": acc_len,
213+
"proc": 1,
214+
"output": output_len,
215+
"fill": fill,
216+
}
217+
)
195218

196219
def _diag_track_fill(self) -> None:
197-
if not self._diag_enabled:
198-
return
199220
self._diag_fill_min = min(self._diag_fill_min, self.fill_fraction)
200221

201222
def _diag_track_ratio(self) -> None:
202-
if not self._diag_enabled:
203-
return
204223
self._diag_ratio_min = min(self._diag_ratio_min, self.sampling_ratio)
205224
self._diag_ratio_max = max(self._diag_ratio_max, self.sampling_ratio)
206225

207226
def send(self, console: Console, audio: npt.NDArray[np.int16]) -> None:
208227
# Scale and remove DC offset
209-
scaled = (audio.astype(np.float32) * self.audio_volume
210-
- console.AUDIO_OFFSET * self.audio_volume)
228+
scaled = (
229+
audio.astype(np.float32) * self.audio_volume
230+
- console.AUDIO_OFFSET * self.audio_volume
231+
)
211232

212233
# Accumulate input to batch variable-length emulator frames into consistent chunks for the
213234
# resampler. The emulator's runFor() may produce anywhere from a few hundred to tens of
@@ -246,6 +267,12 @@ def send(self, console: Console, audio: npt.NDArray[np.int16]) -> None:
246267
# Drop excess frames if we're overrun
247268
if frames > space:
248269
self._overruns += 1
270+
logger.warning(
271+
"Audio overrun: dropping %d of %d frames (fill=%.2f)",
272+
frames - space,
273+
frames,
274+
self.fill_fraction,
275+
)
249276
resampled = resampled[:space]
250277
frames = space
251278

@@ -317,6 +344,12 @@ def _audio_stream(self) -> Generator[bytes, int, None]:
317344
# Log if we're underrunning
318345
if read_size < required_frames:
319346
self._underruns += 1
347+
logger.warning(
348+
"Audio underrun: requested %d, got %d (fill=%.2f)",
349+
required_frames,
350+
read_size,
351+
self.fill_fraction,
352+
)
320353

321354
# Send audio to output and get next required frames
322355
required_frames = yield result.tobytes()

gambaterm/main.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
from __future__ import annotations
33

44
import time
5+
import logging
56
import argparse
67
from pathlib import Path
7-
from typing import ContextManager, TYPE_CHECKING
8+
from typing import Any, ContextManager, Optional, TYPE_CHECKING
89
import dataclasses
910
from dataclasses import dataclass, field
1011

@@ -23,6 +24,45 @@
2324
if TYPE_CHECKING:
2425
from typing import Self
2526

27+
_DEFAULT_LOGFMT = " ".join(("%(levelname)s", "%(filename)s:%(lineno)d", "%(message)s"))
28+
29+
30+
def make_logger(
31+
name: str,
32+
loglevel: str = "info",
33+
logfile: Optional[str] = None,
34+
logfmt: str = _DEFAULT_LOGFMT,
35+
filemode: str = "a",
36+
) -> logging.Logger:
37+
"""Create and return a configured logger (following telnetlib3 pattern)."""
38+
lvl = getattr(logging, loglevel.upper(), None)
39+
if lvl is None:
40+
lvl = logging.getLevelName(loglevel.upper())
41+
_cfg: dict[str, Any] = {"format": logfmt}
42+
if logfile:
43+
_cfg["filename"] = logfile
44+
_cfg["filemode"] = filemode
45+
logging.basicConfig(**_cfg)
46+
logging.getLogger().setLevel(lvl)
47+
logging.getLogger(name).setLevel(lvl)
48+
return logging.getLogger(name)
49+
50+
51+
def add_logging_arguments(parser: argparse.ArgumentParser) -> None:
52+
parser.add_argument(
53+
"--logfile", default=None, help="File path for log output (default: stderr)"
54+
)
55+
parser.add_argument(
56+
"--loglevel",
57+
default="warn",
58+
help="Logging level: debug, info, warn, error (default: warn)",
59+
)
60+
parser.add_argument(
61+
"--logfmt",
62+
default=_DEFAULT_LOGFMT,
63+
help="Log format string (default: LEVEL file:lineno message)",
64+
)
65+
2666

2767
@dataclass
2868
class AppConfig:
@@ -151,11 +191,18 @@ def main(
151191
add_base_arguments(parser)
152192
add_input_file_arguments(parser)
153193
add_tuning_arguments(parser)
194+
add_logging_arguments(parser)
154195
add_local_only_arguments(parser)
155196
console_cls.add_console_arguments(parser)
156197

157198
# Parse arguments
158199
namespace = parser.parse_args(parser_args)
200+
make_logger(
201+
__name__,
202+
loglevel=getattr(namespace, "loglevel", "warn"),
203+
logfile=getattr(namespace, "logfile", None),
204+
logfmt=getattr(namespace, "logfmt", _DEFAULT_LOGFMT),
205+
)
159206
disable_audio = getattr(namespace, "disable_audio", False)
160207
args = LocalAppConfig.from_namespace(namespace)
161208

gambaterm/ssh.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@
3838
add_base_arguments,
3939
add_input_file_arguments,
4040
add_tuning_arguments,
41+
add_logging_arguments,
42+
make_logger,
43+
_DEFAULT_LOGFMT,
4144
AppConfig,
4245
)
4346
from .console import Console, GameboyColor
@@ -533,6 +536,7 @@ def main(
533536
add_base_arguments(parser)
534537
add_input_file_arguments(parser)
535538
add_tuning_arguments(parser)
539+
add_logging_arguments(parser)
536540
console_cls.add_console_arguments(parser)
537541
parser.add_argument(
538542
"--bind",
@@ -570,6 +574,12 @@ def main(
570574

571575
# Parse arguments
572576
namespace = parser.parse_args(parser_args)
577+
make_logger(
578+
__name__,
579+
loglevel=getattr(namespace, "loglevel", "warn"),
580+
logfile=getattr(namespace, "logfile", None),
581+
logfmt=getattr(namespace, "logfmt", _DEFAULT_LOGFMT),
582+
)
573583
bind: str = namespace.__dict__.pop("bind")
574584
port: int = namespace.__dict__.pop("port")
575585
password: str = namespace.__dict__.pop("password")

gambaterm/telnet.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@
3232
add_base_arguments,
3333
add_input_file_arguments,
3434
add_tuning_arguments,
35+
add_logging_arguments,
36+
make_logger,
37+
_DEFAULT_LOGFMT,
3538
AppConfig,
3639
)
3740
from .console import Console, GameboyColor
@@ -444,6 +447,7 @@ def main(
444447
add_base_arguments(parser)
445448
add_input_file_arguments(parser)
446449
add_tuning_arguments(parser)
450+
add_logging_arguments(parser)
447451
console_cls.add_console_arguments(parser)
448452
parser.add_argument(
449453
"--bind",
@@ -464,8 +468,7 @@ def main(
464468
"--robot-check",
465469
action="store_true",
466470
default=False,
467-
help="reject bots by checking if client responds to "
468-
"cursor position requests",
471+
help="reject bots by checking if client responds to cursor position requests",
469472
)
470473
parser.add_argument(
471474
"--port",
@@ -488,6 +491,12 @@ def main(
488491
)
489492

490493
namespace = parser.parse_args(parser_args)
494+
make_logger(
495+
__name__,
496+
loglevel=getattr(namespace, "loglevel", "warn"),
497+
logfile=getattr(namespace, "logfile", None),
498+
logfmt=getattr(namespace, "logfmt", _DEFAULT_LOGFMT),
499+
)
491500
bind: str = namespace.__dict__.pop("bind")
492501
port: int = namespace.__dict__.pop("port")
493502
robot_check: bool = namespace.__dict__.pop("robot_check")

0 commit comments

Comments
 (0)